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;

Вы можете прочитать больше в другом месте, но основные особенности:

  1. Файлы модуля выполняются синхронно, и в конце их выполнения синхронизации все, что присутствует в module.exports, доступно для файлов, которые require() этот модуль. Это означает, что экспорт может создаваться динамически.
  2. Вы можете либо добавить свойства к существующему объекту exports, который начинается так же, как module.exports, либо переназначить все module.exports одному объекту (который может оказаться объектом).
  3. Причина, по которой 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.

Надеемся, что приведенные выше шаги являются хорошей отправной точкой для минимизации путаницы и оттока разработчиков при адаптации к новым технологиям.