Модули JavaScript
Модули JavaScript теперь поддерживаются во всех основных браузерах!
Эта статья объясняет, как использовать JS-модули, как их ответственно развертывать, и как команда Chrome работает над их дальнейшим улучшением в будущем.
Что такое JS-модули?
JS-модули (также известные как «ES-модули» или «модули ECMAScript») — это большая новая функция или, скорее, набор новых функций. Возможно, вы ранее использовали пользовательскую систему модулей JavaScript. Возможно, вы использовали CommonJS, как в Node.js, или, возможно, AMD, или что-то другое. У всех этих систем модулей есть одна общая черта: они позволяют импортировать и экспортировать данные.
Теперь JavaScript имеет стандартизированный синтаксис для этого. Внутри модуля вы можете использовать ключевое слово export
, чтобы экспортировать практически что угодно. Вы можете экспортировать const
, function
или любую другую переменную или декларацию. Просто добавьте перед заявлением переменной export
, и все готово:
// 📁 lib.mjs
export const repeat = (string) => `${string} ${string}`;
export function shout(string) {
return `${string.toUpperCase()}!`;
}
Затем вы можете использовать ключевое слово import
, чтобы импортировать модуль из другого модуля. Здесь мы импортируем функции repeat
и shout
из модуля lib
и используем их в нашем модуле main
:
// 📁 main.mjs
import {repeat, shout} from './lib.mjs';
repeat('hello');
// → 'hello hello'
shout('Modules in action');
// → 'MODULES IN ACTION!'
Вы также можете экспортировать значение по умолчанию из модуля:
// 📁 lib.mjs
export default function(string) {
return `${string.toUpperCase()}!`;
}
Такие экспорты default
можно импортировать под любым именем:
// 📁 main.mjs
import shout from './lib.mjs';
// ^^^^^
Модули немного отличаются от классических сценариев:
-
В модулях по умолчанию включен строгий режим.
-
Синтаксис комментариев в стиле HTML не поддерживается в модулях, хотя он работает в классических сценариях.
// Не используйте синтаксис комментариев в стиле HTML в JavaScript!
const x = 42; <!-- TODO: Переименовать x в y.
// Вместо этого используйте обычный однострочный комментарий:
const x = 42; // TODO: Переименовать x в y. -
Модули имеют лексический верхний уровень области видимости. Это означает, например, что выполнение
var foo = 42;
внутри модуля не создаст глобальную переменнуюfoo
, доступную черезwindow.foo
в браузере, хотя это было бы верно для классического сценария. -
Аналогично,
this
внутри модулей не ссылается на глобальныйthis
, а вместо этого равноundefined
. (ИспользуйтеglobalThis
, если вам нужен доступ к глобальномуthis
.) -
Новый статический синтаксис
import
иexport
доступен только в модулях — он не работает в классических сценариях. -
Высокоуровневое
await
доступно в модулях, но не в классических сценариях. Соответственно,await
нельзя использовать как имя переменной в модуле, хотя в классических сценариях переменные могут быть названыawait
за пределами асинхронных функций.
Из-за этих различий один и тот же код JavaScript может вести себя по-разному, если его интерпретировать как модуль или как классический сценарий. Таким образом, среда выполнения JavaScript должна знать, какие сценарии являются модулями.
Использование JS-модулей в браузере
В вебе вы можете указать браузерам обрабатывать элемент <script>
как модуль, задав атрибут type
как module
.
<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>
Браузеры, которые понимают type="module"
, игнорируют скрипты с атрибутом nomodule
. Это означает, что вы можете отправить модульную нагрузку браузерам, поддерживающим модули, одновременно предоставляя резервную версию для других браузеров. Возможность различать это просто удивительна, особенно с точки зрения производительности! Подумайте: только современные браузеры поддерживают модули. Если браузер понимает ваш модульный код, он также поддерживает функции, которые существовали ранее, такие как стрелочные функции или async
-await
. Вам больше не нужно транспилировать эти функции в вашем модульном пакете! Вы можете предоставлять меньшие и почти нетранспилированные модульные нагрузки для современных браузеров. Только устаревшие браузеры получают нагрузку nomodule
.
Так как модули по умолчанию выполняются с задержкой, вы можете захотеть загрузить скрипт nomodule
также с задержкой:
<script type="module" src="main.mjs"></script>
<script nomodule defer src="fallback.js"></script>
Отличия модулей и классических скриптов в браузерах
Как теперь известно, модули отличаются от классических скриптов. Помимо платформенно-независимых различий, описанных выше, существуют некоторые специфические для браузеров различия.
Например, модули выполняются только один раз, тогда как классические скрипты выполняются каждый раз, когда вы добавляете их в DOM.
<script src="classic.js"></script>
<script src="classic.js"></script>
<!-- classic.js выполняется несколько раз. -->
<script type="module" src="module.mjs"></script>
<script type="module" src="module.mjs"></script>
<script type="module">import './module.mjs';</script>
<!-- module.mjs выполняется только один раз. -->
Также модульные скрипты и их зависимости загружаются с использованием CORS. Это означает, что любые скрипты модулей с другого происхождения должны быть отправлены с соответствующими заголовками, такими как Access-Control-Allow-Origin: *
. Это не касается классических скриптов.
Еще одно различие связано с атрибутом async
, который позволяет скрипту загружаться без блокирования HTML-парсера (как defer
), но при этом выполнять скрипт как можно быстрее, без гарантированного порядка и без ожидания завершения парсинга HTML. Атрибут async
не работает для встроенных классических скриптов, но работает для встроенных <script type="module">
.
Замечание о расширениях файлов
Вы могли заметить, что мы используем расширение .mjs
для модулей. В интернете расширение файла не имеет значения, если файл отправляется с MIME-типом JavaScript text/javascript
. Браузер узнает, что это модуль, благодаря атрибуту type
в элементе скрипта.
Тем не менее, мы рекомендуем использовать расширение .mjs
для модулей по двум причинам:
- Во время разработки расширение
.mjs
делает абсолютно ясным для вас и всех, кто смотрит ваш проект, что файл является модулем, а не классическим скриптом. (Иногда это не очевидно, если смотреть только на код.) Как упоминалось ранее, модули обрабатываются иначе, чем классические скрипты, так что это отличие чрезвычайно важно! - Это гарантирует, что ваш файл будет интерпретирован как модуль в средах выполнения, таких как Node.js и
d8
, а также инструментами сборки, такими как Babel. Хотя эти среды и инструменты имеют собственные способы интерпретации файлов с другими расширениями как модулей через конфигурацию, расширение.mjs
является кроссплатформенным способом для обеспечения того, чтобы файлы обрабатывались как модули.
Примечание: Чтобы развернуть .mjs
в интернете, ваш веб-сервер должен быть настроен на отправку файлов с этим расширением с использованием соответствующего заголовка Content-Type: text/javascript
, как упоминалось выше. Кроме того, вам может потребоваться настроить ваш редактор для обработки файлов .mjs
как .js
, чтобы получить подсветку синтаксиса. Большинство современных редакторов уже делают это по умолчанию.
Спецификаторы модулей
При использовании import
для модулей строка, указывающая местоположение модуля, называется «спецификатором модуля» или «спецификатором импорта». В нашем предыдущем примере спецификатор модуля - это './lib.mjs'
:
import {shout} from './lib.mjs';
// ^^^^^^^^^^^
В браузерах действуют некоторые ограничения на спецификаторы модулей. Так называемые «голые» спецификаторы модулей пока не поддерживаются. Это ограничение указано, чтобы в будущем браузеры могли разрешить пользовательским загрузчикам модулей придавать особое значение голым спецификаторам модулей, таким как следующие:
// Пока не поддерживается:
import {shout} from 'jquery';
import {shout} from 'lib.mjs';
import {shout} from 'modules/lib.mjs';
С другой стороны, следующие примеры поддерживаются:
// Поддерживается:
import {shout} from './lib.mjs';
import {shout} from '../lib.mjs';
import {shout} from '/modules/lib.mjs';
import {shout} from 'https://simple.example/modules/lib.mjs';
На данный момент спецификаторы модулей должны быть полными URL-адресами или относительными URL-адресами, начинающимися с /
, ./
или ../
.
Модули выполняются с задержкой по умолчанию
Классические <script>
по умолчанию блокируют HTML-парсер. Вы можете обойти это, добавив атрибут defer
, что гарантирует, что загрузка скрипта происходит параллельно с парсингом HTML.
Сценарии модулей по умолчанию выполняются с задержкой. Таким образом, нет необходимости добавлять defer
к вашим тегам <script type="module">
! Загрузка основного модуля происходит параллельно с разбором HTML, и то же самое относится ко всем зависимым модулям!
Другие возможности модулей
Динамический import()
До сих пор мы использовали только статический import
. В случае статического import
, весь граф модулей должен быть загружен и выполнен до того, как ваш основной код сможет начать выполняться. Иногда вы не хотите загружать модуль заранее, а предпочитаете загружать его по запросу, только когда он вам понадобится — например, когда пользователь нажимает на ссылку или кнопку. Это улучшает производительность начальной загрузки. Динамический import()
позволяет это сделать!
<script type="module">
(async () => {
const moduleSpecifier = './lib.mjs';
const {repeat, shout} = await import(moduleSpecifier);
repeat('hello');
// → 'hello hello'
shout('Dynamic import in action');
// → 'DYNAMIC IMPORT IN ACTION!'
})();
</script>
В отличие от статического import
, динамический import()
можно использовать в обычных сценариях. Это простой способ постепенно начать использовать модули в существующей кодовой базе. Для получения дополнительной информации см. нашу статью о динамическом import()
.
Примечание: webpack имеет свою версию import()
, которая умно разделяет импортируемый модуль на отдельный сегмент, отдельный от основного пакета.
import.meta
Еще одной новой функцией, связанной с модулями, является import.meta
, который предоставляет метаданные о текущем модуле. Точные метаданные, которые вы получаете, не указаны в ECMAScript; это зависит от среды хоста. Например, в браузере вы можете получить другие метаданные, чем в Node.js.
Вот пример использования import.meta
в вебе. По умолчанию изображения загружаются относительно текущего URL в HTML-документах. import.meta.url
позволяет загружать изображение относительно текущего модуля.
function loadThumbnail(relativePath) {
const url = new URL(relativePath, import.meta.url);
const image = new Image();
image.src = url;
return image;
}
const thumbnail = loadThumbnail('../img/thumbnail.png');
container.append(thumbnail);
Рекомендации по производительности
Продолжайте использовать бандлеры
С модулями стало возможным разрабатывать веб-сайты без использования бандлеров, таких как webpack, Rollup или Parcel. Использовать нативные модули JS напрямую допустимо в следующих сценариях:
- во время локальной разработки
- в продакшене для небольших веб-приложений с менее чем 100 модулями и относительно простой деревовидной структурой зависимостей (например, с максимальной глубиной менее 5)
Однако, как мы узнали во время анализа узких мест загрузочного конвейера Chrome при подгрузке модульной библиотеки, состоящей из ~300 модулей, производительность загрузки бандлированных приложений лучше, чем у небандлированных.
Одна из причин этого заключается в том, что синтаксис статического import
/export
поддается статическому анализу, что позволяет инструментам бандлинга оптимизировать ваш код путем устранения неиспользуемых экспортов. Статический import
и export
— это не просто синтаксис; это важная инструментальная функция!
Наше общее предложение заключается в том, чтобы продолжать использовать бандлеры перед выпуском модулей в продакшен. В определенном смысле, бандлинг — это подобная оптимизация, как и минификация вашего кода: это дает преимущество в производительности, так как вы отправляете меньше кода. Бандлинг имеет тот же эффект! Продолжайте использовать бандлинг.
Как всегда, функция покрытия кода в DevTools может помочь вам определить, отправляете ли вы пользователям ненужный код. Мы также рекомендуем использовать разделение кода, чтобы разделить пакеты и отложить загрузку не критических для первого значительного отрисовывания сценариев.
Компромиссы между бандлингом и отправкой небандлированных модулей
Как и всегда в веб-разработке, все имеет свои компромиссы. Отправка небандлированных модулей может снизить производительность начальной загрузки (холодный кэш), но может улучшить производительность загрузки для последующих визитов (теплый кэш) по сравнению с отправкой одного пакета без разделения кода. Для 200 КБ кодовой базы изменение единственного детализированного модуля и его единственной загрузки с сервера для последующих визитов намного лучше, чем повторная загрузка всего пакета.
Если вы больше обеспокоены опытом посетителей с теплыми кэшами, чем производительностью первых визитов, и у вас есть сайт с менее чем несколькими сотнями детализированных модулей, вы можете экспериментировать с отправкой небандлированных модулей, измерять влияние на производительность для холодной и теплой загрузки, а затем принять решение на основе данных!
Инженеры браузеров активно работают над улучшением производительности модулей «из коробки». Со временем мы ожидаем, что использование модулей без упаковки станет возможным в большем числе ситуаций.
Используйте мелко-зернистые модули
Привыкайте писать ваш код, используя небольшие, тщательно продуманные модули. Во время разработки, как правило, лучше иметь лишь несколько экспортов на модуль, чем вручную объединять множество экспортов в один файл.
Рассмотрим модуль под названием ./util.mjs
, который экспортирует три функции: drop
, pluck
и zip
:
export function drop() { /* … */ }
export function pluck() { /* … */ }
export function zip() { /* … */ }
Если вашему проекту действительно нужна только функция pluck
, вы, вероятно, импортируете её следующим образом:
import {pluck} from './util.mjs';
В этом случае (без этапа упаковки при сборке) браузеру все равно придется скачать, проанализировать и скомпилировать весь модуль ./util.mjs
, даже если реально нужна только одна экспортируемая функция. Это расточительно!
Если pluck
не разделяет код с drop
и zip
, то лучше переместить её в свой собственный мелко-зернистый модуль, например ./pluck.mjs
.
export function pluck() { /* … */ }
Тогда мы можем импортировать pluck
без необходимости обрабатывать drop
и zip
:
import {pluck} from './pluck.mjs';
Примечание: Вы могли бы использовать экспорт default
вместо именованного экспорта здесь, в зависимости от ваших предпочтений.
Это не только делает ваш исходный код простым и понятным, но также сокращает необходимость удаления «мертвого кода», которая выполняется упаковщиками. Если один из модулей в вашем исходном коде не используется, он никогда не импортируется, а следовательно, браузер его не загружает. Модули, которые действительно используются, могут быть индивидуально кэшированы браузером. (Инфраструктура для реализации этого уже появилась в V8, и работа в этом направлении ведется также и для Chrome.)
Использование мелко-зернистых модулей помогает подготовить ваш код к будущему, где может появиться решение для упаковки модулей на уровне веба.
Предзагрузка модулей
Вы можете оптимизировать доставку ваших модулей, используя <link rel="modulepreload">
. Это позволяет браузерам загружать, предварительно анализировать и компилировать модули и их зависимости.
<link rel="modulepreload" href="lib.mjs">
<link rel="modulepreload" href="main.mjs">
<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>
Это особенно важно для больших деревьев зависимостей. Без rel="modulepreload"
браузеру нужно выполнять множество HTTP-запросов, чтобы составить полное дерево зависимостей. Однако, если вы определяете полный список зависимых модульных скриптов с помощью rel="modulepreload"
, браузеру не нужно постепенно искать эти зависимости.
Используйте HTTP/2
Использование HTTP/2, где это возможно, всегда является хорошим советом по производительности, хотя бы из-за поддержки мультиплексирования. С HTTP/2 мультиплексированием несколько запросов и ответов могут отправляться одновременно, что полезно для загрузки дерева модулей.
Команда Chrome исследовала, может ли другая особенность HTTP/2, а именно сдвиг сервера HTTP/2, стать практическим решением для развёртывания высокомодульных приложений. К сожалению, сдвиг сервера HTTP/2 сложен для правильной реализации, а текущие реализации веб-серверов и браузеров недостаточно оптимизированы для сценариев использования высокомодульных веб-приложений. Сложно отправить только те ресурсы, которых у пользователя еще нет в кеше, например, а решение этой проблемы путем передачи серверу полного состояния кеша источника создает риск нарушения конфиденциальности.
Так что, конечно, используйте HTTP/2! Просто имейте в виду, что сдвиг сервера HTTP/2 (к сожалению) не является универсальным решением.
Внедрение JS модулей в веб
JS модули постепенно внедряются в веб. Наши счетчики использования показывают, что 0.08% всех загрузок страниц в настоящее время используют <script type="module">
. Учтите, что это число исключает другие точки входа, такие как динамический import()
или worklets.
Что ждет JS модули дальше?
Команда Chrome работает над улучшением опыта разработки с JS модулями различными способами. Давайте обсудим некоторые из них.
Более быстрый и детерминированный алгоритм разрешения модулей
Мы предложили изменить алгоритм разрешения модулей, чтобы устранить его недостатки в скорости и детерминизме. Новый алгоритм теперь доступен как в спецификации HTML, так и в спецификации ECMAScript, а также реализован в Chrome 63. Ожидайте, что это улучшение скоро появится в других браузерах!
Новый алгоритм гораздо более эффективный и быстрый. Вычислительная сложность старого алгоритма была квадратичной, то есть 𝒪(n²), относительно размера графа зависимостей, именно так он был реализован в Chrome на тот момент. Новый алгоритм является линейным, то есть 𝒪(n).
Кроме того, новый алгоритм детерминированно сообщает об ошибках разрешения. Если в графе содержится несколько ошибок, то разные запуски старого алгоритма могли сообщать о разных ошибках как о причинах сбоя разрешения. Это усложняло отладку. Новый алгоритм гарантированно сообщает об одной и той же ошибке каждый раз.
Ворклеты и веб-воркеры
Теперь Chrome реализует ворклеты, которые позволяют веб-разработчикам настраивать предопределённую логику в «низкоуровневых частях» веб-браузеров. С помощью ворклетов веб-разработчики могут внедрять JS-модули в конвейер рендеринга или обработки аудио (а возможно, и другие конвейеры в будущем!).
Chrome 65 поддерживает PaintWorklet
(также известный как CSS Paint API) для управления способом отображения элемента DOM.
const result = await css.paintWorklet.addModule('paint-worklet.mjs');
Chrome 66 поддерживает AudioWorklet
, который позволяет управлять обработкой аудио с помощью вашего собственного кода. В той же версии Chrome началось OriginTrial для AnimationWorklet
, что позволяет создавать анимации, связанные с прокруткой, и другие высокопроизводительные процедурные анимации.
Наконец, LayoutWorklet
(также известный как CSS Layout API) теперь реализован в Chrome 67.
Мы работаем над добавлением поддержки использования JS-модулей с выделенными веб-воркерами в Chrome. Вы уже можете попробовать эту функцию, включив chrome://flags/#enable-experimental-web-platform-features
.
const worker = new Worker('worker.mjs', { type: 'module' });
Поддержка JS-модулей для общих воркеров и сервисных воркеров появится скоро:
const worker = new SharedWorker('worker.mjs', { type: 'module' });
const registration = await navigator.serviceWorker.register('worker.mjs', { type: 'module' });
Карты импорта
В Node.js/npm часто импортируют JS-модули по их «названию пакета». Например:
import moment from 'moment';
import {pluck} from 'lodash-es';
На данный момент, согласно спецификации HTML, такие «голые спецификаторы импорта» вызывают исключение. Наше предложение по картам импорта позволяет такому коду работать в вебе, включая продакшн-приложения. Карта импорта — это JSON-ресурс, который помогает браузеру конвертировать голые спецификаторы импорта в полные URL.
Карты импорта всё ещё находятся на стадии предложения. Хотя мы много думали над тем, как они решают различные случаи использования, мы продолжаем общаться с сообществом и ещё не составили полную спецификацию. Ваши отзывы приветствуются!
Веб-упаковка: родные пакеты
Команда загрузки Chrome в настоящее время исследует родной формат веб-упаковки как новый способ распространения веб-приложений. Основные характеристики веб-упаковки:
Подписанные HTTP-обмены, которые позволяют браузеру доверять тому, что одна пара HTTP-запрос/ответ была сгенерирована указанным источником; Собранные HTTP-обмены, то есть коллекция обменов, которые могут быть подписанными или неподписанными, с некоторыми метаданными, описывающими, как интерпретировать пакет целиком.
Вместе, такой формат веб-упаковки позволит множественным ресурсам одного источника быть безопасно встроенными в один HTTP-ответ на GET
.
Существующие инструменты сборки, такие как webpack, Rollup или Parcel, в настоящее время создают единый JavaScript-пакет, в котором теряются семантика оригинальных отдельных модулей и ресурсов. С родными пакетами браузеры смогут распаковывать ресурсы до их изначального вида. Если упростить, то можно представить Собранный HTTP-обмен как пакет ресурсов, к которым можно получить доступ в любом порядке через оглавление (манифест), а содержащиеся ресурсы могут быть эффективно сохранены и обозначены в зависимости от их относительной важности, при этом сохраняя концепцию отдельных файлов. Благодаря этому родные пакеты могут улучшить процесс отладки. При просмотре ресурсов в DevTools браузеры смогут указать на исходный модуль без необходимости сложных исходных карт.
Прозрачность формата нативного пакета открывает различные возможности для оптимизации. Например, если браузер уже имеет часть нативного пакета, сохраненного локально, он может сообщить об этом веб-серверу, чтобы загрузить только недостающие части.
Chrome уже поддерживает часть предложения (SignedExchanges
), но сам формат пакета и его применение к сильно модульным приложениям все еще находятся в стадии изучения. Ваши отзывы приветствуются в репозитории или по электронной почте на адрес [email protected]!
Многоуровневые API
Внедрение новых функций и веб-API влечет за собой постоянные затраты на обслуживание и выполнение — каждая новая функция загрязняет пространство имен браузера, увеличивает затраты на запуск и создает новую поверхность для возникновения ошибок в кодовой базе. Многоуровневые API представляют собой усилия по реализации и предоставлению высокоуровневых API для веб-браузеров более масштабируемым способом. JS-модули являются ключевой технологией, обеспечивающей возможность применения многоуровневых API:
- Поскольку модули явно импортируются, требование предоставления многоуровневых API через модули обеспечивает, что разработчики платят только за те API, которые они используют.
- Благодаря настраиваемой загрузке модулей многоуровневые API могут иметь встроенный механизм автоматической загрузки полифилов в браузерах, которые не поддерживают многоуровневые API.
Детали того, как модули и многоуровневые API работают вместе, все еще прорабатываются, но текущие предложения выглядят примерно так:
<script
type="module"
src="std:virtual-scroller|https://example.com/virtual-scroller.mjs"
></script>
Элемент <script>
загружает API virtual-scroller
либо из встроенного набора многоуровневых API в браузере (std:virtual-scroller
), либо с резервного URL-адреса, указывающего на полифил. Этот API может делать все, что умеют JS-модули в веб-браузерах. Один из примеров — определение пользовательского элемента <virtual-scroller>
, чтобы следующий HTML прогрессивно улучшался по мере необходимости:
<virtual-scroller>
<!-- Контент размещается здесь. -->
</virtual-scroller>
Благодарности
Спасибо Доменику Деникола, Георгу Нейсу, Хироки Накагаве, Хирошиге Хаяшидзаки, Якобу Груберу, Кохэи Уэно, Кунихико Сакемото и Янг Гуо за то, что сделали работу JavaScript-модулей быстрой!
Также отдельное спасибо Эрику Байделману, Джейку Арчибальду, Джейсону Миллеру, Джеффри Познику, Филипу Уолтону, Робу Додсону, Сэму Даттону, Сэму Торогуду и Томасу Штайнеру за чтение черновой версии этого руководства и предоставленные отзывы.