Мне тут на днях попеняли, что, мол, я не в курсе, "что из esm до сих пор нельзя собрать бандл без транспиляции". Ну что я могу сказать... я действительно не в курсе :) На мой взгляд, es-модули придумали как раз для того, чтобы загружать по мере необходимости JS-код непосредственно в браузер, и собирать модули в бандлы - это, ну... как гладить кошку против шерсти.

Я понимаю, что традиции / привычки / требования бизнеса / обратная совместимость / корпоративная этика и т.п. говорят о том, что код для браузерных приложений должен поставляться в бандлах и точка! Тем не менее, в некоторых случаях (малые приложения, быстрое прототипирование, распределённая разработка) сборка бандлов является излишней и код в браузер можно и нужно загружать непосредственно в виде es-модулей.

Статический импорт

В качестве примера я приведу код приложения, состоящего из одного файла, который все необходимые модули загружает через unpkg.com (глобальный CDN для npm-модулей). Можно весь свой код оформить в виде набора npm-пакетов, а у своего хостера держать только один головной файл index.html. Демо-приложение довольно бесполезное с практической точки зрения - я взял два первых попавшихся, несвязанных между собой, npm-пакета, которые написаны в виде es-модулей:

  • store-esm: хранение данных в браузере в виде "ключ/значение" (первые версии оригинального пакета вышли в 2010-м году, сейчас пакет популярностью не пользуется);

  • @cloudfour/twing-browser-esm: использование Twig-шаблонов в браузере.

Главное в них, что это не специально мной созданные для демонстрации пакеты, а действительно первые попавшиеся. Первый пакет (store-esm) состоит из отдельных es-модулей, второй пакет (@cloudfour/twing-browser-esm) - самый что ни на есть бандл в виде одного единственного es-модуля "весом" в 1.7Мб, чисто для сравнения поведения модулей в браузере.

Код всего HTML-файла
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>The static import</title>
    <script type="module">
        // IMPORT
        import engine from 'https://unpkg.com/store-esm@3.0.0/src/store-engine.js';
        import storages from 'https://unpkg.com/store-esm@3.0.0/storages/all.js';
        import plugins from 'https://unpkg.com/store-esm@3.0.0/plugins/all.js';
        import {
            TwingEnvironment,
            TwingLoaderArray
        } from 'https://unpkg.com/@cloudfour/twing-browser-esm@5.1.1/dist/index.mjs';

        // FUNCS
        function useStore(engine, storages, plugins) {
            const store = engine.createStore(storages, plugins);
            store.set('user', {name: 'Alex Gusev'});
            console.log(store.get('user'));
        }

        function useTwing(TwingEnvironment, TwingLoaderArray) {
            const templates = {
                'index.twig': `
<h1>{{ title }}</h1>
<p>{{ message }}</p>
`
            };
            const loader = new TwingLoaderArray(templates);
            const twing = new TwingEnvironment(loader);
            const context = {
                title: 'Hello, guys!',
                message: 'Welcome to using Twing in the browser.'
            };
            twing.render('index.twig', context).then((output) => {
                document.body.innerHTML = output;
            }).catch((err) => {
                console.error('Error rendering template:', err);
            });
        }

        // MAIN
        useStore(engine, storages, plugins);
        useTwing(TwingEnvironment, TwingLoaderArray);
    </script>
</head>
<body></body>
</html>

Код загрузки es-модулей и их использования:

<script type="module">
    // IMPORT
    import engine from 'https://unpkg.com/store-esm@3.0.0/src/store-engine.js';
    import storages from 'https://unpkg.com/store-esm@3.0.0/storages/all.js';
    import plugins from 'https://unpkg.com/store-esm@3.0.0/plugins/all.js';
    import {
        TwingEnvironment,
        TwingLoaderArray
    } from 'https://unpkg.com/@cloudfour/twing-browser-esm@5.1.1/dist/index.mjs';

    // FUNCS
    function useStore(engine, storages, plugins) {...}

    function useTwing(TwingEnvironment, TwingLoaderArray) {...}

    // MAIN
    useStore(engine, storages, plugins);
    useTwing(TwingEnvironment, TwingLoaderArray);
</script>

Основные преимущества

Использование CDN unpkg.com значительно снижает требования к собственному хостеру, а если хостер "берёт денег" за объём скачиваемой информации, то тут уже видна и явная финансовая выгода.

Оптимизация использования интернета за счёт возможности кэширования на разных уровнях GET-запросов к единому источнику. Это преимущество из области "защита окружающей среды при помощи сортировки мусора на собственной кухне", и тем не менее, на больших объёмах оно работает.

Возможность использования разных версий одного и того же пакета в разных частях приложения. Когда я интегрировал различные плагины в электронные магазины на платформе "Magento", количество различных экземпляров библиотеки jQuery, которые плагины тянули с собой, иногда поднималось под десяток. У бекэнд-приложений, собираемых при помощи npm, это вызвало бы конфликт версий, а в браузере - пожалуйста. Я уверен, что и при сборке бандла так же можно, но думаю, что цена решения вопроса будет чуть выше, чем просто указать номер версии в адресе экспорта.

Ну и самое главное лично для меня преимущество - возможность работать в браузерном приложении с тем же кодом, что и в IDE. Да, конечно же "сорсмапы врдое бы уже изобрели", но на мой взгляд отладка с сорсмапами, это как битва Персея с Медузой Горгоной с её визуализацией через полированный щит. Если бы Персей мог обходиться без щита, оно было бы тупо быстрее.

Исходный код модулей в браузере
Исходный код модулей в браузере

Основные недостатки

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

Размер файла и время загрузки es-бандла
Размер файла и время загрузки es-бандла

В сжатом состоянии бандл весом в 1.7Мб занимает 487Кб и загружается 124мс из которых 18.36мс занимает ожидание ответа сервера и 104.64мс - загрузка содержимого:

Тайминг для бандла
Тайминг для бандла

Следующий за ним util.js весит почти в 500 раз меньше, а время загрузки меньше всего в два раза:

Тайминг для отдельного модуля
Тайминг для отдельного модуля

Основные потери времени - ожидание ответа сервера, что в случае большого количества es-модулей (файлов) складывается в приличное суммарное время. Это несколько сглаживается наличием дискового кэша браузера:

Время загрузки es-модулей из дискового кэша
Время загрузки es-модулей из дискового кэша

и очень сильно - наличием кэша в оперативной памяти:

Время загрузки es-модулей из кэша оперативной памяти
Время загрузки es-модулей из кэша оперативной памяти

Если приложению для первоначального запуска нужны абсолютно все модули, то смысла в загрузке файлов по-одиночке нет, выгоднее бандлом. А вот если приложение допускает подгрузку модулей пакетами по паре десятков штук, то есть смысл загружать и помодульно.

Динамический импорт

Преимущества динамического импорта перед статическим в том, что у нас появляется возможность связывать весь наш код не только на этапе его написания, но и на этапе его выполнения:

        const rnd = Math.floor(Math.random() * 2);
        if (rnd) {
            const {default: engine} = await import('https://unpkg.com/store-esm@3.0.0/src/store-engine.js');
            const {default: storages} = await import('https://unpkg.com/store-esm@3.0.0/storages/all.js');
            const {default: plugins} = await import('https://unpkg.com/store-esm@3.0.0/plugins/all.js');
            useStore(engine, storages, plugins);
        } else {
            const {
                TwingEnvironment,
                TwingLoaderArray
            } = await import('https://unpkg.com/@cloudfour/twing-browser-esm@5.1.1/dist/index.mjs');
            useTwing(TwingEnvironment, TwingLoaderArray);
        }

В приведённом выше примере в случайном порядке загружается либо пакет store-esm , либо пакет @cloudfour/twing-browser-esm.

Загруженные исходники для `rnd = 0`
Загруженные исходники для `rnd = 0`

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

В общем, если статический импорт es-модулей в браузерных приложениях можно было бы сравнить с перемещением по поверхности земли, то динамический импорт добавляет нам третье измерение - даёт возможность летать. В принципе, ничего не мешает при помощи динамического импорта использовать в нашем одностраничном приложении любой пакет из имеющихся на unpkg.com (если он написан в es-модулях и для браузера, разумеется).

"IoC через DI"

Ну, а раз уж мы "легализовали" использование es-модулей в браузере и даже перешли от "статического связывания кода на момент его написания" к "динамическому на момент его выполнения", то тут уже остаётся всего пару шагов до окончательного грехопадения - до использования в коде инверсии контроля через внедрение зависимостей.

Вот содержимое типового es-модуля из тех, которые я использую в своих приложениях:

export default class Fl32_Auth_Front_Mod_User {
    /**
     * @param {Fl32_Auth_Front_Defaults} DEF
     * @param {TeqFw_Core_Shared_Api_Logger} logger -  instance
     * @param {TeqFw_Web_Api_Front_Web_Connect} api
     * @param {Fl32_Auth_Shared_Web_Api_User_Create} endUserCreate
     * @param {Fl32_Auth_Shared_Web_Api_User_ReadKey} endReadKey
     * @param {Fl32_Auth_Shared_Web_Api_User_Register} endUserReg
     * @param {Fl32_Auth_Front_Mod_Crypto_Key_Manager} modKeyMgr
     * @param {Fl32_Auth_Front_Mod_Password} modPassword
     * @param {Fl32_Auth_Front_Store_Local_User} storeUser
     */
    constructor(
        {
            Fl32_Auth_Front_Defaults$: DEF,
            TeqFw_Core_Shared_Api_Logger$$: logger,
            TeqFw_Web_Api_Front_Web_Connect$: api,
            Fl32_Auth_Shared_Web_Api_User_Create$: endUserCreate,
            Fl32_Auth_Shared_Web_Api_User_ReadKey$: endReadKey,
            Fl32_Auth_Shared_Web_Api_User_Register$: endUserReg,
            Fl32_Auth_Front_Mod_Crypto_Key_Manager$: modKeyMgr,
            Fl32_Auth_Front_Mod_Password$: modPassword,
            Fl32_Auth_Front_Store_Local_User$: storeUser,
        }
    ) {...}
}

В нём нет ни статических, ни даже динамических импортов. В конструкторе класса описываются все зависимости, которые нужны объектам этого класса для работы. В таком виде es-модуль может быть использован и в браузере, и в nodejs-приложении - нужно при вызове конструктора создать все требующиеся зависимости и передать их через параметры. Это может сделать либо программист, вручную, либо Контейнер Объектов, автоматически - согласно правилам преобразования идентификаторов зависимостей в пути к исходникам.

"IoC через DI" очень давняя технология, можно сказать почти древняя. Очень широко использовалась в PHP (Magento, Zend, Symfony, ...) и до этого в Java (Spring). Это из того, что я лично пользовал. В PHP даже есть стандарт для описания интерфейса контейнера объектов - PSR-11. Разработчики на C# Mark Seemann и Steven van Deursen книгу про DI написали, где очень подробно объяснили плюсы/минусы технологии и сравнили с другими IoC-подходами. В общем, "IoC через DI" давно завоевала и прочно утвердила свою репутацию во многих языках программирования.

С учётом возможности преобразования, в зависимости от контекста, в Контейнере Объектов одних идентификаторов в другие, а также с учётом возможности постобработки готовых объектов перед их внедрением, можно сказать что разработка приложения с использованием DI настолько же сильно отличается от разработки с использованием динамического импорта, насколько разработка с использованием динамического импорта отличается от разработки с использованием только статического.

Преобразование идентификатора зависимости в объект перед внедрением
Преобразование идентификатора зависимости в объект перед внедрением

Заключение

Я действительно не использую транспиляцию для сборки бандла из es-модулей, я загружаю es-модули в браузер напрямую. А для ускорения этого процесса я на live-сервере собираю обычный zip-архив из файлов, которые предполагается использовать на фронте, загружаю его на клиента и распаковываю в cacheStorage браузера. После чего использую этот кэш в Service Worker'е при обращениях к esm.

Ну, как-то так получилось, что у меня и бандл из esm есть, и транспиляции нет.

Disclaimer

Подход к загрузке es-модулей, описанный в данной статье, не является общепринятым в JS-коммьюнити и не может быть рекомендован для повседневного употребления. Используйте его по своему усмотрению. Все примеры надуманные и не несут практической пользы.

Комментарии (15)


  1. i360u
    26.06.2024 17:22
    +2

    из esm до сих пор нельзя собрать бандл без транспиляции

    Хм... Што? Как же я это делаю, если нельзя?

    обычный zip-архив из файлов

    Ээээ... Што? Серьезно?

    Ну и тема про загрузку зависимостей через CDN (или со своих эндпоинтов) без описания работы importmaps - это незачет. Ну, то есть, вся эта кухня прекрасно работает без богомерзких сорсмапов и без необходимости что-то грузить атомарно, со всеми плюшками в виде прямого доступа к исходникам в дев-окружении со всеми их типами.


  1. 19Zb84
    26.06.2024 17:22
    +1

    обычный zip-архив из файлов

    Я как то об этом не подумал )))) Надо попробовать будет )))))

    Я бандлер не вставлял ещё, но когда надо esbuild использую. Он единственный подходит мне.


  1. SuperCat911
    26.06.2024 17:22
    +1

    Спасибо за публикацию!

    Транспиляция транспиляции рознь. Например, перевод из Typescript в JavaScript - это транспиляция, а ещё при сборке бандла на esbuild код модулей транспилируется в iife (или что-то подобное), при этом сам код модуля может остаться почти незатронутым на том же языке (если версия js остаётся той же для бандла). То есть в первом случае генерируется новый код полностью, а во втором - склеиваются файлики модулей особым способом. Второй случай называть транспиляцией язык не поворачивается, хотя под капотом почти те же процессы.

    Кстати, если юзать бандлер rollup для создания бандла без минификации, то на выходе есть большая вероятность получить один большой склеенный js-файл из кусочков модулей. Так что новый код генерируется, но он довольно идентичен источнику.

    По поводу cache storage. Честно говоря, я не понял: а зачем вы используете cache storage при работе с esm? Ну, окей, предположим 2 разных модуля импортируют третий модуль по одному и тому же урлу. Я ни разу не замечал, что браузер будет по сети обращаться повторно. Может быть проблема надумана?

    Можно в принципе делать минифицированный бандл с сорсмапом. И настроить сервер на выдачу в gzip. Будет тот же эффект, и ошибки можно ловить.


    1. flancer Автор
      26.06.2024 17:22
      +2

      cacheStorage + Service Worker даёт возможность работать веб-приложению в режиме offline. Это из области PWA для мобильных в областях с пониженным качеством интернета.


      1. SuperCat911
        26.06.2024 17:22

        Хорошо, спасибо. Теперь понял.


  1. SuperCat911
    26.06.2024 17:22
    +3

    Кстати, относительные пути и тем более урлы, указанные в импорте модулей в ваших сорсах можно заменить на некие неймспейсы, описав их в importmap. https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap

    Прекрасная штука, сам юзаю.


    1. flancer Автор
      26.06.2024 17:22
      +1

      Да, namespaces - это отличный инструмент для организации кода. В Java он был с самого начала (1996), в PHP добавили в версии 5.3 (2009), в JS их упорно путают с областью действия/видимости (scope). Я у себя в коде не использую importmap на уровне браузера, вместо этого делаю аналогичный маппинг на уровне Контейнера Объектов (одинаково работает и в браузере, и в ноде). Когда я начинал его делать в 2019-м, importmap'ов ещё не было, они где-то с 21-го года начали появляться в браузерах (в Safari так вообще с 2023-го). Возможно, с ними у меня получилось бы по-другому, но как есть. На данный момент я не вижу смысла использовать у себя importmap. У меня этот функционал встроен в Контейнер Объектов.


      1. SuperCat911
        26.06.2024 17:22
        +2

        Спасибо. В принципе, соглашусь. Разная среда (nodejs и браузер), и как следствие разные загрузчики. А вы нашли компромисс.

        Из интересного, в качестве альтернативы вашему решению (не призываю, а просто ради любопытства), можно посмотреть в сторону загрузки импортмапа в браузере через файл, а также динамическую загрузку импортмапов. https://github.com/WICG/import-maps?tab=readme-ov-file#import-map-processing

        В общем, идея в том, что из package.json можно создать файл import map. А в своем коде проверять среду. Если среда - это браузер, то можно динамически подгрузить этот importmap. И тогда это будет довольно универсальным решением.

        Чем оно принципиально отличается от вашего? Тем, что можно использовать имена пакетов в браузере, прямо как в ноде. Без урлов и относительных путей. Но это удобно только при работе с внешними вашими подключенным библиотеками.

        Если же речь о работе с модулями в коде, где идёт основная бизнес-логика приложения, то вполне нормально работать с относительными путями.


        1. flancer Автор
          26.06.2024 17:22

          Тем, что можно использовать имена пакетов в браузере, прямо как в ноде.

          Я как раз пытался уйти от привязки к физической адресации (пакет:путь/к/файлу/в/пакете) и перейти к логической адресации Vendor_Project_Plugin_Area_Service. Для меня было важно уйти от статического связывания файлов в момент написания кода и перейти к динамическому связыванию в момент выполнения. При внедрении зависимостей через конструктор всё равно нужно преобразовать идентификатор зависимости в путь к конкретному экспорту внутри es-модуля с учётом условий проекта, в котором код выполняется. При логической адресации не так странно, что получаешь имплементацию в месте внедрения интерфейса.

          Объявляешь в базовом модуле зависимость от базового интерфейса:

          export default class Fl32_Auth_Back_Web_Api_Password_Salt_Read {
              /**
               * @param {Fl32_Auth_Back_Api_Mod_User} modUser
               */
              constructor(
                  {
                      Fl32_Auth_Back_Api_Mod_User$: modUser,
                  }
              ) {}
          }

          А по факту Контейнер Объектов внедрит имплементацию этого интерфейса в соотв. проекте. Типа такой:

          /**
           * @implements Fl32_Auth_Back_Api_Mod_User
           */
          export default class Lp_Desk_Back_Di_Replace_Fl32_Auth_Back_Api_Mod_User {...}

          Ну и в случае привязки к именам пакетов и файлу внутри конструктор выглядел бы чуть чудовее:

          /**
           * @param {Fl32_Auth_Back_Api_Mod_User} modUser
           */
          constructor(
              {
                  '@flancer32/teq-ant-auth/src/Back/Api/Mod/User.mjs#default': modUser,
              }
          ) {}

          В общем, importmap хорош для статических импортов. В динамических уже можно вычислять самому путь к нужному ресурсу, а в моём DI он идёт как пятое колесо в телеге.

          Принцип importmap'а, по большому счёту, совпадает с сопоставленим логического namespace'а физическому пути к файлу в моём Контейнере. Только importmap делает это сопоставление на уровне браузера, а я - на уровне Контейнера. Но перед началом работы и то, и то нужно конфигурить.


          1. SuperCat911
            26.06.2024 17:22
            +1

            Директиву @ts-check не используете?


            1. flancer Автор
              26.06.2024 17:22

              Не использую. Спасибо за наводку, посмотрю, что она может в IDEA.


              1. SuperCat911
                26.06.2024 17:22

                Я просто увидел, что в вашем в jsdoc есть упоминание интерфейса, но нет его объявления (отсутствует явный импорт типа). Очевидно, это сделано в другом модуле.

                Я к тому, что я сам приверженец чистого js с jsdoc. Но jsdoc не классический, а на базе typescript. Может быть заинтересует вас.


                1. flancer Автор
                  26.06.2024 17:22

                  Я стараюсь не использовать статических импортов, а вместо этого использую namespace'ы. Из имени интерфейса (Fl32_Auth_Back_Api_Mod_User) в месте его применения разработчику понятно, где искать объявление самого интерфейса. IDE же сканирует JSDoc'и в исходниках и составляет свою таблицу имён классов/интерфейсов/т.п. с привязкой к файлам и ей всё равно, как называются соответствующие объекты кода.

                  По большому счёту, код интерфейса нужен только на момент написания/редактирования кода, чтобы IDE контролировала его имплементации и показывала их взаимосвязь с интерфейсом, облегчая разработчику навигацию по коду. У меня это не исполняемый файл, а документ в стиле JS (@flancer32/teq-ant-auth/src/Back/Api/Mod/User.js):

                  /**
                   * An interface for an application specific model of the user.
                   * This interface is used by `Fl32_Auth_Back_Mod_Session` to retrieve the app specific user profile.
                   *
                   * @interface
                   */
                  export default class Fl32_Auth_Back_Api_Mod_User {
                  
                      /**
                       * Read the application-specific data from storage (RDb, ...) to use on the backend.
                       *
                       * @param {TeqFw_Db_Back_RDb_ITrans} [trx]
                       * @param {number} userBid
                       * @returns {Promise<{profileBack: *, profileFront: *}>}
                       */
                      async readProfiles({trx, userBid}) {}
                  
                      /**
                       * Load app specific user data from a storage (RDb, ...).
                       * @param {TeqFw_Db_Back_RDb_ITrans} [trx]
                       * @param {*} userRef - app-specific identifier for the user (email, uuid, ...) or some object
                       * @returns {Promise<{bid:number, dbUser: Fl32_Auth_Back_Store_RDb_Schema_User.Dto}>}
                       */
                      async read({trx, userRef}) {}
                  }

                  Его нет нужды куда-либо импортировать в виде исходника, это просто документ. Я думаю, что можно было бы объявить аналогичный интерфейс и чисто в JSDoc'ах так, чтобы его понимали и разработчики, и IDE, но это будет более многословно, чем в JS.

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


                  1. SuperCat911
                    26.06.2024 17:22
                    +1

                    Интерфейсы не попадают в js файл.

                    Работать с ts-типами в чистом js пока можно двумя способами:

                    1. jsdoc, типы в котором описываются в формате ts.

                    2. Отдельный файл file.d.ts рядом со скриптом file.js.

                      Если какую-то конструкцию ts нельзя преобразовать в jsdoc, то ее публикуют в d.ts.


  1. senfiaj
    26.06.2024 17:22
    +2

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

    Кстати, есть возможность заранее загрузить модули, особенно помогает, когда есть цепочка зависимостей. Не знаю насколько поможет.

    <link rel="modulepreload" href="module/path.js">