Введение
В оборонных кругах существует поговорка: «любители говорят о стратегии; профессионалы говорят о логистике». Другими словами, то, что кажется самым обыденным элементом сложных инженерных задач (своевременное перемещение груза из точки А в точку Б), на удивление является критически важным элементом успеха.
Если бы мне пришлось проводить аналогию, я бы сказал о сообществе разработчиков, что «любители говорят о коде, профессионалы — об интеграции». Оказывается, писать код (особенно с нуля) удивительно легко, тогда как собрать код воедино (особенно код, который вы написали не сами) удивительно сложно.
Итак, в мире JavaScript, как мы собираем код вместе? Ну, это зависит от обстоятельств. В год нашего господина две тысячи двадцать второй, через 26 лет после выхода JavaScript, у нас все еще нет последовательного способа объединять единицы кода вместе. У нас даже нет последовательного способа определить, что это за единицы кода!
Проблемы
Обратите внимание на слово «последовательный». Есть много способов, которыми вы можете пойти на это, но мало способов, которые действительно совместимы. Давайте разделим это на три конкретные проблемы:
-
Как управляются пакеты?
-
Как экспортируются модули?
-
Как задаются модули?
Например, ответом на вопрос №1 может быть NPM, Yarn или какой-нибудь CDN. Это также может быть просто git submodules. (По причинам, в которые я не буду углубляться, я предпочитаю последний подход, в частности потому, что он полностью отделен от разрабатываемого модуля — и даже от языка, на котором вы разрабатываете).
Ответом на вопрос #2 может быть что-то вроде модулей AMD/RequireJS, или CommonJS/Node, или теги сценариев на уровне браузера в глобальной области видимости (фу!). Конечно, Browserify или WebPack могут помочь вам здесь, если вы действительно большой поклонник последнего. Я большой поклонник AMD/RequireJS, но нельзя спорить с тем, что возможность запуска (и тестирования) кодовой базы из командной строки (локально или удаленно) является огромным преимуществом, как для разработки (просто возиться), так и для развертывания (например, автоматизированное тестирование из задания CI).
Ответ на вопрос №3 немного более тонкий, в немалой степени потому, что в случае с чем-то вроде CommonJS/Node он полностью неявный. В AMD/RequireJS у вас есть определенные параметры «require», «exports» и «module» для функции «define()». Они существуют и в CommonJS/Node, но они подразумеваются. Попробуйте как-нибудь вывести «module» в console.log и посмотрите на все сочные детали, которые вы упустили.
SFJMs и UMD
Но это не включает содержимое вашего package.json (если оно есть), и даже в AMD/RequireJS нет специального стандарта для прикрепления метаданных и других свойств модуля. Это одна из причин, по которой я собрал стандарт SFJM в предыдущей статье dev.to:
https://dev.to/tythos/single-file-javascript-modules-7aj
Но независимо от вашего подхода, загрузчик модулей (например, проблема экспорта, описанная в #2 выше) будет «залипать». Это одна из причин появления стандарта UMD, о котором отлично написал Джим Фишер:
https://jameshfisher.com/2020/10/04/what-are-umd-modules/
UMD определяет заголовок, который должен быть вставлен перед вашим define-подобным закрытием. Он используется несколькими крупными библиотеками, включая поддержку определенных конфигураций сборки, например, THREE.js:
https://github.com/mrdoob/three.js/blob/dev/build/three.js
Заголовок
Заголовок UMD имеет несколько вариантов, но мы рассмотрим следующий из статьи Джима Фишера:
// myModuleName.js
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'b'], factory);
} else if (typeof exports === 'object' && typeof exports.nodeName !== 'string') {
// CommonJS
factory(exports, require('b'));
} else {
// Browser globals
factory((root.myModuleName = {}), root.b);
}
}(typeof self !== 'undefined' ? self : this, function (exports, b) {
// Use b in some fashion.
// attach properties to the exports object to define
// the exported module properties.
exports.action = function () {};
}));
Фактически здесь представлены три варианта использования: AMD/RequireJS; CommonJS/Node; и глобальные файлы браузера. Однако давайте будем честными — это уродливо. (Это не халтура в Jim, это общая проблема UMD.) Среди прочего, вот что меня беспокоит:
-
Он просто громоздкий — это много текста, который нужно вставить в начало каждого модуля.
-
Он на самом деле слишком старается — я никогда не находил необходимости в поддержке браузерных глобалов, мне просто нужно, чтобы мои однофайловые модули JavaScript на базе AMD/RequireJS могли работать/тестироваться в среде CommonJS/Node
-
Списки зависимостей явно привязаны к заголовку — поэтому его нельзя использовать повторно. Вы должны настраивать его для каждого модуля! Сравните это с простым указанием
const b = require('b');
в самой фабрике закрытия, и вы увидите большую разницу. -
Я не заинтересован в одинаковом отношении к юзкейсам. Я пишу на AMD/RequireJS, а загрузка CommonJS/Node — это крайний случай.
Основная проблема с последним пунктом заключается в том, что AMD/RequireJS уже дают нам очень чистый интерфейс закрытия и явного определения модуля. Именно CommonJS/Node требуют хака. Итак, можем ли мы упростить заголовок и сосредоточиться на адаптации последнего к первому? Желательно таким образом, чтобы это не зависело от зависимостей? Ну, поскольку я пишу эту статью, вы, вероятно, можете сказать, что ответ «да».
Мой подход
Давайте начнем с символов. Что доступно, а что нет? Начнем с того, что модуль AMD/RequireJS уже определен и работает. Если вы перенесетесь в сознание интерпретатора CommonJS/Node, то первое, что вы поймете, это то, что в то время как «require», «exports» и «module» уже определены неявно, фабрика «define» не определена. Итак, это корень нашей проблемы: нам нужно определить фабрику «define» (ха-ха), которая будет направлять CommonJS/Node для последовательной интерпретации закрытия определения модуля.
Есть хороший пример условного условия для этого из UMD, который мы можем позаимствовать (и немного подкорректировать):
if (typeof(define) !== "function" || define.amd !== true) {
Интересно, что вы не можете просто проверить, существует ли define. Вы должны убедиться, что он не существует ВНУТРИ AMD, потому что CommonJS/Node может сохранить символ «define» вне этого контекста — например, в области видимости другого модуля, который «require()»-устанавливает этот. Странно, но факт.
Итак, теперь наша цель — определить «define()». Как это можно адаптировать к области применения CommonJS/Node? Нам нужно обеспечить существование идентичного интерфейса «define()»:
-
Он должен принимать один параметр, анонимную функцию (которую мы будем называть здесь «фабрикой»), внутри закрытия которой определяется содержимое модуля.
-
Эта функция должна иметь следующий интерфейс: «require» (функция, которая разрешает/возвращает любые зависимости модуля на основе пути); «exports» (объект, определяющий, какие символы будут доступны внешним модулям); и «module» (определение свойств модуля, включающее «module.exports», которое указывает на «exports».
-
Define должна вызвать эту функцию и вернуть экспортные символы модуля. (В случае определения, совместимого с SFJM, это также будет включать метаданные модуля, подобные package.json, включая карту зависимостей).
Последний пункт интересен тем, что а) уже есть несколько ссылок на экспорт модуля, и б) даже AMD/RequireJS поддерживает несколько/опциональных маршрутов для экспортных символов. И это один из самых сложных вопросов, лежащих в основе кросс-совместимости: символ «exports» может сохраняться и неправильно отображаться CommonJS/Node, если его явно не вернуть!
Спасибо, экспорт, ты настоящий (то, что мешает нам достичь) MVP.
Господи, какой кошмар. По этой причине мы собираемся изменить принцип работы закрытия фабрики:
-
Мы собираемся явно «отключить» параметр «exports», передавая пустой объект («{}») в качестве второго параметра фабрики.
-
Мы будем явно возвращать экспорты модуля из реализации фабрики.
-
Мы собираемся явно сопоставить результаты вызова фабрики со свойством (на уровне файла) «module.exports».
Сочетание этих изменений означает, что, хотя AMD/RequireJS поддерживает множество маршрутов, мы собираемся ограничить наши реализации модулей явным возвратом символов экспорта из вызова фабрики, чтобы направить их в правильный символ CommonJS/Node.
Если вы этого не сделаете — и я потерял несколько волос, отлаживая это — вы получите очень «интересную» (читай: безумную только с точки зрения CommonJS/Node) ошибку, когда родительский модуль (require()’ing зависимого модуля) «перекрещивает провода» и экспортные символы сохраняются между диапазонами.
Это причудливо, особенно потому, что это происходит ТОЛЬКО ВНЕ REPL! Таким образом, вы можете запустить эквивалентные методы модуля из REPL, и они будут в порядке — но попытка отобразить их в самом модуле (и затем, скажем, вызвать их из командной строки) будет ломаться каждый раз.
Итак, как это выглядит практически? Это означает, что определение «define», которое мы помещаем в условие, написанное выше, выглядит примерно так:
define = (factory) => module.exports = factory(require, {}, module);
Это также означает, что закрытие нашего модуля начинается с явного отключения символа «exports», чтобы бедный старый CommonJS/Node не перепутал провода:
define(function(require, _, module) {
let exports = {};
Вздох. Когда-нибудь все это обретет смысл. Но тогда это будет не JavaScript. 😉
Примеры
Как же это выглядит «на природе»? Вот проект на GitHub, который предоставляет достаточно наглядный пример:
https://github.com/Tythos/umd-light/
Краткий экскурс:
-
«index.js» показывает, как точка входа может быть обернута в одно закрытие, которое использует вызов «require()» для прозрачной загрузки зависимости.
-
«index.js» также показывает нам, как добавить хук в стиле SFJM для (из CommonJS/Node) запуска точки входа («main»), если этот модуль будет вызван из командной строки.
-
«.gitmodules» говорит нам, что зависимость управляется как подмодуль
-
«lib/» содержит подмодули, которые мы используем
-
«lib/jtx» — это ссылка на конкретный подмодуль (не забудьте про submodule-init и submodule-update!); в данном случае он указывает на следующую утилиту расширений типов JavaScript, однофайловый модуль JavaScript которой можно посмотреть здесь:
https://github.com/Tythos/jtx/blob/main/index.js
- Этот модуль использует тот же заголовок «UMD-light» (как я его пока называю).
Проблемный ребенок
А теперь дикая карта. На самом деле существует еще один подход к экспорту модулей, о котором мы не упомянули: использование импорта/экспорта модулей в стиле ES6. И я буду честен — я потратил нездоровую часть своих выходных, пытаясь понять, есть ли какой-нибудь разумный и несложный способ расширить кросс-совместимость для реализации ES6/MJS. Мой вывод: это невозможно сделать — по крайней мере, без серьезных компромиссов. Рассмотрим:
-
Они несовместимы с CommonJS/Node REPL — так что вы теряете возможность проверять/тестировать из этой среды.
-
Они несовместимы с define closure/factory — так что все эти преимущества теряются.
-
Они прямо противоречат многим принципам проектирования (не говоря уже о реализации) веб-ориентированного стандарта AMD/RequireJS, включая асинхронную загрузку (это в названии, люди!).
-
У них есть… интересные предположения относительно путей, которые могут быть очень проблематичными в разных средах — и поскольку это стандарт на уровне языка, вы не можете расширить/настроить его, отправив MR, скажем, в проект AMD/RequireJS (что я делал пару раз) — не говоря уже о кошмаре, который это вызывает в вашей IDE, если контексты путей перепутаны!
-
Перетряхивание деревьев, которое вы должны уметь делать из частичного импорта (например, извлечение символов), сэкономит вам буквально ноль денег в веб-среде, где ваши самые большие затраты — это просто получение JS с сервера и через интерпретатор.
Если уж на то пошло, то лучше всего (как и в случае с THREE.js) использовать их только для разбиения кодовой базы на части (если она слишком велика для однофайлового подхода, чего я стараюсь избегать в любом случае), а затем объединять эти части во время сборки (с помощью WebPack, Browserify и т.д.) в модуль, который использует заголовок в стиле CommonJS/Node, AMD/RequireJS или UMD для обеспечения кросс-совместимости. Извини, импорт/экспорт ES6, но ты, возможно, ухудшил ситуацию. ;(