12k
All articles

Использование npm-пакетов напрямую из браузера

Используйте npm-пакеты в обычном HTML с import maps и URL CDN. Разберитесь, как выбрать ESM или CommonJS, закрепить версии и обойтись без сборки.

OpenReplay Team
OpenReplay Team
Использование npm-пакетов напрямую из браузера

Вы можете использовать npm-пакет на обычной HTML-странице без бандлера, без node_modules и без конфигурационного файла — достаточно объявить import map, который сопоставляет «голый» (bare) спецификатор с CDN-URL, отдающим этот пакет как ES-модуль.

Одна страница, одна библиотека, одно взаимодействие — зачастую это не стоит целого Vite-проекта с его dev-сервером, каталогом сборки и процедурой деплоя. При этом ломается обычно не синтаксис import map: npm-пакеты поставляются в трёх различных форматах модулей, и только два из них вообще работают в браузере. В этой статье разбирается, как определить, какой формат перед вами, какими двумя способами загрузить его с CDN и почему незакреплённый (unpinned) URL — это ошибка корректности, а не вопрос стиля.

Ключевые выводы

  • Import map — это JSON-блок внутри тега <script type="importmap">, который сообщает браузеру, в какой URL разрешается голый спецификатор вроде canvas-confetti. Это ровно та же работа, которую бандлер выполняет на этапе сборки, только перенесённая в саму страницу.
  • Import map не спасёт пакет, поставляемый только в CommonJS, поскольку карта меняет то, как разрешается спецификатор, а не то, в каком формате написан файл.
  • MDN относит import maps к статусу Baseline Widely available: поддержка во всех браузерах есть с марта 2023 года.
  • Закрепляйте точную версию в каждом CDN-URL внутри карты, иначе код, который выполняет ваша страница, может измениться без деплоя и без коммита.
  • Отказ от этапа сборки означает отсутствие tree shaking: вы поставляете всё содержимое пакета, а не только используемые части.

Когда можно обойтись без этапа сборки?

Обходитесь без сборки тогда, когда стоимость её поддержки переживает то, что она собирает. Сюда относятся демо в стиле CodePen, отдельный интерактивный виджет, вставленный в шаблон WordPress или во view на Rails, внутренний дашборд, которым пользуются два человека, и любой прототип, срок жизни которого измеряется днями. Критерий здесь не размер, а владение: если через полгода никто не станет обновлять тулчейн, то тулчейн — это обязательство, а не актив. Всё, что, как вы ожидаете, будет расти, уйдёт в реальный трафик или будет передано команде, по-прежнему место бандлеру.

Три вида файлов, из которых в браузере работают два

npm-пакет поставляется в одном из трёх форматов модулей, и только два из них работают в браузере, поэтому выясните, какую сборку отдаёт пакет, прежде чем писать хоть какую-то import map. Классический или UMD-файл работает в обычном <script src> и назначает глобальную переменную. ES-модулю нужны type="module" и инструкции import. Сборка CommonJS, написанная с require() и module.exports, в браузере не выполняется вообще.

Быстрее всего это выяснить, установив пакет и прочитав его содержимое:

npm install canvas-confetti
ls node_modules/canvas-confetti/dist
cat node_modules/canvas-confetti/package.json

В этом выводе смотрите на две вещи: расширения файлов в пакете и поля точек входа. Документация Node по пакетам определяет main, exports и type; module — это соглашение экосистемы, которое читают бандлеры и CDN, а не поле, специфицированное Node. В некоторых пакетах также есть поле jsdelivr или unpkg, указывающее на готовую для браузера сборку. Например, canvas-confetti@1.9.4 объявляет "main": "src/confetti.js", "module": "dist/confetti.module.mjs" и "jsdelivr": "dist/confetti.browser.js" в своём package.json, из чего видно, что существуют и браузерная сборка, и сборка в виде ES-модуля.

ФорматКак распознатьЧто нужно браузеруБез этапа сборки
Classic / UMD.umd.js, dist/*.browser.js или исходник, присваивающий значения в windowНичего особенного<script src>, затем использование глобальной переменной
ES-модуль.mjs, import/export в исходниках, "type": "module"type="module"Import map плюс module-скрипт
CommonJS.cjs, require(), module.exports, "type": "commonjs"Сначала конвертацияCDN, транспилирующий в ESM, либо этап сборки

Именно на последней строке большинство попыток проваливается молча. Import map не спасёт пакет, поставляемый только в CommonJS, поскольку карта меняет то, как разрешается спецификатор, а не то, в каком формате написан файл.

Простой подход: один тег script с CDN

Если пакет поставляет классическую или UMD-сборку, вся интеграция сводится к одному тегу script. Имя глобальной переменной выбирает автор пакета, а не вы, поэтому загляните в README: в README canvas-confetti сказано, что CDN-сборка помещает функцию confetti в window.

<!doctype html>
<html lang="en">
  <body>
    <button id="go">Celebrate</button>
    <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/dist/confetti.browser.js"></script>
    <script>
      document.getElementById('go').addEventListener('click', () => confetti());
    </script>
  </body>
</html>

Если пакет поставляется в ESM или CommonJS, конвертацию берёт на себя CDN. Запрос к эндпоинту /+esm у jsDelivr возвращает готовый к использованию в браузере ES-модуль, и jsDelivr описывает это как нечто большее, чем простую замену синтаксиса: сервис определяет правильную точку входа по собственным полям пакета, при необходимости конвертирует CommonJS, подтягивает зависимости в ответ, а также вырезает лишнее и минифицирует результат. esm.sh выполняет аналогичную задачу, используя грамматику URL https://esm.sh/PKG[@SEMVER][/PATH]. Любой из вариантов даёт вам URL, который можно подставить прямо в инструкцию import.

Более удачный подход: тег script с типом importmap

Import map — это JSON-блок внутри тега <script type="importmap">, который сопоставляет голые спецификаторы с URL, так что код ваших модулей выглядит ровно так же, как внутри бандлера.

<!doctype html>
<html lang="en">
  <body>
    <button id="go">Celebrate</button>
    <script type="importmap">
      {
        "imports": {
          "canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4"
        }
      }
    </script>
    <script type="module">
      import confetti from 'canvas-confetti';
      document.getElementById('go').addEventListener('click', () => confetti());
    </script>
  </body>
</html>

Выигрыш составляет одна строка. Без карты каждый файл, которому нужна библиотека, повторяет CDN-URL и версию:

import confetti from 'https://esm.sh/canvas-confetti@1.9.4';

С картой версия указана ровно в одном месте, а инструкция import без изменений переносится в проект со сборкой.

На практике важны четыре правила. Первое: порядок определяет, заработает ли карта вообще — браузер должен прочитать её до того, как встретит любой module-скрипт, импортирующий через неё, поэтому блок <script type="importmap"> размещается выше такого кода. Второе: стандарт HTML допускает наличие в документе более одной карты и описывает, как они объединяются, но поддержка этого в движках неоднородна, так что пишите одну карту на документ. Третье: относительные значения должны начинаться с /, ./ или ../. Четвёртое: завершающий слеш с обеих сторон сопоставления отображает целый каталог пакета, а не одну точку входа:

<script type="importmap">
  {
    "imports": {
      "canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4",
      "canvas-confetti/": "https://esm.sh/canvas-confetti@1.9.4/"
    }
  }
</script>
<script type="module">
  import confetti from 'canvas-confetti';
  import { default as raw } from 'canvas-confetti/dist/confetti.module.mjs';
</script>

MDN оценивает import maps как Baseline Widely available — они присутствуют в браузерах с марта 2023 года, так что полифил больше не является частью обычной настройки. Если вам всё же нужна проверка во время выполнения, её даёт HTMLScriptElement.supports(), используемый как HTMLScriptElement.supports?.("importmap").

Есть подвох, который не сопровождается никаким сообщением об ошибке: ES-модули загружаются по правилам CORS, поэтому открытие HTML-файла с диска не сработает, хотя тот же самый файл заработает, как только его отдаст локальный сервер.

Закрепляйте версию — всегда

Закрепляйте точную версию в каждом CDN-URL внутри карты. Незакреплённый URL или URL с диапазоном версий означает, что код, который выполняет ваша страница, может измениться без деплоя, без коммита и без чего-либо в репозитории, что объяснило бы разницу. Поведение страницы в продакшене становится функцией от часов CDN, а не от вашей истории в git, и рядовой баг-репорт превращается в археологические раскопки: HTML не менялся, логи сервера не менялись, а JavaScript другой.

Это единственное правило, нарушение которого не даёт никакого выигрыша. canvas-confetti@1.9.4 — это факт, о котором можно рассуждать; canvas-confetti@latest — это обещание, которое даёт кто-то другой.

От чего вы отказываетесь?

Загрузка пакетов с CDN даёт стороннему источнику возможность выполнять произвольный скрипт в контексте вашей страницы. Сузить это можно с помощью CSP и subresource integrity: MDN отмечает, что JSON-объект import map принимает ключ integrity наряду с imports и scopes, сопоставляя URL модулей с SRI-хешами вида sha384-…. Если вы предпочитаете полностью контролировать путь доставки, раздача собственных ресурсов — это отдельная схема, рассмотренная в материалах о роли CDN в производительности фронтенда и в сравнении CDN-платформ.

Вместе с этим подходом приходят ещё три издержки. Нет tree shaking, поэтому вы поставляете всё содержимое пакета, а не только используемые части: для демо это честный размен, для приложения, которое должно расти, — плохой. Глубокий граф зависимостей, разрешаемый во время выполнения, означает, что браузер обнаруживает каждый модуль только после загрузки родительского, — именно поэтому CDN вмешиваются: esm.sh по умолчанию встраивает подмодули пакета в ответ, оставляя за скобками лишь те, которые общие для точек входа, объявленных в поле exports, а параметр ?bundle=false это отключает. И режим отказа здесь тихий: документ парсится, вёрстка отрисована целиком, а один модуль так и не приходит, потому что прокси, расширение или правило CSP заблокировали источник. Это как раз тот класс багов, который session replay выявляет быстрее, чем отчёт об ошибке, поскольку никакое исключение так и не было выброшено.

Для чего-либо существенного в продакшене используйте бандлер. Этот приём — для того, что бандлера не оправдывает.

Начните с чтения самого пакета, прежде чем написать хоть строку HTML: посмотрите список файлов, прочитайте main, module, exports и type и уже на этом основании решите, что вам нужно — тег script, import map или всё-таки этап сборки.

Часто задаваемые вопросы

Можно ли хранить import map в отдельном JSON-файле вместо инлайна в HTML?

Нет. Спецификация вообще запрещает элементу script с типом importmap иметь атрибут src, а также async, nomodule, defer, crossorigin, integrity и referrerpolicy, так что JSON должен находиться внутри документа. Если карта генерируется, рендерите её в страницу на стороне сервера, а не подключайте ссылкой, и располагайте выше первого module-скрипта.

Как загрузить две разные версии одного пакета на одной странице?

Используйте ключ scopes. Scope привязывает дополнительную карту спецификаторов к пути URL, поэтому скрипты, загруженные из-под этого пути, могут разрешать пакет в одну закреплённую версию, тогда как остальная часть страницы разрешает его в другую. Если совпадают сразу два scope, первым проверяется более длинный путь, а карта imports служит запасным вариантом. Более простая альтернатива — дать каждой версии собственный голый спецификатор.

Действуют ли import maps на web workers или на атрибут src тега script?

Нет. Карта переписывает спецификаторы только в инструкциях import и вызовах import() в самом документе. URL в атрибуте src тега script через неё не проходит, как и всё, что загружается внутри worker или worklet. Динамический импорт внутри модуля документа через карту разрешается, но входному скрипту worker и его собственным импортам нужны полные URL.

Что произойдёт, если голого спецификатора нет в import map?

Разрешение выбрасывает TypeError ещё до запуска модуля, и два движка формулируют это по-разному. Chrome сообщает, что не удалось разрешить спецификатор модуля, называет сам спецификатор и добавляет, что относительные ссылки должны начинаться с /, ./ или ../ (каждый из этих трёх вариантов в реальном сообщении заключён в кавычки). Firefox сообщает: The specifier “canvas-confetti” was a bare specifier, but was not remapped to anything. Relative module specifiers must start with “./”, “../” or “/”. В коде вашего приложения ничего не выбрасывается, поэтому страница отрисовывается нормально, и не работает только та функциональность, которая опирается на этот модуль.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.