Основы модуля JavaScript

JavaScript

В нашей повседневной разработке мы в основном используем модули, будь то CommonJS из Node.js, или модули ES из ES6, или даже более старые AMD, UMD и т. д. Внедрение модулей многое привнесло в нашу разработку. много проблем для нас. Но что именно он решает?

Давайте начнем с истории разработки модулей JavaScript, до последних модулей ES и переосмыслим модули.

1. История модулей JavaScript

1.1 Ванильный JS (1995~2009)

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

Так что же означает техническое обслуживание? Относится к сохранению переменных. Потому что при непрерывной итерации проекта совместная разработка нескольких человек неизбежна. На заре JS все переменные записывались в глобальной области видимости, так в чем может быть проблема? Перезапись, изменение и удаление переменных — очень неприятная проблема. Очень вероятно, что вдруг однажды ваша функция сообщит об ошибке из-за того, что одна из ваших переменных была удалена другим разработчиком.

Таким образом, первоначальная цель введения модулей — понять управление переменными. Конечно, есть и другие преимущества, такие как инкапсуляция кода, повторное использование и так далее.image.pngИтак, в первые дни, без поддержки стандарта модулей, как разработчики добивались эффекта подобных модулей? Есть 2 способа.

1.1.1 Буквенный образец объекта

Используйте встроенные объекты JS для управления переменными:

function Person(name) {
  this.name = name;
}

Person.prototype.talk = function () {
  console.log("my name is", this.name);
};

const p = new Person("anson");
p.talk();

так что черезnew PersonСпособ управления переменными внутри объекта.

1.1.2 IIFE (Выражение немедленно вызываемой функции)

Мы знаем, что в JavaScript есть понятие области действия (Scope) Переменные внутри области видимости видны только внутри области видимости. До ES6 было только два типа областей видимости, а именно:

Управление переменными упоминалось выше, поэтому определенно лучше контролировать область действия переменных как можно меньше, поэтому нет сомнений, что запись переменных в функциях — лучший способ. Однако это поднимает другой вопрос, как должны быть предоставлены переменные в функции для внешнего использования?image.pngВ первые дни нет хорошего решения этой проблемы, вы должны предоставить переменную глобальной области видимости, такой как классический jQuery.image.pngИ разработчики обычно используют IIFE для достижения:

// lib.js
(function() {
  const base = 10;
  this.sumDOM = function(id) {
    // 依赖 jQuery
    return base + +$(id).text();
  }
})();

импортировать в HTMLlib.js:

// index.html
<html>
  <head>
    <script src="/path/to/jquery.js"></script>
    <script src="/path/to/lib.js"></script>
  </head>
  <body>
    <script>
      window.sumDOM(20);
    </script>
  </body>
</html>

Но у IIFE есть несколько проблем:

  • по крайней мере одна переменная загрязняет глобальную область видимости;
  • Зависимости между модулями неоднозначны и неоднозначны (lib.jsНеспособность интуитивно увидеть зависимостиjquery.js);
  • Порядок загрузки не может быть гарантирован, и его нелегко поддерживать (необходимо убедиться, чтоjquery.jsДолжен бытьlib.jsдо завершения загрузки, иначе будет сообщено об ошибке).

image.pngПоэтому JavaScript очень нуждается в модульном стандарте для решения вышеуказанных проблем.

1.2 Несобственный формат модуля и загрузчик модуля (2009–2015)

Поскольку модули могут решить для нас вышеуказанные проблемы, разработчики пытаются самостоятельно разработать некоторые неродные стандарты модулей, такие какCommonJS,AMD (Asynchronous Module Definition),UMD (Universal Module Definition), а затем сопоставьте соответствующий загрузчик модулей, например cjs-loader,RequireJS,SystemJSЭффект модуля может быть достигнут.Давайте рассмотрим несколько популярных ненативных стандартов модулей.

1.2.1 CommonJS (CJS)

В 2009 году Кевин, инженер из Mozilla, предложил создать стандартный модуль CommonJS для JavaScript, работающий вне браузера, который в основном используется на стороне сервера, напримерNode.js. Поскольку эффект использования хороший, он также используется при разработке модулей браузера, но поскольку браузер не поддерживает CommonJS, код необходимо преобразовать в ES5 через транспилятор, такой как Babel, для запуска в браузере.

CommonJS характеризуется использованиемrequireимпортировать зависимости,exportsдля экспорта интерфейса.

// lib.js
module.exports.add = function add() {};

// main.js
const { add } = require("./lib.js");
add();

1.2.2 AMD

Поскольку CommonJS изначально предназначен для применения на стороне сервера, загрузка и выполнение модулей также синхронны (поскольку ввод-вывод локальных файлов выполняется очень быстро). Однако синхронный метод не является дружественным для браузера, так как файлы модуля загружаются по сети в браузере, и при загрузке модуля блокируется один поток, что недопустимо. Итак, в 2011 году кто-то предложил AMD, который совместим с CommonJS и поддерживает асинхронную загрузку.image.pngAMD характеризуется использованиемdefine(deps, callback)для асинхронной загрузки модулей.

// Calling define with a dependency array and a factory function
define(['dep1', 'dep2'], function (dep1, dep2) {
    //Define the module value by returning a value.
    return function () {};
});

1.2.3 UMD

Из-за популярности CommonJS и AMD некоторые люди предложили стандарт модуля UMD, который совместим с форматами AMD, CommonJS и Global Variable, обнаруживая различные характеристики среды.

// UMD
(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(['jquery', 'underscore'], factory);
  } else if (typeof exports === 'object') {
    // Node, CommonJS-like
    module.exports = factory(require('jquery'), require('underscore'));
  } else {
    // Browser globals (root is window)
    root.returnExports = factory(root.jQuery, root._);
  }
}(this, function ($, _) {
  //    methods
  function a(){};    //    private because it's not returned (see below)
  function b(){};    //    public because it's returned
  function c(){};    //    public because it's returned
  //    exposed public methods
  return {
    b: b,
    c: c
  }
}));

Поскольку UMD имеет хорошую совместимость, многие библиотеки предоставляют версии UMD.

1.3 ESM (с 2015 г. по настоящее время)

С постепенной стандартизацией и стандартизацией ECMAScript ES6 (ES 2015) был наконец выпущен в 2015 году. В этом обновлении версии был сформулирован стандарт модуля JS, а именно модули ES, и использовались модули ES.importобъявить зависимости,exportОбъявите интерфейс.

// lib.mjs
const lib = function() {};
export default lib;

// main.js
import lib from './lib.mjs';

По состоянию на 2018 год большинство основных браузеров уже поддерживают модули ES, которые передаются в HTML как<script>добавлено вtype="module"атрибут для объявления типа ESM.

Есть несколько предостережений по использованию модулей ES в HTML:

  • Строгий режим включен по умолчанию"use strict";
  • По умолчаниюdeferвыполнение нагрузки;
  • включено по умолчаниюCORSперекрестный домен;
  • В одном и том же документе один и тот же модуль будет загружен и выполнен только один раз;

Благодаря выпуску модуля ES модуля стандарта модуля ES окружающая экосистема JS медленно движется в направлении модулей ES. Node.js Добавлена ​​поддержка модулей ES в 14.x; модуль Bundler какRollupОба используют модули ES в качестве стандарта модуля по умолчанию; иDeno,TypeScriptи т.п.

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

2. Как работает ЕСМ

Прежде чем представить, как работают модули ES, давайте разберемся с некоторыми понятиями.

Объем модуля

Все мы знаем, что в ES6 появилась новая область видимости:Область блока, но есть еще одинОбъем модуля, используемый для управления переменными внутри модуля. В отличие от области действия функции, в области модуля вам необходимо явно указать экспортируемую переменную, также известную какexport; и вам нужно явно указать импортируемую переменную, также известную какimport. Таким образом, вам больше не нужно загрязнять глобальную область видимости.image.pngПоскольку зависимости между модулями явные и понятные, вам больше не нужно беспокоиться о том, сообщит ли ваш модуль об ошибке из-за того, что jquery не загружен предварительно, потому что это подскажет вам на этапе компиляции.

Запись модуля

Когда мы используем модули, мы на самом деле строимграфик зависимости модуля. Вы передаете файл модуля в качестве точки входа (Entry Point), механизм JSimportОператор объявления рекурсивно запрашивает, загружает и анализирует подмодули.

Здесь main.js используется как точка входа, а затем зависит от двух других подмодулей counter.js и display.js.image.pngРазбор относится к разбору файла модуля в структуру данных.Module Record, Module Record записываетimport,export,codeи другую информацию для последующего связывания и оценки.image.png

Запись среды модуля

Когда JS двигатель должен выполнить объем, он создастEnvironment Record(Запись среды), связанная с этой областью, используемая для хранения переменных и функций в пределах области. В дополнение к сохранению объявлений переменных верхнего уровня в модуле запись среды модуля также сохраняет объявления переменных в модуле.importсвязывать переменные.

Environment Record имеет очень важное поле[[OuterEnv]]Используется для указания на внешнюю запись среды, которая очень похожа на цепочку прототипов и заканчиваетсяnull.image.pngКак показано на фиг.lib-a.jsа такжеlib-b.jsявляются двумя независимыми модулями, записи среды — это ModuleEnvironmentRecord-lib-a и ModuleEnvironmentRecord-lib-b соответственно.[[OuterEnv]]Все указывают на GlobalEnvironmentRecord, который реализует разделение переменных между модулями.

Рабочий процесс модулей ES можно разделить на 3 этапа:

  1. Construction- Запрашивать, загружать и анализировать модули как запись модуля;
  2. Linking- Создание записей среды и корреляция между модулямиimport,exportсвязь;
  3. Evaluation- Выполнение кода верхнего уровня и заполнение записи среды.

Все говорят, что ЭСМ выполняется асинхронно, потому что эти три этапа независимы и отделимы, но это не значит, что он должен быть реализован асинхронно, он может выполняться и синхронно, например, в CJS он синхронен для выполнения.

Потому чтоESM specВ нем рассказывается только о том, как анализировать запись модуля, как выполнять связывание между модулями, как выполнять оценку модуля. Но нет упоминания о том, как получить файл модуля, который загружается разными загрузчиками в разных операционных средах. Для браузеров вHTML specМетод асинхронной загрузки используется в .image.pngЗагрузчик отвечает не только за загрузку модулей, но и за вызов методов ESM, таких какParseModule,Module.Link,Module.Evaluate. Загрузчик управляет порядком выполнения этих методов.image.png

2.1 Construction

Этап строительства в основном делится на 3 этапа:

  1. Найдите путь к модулю, также называемый разрешением модуля;
  2. Получить файлы модулей (скачать из сети или загрузить из файловой системы);
  3. Разобрать файл модуля как запись модуля;

Загрузчик отвечает за адресацию и загрузку модулей. Сначала нам нужен входной файл, который в HTML обычно представляет собой<script type="module">тег для обозначения файла модуля (обычно используется в Node.*.mjsдля представления файла модуля или изменения package.json"type": "module")image.pngКак модуль находит следующий подмодуль? Это требуетimportзаявление, вimportЧасть оператора объявления называетсяmodule specifier, который сообщает загрузчику, как найти адрес следующего подмодуля.image.pngУведомлениеmodule specifierСуществуют разные методы интерпретации в разных средах (браузер, узел), и процесс интерпретации также называетсяmodule resolution. Например, в браузерах поддерживаются только URL-адреса.module specifier; в то время как Node также поддерживаетbare module specifier, что мы обычно пишемimport moment from "moment";. W3C также продвигаетсяimport mapsфункции для поддержкиbare module specifier.

Вы можете узнать, от каких подмодулей зависит текущий модуль, только после синтаксического анализа текущего модуля как записи модуля.Затем вам нужно разрешить подмодули, получить подмодули и разобрать подмодули, и продолжать зацикливать этот процесс разрешение -> выборка -> разбор, как показано на следующий рисунок:image.pngЕсли весь процесс, основной поток ждет загрузки каждого файла модуля, то зависнет вся очередь задач. Поскольку ваша загрузка в браузере медленная, загрузка модуля разделена на 3 этапа в спецификации ESM.

Разделение этапов также является основным отличием CJS от ESM, поскольку CJS загружает все локальные файлы, поэтому, естественно, нет необходимости учитывать проблемы ввода-вывода. Это означает, что Node заблокирует основной поток для загрузки этого модуля, а затем синхронно выполнит связывание и оценку.image.pngПриведенный выше код выполняется дляrequire, а затем вам нужно загрузить подмодуль, немедленно переключиться на подмодуль выборки, а затем продолжить выполнение подмодуля оценки, все это происходит синхронно. Вот почему в Node вы можетеmodule specifierпеременные используются в .

Но для ESM все по-другому, потому что ESM необходимо построить весь граф зависимостей модулей перед выполнением Evaluation, включая разрешение, выборку и анализ всех модулей. Итак, ЕСМ вmodule specifierПеременные нельзя использовать в .

Но у этого также есть преимущество, то есть Rollup, Webpack и другие сборщики модулей могут выполнять статический анализ ESM во время компиляции и выполнять встряхивание дерева для удаления мертвого кода.image.pngЕсли вы действительно хотите использовать переменные в ESM какmodule specifier, то вы можете использоватьdynamic import import(${path}/foo.js)чтобы импортировать новый модуль, новая запись модуля автоматически создаст новый график зависимости модуля.image.pngХотя новый график зависимости модуля не создает новую запись модуля, использование загрузчикаModule MapМодуль Record для глобального отслеживания, кэширования. Это гарантирует, что файл модуля извлекается только один раз. Каждая глобальная роль будет иметь отдельную карту модуля домена, которая также будет независима от каждой карты модуля iframe.

Думайте о карте модуля как о простом объекте карты ключ/значение. Например, модуль, загружаемый в первый раз, будет отмечен статусомfetching, затем сделайте запрос и продолжите получение следующего файла модуля.image.pngМы можем понять взаимосвязь между Document и Module Map, взглянув на следующую диаграмму:module-map.pngМежду документом и картой модуля существует отношение один к одному: main.js имеет свою собственную карту модуля, базовые iframe-a и iframe-b также имеют свою собственную карту модуля. Таким образом, несмотря на то, что адреса их внутренних зависимостей одинаковы, они все равно будут неоднократно запрашивать загрузки.image.pngХорошо, после загрузки файла механизм JS разберет файл модуля в запись модуля, которая сохранит импорт, экспорт, код и другую информацию в модуле.image.pngЗапись модуля будет кэширована в карте модуля.image.png

2.2 Linking

После того, как все записи модулей проанализированы, движку JS необходимо связать все модули. Механизм JS берет запись модуля файла ввода main.js в качестве отправной точки, рекурсивно связывает модули в порядке глубины и создает запись модуля для каждой записи модуля.Module Environment Record, используемый для управления переменными в записи модуля.

Как это связано? Движок JS создает запись среды модуля для всех подмодулей counter.js и display.js в текущем модуле main.js и записывает подмодули в подмодуль.exportПеременные обязаны выделять для них место в памяти.image.pngЗатем управление возвращается на предыдущий уровень, то есть текущий модуль main.js, к main.jsimportПеременные связаны, обратите внимание здесь в main.jsimportМесто в памяти, указанное в count.js, display.jsexportЯчейки памяти, на которые указывают переменные, согласованы, таким образом связывая отношения между родительским и дочерним модулями.image.pngНо в CJS все по-другому. В CJS всеmodule.exportsобъект копируется.image.pngЭто означает, что модуль экспорта изменяет значение переменной позже, а модуль импорта не обновляет его автоматически.

Вместо этого ESM использует технику, называемую живыми привязками. Родительский и дочерний модули указывают на одно и то же место в памяти, поэтому, когда модуль экспорта изменяет значение переменной, модуль импорта будет немедленно обновлен.

Следует отметить, что толькоexportingмодуль может быть правильнымexportизменить значение переменной,importingмодуль не может быть изменен. Можно сказатьexportingМодуль имеет права на чтение и запись, в то время какimportingМодули имеют доступ только для чтения.image.pngОдной из причин использования живых привязок является то, что они помогают связать все модули вместе без запуска какого-либо кода. Это полезно, когда мы сталкиваемся с циклическими зависимостями в Evaluation.

Теперь приступим к выполнению кода и заполнению вышеуказанной памяти.

2.3 Evaluation

После того, как модуль связан друг с другом, двигатель JS реализован через модули в коде верхнего уровня, так что выimport,exportОператоры не могут быть написаны внутри функций.image.pngНо выполнение кода верхнего уровня может иметь побочные эффекты, такие как отправка сетевых запросов, поэтому вы определенно не хотите, чтобы один и тот же модуль выполнялся несколько раз. Вот почему карта модуля используется для глобального кэширования записи модуля.evaluated, то следующее выполнение будет автоматически пропущено, что гарантирует, что модуль будет выполнен только один раз. Как и этап связывания, это также обход в глубину записи модуля.

Проблема цикла зависимости, упомянутая в конце Linking, обычно представляет собой сложный цикл зависимости Вот простой пример для иллюстрации:image.pngMain.js и counter.js циклически зависят друг от друга. Давайте сначала рассмотрим проблему цикла зависимостей в CommonJS:image.pngПервый main.js выполняется дляrequire("./counter.js"), а затем введите counter.js для выполнения, чтобы получить main.js вmessage, и это времяundefined, поэтому counter.js скопированundefined.image.pngПосле выполнения counter.js (обратите внимание, что мы установили setTimeout в конце, чтобы увидеть, будет ли сообщение автоматически обновляться), элемент управления возвращается к main.js для продолжения выполнения кода и, наконец,messageназначить как"Eval complete".image.pngНо поскольку в CommonJSimportЗначение переменной верноexportКопия значения переменной, поэтому в counter.jsmessageи не будет обновляться.image.pngМодули ES используют живые привязки, поэтому они будут автоматически обновляться в counter.js.messageценность .

3. Смешение CJS и ESM

По историческим причинам большинство пакетов в npm написано на CJS, но с появлением ESM разработчики начали использовать ESM для написания модулей. Чтобы максимизировать повторное использование пакетов в npm, неизбежно импортировать CJS в ESM. И из-за различий в способах загрузки модулей CJS не может импортировать ESM, а ESM может импортировать CJS.

Хотя ESM может импортировать CJS, существуют некоторые ограничения в использовании.

3.1 ESM поддерживает только импорт CJS по умолчанию

ESM поддерживает CJSdefault import, но не поддерживаетnamed import,который:

import pkg from 'lib.cjs'; // work
import { fn1, fn2 } from 'lib.cjs'; // error

Зачем?结合上面的 ESM 工作原理,ESM 是对模块变量进行静态分析的,而 CJS 的模块变量是动态计算的。所以 ESM 还在还没执行代码的第一阶段 Construction,又如何能计算出 CJS 的模块变量呢?

Но в версии Node 14.13.0 Node.Добавлена ​​поддержка именованного экспорта CJS, который поддерживает большинство модулей CJS.image.pngПочему большинство? Сделан официальный сайт Nodeиллюстрировать:

The detection of named exports is based on common syntax patterns but does not always correctly detect named exports. In these cases, using the default import form described above can be a better option.

Обратите внимание на обнаружение ключевого слова, полученное в результате анализа текста на основе синтаксиса модуля CJS.named exports, поэтому его правильность не гарантируется. В этом случае используйтеdefault importэто лучший выбор.

Node использует метод, называемыйcjs-module-lexerБиблиотека синтаксического анализа модуля CJS выполняет статический синтаксический анализ содержимого модулей CJS и поддерживает только простыеexportsписьменность, напримерexports.name = ...илиmodule.exports = require('...'), вот пример, который можно проанализировать:

// correct.cjs
exports.a = 1;
exports.b = 2;
if (Math.random() > 0.5) {
  exports.c = 3;
}

// main.mjs
import { a, b, c } from './correct.cjs';
// 执行 main.mjs 无异常

Примеры, которые невозможно проанализировать:

// wrong.cjs
// 使用 tmp 来设置 exports
const tmp = exports;
tmp.a = 1;
tmp.b = 2;
if (Math.random() > 0.5) {
  tmp.c = 3;
}

// main.mjs
import { a, b, c } from './wrong.cjs';
// 执行 main.mjs 报错

Выполнение приведенного выше примера сообщит о следующей ошибке:

file:///E:/javascript-modules/esm-app/dual/index.mjs:1
import { a, b, c } from "./lib.cjs";
         ^
SyntaxError: Named export 'a' not found. The requested module './lib.cjs' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:

import pkg from './lib.cjs';
const { a, b, c } = pkg;

Вы можете подумать, кто бы это написал, к сожалению, довольно много известных библиотек так написано, напримерlodash,chalk.image.png image.pngдля неанализируемогоnamed exportsмодуль, Node даст нам предложения по ошибкеdefault import, а затем деконструированный, есть еще одна строка кода:

CommonJS modules can always be imported via the default export, for example using:

import pkg from './lib.cjs'; const { a, b, c } = pkg;

3.2 ИспользованиеESM WrapperРеализовать именованный экспорт для CJS

Если мы действительно хотим использовать в ESMnamed exportsCJS, то мы можем предоставить ESM Wrapper для CJS, который должен инкапсулировать слой кода в соответствии с сообщением об ошибке Node, использовать импорт по умолчанию для CJS, а затем повторно экспортировать указанные в нем переменные один раз:

// lib.cjs
const tmp = exports;
tmp.a = 1;
tmp.b = 2;
if (Math.random() > 0.5) {
  tmp.c = 3;
}

// lib-esm-wrapper.mjs
import lib from "./lib.cjs";
export const { a, b, c } = lib;

// main.mjs
import { a, b, c } from "./lib-esm-wrapper.mjs";
console.log(a);
console.log(b);
console.log(c);

Поэтому, когда пользователям нужны модули ESM, но в настоящее время доступны только модули CJS, они могут рассмотреть возможность написания простой оболочки ESM для упаковки.

4. Напишите библиотеку, которая поддерживает несколько форматов модулей.

Иногда мы находимся в подготовке библиотеки, мы надеемся, что библиотека поддерживает как форматы CJS, так и ESM, мы, вероятно, покидают .jsonmoduleЭто поле довольно знакомо. Это обычное поле. Оно в основном используется в модулях Module Bundler, таких как Webpack и Rollup, для проверки того, поддерживает ли пакет ESM.Node не распознает это поле.

В Node 12+ мы можем использовать package.jsonexportsполя для поддержки различных файлов модулей для конфигурации пакета, Node будет использоватьimportещеrequireДля загрузки верните соответствующий файл модуля:

// package.json
{
  "exports": {
   	"import": "./lib.mjs",
    "require": "./lib.cjs" 
  }
}

// app.mjs
import { value } from "lib";
console.log("value from mjs", value);

// app.cjs
const value = require("lib").value;
console.log("value from cjs", value);

Справочная статья