Node.js, TC-39 и модули

Перевод статьи James M Snell. Node.js, TC-39, and Modules. Опубликовано с разрешения автора.

На этой неделе (примечание переводчика: оригинальная статья написана 29 сентября 2016 года, но на момент перевода все описанные в ней проблемы являются актуальными) я первый раз участвовал во встрече TC-39. Для тех, кто не знает, TC-39 это комитет, определяющий развитие языка ECMAScript (или «JavaScript», более известное в широких кругах название). На встречах различные нюансы и детали языка JavaScript утверждаются (часто болезненно) и прорабатываются так, чтобы гарантировать, что язык программирования JavaScript продолжает развиваться и удовлетворять потребности разработчиков.

Причина моего присутствия на собрании TC-39 на этой неделе довольно проста: одна из новых функций языка JavaScript, определенных TC-39, а именно, модули, вызывает у команды Node.js некоторую озабоченность. Мы (и, в особенности, Брэдли Фариас — @bradleymeck в Твиттер) пытались выяснить, как наилучшим образом реализовать поддержку ECMAScript Modules (ESM) в Node.js, не вызывая лишних сложностей и не внося путаницу.

Проблема не в том, что мы не можем внедрить ESM в Node.js, следуя современной спецификации, а в том, что буквальное следование спецификации означало бы сокращение ожидаемой функциональности и принесло бы не лучший опыт для Node.js разработчиков. Мы хотим быть уверены, что реализация ESM в Node.js одновременно и оптимизирована, и удобна. Из-за сложности этих вопросов встреча лицом к лицу с членами TC-39 казалась наиболее продуктивным путем вперед. К счастью, я думаю, что мы достигли значительного прогресса.

Для лучшего понимания того, что именно нас волнует, уделите немного времени на мое объяснение, какие фундаментальные проблемы вызывают у нас наибольшую озабоченность.

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

И ещё одно уточнение: все здесь основано на моем собственном восприятии встречи с TC-39. Весьма вероятно, что обсуждение будет продолжать развиваться и, в конечном итоге, все будет выглядеть значительно иначе, чем то, что я здесь описываю. Я пишу это только для того, чтобы предоставить текущие состояние обсуждения.

ECMAScript Modules vs. CommonJS: Или… что такое модуль?

Node.js и TC-39 имеют очень разные представления о том, что такое «модуль», как он описывается, как загружается в память и используется.

Почти с самого начала в Node.js была своя модульная система, полученная из довольно слабо определенной спецификации под названием CommonJS.

Вкратце, символы, экспортированные одним JavaScript файлом (такие как функции и переменные), становятся доступными для использования другим файлом JavaScript. В Node.js это выполняется с помощью функции require(). Во время вызова вида require(“foo”) внутри Node.js выполняется очень специфическая последовательность шагов.

Первый шаг — разрешить(«разрезолвить») спецификацию «foo» в абсолютный путь к какому-то артефакту, который понимает Node.js. Этот процесс разрешения включает в себя несколько внутренних шагов, которые по существу обходят локальную файловую систему для поиска любого нативного модуля, JavaScript файла или JSON документа, соответствующему переданному «foo». Результатом шага разрешения является абсолютный путь к файлу, из которого артефакт, заданный параметром «foo», может быть загружен в Node.js и использован.

Загрузка полностью определяется тем, к какому типу относится абсолютный путь к файлу, созданный на шаге разрешения. Например, если он является собственным модулем Node.js, то загрузка включает динамическую привязку указанной библиотеки к текущему процессу Node.js. Если это JSON или JavaScript файлы, их содержимое считывается в память после того, как наличие файла будет подтверждено. Важно отметить, что загрузка (Loading) JavaScript не тоже самое, что исполнение (Evaluating) JavaScript. Первое относится строго к загрузке текстового содержимого файла в память, в то время как второе касается передачи этого текста в виртуальную машину JavaScript для разбора и исполнения.

Если загруженный артефакт является JavaScript файлом, то Node.js в настоящее время предполагает, что файл является модулем CommonJS. То, что Node.js делает дальше, является критически важным, и это часто не понимают разработчики, создающие Node.js приложения. Перед передачей загруженного текста JavaScript в виртуальную машину для исполнения, весь переданный код переносится внутрь функции.

Для примера, файл foo.js:

const m = 1;
module.exports.m = m;

Фактически исполняется в Node.js как функция вида:

function (exports, require, module, __filename, __dirname) {
const m = 1;
module.exports.m = m;
}

Затем Node.js использует среду выполнения JavaScript для исполнения этой функции. Различные global артефакты, такие как export, module, __filename и __dirname, которые обычно используются в модулях Node.js, фактически не являются глобальными значениями в традиционном смысле JavaScript. Вместо этого они являются функциональными параметрами, значения которых передаются Node.js в функцию-обёртку при вызове.

Эта функция-обертка по существу является фабричным методом. Объект exports — это обычный объект JavaScript. Функция-обертка присоединяет функции и свойства к этому объекту exports. Как только функция-обертка возвращается, объект exports кэшируется, а затем отдаётся как возвращаемое значение для метода require().

Ключевой концепцией для понимания этого конкретного обсуждения является то, что нет способа заранее определить, какие символы будут экспортированы модулем CommonJS до тех пор, пока функция-обёртка не будет исполнена.

Это критическая разница между CommonJS и ECMAScript модулями, потому как в противовес динамическому экспорту CommonJS, экспорт ESM определяется лексически. То есть символы, экспортируемые ESM, определяются, когда JavaScript код анализируется, до его фактического исполнения.

Например, рассмотрим следующий простой ECMAScript модуль:

export const m = 1;

Во время анализа этого кода (но до его фактического исполнения) создается внутренняя структура, называемая Module Record. Внутри этой структуры, помимо других ключевых кусочков информации, находится статический список символов, экспортируемый модулем. Они идентифицируются парсером, который ищет использование ключевого слова export. Из-за отсутствия лучшего терма, символы в Module Record по сути указывают на вещи, которые еще не существуют. Только после того, как будет создана эта Module Record, будет фактически исполнен код модуля. Хотя здесь много скрытых деталей, которые я замалчиваю, ключевым моментом является то, что определение, какие символы экспортируются с помощью ESM, происходит перед исполнением.

Когда код использует ECMAScript модуль, он использует оператор import:

import {m} from “foo”;

Этот код попросту говорит «Я собираюсь использовать символ m, экспортируемый модулем ‘foo’».

Этот оператор представляет собой лексический оператор, используемый для установления связи между импортирующим скриптом и модулем «foo» при анализе кода. Как указывает современная спецификация ECMAScript модулей, эта связь должна быть проверена до какого-либо исполнения кода — это означает, что реализация должна убедиться, что символ «m» действительно экспортируется «foo» перед исполнением JavaScript файла.

Для тех, кто знаком со строго типизированными объектно-ориентированными языками программирования, такими как Java или C++, это должно быть хорошо знакомо, потому что это аналогично работе с объектом через интерфейс. Экспортируемые символы проверяются и связываются перед исполнением, и ошибки будут вызваны только если символы на самом деле не исполняются на этапе выполнения.

Для Node.js возникает проблема, когда «foo» не является ESM с лексически определенным набором экспортов, а является модулем CommonJS с динамически определенным набором экспортов. Конкретно: когда я говорю import {m} from “foo”, ESM в настоящее время требует, чтобы можно было определить, что m экспортируется “foo” до исполнения. Но, как мы уже видели, поскольку «foo» является CommonJS модулем, невозможно определить, что m экспортируется до окончания парсинга. Конечным результатом является то, что реализация именованных экспортов и импортов из CommonJS (критически важная особенность ECMAScript модулей) просто была бы невозможна в соответствии с текущей спецификацией ESM.

Это не очень хорошо, так что мы (люди из Node.js) обратились к TC-39, чтобы узнать, могут ли быть сделаны некоторые изменения в спецификации. Сначала мы немного опасались, но оказалось, что TC-39 очень заботится о том, чтобы Node.js мог эффективно внедрять ESM, и рассматривает ряд изменений спецификации, чтобы сделать работу лучше в среде Node.js.

Порядок исполнения

Предлагается одно конкретное изменение — учет динамически определенных модулей. По существу, когда я импортирую {m} из «foo», и оказывается, что «foo» не является ESM с лексически определенным экспортом, вместо того, чтобы бросать ошибку и останавливать (что и делает сейчас спецификация) процесс исполнения, можно было бы поместить «foo» и импортирующий скрипт в некое промежуточное состояние ожидания, отложив проверку импортированных символов до тех пор, пока не будет исполнен код динамического модуля. После того, как будет произведено исполнение, создание Module Record для CommonJS модуля может быть завершено и проверены импортированные ссылки. Эта модификация стандарта модуля ECMAScript позволяет именованным экспортам и импортам из модулей CommonJS “просто работать”. (Хотя, есть несколько ошибок в отношении некоторых случаев циклической зависимости).

Давайте рассмотрим несколько примеров.

У меня есть приложение, зависящее от ESM A, который в свою очередь зависит от CommonJS модуля B.

Код моего приложения (myapp.js):

const foo = require('A').default
foo()

Код модуля A:

import {log} from "B"
export default function() {
log('hello world')
}

Код модуля B:

module.exports.log = function(msg) {
console.log(msg);
}

При запуске node myapp.js, вызов require(‘A’) обнаружил бы, что загружаемая зависимость является ESM (далее будет рассказано, как это обнаружение, вероятно, будет сделано). Вместо загрузки модуля с использованием функции-обёртки, которая в настоящее время используется для CommonJS модулей, Node.js будет использовать спецификацию модуля ECMAScript для синтаксического анализа, инициализации и исполнения «A». Когда код для «A» разбирается и создаётся Module Record, выясняется, что «B» не является ESM, поэтому этап валидации, проверяющий, что log экспортируется «B», будет отложен. Загрузчик ESM начнет свою фазу исполнения. Сначала был бы исполнен «B», через вызов существующей функции-обертки CommonJS, результаты которой будут возвращены загрузчику ESM для завершения построения Module Record. Вторым шагом он будет исполнять код для «A», используя эту завершенную Module Record.

Для подавляющего большинства случаев использования эта загрузочная модель должна работать очень хорошо. Сложность начинается там, где возникает циклическая зависимость между модулями. Любой, кто ранее использовал CommonJS модули с циклическими зависимостями, знает, что есть некоторые довольно странные случаи зависимости от порядка, в котором загружаются эти модули. Ряд похожих проблем будет и в случае, когда существует циклическая зависимости между CommonJS модулями и ESM.

Код myapp.js остается таким же, как и выше. Однако A зависит от B, который, в свою очередь, зависит от A.

Код модуля A:

const b = require('B')
exports.b = b.foo()
exports.a = 1

Код модуля B:

import {a} from "A"
export const foo () => a

Это довольно надуманный случай, созданный только для того чтобы проиллюстрировать проблему. Циклической зависимость здесь становится практически невозможной для исполнения, потому что, когда ESM «B» связан и исполнен, символ «a» еще не объявлен и не экспортирован CommonJS модулем «A». Этот тип случая, скорее всего, должен рассматриваться как ошибка ссылки (reference error).

Однако, если мы изменим код для B:

import A from “A”
export foo () => A.a

Циклическая зависимость работает, потому что, когда модуль CommonJS импортируется с помощью инструкции import, объект module.exports становится экспортом по умолчанию. Код ESM в этом случае связывается со стандартным экспортом модуля CommonJS, а не с символом.

Иными словами, именованные импорты из модуля CommonJS будут работать только в том случае, если между модулем ESM и модулем CommonJS не существует циклической зависимости.

Еще одно ограничение, вызванное различиями между CommonJS и ESM, заключается в том, что любая мутация в экспорт CommonJS после первоначального исполнения не будет доступна как именованный импорт. Например, предположим, что ESM A зависит от модуля CommonJS B.

Предположим, что код для B:

module.exports.foo = function(name, key) {
module.exports[name] = key
}

Когда «A» импортирует «B», единственными экспортированными символами, которые будут доступны для использования в качестве именованных импортов, будет символ по умолчанию и «foo». Ни один из символов, добавленных в module.exports при вызове функции foo, не будет доступен как именованный импорт. Однако они будут доступны через экспорт по умолчанию. Следующее, например, должно работать просто отлично:

import {foo} from “B”
import B from "B"
foo("abc", 123)
if (B.abc === 123) { /** ... **/ }

require() vs import

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

Другими словами, если у меня есть CommonJS модуль A, следующий код вызовет ошибку, потому что оператор import не может быть вызван из CommonJS:

const b = require(‘B’)
import c from "C"

Если вы работаете в CommonJS модуле, правильным способом загрузки и использования ESM будет использование require:

const b = require(‘B’)
const c = require('C')

Изнутри ESM, require() будет доступен, только если он специально импортирован. Точная спецификация, используемая для import require(), пока не определена, но по существу это будет что-то вроде:

import {require} from “nodejs”
require(“foo”)

Однако, поскольку можно будет напрямую импортировать из CommonJS модуля, для использования require() должно быть очень мало причин.

В качестве побочного замечания: у пользователей Node.js был ряд других проблем, например загрузка ESM всегда должна быть асинхронной, что потребовало бы использования Promises на всем графе зависимостей. TC-39 заверил нас (и изменения, описанные выше, позволяют это), что загрузка не обязательно должна быть асинхронной. Это очень хорошая новость.

Что насчёт import()

У TC-39 есть предложение, в котором будет введена новая функция import(). Использование этой функции было бы совсем другим, чем использование оператора import, показанное в приведенных выше примерах. Рассмотрим следующий пример:

import {foo} from “bar”
import(“baz”).then((module)=>{/*…*/}).catch((err)=>{/**…*/})

Первый оператор импорта является лексическим. Как описано выше, он обрабатывается и проверяется при анализе кода. Функция import(), с другой стороны, обрабатывается при исполнении. Она также импортирует модуль ESM (или CommonJS), но, как и метод require() в Node.js, в настоящее время полностью работает во время исполнения. Однако, в отличие от require(), import() возвращает Promise, позволяя (но не требуя) сделать загрузку базового модуля полностью асинхронной.

Поскольку функция import() возвращает Promise, возможны такие вещи, как await import ("foo"). Однако важно отметить, что import() далек от завершения в TC-39. Также не совсем ясно, сможет ли Node.js полностью реализовать асинхронную загрузку с помощью функции import().

Обнаружение CommonJS vs. ESM

Независимо от того, использует ли код require(), import или import() для загрузки модулей, необходимо иметь возможность определять тип импортируемого модуля, чтобы Node.js мог знать соответствующий способ загрузки и обработки.

Традиционно реализация функции require() в Node.js основывалась на расширениях файлов. Например, файлы *.node загружаются как нативные модули, файлы *.json просто передаются через JSON.parse, а файлы *.js обрабатываются как модули CommonJS.

С внедрением ESM потребовался механизм для разграничения CommonJS модулей и ESM. Было предложено несколько подходов.

Одно из предложений — убедиться, что файл JavaScript может быть однозначно распарсен как ESM или что-то еще. Другими словами, когда я разбираю немного JavaScript, факт, что это ESM или нет, должен быть очевиден в результате операции парсинга. Такой подход называется «недвусмысленной грамматикой». К сожалению, достаточно сложно добиться того, чтобы это могло работать.

Еще одно предложение, которое было рассмотрено, — это добавление метаданных в файл package.json. Если какое-либо определенное значение содержится в файле package.json, модуль загружается как ESM, а не как модуль CommonJS.

Третье предложение — использовать новое расширение файла (*.mjs) для идентификации модулей ECMAScript. Этот подход наиболее точно соответствует тому, что уже делает Node.js.

Например, предположим, что у меня есть скрипт приложения myapp.js и модуль ESM, определенный в отдельном файле.

Используя однозначный подход к грамматике, Node.js должен иметь возможность анализировать JavaScript во втором файле и автоматически определять, что он имеет дело с ESM. При таком подходе файл ESM может использовать расширение файла *.js, и все будет просто работать. Однако, как я уже сказал, однозначная грамматика является довольно сложной задачей, чтобы ее использовать, и существует ряд крайних случаев, которые затрудняют решение задачи.

Используя подход package.json, ESM либо должен поставляться в своем собственном каталоге (по сути это полноценный пакет), либо в корне должен быть файл package.json, содержащий метаданные, указывающие, что JavaScript, содержащий ESM файл, по сути является ESM. Этот подход менее идеален из-за того, что требуется дополнительная обработка package.json.

При использовании подхода с *.mjs расширением, ESM код помещается в файл как foo.mjs. После того, как Node.js разрезолвит спецификатор в абсолютное имя файла, будет выполнен анализ расширения файла, так же, как в настоящее время уже происходит с нативными модулями и файлами JSON. Если Node.js видит расширение файла *.mjs, то файл загружается и обрабатывается как ESM. Файлы с расширением * .js будут загружены как CommonJS модули.

На текущий момент расширение файла *.mjs выглядит как наиболее жизнеспособный вариант, если, конечно, не получится обработать все различные пограничные случаи для однозначной грамматики.

Проблемы с идемпотентностью

Вообще говоря, вызов require(‘foo’) несколько раз вернет точно такой же экземпляр модуля. Однако возвращаемый объект является изменяемым, и модули могут модифицировать другие модули либо с помощью “monkeypatching” отдельных методов и символов, либо полностью заменяя функциональность. В настоящее время такая вещь чрезвычайно распространена в экосистеме Node.js.

Например, предположим, что myapp.js имеет две зависимости A и B. Обе являются модулями CommonJS. A также зависит от B и расширяет его.

Код myapp.js:

const A = require('A')
const B = require('B')
B.foo()

Код модуля A:

const B = require(‘B’)
const foo = B.foo
B.foo = function() {
console.log('intercepted!')
foo()
}

Код модуля B:

module.exports.foo = function() {
console.log('foo bar baz')
}

В этом случае require(‘B’) при вызове внутри A возвращает другой результат, чем require(‘B’), вызываемый внутри myapp.js.

С модулями ECMAScript этот тип манкипатчинга модулей не так прост. Причина двоякая: A) импорт линкуется до выполнения, B) импорт должен быть идемпотентным — возвращать точно такой же неизменяемый набор символов каждый раз, когда импорт вызывается в данном контексте. В практическом смысле это означает, что ESM A не может легко манкипатчить ESM B при использовании именованного импорта.

Эффект этого правила эквивалентен следующему коду в myapp.js

const B = require('B')
const foo = B.foo
const A = require('A')
foo()

Здесь модуль A все еще модифицирует foo в B, но, поскольку перед этой модификацией была сохранена ссылка на foo, вызов foo() вызывает исходную функцию, а не модифицированную. Внутри ESM нет способа импортировать B, который возвращал бы модификации, сделанные в A.

Существует множество сценариев, в которых это правило идемпотентности вызывает проблемы. Примерами могут служить мокинг(mocking), APM и шпионаж(spying) в целях тестирования. К счастью, существует множество способов, решающих это ограничение. Один из подходов заключается в добавлении перехватчиков в фазу загрузки, позволяющих обернуть экспорт ESM. Другое — для TC-39 разрешить замену загруженных ESM. Здесь рассматривается несколько механизмов. Хорошей новостью является то, что, хотя перехват ESM будет отличаться от перехвата модулей CommonJS, он всё же будет возможен.

Многое нужно сделать

Есть тонны дополнительной работы, которую нужно сделать, и все, что обсуждалось выше, еще не окончательно. Есть много деталей для проработки, и, в итоге, финальный вариант может выглядеть совсем иначе. Важно то, что Node.js и TC-39 работают вместе над решением проблем, что является отличным и очень приятным шагом в правильном направлении.


Читайте нас на Медиуме, контрибьютьте на Гитхабе, общайтесь в группе Телеграма, следите в Твиттере и канале Телеграма, скоро подъедет подкаст. Не теряйтесь.

Like what you read? Give Andrey Melikhov a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.