Groupon поддържа буквално стотици NPM модули, както с отворен код, така и вътрешни. Много от тях се консумират от нашия персонализиран базиран на NodeJS междинен уеб слой, който наричаме „Ниво на взаимодействие“ (самото по себе си тема за друга публикация някой ден).

Докато хората пишат нови модули, често срещаният въпрос е „кой е най-добрият начин да експортирате неща от нашите публикувани модули, за да увеличите максимално съвместимостта?“ - и това е темата на тази публикация. Първо малко предистория и история:

Аромати на изнесени модули

CommonJS в Node

В началото имаше 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 Module (повече за това по-долу):

// 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 модули в Node

Това е „голяма тема“, но ето основните моменти:

Първо, три примерни файла за експортиране на 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');
}

BabelScript / Webpack / TypeScript

Когато бяха обявени ранните предложения за 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

Все още не публикувайте Native ES модули (чрез цели за компилиране или написани на ръка) в NPM пакети. CommonJS е много по-лесен за използване от ESModules отколкото обратното и в момента ~90% от нашия бекенд код в Groupon е CommonJS на NodeJS. Докато нашите екипи преминават към писане на нов код на приложение с помощта на Native 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.

Надяваме се, че стъпките по-горе са добра отправна точка за минимизиране на объркването и оттеглянето на разработчиците, докато се адаптират към новите технологии.