Съвети за писане на ES модули в Node.js

Напоследък работих малко с ES модули в рамките на Node.js. Открих, че документацията и публикациите в блогове са малко неравномерни с някои подробности, но не всички, и документи са навсякъде. Така че тази публикация е по същество всичко, което научих за използването на ES модули в рамките на Node.js, преход от CommonJS, всичко на едно място.

Модулите EcmaScript бяха дефинирани в спецификацията ES2015, въведена през юни 2015 г. Те бяха представени за първи път в Node.js през септември 2017 г. зад флага --experimental-modules и трябва да се поддържат без флага през 2020 г. Има редица предимства на модулите ES, а именно

  • Универсална модулна система между браузъри и Node.js.
  • Статичен анализ на код, позволяващ дървовидно разклащане на ES модули с помощта на пакетни рамки като Rollup и Webpack. Изпратете само кода, използван във вашето приложение.

Node.js ще поддържа ниво на съвместимост между модулите CommonJS и ES. Тази публикация помага да се предоставят полезни съвети за писане на ES модули за тези, които са свикнали да пишат CommonJS модули.

Има редица разлики между модулите CommonJS и ES, които трябва да бъдат разбрани. Основните разлики са

  • ES модулите използват .mjs файла, CommonJS използва .js файла. Node.js използва файловото разширение, за да определи коя модулна система да зареди.
  • ES модулите се импортират статично, докато модулите CommonJS се импортират динамично.
  • Алгоритъмът за разрешаване на Node.js ще импортира .mjs версията на модул преди .js версия, ако съществуват две версии с едно и също име.
  • Можете да импортирате модули, като използвате URL път
  • CommonJS въвежда модули като module обект. По същество нормален JavaScript обект, който можете да мутирате и основно да третирате като нормален обект. ES модули не въвежда модула като нормален JavaScript обект, с някои ограничения за това как взаимодействате с ES модул.

По-долу са някои съвети за писане на ES модули в Node.js, ако сте свикнали с CommonJS модули.

Статично импортиране на модули

Синтаксисът import module from 'module' въвежда модула глобално и само за четене. Това променя начина, по който ги използвате по няколко начина.

  • Всички ваши import module from 'module' изрази трябва да са в горната част на вашия модул/скрипт
  • Не можете да смесвате и съпоставяте синтаксис на CommonJS и ES модул в един и същи модул/скрипт. По принцип, ако това е .mjs файл, уверете се, че има require() изрази във вашия код.
  • ES модул може да импортира CommonJS модул. Имайте предвид как методите и свойствата за експортиране на съответните модули и начинът, по който се импортират, са леко различни във всеки случай. За да разширите примера в тази публикация, ако имате следния код
//foobar.js
function foo() {
  return 'bar';
}
function bar() {
  return 'foo';
}
module.exports.foo = foo;
module.exports.bar = bar;

Можете да го посочите в CommonJS модул като този

//app.js
const { foo, bar } = require('foobar');
console.log(foo(), bar());

и еквивалентът в модулите ES за препратка към същия модул CommonJS, foobar.js, ще бъде

//app.mjs
import { foo, bar } from 'foobar'
console.log(foo(), bar());

Въпреки че синтаксисът изглежда подобен, това, което се случва, е малко по-различно. В примера на CommonJS редът const { foo, bar } = require('foobar') използва деструктуриране на обекти, за да извлече обектите foo и bar от require('foobar'). В примера на ES модул синтаксисът import { foo, bar } from 'foobar' директно импортира именуваните експорти foo и bar.

Синтаксисът на CommonJS основно прави това.

const foobar = require('foobar')
const { foo, bar } = foobar
console.log(foo(), bar());

Синтаксисът на CommonJS използва две стъпки за създаване на обектите foo и bar срещу синтаксиса на модула ES, който го прави в една стъпка.

Ключът към разбирането как да използвате модулите CommonJS в модулите ES е да разберете как работят експортите по подразбиране и именуванията. За да разширите горния пример, ако сте използвали следния синтаксис

//app.mjs
import foobar from 'foobar'
const { foo, bar } = foobar
console.log(foo(), bar());

Ще получите грешка. Причината е, че модулът CommonJS, foobar.js, няма експорт по подразбиране. За да въведете съответните методи от foobar.js, трябва или да ги въведете по име, както прави първоначалният работен пример, или да ги импортирате под едно пространство от имена като това.

//app.mjs
import * as foobar from 'foobar'
const { foo, bar } = foobar
console.log(foo(), bar());

Операторът * препраща към всички методи и свойства, експортирани от foobar.js, а as foobar създава пространство от имена на модули, наречено foobar, което можете да използвате за препратка към отделни методи.

Синтаксисът import module from 'module' може да се използва само когато модулът, който се импортира, има експорт по подразбиране.

  • Не можете да препращате към никакви методи, свойства или обекти на импортирания модул в оператора import. Така че кодирайте като този
const version = require('./package.json').version

трябва да се пренапише по този начин

import * as pkg from `./package.json`
const version = pkg.version
  • ES модулите са само за четене, така че не можете да ги променяте, след като бъдат импортирани. Синтаксис като този, който работи в CommonJS.
const module = require('./module')
const func = () => { return 'this is a function' }
Object.assign(module, func)

спрямо еквивалента на модула ES

import module from 'module'
const func = () => { return 'this is a function' }
Object.assign(module, func)

Примерът за CommonJS ще работи, примерът за ES модул няма да работи. Ако използвате този модел, ще трябва да измислите друг начин да постигнете това, което искате.

Експортиране на модули

Начинът, по който експортирате функции, методи и обекти с ES модули, се променя леко. Най-голямата промяна е въвеждането на изрично експортиране по подразбиране със синтаксиса export default.

С ES модулите по подразбиране и наименуваните експорти могат да съществуват едновременно. Например.

//foobar.mjs
function foo() {
  return 'bar';
}
function bar() {
  return 'foo';
}
export { foo }
export default bar

Примерът по-горе има наименуван експорт foo и експорт по подразбиране bar, така че за да ги импортирате, можете да използвате кода по-долу

//app.mjs
import { foo } from 'foobar'
import foobar from 'foobar'
console.log(foo()); // 'bar'
console.log(foobar()); // 'foo'

Този пример импортира foo като модул, наречен foo, и импортира bar като модул, наречен foobar. По-полезен пример може да бъде, когато експортирате клас като експорт по подразбиране, а помощните методи като наименувани експорти сами по себе си.

Ако се опитате да направите това с модули CommonJS и синтаксиса module.exports, експортите ще се заменят взаимно. Например

//app.js
function foo() {
  return 'bar';
}
function bar() {
  return 'foo';
}
module.exports.foo = foo;
module.exports = bar;

И съответния импорт с require()

const { foo } = require('foobar'); //will create an error
const foobar = require('foobar')
console.log(foo()); // Error!!
console.log(foobar()); // 'foo'

В горния пример на CommonJS, в ред module.exports = bar предишният обект module.exports.foo е презаписан и не е експортиран, така че ще има грешка, ако бъде препратен при импортиране.

Друга полезна функция на експортирането на ES модули е възможността за експортиране директно от импортиран модул. Например можете да напишете следното

export { foo } from 'foobar'

Което е еквивалентът на модула ES на CommonJS кода по-долу.

const { foo } = require('foobar')
module.exports.foo = foo

Като цяло, експортирането на ES модул е ​​по-гъвкаво и ясно.

Работа с файлове в ES модули

Начинът, по който препращате и работите с файлове в ES модулите, се променя.

Изявленията __filename и __dirname не работят в ES модули. За еквивалентната операция трябва да използвате синтаксис import.meta.url. И така, код, който би бил написан така в CommonJS

console.log(`Current file is ${__filename}`);
console.log(`Current directory is ${__dirname}`);

Ще трябва да се пренапише по този начин в ES модули

const currentFile = import.meta.url;
const currentDirectory = new URL(import.meta.url).pathname;
console.log(`Current file is ${currentFile}`);
console.log(`Current directory is ${currentDirectory}`);

Основната разлика е, че import.meta.url пренася името на файла в URL формат, така че използва file:// за препратка към файла. Този синтаксис в момента е в „етап 3 от процеса TC39“ и е реализация на JavaScript, а не специфична за Node.js. Така че кодът, написан с помощта на този синтаксис, може да се използва както в браузъра, така и в Node.js.

Като цяло трябва да започнете да използвате новото внедряване на Node.js на URL WHATWG URL API, дефинирано в документите на Node тук, за операции с файлове и пътища. Модулите Node.js Path и File все още работят с ES модули, но можете също да използвате новата реализация на URL за работа с файлове. На този етап синтаксисът на модула ES в Node.js работи само с URL адреси, използващи file://, въпреки че спецификацията поддържа всички URL адреси, така че се надяваме, че в бъдеще ще можем да импортираме модули чрез отдалечен URL адрес в Node.

Динамично импортиране с ES модули

Докато ES модулите се импортират статично, има възможност за динамично импортиране на ES модули с помощта на синтаксиса import(). Този синтаксис също е на етап 3 от процеса TC39 и се поддържа от Node.js зад флага --experimental-modules.

Синтаксисът import() е подобен на функция и може да се използва много подобно на синтаксиса require(). Той динамично импортира ES модули, срещу require(), който може да импортира само CommonJS модули.

Реалността е, че не е необходимо често да зареждате динамично модули на JavaScript, обичаен случай на употреба е горещото зареждане на генериран код в приложение, но в повечето случаи, когато използвате модул, статичното му импортиране е за предпочитане.

Ключовото нещо, което трябва да знаете със синтаксиса import(), е как да препращате към експортираните методи и обекти. Ако имате модул като по-долу

//foobar.mjs
function foo() {
  return 'bar';
}
function bar() {
  return 'foo';
}
export { foo, bar };

Има два именувани експорта, foo и bar, които могат да бъдат импортирани динамично по този начин

//app.mjs
const currDir = new URL(import.meta.url).pathname
const fooBarLocation = new URL(currDir, '/foobar.mjs')
const foobar = import(fooBarLocation) //full path to foobar.mjs file
console.log(foobar.foo(), foobar.bar());

Трябва да посочите пълния път до модула, който импортирате динамично. Горният пример използва новата реализация на URL, за да получи текущото местоположение на файла и да го свърже с относителния път на модула и ще работи в Node.js и в браузъра.

Сега да кажем, че имате модул, който има експорт по подразбиране, има малка разлика в начина, по който се импортира, а именно експортите по подразбиране ще бъдат импортирани под пространството на имената .default, така че така.

//foobar.mjs
function foobar() {
  return 'foobar';
}
export default foobar;

Динамично импортиран по този начин

const currDir = new URL(import.meta.url).pathname
const fooBarLocation = new URL(currDir, '/foobar.mjs')
const foobar = import(fooBarLocation) //full path to foobar.mjs file
console.log(foobar.default());

Когато динамично импортирате ES модули с експорти по подразбиране, просто не забравяйте, че препращате към експорта по подразбиране, като използвате module.default, а не module.

Също така трябва да се отбележи, че ако динамично импортирате ES модули с помощта на import(), всеки анализ на статичен код или разклащане на дърво няма да работи върху тези динамично импортирани модули.

Package.json и изпращане на ES модули?

И така, какво се случва с package.json с ES модули? Договорената спецификация е, че все още използвате main, за да посочите входната си точка към вашия ES модул. Пакети като Rollup и Webpack използваха module за указване на отделна входна точка преди Node да поддържа ES модули.

Сега под флага --experimental-modules можете да укажете .mjs файл под main в package.json или изобщо да не указвате файлово разширение и алгоритъм за разрешаване на Node.js с търсене на .mjs файл преди .js файл.

Това ви позволява да извършвате двойно експортиране на CommonJS и ES модули за обратна съвместимост с по-стари версии на Node.js или за разработчици, които не искат, все още не използват ES модули.

Пример package.json, който експортира модули CommonJS и ES и използва Babel за транспилиране, би изглеждал така.

{                         
  "name": "dualexport-module",                         
  "version": "1.0.0",                         
  "description": "An example of a module exported with CJS and ESM.",                         
  "license": "MIT",                         
  "main": "index",                         
  "module": "index.mjs",                                               
  "engines": {"node": ">= 6.x"},
  "scripts": {
    "build": "npm run build:cjs && npm run build:mjs",
    "build:js": "babel src",
    "build:cjs": "npm run build:js -- --env-name cjs --out-dir dist/",                           
    "build:mjs": "npm run build:js -- --env-name mjs --out-dir dist/module && for file in $(find dist/module -name '*.js'); do mv \"$file\" `echo \"$file\" | sed 's/dist\\/module/dist/g; s/.js$/.mjs/g'`; done && rm -rf dist/module",                           },                           
  "dependencies": {},
  "devDependencies": {}                       
}

В този пример main просто указва index без разширение. Ако изпълните този пакет с флаг --experimental-modules, той първо ще търси версията .mjs, ако го изпълните без флага, той ще търси версията .js и напълно игнорира версията .mjs.

package.json също има 4 скрипта за изграждане

  • "build:js" изпълнява Babel срещу папката src
  • "build:cjs" изпълнява скрипта build:js, задавайки променлива на средата за компилиране на cjs, за да каже на Babel да експортира CommonJS модули към dist/
  • "build:mjs изпълнява скрипта build:js, задавайки променлива на средата за изграждане на mjs, за да каже на Babel да експортира ES модули за dist/module, след което изпълнява командата find, за да архивира всички .js файлове в dist/module и след това ги премества в dist, като същевременно ги преименува от .js на .mjs

Това използва Babel за извършване на изходния код на трансформация, написан с помощта на ES модули и използване на разширението .mjs към версия на CommonJS и ES модул, която може да се използва от всяка модулна система.

Конфигурацията .babelrc.js, която върви заедно с този package.json, изглежда така

module.exports = { 
  presets: [
    ['@babel/preset-env', { targets: ['node 6'] }],
  ],
  plugins: [
    ['@babel/plugin-transform-classes', { loose: true }],
    ['@babel/plugin-transform-destructuring', { loose: true }],
    ['@babel/plugin-transform-spread', { loose: true }],
    ['@babel/plugin-syntax-dynamic-import'],
    ['@babel/plugin-syntax-import-meta']
  ],
  env: { 
    cjs: { 
      presets: [
        ['@babel/preset-env', { modules: 'commonjs' }],
      ],
    },
    mjs: {
      presets: [
        ['@babel/preset-env', { modules: false }],
      ], 
    },
  },
};

Приставките Babel ['@babel/plugin-syntax-dynamic-import'] и ['@babel/plugin-syntax-import-meta'] са добавени специално за работа с новия синтаксис на модула ES за import.meta.url и import(). Проблемът е, че тези проблеми позволяват парсване на import.meta.url и import(), но не ги трансформират, така че ще внесат ES синтаксис във вашия CommonJS модул, който няма да успее. Заобиколното решение за това е да се създаде ръчно ES модул и CommonJS версия на модула, като се използва съответният разрешен синтаксис, а в конфигурацията за транспилация на CommonJS да се игнорира версията на ES модула при транспилиране. Разширяването на горната конфигурация ще изглежда така

module.exports = { 
  presets: [
    ['@babel/preset-env', { targets: ['node 6'] }],
  ],
  plugins: [
    ['@babel/plugin-transform-classes', { loose: true }],
    ['@babel/plugin-transform-destructuring', { loose: true }],
    ['@babel/plugin-transform-spread', { loose: true }],
    ['@babel/plugin-syntax-dynamic-import'],
    ['@babel/plugin-syntax-import-meta']
  ],
  env: { 
    cjs: { 
      presets: [
        ['@babel/preset-env', { modules: 'commonjs' }],
      ],
      ignore: [
        'src/functionWithImportMeta.mjs',
        'src/functionWithDynamicImport.mjs',
      ]
    },
    mjs: {
      presets: [
        ['@babel/preset-env', { modules: false }],
      ], 
    },
  },
};

Тази конфигурация на Babel също предполага, че сте написали кода си с помощта на .mjs модули и конфигурацията на transpile, задействана от променливата на средата esm, не трансформира синтаксиса на модула.

package.json запазва module като отделна входна точка поради причини за обратна съвместимост, въпреки че не е строго необходимо, тъй като Rollup може да разреши .mjs файлове с правилната конфигурация на rollup-plugin-node-resolve

Тези package.json и .babelrc.js се основават на конфигурацията, използвана за модула graphql npm, който от версия 14 нататък е написан с помощта на ES модули с разширение .mjs и доставя както .js, така и .mjs версии на модула.

Огромното предимство, което това дава на всеки, който използва graphql с пакет като Rollup, е, че можете да разпространявате модул с основната функционалност на GraphQL за GraphQL сървър на около 350KB спрямо приблизително 2.4MB на целия модул с двете версии .mjs и .js.

ES модули за CLI

Това зависи много от платформата, но общият съвет е да изчакате флагът --experimental-modules да бъде премахнат, преди да използвате ES модули в CLI.

Ако използвате MacOS и създадете изпълним файл в ./bin и го посочите правилно в package.json и използвате #!/usr/bin/env node--experimental-modules shebang за този файл, ще работи. Но ще работи само на MacOS, няма да работи на друга операционна система, базирана на Linux. Болката ме научи на това.

Причината е, че в системите на Linux той ще вземе само един параметър в shebang, битът #/usr/bin/env в него, и няма да прочете флага --experimental-modules, тъй като това е втори параметър към shebang, тъй като node е първият параметър.

Така че, освен ако не доставяте CLI само за MacOS, уверете се, че сте прехвърлили кода на CommonJS.

Полезни връзки за четене

Надяваме се, че намирате това за полезно и сте спасени от част от болката, през която преминах! Преминаването към ES модулите сега ще ви даде преднина, преди флагът --experimental-modules да бъде премахнат, и ще ви помогне с внедряването на спецификацията, тъй като потенциални грешки могат да бъдат намерени, преди флагът да бъде премахнат.

Ант Стенли