История ES6-модулей
Владислав Власов, инженер-программист в Developer Soft и преподаватель курса Нетологии, специально для блога написал цикл статей о EcmaScript6. В первой части на примерах рассмотрели динамический анализ кода в EcmaScript с помощью Iroh.js, во второй сосредоточились на реализации отменяемых Promises. В этой статье поговорим об истории ES6-модулей.
История языка EcmaScript простирается от простого языка сценариев в браузере вплоть до современного языка общего назначения, работающего в различных хост-окружениях. Вместе с усложнением языка появилась и необходимость организации модульной структуры и переиспользования кода с помещением его в библиотеки. Первые библиотеки импортировались за счет загрузки соответствующего JS-файла с хоста поставщика или CDN, а взаимодействие производилось, как правило, посредством экспорта функций и классов с заранее известными именами в глобальное пространство — объект window.
Программа обучения: «Основной курс по JavaScript»
Такая схема применялась достаточно долго, и в простых случаях работает она вполне успешно.
Сложности начинаются, когда библиотек и взаимосвязей между ними становится слишком много.
Во-первых, засоряется глобальный хост-объект, и все подключаемые библиотеки должны импортировать не конфликтующие уникальные имена. Во-вторых, нет никакого явного способа обеспечить взаимодействие между библиотеками и осуществить переиспользование.
Вопрос вложенных зависимостей может решаться с помощью dynamic script injection в DOM-модели, а переиспользование может достигаться за счет экспорта с известным именем в глобальном хост-объекте, однако это не универсальное решение и строится оно исключительно на неявном соглашении между авторами библиотек и использующим их клиентском сценарии. Частично согласование имен решается посредством передачи CDN-серверу в query string параметров, специфицирующим пространства имен для загружаемой JS-библиотеки, но это также не универсально.
Остаются некоторое фундаментальные проблемы, связанные с асинхронной загрузкой и взаимодействием с DOM-моделью. Некоторые библиотеки должны быть загружены раньше других, если вторые имеют зависимость от первых. В случае с динамическим импортом это требует правильной установки async-флага или манипуляции с событием readystatechanged, в зависимости от вендора и версии браузера.
Конечно, и для этого общего случая есть решение, описанное в статье. Однако, во-первых, оно требует тщательного слежения за зависимостями во всех загружаемых библиотеках, и во-вторых, если некоторые библиотеки представляют собой polyfills, которым требуется отслеживание DOM-состояния и событий. В случае defer fallfack это не заработает.
Для универсального решения вышеописанных задач было разработано несколько стандартов организации библиотечных модулей для JS, самые известные из них — AMD (Asyncronous module definition), UMD (Universal module definition) и CommonJS. За счет следования авторами модулей общего формата декларации и наличия общего загрузчика файлов, большинство проблем было решено.
Тем временем активно развивалась платформа Node.JS, где зависимости модулей были решены совершенно другим способом — посредством синхронного require-вызова, а модули имели соответствующий специфичный формат. Тогда технический комитет TC-39 начал разработку универсального средства импорта модулей, которое должно было решать все вышеобозначенные задачи и при этом работать одинаково на сервере и клиенте и обеспечивать синхронную и асинхронную семантику загрузки модуля. Таким средством стали ES6-модули.
Поддержка ES6-модулей посредством transpile и bundle builder
С появлением спецификации Ecmascript 262 version 6 и последующих редакций, в язык добавлялось множество новых синтаксических конструкций и native-функций. Как правило, большинство из них могло легко запускаться и на старых версиях JS-движков за счет предварительного transpile-инга — для синтаксических конструкций, и добавления polyfills — для недостающих функций.
ES6-модули же обеспечивали синхронную не блокирующую семантику загрузки, binding-привязки для экспортируемых/импортируемых сущностей, модульная область видимости и другие аспекты, которые не просто обеспечить обычным transpile-ингом.
Разработчики хотели создавать веб-приложения на актуальном диалекте Ecmascript 6, 7, 8-й и поздних версиях, а для этого требовалось удобство по выполнению transpile-инга и добавлению соответствующих polyfills для приложений автоматическим образом, чтобы разработанное приложение могло работать и в относительно старых браузеров без проблем.
Совокупным решением этих задач стали bundle builder, настраиваемые вместе с подключаемыми transpilers и polyfills. Идея состоит в том, что код приложения преобразуется в эталонный диалект, который считается поддерживаемыми всеми актуальными браузерами, например, ES3 или ES5 — в зависимости от задачи. После этого все файлы библиотечных модулей и кода приложения соединяются в один большой файл — так называемый bundle. Этот файл отправляется на клиент и уже не требует никаких синхронных или асинхронных импортов, поскольку весь необходимый код уже находится в bundle и доступен по кодовым номерам.
Известные решения, имплементирующие соответствующий подход: Browserify и Webpack, причем последний в настоящее время является фактически стандартом де-факто. Транспайлером де-факто является Babel. Предложенная схема имеет большое количество преимуществ.
Во-первых, благодаря наличию в схеме transpiler-а, исходный проект может быть фактически написан на любом языке. Как правило, это EcmaScript или TypeScript последней версии, но возможности по расширению синтаксиса практически безграничны. Одно из известных расширений для ES — JSX, используемый в библиотеке React и ее производных.
Во-вторых, за счет контроля преобразования кода в фазе transpile-инга, имеется возможность внедрения поддержки даже такой функциональности, как ES6 proxy или рефлексивной информации в коде.
Среди интересных следствий применения bundle-ирования кода — возможность написание клиентского кода на языке F# или Ocaml и многое другое.
Помимо очевидных преимуществ, решение с bundle-ированием имеет и ряд очевидных недостатков.
Во-первых, результирующий bundle, даже с учетом возможного сжатия, имеет довольно большой объем и может быть ощутим при мобильном трафике. Во-вторых, bundle включает в себя абсолютно все зависимости веб-приложения, которые будут загружены и интерпретированы в браузере пользователя, даже если тот не воспользуется элементами веб-приложения, в которых они нужны. В-третьих, пропадает возможность кэширования библиотечных зависимостей, поскольку bundle или полностью актуален, или требует полного обновления.
Негативные эффекты проявляются и при разработке и отладке приложения. Поскольку bundle-ирование почти всегда сопряжено с transpile, то процесс получения нового bundle, особенно для крупного проекта, может идти долго. Это означает, что в процессе разработки и отладки, после внесения очередного изменения, требуется пересборка bundle и загрузка его новой версии на клиент. Кроме того, за счет машинного преобразования исходного кода, он становится практически нечитаем, что ведет к сложностям в использовании отладчика в браузере.
Конечно, большая часть обозначенных выше проблем имеет свои решения. Для того чтобы в production-режиме не загружать в браузер весь код приложения целиком, в webpack используется технология code chunk splitting. Можно использовать и динамическую версию импорта, возвращающую Promise и обеспечивающую асинхронную загрузку целевого модуля.
Для отладочных целей тоже имеются решения. Просмотр оригинального исходного кода, и даже навигация по нему в отладчике браузера достигается посредством спецификации source maps, внедряемых в целевой bundle в режиме разработки. Частичное обновление без полной перезагрузки bundle решается при помощи Hot Module Reload, хотя действительно инкрементальное обновление корректно работает только в простых случаях.
Нативная поддержка ES6-модулей
Схема с bundle-ированием зависимостей была актуальной для своего времени, но на текущий момент все современные браузеры имеют нативную поддержку ES6-модулей.
Это требует пересмотра взгляда на сборку современных web-приложений, поскольку bundle были необходимостью из-за несовершенства и отсутствия требуемой функциональности в браузерах. После её появления, использование нативных конструкций обеспечивает гораздо лучший результат.
Во-первых, излишний transpile синтаксических конструкций и замены его на эмулирующий код приводит к замедлению и затруднению оптимизаций. Это касается async и generator-функций, заменяемых на regenerator runtime, и лексических переменных let/const, преобразуемых в неоптимальные var-декларации.
Конечно, это не имеет прямого отношения к ES6-модулям как таковым, но обычно определяется схемой сборки и доставки на клиент приложения. В этом смысле это взаимосвязанные вещи.
Во-вторых, модули эффективны с точки зрения производительности. ES6 модули загружаются и исполняются отложенным образом по умолчанию. Это значит, что невозможно по ошибке осуществить добавление блокирующих модулей к веб-приложению, и соответственно из коробки нет никакой SPOF проблемы.
Для сохранения работоспособности в старых браузерах, не имеющих поддержку ES6-модулей, можно иметь собранный bundle и отдавать его для старых агентов. При этом, благодаря особенностям конструкции импорта ES6-модулей, не требуется условной настройки webpack с сегрегацией поставляемого кода в зависимости от User-Agent строки браузера, или средств feature discovery.
Для разграничения достаточно следующего кода:
head
script src=«app/index.js» type=«module»/script
script src=«dist/bundle.js» defer nomodule/script
/head
!-- --
/html
Браузер без поддержки ES6-модулей просто загрузит dist/bundle.js и будет работать по старой схеме. Современный браузер возьмет app/index.js в качестве точки входа и будет загружать зависимые ресурсы автоматически.
О вопросах эффективной настройки webpack-а для рассмотренной выше схемы, асинхронной и отложенной загрузки модулей, кэшировании зависимостей, inline-модулях и CORS-политиках для них можно прочесть более детально: «ES6 modules support lands in browsers: is it time to rethink bundling?» и «ECMAScript modules in browsers».
Итоги
Язык EcmaScript прошел большую историю и продолжает развиваться по сей день. Многие решения были актуальны для своего времени и позволяли решать задачи, в том числе упреждающую поддержку функциональности, еще не встроенной в клиентских агентах. Сейчас браузеры и Node.js-сервер выпускает обновление версий достаточно часто, добавляя в них современную функциональность EcmaScript.
В итоге решения, позволяющие в прошлом обеспечивать эмуляцию поддержки новых возможностей в популярных версиях браузеров на сегодня применимы к устаревшим агентам, которые, в зависимости от задачи, имеет смысл поддерживать отдельно или вообще исключить.
Предварительное разрешение и связывание модулей и их последующее bundle-ирование, еще недавно бывшее основным способом поддержки ES6 modules в большинстве браузеров, сейчас оказывает на них негативное влияние и мешает оптимизациям и средствам кэширования.
Читать ещё: «JavaScript: с чего начать»
Таким образом, настраивая сборку веб-приложения, целесообразно предоставлять современным агентам код на современном EcmaScript, включая синтаксические элементы и импорты/экспорты модулей.
Мнение автора и редакции может не совпадать. Хотите написать колонку для «Нетологии»? Читайте наши условия публикации.
Полный текст статьи читайте на Нетология