Groupon поддерживает буквально сотни модулей NPM, как открытых, так и внутренних. Многие из них потребляются нашим пользовательским веб-слоем промежуточного программного обеспечения на основе NodeJS, который мы называем «Уровень взаимодействия» (сама тема для другой публикации когда-нибудь).
Когда люди пишут новые модули, часто возникает вопрос: «Как лучше всего экспортировать вещи из наших опубликованных модулей, чтобы добиться максимальной совместимости?» - и это тема этого поста. Сначала немного предыстории и истории:
Ароматы экспортируемых модулей
CommonJS в узле
Вначале был CommonJS:
Вот 3 примера файлов с экспортом:
// export1.js - exporting individual properties w/ CommonJS 'use strict';
function foo() { } exports.foo = foo; exports.bar = 42;
// export2.js - exporting a single object w/ CommonJS 'use strict';
function baz() { } const garply = 88; module.exports = { baz }; // dynamically (conditionally!) exported! if (Math.random() > 0.5) module.exports.garply = garply;
// export3.js - exporting a bare function w/ CommonJS 'use strict';
function quux() { } module.exports = quux;
// sometimes there are extra properties added to the bare function quux.yadda = 42;
Эти экспорты CommonJS можно импортировать либо в другие файлы CommonJS, либо в файлы модуля ES (подробнее об этом ниже):
// import.js - importing CommonJS modules into a CJS file 'use strict';
const { foo, bar } = require('./export1'); const { baz, garply } = require('./export2'); if (Math.random > 0.9) { // can also dynamically decide when to import const quux = require('./export3'); // can poke into properties tacked onto functions const { yadda } = require('./export3'); }
// import.mjs - importing CommonJS modules into an ESM file // node is willing to turn exported objects into named exports import { foo, bar } from './export1.js'; import { baz, garply } from './export2.js'; import quux from './export3.js'; // cannot access added property "yadda" directly; hence: const { yadda } = quux;
Вы можете прочитать больше в другом месте, но основные особенности:
- Файлы модуля выполняются синхронно, и в конце их выполнения синхронизации все, что присутствует в
module.exports
, доступно для файлов, которыеrequire()
этот модуль. Это означает, что экспорт может создаваться динамически. - Вы можете либо добавить свойства к существующему объекту
exports
, который начинается так же, какmodule.exports
, либо переназначить всеmodule.exports
одному объекту (который может оказаться объектом). - Причина, по которой
const { a, b } = require(...
работает, заключается в том, что это соглашение об экспорте объекта, но вы можете экспортировать все, что хотите (голая функция, массив, число и т. д.).
Собственные модули ES в узле
Это большая тема, но вот основные моменты:
Во-первых, три примера файлов экспорта модулей ES:
// export1.mjs - named exports w/ ESM export function foo() { } export const bar = 42;
// export2.mjs - more named exports w/ ESM export function baz() { } // cannot dynamically choose to not export things // i.e. no if (...) export possible export const garply = 88;
// export3.mjs - default export w/ ESM export default function quux() { } // can export something *other* than default also export const yadda = 99;
Экспорт модуля ES можно легко импортировать в другие файлы модуля ES и можно импортировать асинхронно (только!) в файлы CommonJS:
// import.mjs - importing ESM w/ ESM import { foo, bar } from './export1.mjs'; import { baz, garply } from './export2.mjs'; // can import default export and others import quux, { yadda } from './export3.mjs';
// import.js - importing ESM w/ CommonJS 'use strict';
// you cannot directly require() ESM files, // you must use import() which is an async operator async function someFn() { const { foo, bar } = await import('./export1.mjs'); const { baz, garply } = await import('./export2.mjs'); // the default export has property "default" const { default: quux, yadda } = await import('./export3.mjs'); }
Бабельскрипт/вебпак/типскрипт
Когда были объявлены ранние предложения модулей ECMAScript, ряд сборщиков (и TypeScript) работали с ним и поддерживали различные синтаксисы, которые были похожи на модули ES, но не полностью семантически совместимы с ними, поскольку в конечном итоге изначально поддерживались в NodeJS 14.
Большинство проектов обычно компилируют эти файлы в CommonJS, поэтому мы должны понимать, во что они на самом деле компилируются. export1.mjs
и export2.mjs
обычно компилируются в свои эквиваленты из раздела CommonJS выше. export3.mjs
будет компилироваться примерно так:
// dist/export3.js 'use strict';
function quux() { } exports.default = quux; exports.yadda = 99;
Это существенно отличается от оригинального CommonJS export3.js
, поскольку экспортируется не голая функция, а скорее объект со свойством default
. Чтобы поддерживать обратную совместимость, когда это необходимо, TypeScript предлагает нестандартный синтаксис:
// export3.ts
function quux() { }
export = quux; // non-standard syntax
// cannot export anything else, like yadda, though you can:
quux.yadda = 99;
Он компилируется в CommonJS точно так же, как исходный export3.js
, но в некоторых случаях делает использование этого файла из других файлов TypeScript более неудобным:
// import.ts
// more non-standard syntax
import quux = require('./export3');
Решения
Так как же правильно поддерживать все различные варианты использования в наших опубликованных пакетах NPM?
Мы не знаем.
Еще.
В настоящее время вот несколько советов:
Не используйте стандартный или голый экспорт в новом коде.
Если вы создаете что-то новое, не экспортируйте голые функции/классы (используя export default
или export =
) публично. Первое приводит к уродливому опыту работы с CommonJS (const { default: coolFn } = require('./cool-fn');
), а второе — к уродливому опыту работы с TS (import coolFn = require('./cool-fn');
). Использование именованного экспорта делает каждую среду максимально похожей.
(Примечание: этот совет применим только к общедоступному интерфейсу вашего пакета NPM — вещи, которые вы экспортируете между внутренними исходными файлами, не применяются)
// bad1.ts // don't do this export default someFn() { }
// bad2.ts // don't do this in NEW code function someFn() { } export = someFn;
// good.ts export function someFn() { } // in CommonJS this is: const { someFn } = require('./good'); // in TS/ESM this is: import { someFn } from './good';
Пишите новые CommonJS, такие как модули ES.
Если вам нужно написать новый код как CommonJS, напишите его в стиле, максимально совместимом с модулями ES. Это означает отсутствие экспортов по умолчанию, как указано выше, но также лучше использовать стиль, который «выглядит» как именованные экспорты, чтобы сделать перенос в будущем четким и недвусмысленным:
// good.js 'use strict'; function someFn() { } exports.someFn = someFn; function anotherFn() { } exports.anotherFn = anotherFn;
(другой вариант — напрямую использовать exports.someFn = () => { }
, у которого есть свои плюсы и минусы, выходящие за рамки этого поста)
Опубликовать как CommonJS
Пока не публикуйте собственные модули ES (через цели компиляции или написанные от руки) в пакетах NPM. CommonJS намного проще использовать из ESModules, чем наоборот, и в настоящее время ~ 90% нашего внутреннего кода в Groupon — это CommonJS на NodeJS. По мере того, как наши команды переходят к написанию нового кода приложения с использованием собственных модулей ES (или TypeScript), это руководство будет постепенно меняться.
Даже если вы пишете код как TypeScript (рекомендуется!) или как NodeJS-совместимый ESM экспортирует напрямую, обязательно скомпилируйте его (используя TypeScript или Babel) в файлы CommonJS*.js
. Кроме того, убедитесь, что type
в вашем package.json
не установлено на module
; это не то, что вы публикуете.
Если вы пишете свой код как ES-модули и используете для компиляции babel, то для правильной работы lint вам может потребоваться назвать ваши исходные файлы .mjs
или включить какой-то раздел overrides
в ваш .eslintrc
, чтобы он правильно распознал .js
файлы в качестве типа модуля, не устанавливая его в package.json
Включить объявления типов
Даже если вы пишете JavaScript, попробуйте включить в свой репозиторий основные .d.ts
файлов — разработчики все чаще пишут TypeScript (или даже используют VSCode, который предоставляет подсказки типов при написании JavaScript), и это очень помогает в написании надежного кода и более быстром рефакторинге.
Подумайте, когда нужно внести КРИТИЧЕСКОЕ ИЗМЕНЕНИЕ
Если вы переносите библиотеку с JavaScript на TypeScript, например, и у нее есть экспорт голой функции, например:
const myFn = require('@grpn/my-fn');
… на самом деле нет хорошего обратно-совместимого способа предложить это в TypeScript — вы должны использовать уродливый TS-хак обратной совместимости:
function myFn() { }
export = myFn;
… который сохраняет ваши операторы CommonJS require()
без изменений… но делает ваши объявления типов и импорты уродливыми:
import myFn = require('@grpn/my-fn');
Поэтому стоит подумать, хороший ли это повод для коммита BREAKING CHANGE сделать его именованным экспортом:
export function myFn() { }
который ломается, потому что теперь использование CommonJS выглядит так:
const { myFn } = require('@grpn/my-fn');
Последние мысли
Детали того, что ваш проект должен экспортировать, будут зависеть от:
- Принятие TypeScript разработчиками вашей экосистемы
- Принятие модуля ES среди пользователей вашего пакета
- Среда выполнения JavaScript, которую вы используете (NodeJS, deno, браузер…) — этот пост был написан в первую очередь для пользователей NodeJS.
Надеемся, что приведенные выше шаги являются хорошей отправной точкой для минимизации путаницы и оттока разработчиков при адаптации к новым технологиям.