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

Оглавление

Вызов: восходящий DX, нисходящий UX

Прохождение кода

Задача: DX вверх по течению, UX вниз по течению

Я не собираюсь врать, ландшафт для объединения библиотек JavaScript совершенно сбивает с толку, особенно когда вы похожи на меня, и это мешает вам от самой интересной части — создания библиотеки!

Вот почему я решил разработать систему сборки и рабочий процесс для создания и тестирования библиотек TypeScript, который работает универсально, независимо от системы сборки приложения потребителя, будь то ESM, AMD или Common JS («это просто работает»™).

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

Помимо базовой универсальности, как мы можем достичь этого, используя лучшие инструменты, доступные в экосистеме JavaScript, для приятного опыта разработки (DX), не жертвуя обратной и кросс-браузерной совместимостью, и позволяя оптимизировать сборку, например встряхивание дерева ( UX)?

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

Терминология

Давайте начнем с выравнивания игрового поля, прояснив некоторые жаргонные термины:

  • Минификация: (обычно) автоматизированный процесс сведения кода к его минимально возможному синтаксису. Имена переменных иногда сокращаются до одной буквы, пробелы удаляются, а весь код помещается в одну строку.
  • Объединение: (обычно) автоматизированный процесс объединения модулей в один файл; это сделано для того, чтобы при обслуживании страницы для загрузки JavaScript по сети необходимо было сделать только один HTTP-запрос. В зависимости от вашей системы сборки это может быть очень преднамеренно настроено, например, чтобы определенные сценарии интерпретировались раньше других, или, если вы используете модули ES (ESM), это может быть то, что сборщик обрабатывает за вас.
  • Bundle: JavaScript, который был объединен и минимизирован в один краткий файл, так что для его загрузки нужно сделать только один HTTP-запрос, и он должен быть как можно меньше, чтобы обеспечить более высокую скорость загрузки. «Разделение кода» на нисходящем направлении — это сознательное нисходящее действие по стратегическому разбиению таких пакетов на более мелкие части для повышения скорости загрузки страницы.
  • Транспиляция: автоматизированный процесс, который использует более современные функции и синтаксис JavaScript и преобразует их в JavaScript, понятный большинству браузеров конечных пользователей. Это часто включает в себя преобразование более новых синтаксисов в более старые и добавление кода полифилла там, где функции ранее не были доступны. Там, где производительность является проблемой, знание вашего потребителя является ключом к тому, чтобы избежать добавления ненужных полифиллов к выходным данным вашей сборки.
  • Цель транспиляции: минимальные требования к браузерам или средам Node, которые вы хотите поддерживать при выполнении своего кода.
  • Модуль: единица кода, которую можно повторно использовать по ссылке из других единиц кода; чаще всего файл, экспорт которого может быть использован через импорт из другого файла

С этого момента мы будем ссылаться на код, который вы хотите отправить, как на «библиотеку», даже если это может быть единый экспорт с ограниченной функциональностью.

Системы, использующие код вашей библиотеки, называются «потребители», и, в зависимости от того, что делает ваша библиотека, потребители могут ссылаться на серверные процессы, одноразовые сценарии, клиентские приложения или даже другие библиотеки.

Я буду часто использовать прилагательное «вниз по течению», чтобы показать, что потребители вашей библиотеки могут иметь несколько уровней глубины (т. е. приложение может косвенно использовать библиотеку, которая использует библиотеку, которая напрямую устанавливает ваш код). как зависимость).

Демистификация загрузки модуля: история

До января 2009 года (Node.js был представлен в мае того же года), кроме того, что было возможно в браузере за счет ненадежного разбиения JavaScript на теги сценариев, включенные в HTML-страницы, не было единого мнения о том, как модулировать блоки кода JavaScript, которые могли бы повторно использовать в средах, отличных от браузера (т. е. на серверах).

Модульность заключалась в написании набора тегов сценария с неявными зависимостями, которые вам [приходилось] заказывать вручную [Источник: RequireJS https://requirejs.org/docs/whyamd.html]. Эти теги сценария, если они не относятся к исходному коду, размещенному в том же проекте, должны быть загружены по сети. Кроме того, возможно, эти скрипты выполняли некоторые собственные асинхронные задачи, прежде чем присваивать значение переменной или глобальному свойству.

Это было особенно проблематично при упорядочении тегов сценария на HTML-странице (потому что порядок этих <script> имел значение!): логика представления, которая должна происходить после асинхронных сетевых вызовов данных, должна была быть определена в первую очередь, чтобы такая логика могла быть вызвана после сетевой вызов прошел успешно.

Если вы попытаетесь использовать $ из jQuery до того, как включите ссылку CDN на jQuery, ваш JS выйдет из строя; то же самое с любыми переменными, определенными в наших собственных <script>s. Было приятно разделить функциональность на несколько сценариев по интересам, но по мере того, как наши сайты становились больше, управление взаимозависимостями между этими файлами становилось все более запутанным.

Переменные волшебным образом были доступны в глобальном пространстве имен, если они были определены в <script>, который механизм рендеринга HTML предварительно проанализировал. Вы должны были быть очень осторожны, чтобы не загромождать глобальное пространство имен, обязательно используя замыкания — раскрывая только те переменные, которые нужны другим сценариям.

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

Эта ситуация была хороша для небольших простых страниц. Но веб-приложения? Удачи!

Введите CommonJS (CJS)

CommonJS был ответом Node.js на эту проблему для целых 12 версий.

Если вы писали Node.js на стороне сервера, вы, скорее всего, видели CommonJS (CJS) на практике. Включение внешних единиц кода с помощью require() и предоставление единиц кода для использования другими модулями через module.exports — вот что делает CJS сразу узнаваемым в средах Node.

…и определение асинхронного модуля (AMD)

AMD отошла от CommonJS, чтобы решить две основные проблемы, связанные с браузером:

  1. сетевая загрузка зависимостей (и собственная потенциальная асинхронность этих зависимостей)

2. определение модуля

RequireJS обычно абстрагируется от AMD. Если вы видели что-то подобное, вы видели AMD в действии:

define(['jquery'] , function ($) {
  // jQuery's loaded! Do stuff with that almighty $
});

Что было хорошо, так это то, что $ jQuery имел имя в скрипте, а не был «волшебным» образом доступен только потому, что он был включен в HTML до этого тега скрипта, который ссылался на этот скрипт, и порядок модулей не был больше повода для беспокойства — ваш скрипт запустится при загрузке его зависимостей.

Примечание. Мой опыт работы с RequireJS и AMD весьма ограничен, поэтому приветствуются любые предложения по улучшению этого раздела!

Определение универсального модуля (UMD)

UMD был ответом на преодоление разрыва между AMD и CJS, чтобы разработчик мог писать модули, которые работали как в браузере, так и на сервере. Это выглядит немного эзотерически, но на высоком уровне это буквально IIFE, который просто условно экспортирует содержимое вашего модуля соответствующим образом в зависимости от значения this (глобальный объект, т. е. global в Node и window в браузере):

(function (root, factory) {
  if (typeof exports === 'object') {
    // CommonJS
    module.exports = factory(require('b'));
  } else if (typeof define === 'function' && define.amd) {
    // AMD
    define(['b'], function (b) {
      return (root.returnExportsGlobal = factory(b));
    });
  } else {
    // Global Variables
    root.returnExportsGlobal = factory(root.b);
  }
}(this, function (b) {
  // Your actual module
  return {};
}));

Обратите внимание на оператор CJS module.exports, если exports является объектом (а не undefined), и на оператор RequireJS-esque, если define является функцией.

Однако вы никогда не писали этот код самостоятельно. Вы бы использовали систему сборки с такими инструментами, как Browserify и Grunt/Gulp, чтобы преобразовать код, который вы написали, в универсальный код.

Примечание. Мой опыт работы с UMD довольно ограничен, поэтому приветствуются любые предложения по улучшению этого раздела!

Итак, вот оно что! Разве это не ответ? UMD работает как с CJS, так и с AMD, поэтому, если я отправлю UMD, я буду поддерживать почти всех?

Любой, кто занимался веб-разработкой в ​​последние годы, сейчас насмехается. Не совсем!

В течение почти 10 лет стандарт почти полностью переместился к совершенно отдельному игроку: ESM — и по многим, многим веским причинам.

ESM (модули ECMAScript / модули ES)

ESM теперь является стандартизированной системой загрузки модулей для JavaScript. Не вдаваясь в мелкие подробности, модули ES решают все проблемы, которые решают CJS и AMD, и даже больше.

Если вы использовали интерфейсный фреймворк или использовали TypeScript в средах Node.js, вы, несомненно, видели синтаксис ESM:

import SomeDefaultImport, { someNamedImport } from '../someModule'
// ...
export { someNamedExport }
export default SomeDefaultExport

С ESM, прежде чем произойдет какая-либо оценка кода, строится граф зависимостей записей модулей на основе импорта и экспорта, которые вы написали. Это дает вашей библиотеке — и конечным пользователям ваших потребителей — возможность статического анализа вашего кода и устранения мертвого кода, что иногда называют «встряхиванием деревьев». С помощью сборщика потребителя, такого как Vite, Snowpack, Webpack или Rollup, написанные вами модули, которые не нужны потребителю вашей библиотеки, не попадают в этот граф зависимостей, и браузеру конечного пользователя не нужно загружать неиспользуемый код.

Node.js, начиная с версии 12, также начал поддерживать ESM.

Вход и выход: решения, решения

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

Давайте выложим наши желаемые входы и выходы:

Ввод (сопровождающий библиотеку DX)

Мы хотим написать нашу библиотеку на TypeScript, чтобы класс ошибок TypeError мог быть обнаружен в нашем редакторе еще до того, как код нашей библиотеки запустится. Этот код нужно будет преобразовать в JavaScript, чтобы потребители могли использовать нашу библиотеку при написании на JavaScript. Кроме того, если потребитель решит также использовать TypeScript, мы должны предоставить объявления типов, чтобы упростить процесс разработки TS для потребителя. Подробнее об этом позже.

Выходы (библиотека-потребитель DX)

Именно здесь на вас как на автора библиотеки начинают падать бесчисленные решения, касающиеся того, как оптимизировать код, отправляемый потребителям node_modules. Пошаговое руководство по коду в этой статье предполагает, что вам нужна оптимизированная «кухонная раковина», но на самом деле она может вам не понадобиться или даже хотеться, в зависимости от ваших вариантов использования.

Знай своего потребителя

Рассмотрите для себя следующие вопросы:

1) Кто мои целевые потребители?

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

Наилучший выбор здесь — это несвязанный ESM, потому что, по-видимому, собственный сборщик приложений потребителя (Webpack, Snowpack, Vite) будет встряхивать мой код, который они не используют, а затем транспилировать для обратной совместимости (Babel) и связывать.

Возможно, потребителями моей библиотеки будут страницы, работающие в браузерах, поддерживающих ESM.

Это звучит резко и сухо. Просто отправьте ESM в разобранном виде! Но все не так просто:

Возможно, набор функций вашей библиотеки зависит от какой-то передовой технологии. Допустим, вы пишете библиотеку для расширения или упрощения использования контейнерных запросов (на момент написания — июнь 2021 г. — контейнерные запросы поддерживались только в Chrome с включенным флагом функции согласия пользователя). Если оставить в стороне регрессии, можно с уверенностью предположить, что, поскольку версии браузеров, которые «поддерживают» контейнерные запросы, также являются версиями, которые должны поддерживать ESM «из коробки», вы можете просто поставить ESM.

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

И опять же, должен ли этот код быть в комплекте? Возможно, используемые потребители веб-сервера не поддерживают HTTP/2, который имеет возможность «мультиплексировать» или объединять запросы на файлы по одному TCP-соединению. HTTP/2 имеет довольно широкую поддержку браузеров примерно с 2015 года, но также имеет значительное ограничение работы только через TLS (HTTPS).

Если браузер застрял на HTTP/1.1, он застрял с 1 TCP-соединением для каждого файла вашей библиотеки, которое требуется браузеру пользователя для объединения вашей библиотеки. Это соответствует потенциально многим, многим нисходящим сетевым запросам даже для построения графа зависимостей ESM потребителя — в отличие от загрузки пакета, где они получат только один (хотя и довольно большой) файл JavaScript на одном запрос. В качестве альтернативы, если веб-сервер потребителя поддерживает HTTP/2, вы можете просто обслуживать отдельные ESM.

Если вы планируете выпустить эту библиотеку с открытым исходным кодом, вам лучше использовать как связанный, так и отдельный ESM. Вы не можете контролировать, какие веб-серверы используют ваши потребители и поддерживают ли их среды разработки и производства HTTPS и HTTP/2. Если вы имеете контроль над всеми этими факторами, возможно, вам понадобится отдельное ESM.

Возможно, потребителями моей библиотеки будутсерверы.

Серверы узлов, начиная с версии 12.0.0, могут использовать импорт стиля ESM с помощью расширения .mjs, а предыдущие версии могут поддерживать его через пакет esm . Независимо от того, используете ли вы ESM или CJS на сервере, объединение и минимизация кода вашей библиотеки не нужны, потому что потребитель будет считывать модули вашей библиотеки напрямую из файловой системы, а не из сети. В результате нет смысла объединять код вашей библиотеки в один мини-файл пакета, потому что вы не пытаетесь оптимизировать пакет, который необходимо отправить по сети.

Что все это означает? Нам нужен разделенный CJS для поддержки старых версий Node и разделенный ESM для поддержки новых версий или тех проектов со старыми установками Node.js, которые используют пакет esm.

Возможно, потребителями моей библиотеки будутбраузеры, использующие RequireJS или AMD, или они специально расширят свои функциональные возможности.

Пакет UMD будет охватывать ваши базовые возможности, а также поддерживать CJS, но, возможно, вы пытаетесь очень конкретно увеличить использование RequireJS или AMD — пакет AMD может быть вашим лучшим и, возможно, единственным вариантом.

Примечание. Мой опыт работы с RequireJS и AMD весьма ограничен, поэтому приветствуются любые предложения по улучшению этого раздела. Еще одно соображение, которое я хотел бы сделать здесь, заключается в том, что если ваши потребители уверены, что используют исключительно AMD, в какой степени полифиллинг UMD раздувает большие пакеты и насколько сильно ударит по производительности использование UMD по сравнению с чистым AMD? Если у вас есть опыт в этом, я буду рад вашему мнению!

Возможно, потребителями моей библиотеки будутдругие библиотеки.

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

2) Как потребители будут использовать код моей библиотеки?

Мы можем исключить целые этапы сборки для нашей библиотеки, исключив то, как библиотека не будет использоваться:

  • Является ли библиотека компонентом внешнего интерфейса? Это в значительной степени исключает все форматы модулей, кроме разделенного ESM, потому что этот фреймворк, несомненно, будет иметь свой собственный сборщик приложений (Vite, Webpack, Snowpack).
  • Является ли библиотека исключительно серверной? Это устраняет необходимость поддержки AMD.
  • Можно ли использовать библиотеку везде, где используется JavaScript, например, для обработки данных? Вам, вероятно, потребуется поддерживать все форматы, объединенные и разделенные, где это необходимо.

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

Пошаговое руководство по коду

Примечание. Статья Матиаса Ремшардта «Свертывание многомодульной системы (esm, cjs…), совместимой с библиотекой npm с помощью TypeScript и Babel», послужила отправной точкой для написания этого руководства. Я бы порекомендовал ознакомиться с их статьей для получения дополнительного контекста.

Результаты и их дальнейшее использование

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

  1. Отдельный ESM, для потребления в
  • одностраничные приложения с системами сборки, поддерживающими ESM, которые будут обрабатывать целевую транспиляцию сборки (ES6/7 → ES6/5) и минимизацию в соответствии со спецификациями потребителей для нас (т. е. create-реагировать-приложение, SvelteKit, Next.js):
import React from 'react'
import MyLib from 'my-lib'
  • последующие современные браузеры, поддерживающие HTTP/2 и ESM, например:
<script type="module">
  import { myNamedExport } from 'my-lib'
  // ...
</script>

2. Связанный ESM для использования нижестоящими современными браузерами, НЕ поддерживающими HTTP/2, но поддерживающими ESM, например:

<script type="module">
  import { myNamedExport } from 'my-lib/lib/index.esm.min.js'
  // ...
</script>

3. Разделенный CJS, для использования в основном серверами (и редко клиентскими приложениями, сборщики которых работают только с CJS):

const myLib = require('my-lib')

4. Связанный UMD для использования клиентскими приложениями, использующими AMD или RequireJS:

<script>
  require.config({
    paths: {
      myLib: 'libs/my-lib/lib/index.umd.min.js'
    }
  });
</script>
<script>
  require(['myLib'], function(myLib) {
    // ...
  })
</script>

Демо-библиотека

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

Экспорт нашей точки входа в библиотеку по умолчанию будет литералом объекта arithmetic, имеющим четыре метода: sum, difference, product и quotient. Потребители смогут import arithmetic from 'my-lib' и делать arithmetic.sum(1, 1) // 2.

Мы также хотим, чтобы они могли исключать difference, product и quotient из своей сборки, когда потребители используют только sum! Как нам это сделать и предоставить хороший API? Мы можем по умолчанию экспортировать эти функции из файлов в корне нашего проекта, позволяя нашим потребителям выполнять import sum from 'my-lib/sum' и sum(1, 1) // 2. Этот вариант использования действительно удобен для таких вещей, как библиотеки компонентов, где потребители, вероятно, не захотят использовать ваши сотни компонентов только для того, чтобы использовать, скажем, 10 из них.

Другим классическим примером такого использования, которое вы, возможно, видели, является lodash: эта библиотека позволяет вам выполнять import { isEqual } from 'lodash', но вам нужно загрузить всю эту библиотеку, чтобы получить один экспорт. Они также позволяют вам делать import isEqual from 'lodash/isEqual', чтобы вам не приходилось тащить все ненужное в свой комплект.

Давайте создадим каталог для проекта. Вы можете создать его в любом месте, где вы обычно пишете код. Мы называем этот проект my-lib :

# Command Line
mkdir my-lib
cd my-lib

Давайте создадим папку src — в ней будет размещаться весь код, относящийся к библиотеке, который мы пишем, потому что это будет каталог, в котором будет работать наша система сборки. Затем мы создадим четыре модуля и точку входа index.js:

# Command Line
mkdir src
# Entry point default export
touch src/arithmetic/sum.ts
touch src/arithmetic/difference.ts
touch src/arithmetic/product.ts
touch src/arithmetic/quotient.ts
touch src/arithmetic/index.ts
# Additional entry points for individual modules
touch src/sum.ts
touch src/difference.ts
touch src/product.ts
touch src/quotient.ts
# Library entry point
touch src/index.ts

Начнем с папки src/arithmetic/, потому что именно там будет определена основная часть кода. Практически все остальное предназначено исключительно для управления экспортом кода.

// src/arithmetic/sum.ts
export type SumFunction = (a: number, b: number) => number
export const sum: SumFunction = (a: number, b: number) => a + b

Следует отметить, что в дополнение к простому экспорту функции мы аннотируем ее типом, который мы собираемся поставлять с библиотекой. Будут типы и для других арифметических функций, которые будут выглядеть почти так же. Мы делаем это только в демонстрационных целях, чтобы убедиться, что типы можно будет импортировать из окончательного вывода сборки. Ввод становится немного интереснее с типом QuotientFunction, который мы разработаем так, чтобы он не работал, когда делитель равен 0. Вот остальные функции:

// src/arithmetic/difference.ts
export type DifferenceFunction = (a: number, b: number) => number
export const difference: DifferenceFunction = (a: number, b: number) => a - b
// src/arithmetic/product.ts
export type ProductFunction = (a: number, b: number) => number
export const product: ProductFunction = (a: number, b: number) => a * b
// src/arithmetic/quotient.ts
export type QuotientFunction = (a: number, b: number) => number | never
export const quotient: QuotientFunction = (a: number, b: number) => {
  if (b === 0) throw new Error('Cannot divide by 0!')
  return a / b
}

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

// src/arithmetic/index.js
import { sum } from './sum'
import type { SumFunction } from './sum'
import { difference } from './difference'
import type { DifferenceFunction } from './difference'
import { product } from './product'
import type { ProductFunction } from './product'
import { quotient } from './quotient'
import type { QuotientFunction } from './quotient'
export type Arithmetic = {
  sum: SumFunction
  difference: DifferenceFunction
  product: ProductFunction
  quotient: QuotientFunction
}
/** Named exports */
export { sum, difference, product, quotient }
export type {
  SumFunction,
  DifferenceFunction,
  ProductFunction,
  QuotientFunction,
}
/** The object literal we plan to default export from the entire library */
const arithmetic: Arithmetic = {
  sum,
  difference,
  product,
  quotient,
}
export default arithmetic

Это захватывает все наши функции и вставляет их как методы в наш объект arithmetic, а также экспортирует всю энчиладу по умолчанию. Так как это наш index.js, этот файл также служит файлом «бочки» для других модулей, которые мы создали — если новым частям нашей библиотеки нужны arithmetic или даже ее части sum, difference, product или quotient, они можно просто захватить их, импортировав из '/path/to/arithmetic'.

В этот момент наши потребители могли бы использовать arithmetic с import arithmetic from 'my-lib/arithmetic', но это не так удобно и удобно в использовании, как могло бы быть. Давайте сделаем все это доступным из корня, чтобы потребитель мог делать import arithmetic from 'my-lib':

// src/index.ts
import arithmetic from './arithmetic'
export * from './arithmetic'
export default arithmetic

И это так просто! Мы указываем наш экспорт по умолчанию, и этот глобальный экспорт шунтирует все наши именованные экспорты из нашего файла ствола.

Как насчет того, чтобы позволить нашим потребителям встряхивать методы, которые им не нужны, как в тех библиотеках компонентов и lodash примерах? Мы делаем что-то очень похожее для всех наших альтернативных точек входа:

// src/sum.ts
import { sum } from './arithmetic'
export * from './arithmetic/sum'
export default sum
// src/difference.ts
import { difference } from './arithmetic'
export * from './arithmetic/difference'
export default difference
// src/product.ts
import { product } from './arithmetic'
export * from './arithmetic/product'
export default product
// src/quotient.ts
import { quotient } from './arithmetic'
export * from './arithmetic/quotient'
export default quotient

Пакет.json

Вместо того, чтобы запускать проект с помощью pnpm (или что у вас есть), вероятно, проще просто создать package.json с нуля:

# Command Line
touch package.json

и добавьте следующее:

// package.json
{
  "name": "my-lib",
  "version": "0.0.1",
  "description": "Your description here",
  "author": "Your Name Here <Your Email Here>",
  "keywords": [
    "your keywords here"
  ],
  "license": "MIT",
  "main": "lib/cjs/index.js",
  "module": "lib/index.js",
  "exports": {
    ".": "./lib/index.js",
    "./sum": "./lib/sum",
    "./difference": "./lib/difference",
    "./product": "./lib/product",
    "./quotient": "./lib/quotient"
  },
  "files": [
    "lib/",
    "README.md",
    "LICENSE"
  ],
  "repository": {
    "type": "git",
    "url": "your repo URL here"
  },
  "scripts": {},
  "dependencies": {},
  "devDependencies": {},
  "peerDependencies": {}
}

Давайте поговорим о некоторых вещах в этом package.json, потому что, если вы обычно не являетесь автором библиотеки, возможно, есть некоторые поля, которые вы не привыкли видеть. Для всех остальных ознакомьтесь с документацией package.json npm.

  • name: само собой разумеется, название вашей библиотеки. Однако это идет с некоторым багажом. Изменение этого параметра после публикации вашей библиотеки в реестре npm приведет к публикации совершенно новой библиотеки с новым именем, если она еще не занята.
  • version: семантическая версия ("semver") библиотеки. Вам нужно будет корректировать это соответствующим образом каждый раз, когда вы публикуете в реестре npm, в зависимости от того, представляют ли ваши изменения (в порядке справа налево) простой патч, незначительное изменение, которое вводит новые функции или улучшения без нарушения каких-либо API-интерфейсов. текущие потребители библиотеки могли уже использовать, или серьезное изменение, которое нарушает существующие API-интерфейсы или иным образом вносит фундаментальные изменения в вашу библиотеку.
  • main: Это должно указывать на корень/индекс вашей библиотеки. Мы установили для нас значение "lib/cjs/index.js", чтобы при компиляции нашего TypeScript в каталог lib/ потребители могли импортировать основной импорт библиотеки оттуда. Современные сборщики будут искать свойства "module" и "exports.import", поэтому наш вывод CJS является хорошим запасным вариантом.
  • module: Недокументированное в документации package.json npm, это поле фактически предназначено для нижестоящего сборщика потребителя. Это указывает их сборщику на точку входа ESM в вашу библиотеку.
  • exports: Вот как мы направляем потребителей к соответствующей точке (точкам) входа в библиотеку. Мы настраиваем это, чтобы позволить пользователям захватить экспорт по умолчанию (основной) ("."), а также удобный способ получить именованные экспорты, которые позволят потребителям встряхнуть все модули, которые они не используют. Если вам интересно, как получить, например, import quotient from 'my-lib/quotient', вот как разрешается эта дополнительная точка входа.
  • types: Это должно указывать на основной файл объявления в вашей библиотеке, который мы будем выводить в папку types/. В Документах TypeScript указывается, что добавление этого зависит от предпочтений: либо вы можете выводить свои файлы *.d.ts вместе с вашими выходными файлами JS (убедившись, что index.d.ts включен в корень пакета), либо вы можете разделить их все на некоторые типы. / выходной каталог и указать на файл объявления индекса. Мы решили сделать последнее.
  • files: Это список файлов, которые должны быть включены в поставляемый пакет. Ваш фактический TypeScript, составляющий библиотеку, не будет включен. Все, что вам обычно нужно для отправки, — это окончательная сборка, лицензия и файл readme.
  • Примечание относительно dependencies против devDependencies против peerDependencies:

dependencies — это те зависимости, от которых зависит ваша библиотека и которые также потребуются средам ваших потребителей для запуска вашего кода.

devDependencies — это те зависимости, которые связаны только с разработкой вашей библиотеки. Таким образом, любой пакет, который вы используете для тестирования, транспиляции, предварительной обработки или минимизации вашего кода, который не попадет в производственную сборку вашей библиотеки, является devDependency. Примеры: typescript, средства запуска тестов и библиотеки утверждений, упаковщики и плагины для упаковщиков, загрузчики файлов, препроцессоры, линтеры, библиотеки принудительного исполнения кода, такие как prettier, eslint и husky, и их плагины. Не отправляйте эти инструменты своим потребителям!

peerDependencies - это те пакеты, которые, как вы предполагаете, ваши потребители уже установили, потому что ваша библиотека предназначена для некоторого расширения функциональности таких пакетов. Здесь все становится сложнее: почему бы просто не отправлять такие пакеты как dependencies?

  • Один из ответов заключается в том, что потребителям, скорее всего, будет трудно использовать определенную версию общего пакета; они не могут просто установить ту, от которой часто зависит ваша библиотека, потому что это нарушит установку других библиотек, от которых зависит потребитель. Вот почему одноранговые зависимости часто определяются в package.json с диапазонами номеров версий, которые поддерживает текущая версия вашей библиотеки.
  • Другой ответ заключается в том, что библиотеки, написанные таким образом, часто рассматривают одноранговую зависимость как «хост-пакет». Классической иллюстрацией этой взаимосвязи является компонент, написанный для использования с пакетом React. react должен быть одноранговой зависимостью для таких библиотек, потому что для правильной работы пакета может быть только один экземпляр React. Диапазоны номеров версий для хост-пакета также играют здесь важную роль: продолжая пример React, если ваш компонент React использует функцию ловушек, представленную в React 16.8.0, ваша библиотека требует, чтобы потребитель установил >=16.8.0.

Компиляция TypeScript и определение целей транспиляции

Для этого первого преобразования нашего кода мы можем объединить компиляцию TypeScript и транспиляцию обратной совместимости в один шаг, используя Babel, используя его с его плагином TypeScript. Прежде чем мы это сделаем, давайте объясним, почему все это необходимо:

Компиляция TypeScript

Мы не можем ожидать, что наши потребители будут использовать TypeScript, но если да, то как мы можем обеспечить хороший опыт для этих разработчиков? Почему мы не можем просто предоставить им TypeScript, тем более что у этих потребителей, скорее всего, уже есть система сборки, которая уже компилирует TypeScript?

Большинство систем сборки, использующих TypeScript, игнорируют node_modules при компиляции из-за невозможности обработки включения всего каталога node_modules в свои системы сборки. Если вы работали с большими приложениями с помощью TypeScript, вы, вероятно, сталкивались с проблемами роста при компиляции большого приложения — это может занять значительное время, особенно если вы просматриваете большую файловую систему и перекомпилируете сохраненные изменения. В результате доставка TS напрямую не является соглашением, и потребители не узнают, что вы это делаете, если ваши документы не вызовут этого или если они фактически не откроют вашу библиотеку из своего каталога node_modules.

Так что же мы делаем вместо этого? Если компиляция удаляет все аннотации типов, как мы можем передать эту информацию потребителям библиотеки? Файлы деклараций. Если вы когда-либо видели файл с расширением *.d.ts, вы видели один из них. Это сгенерированные файлы, которые отделяют ваши аннотации типов от вашего кода; кроме того, если вы используете синтаксис ESM для экспорта аннотаций типов, они будут доступны вашим потребителям для импорта и статического ввода своих реализаций вашего кода.

Транспиляция JavaScript

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

Давайте все это настроим!

Сначала нам понадобится несколько пакетов. Все это будет devDependencies:

  • typescript: хотя, скорее всего, это установлено другими разработчиками в вашей команде, устраните любые потенциальные головные боли, сделав двоичный файл tsc доступным из проекта, чтобы он был там, когда они установят после удаления вашего репозитория.
  • @babel/cli: мы будем использовать этот двоичный файл в наших сценариях сборки package.json.
  • @babel/core: Вавилон. Для тех, кто не знаком, это инструмент для преобразования более многофункциональных версий JavaScript в синтаксис и полифиллы, которые могут читать старые браузеры.
  • @babel/preset-typescript: Транспиляция TypeScript в JavaScript (Статья Матиаса Ремшардта **рационализирует этот инструмент как более гибкий, чем tsc для определения целей транспиляции для разных сред, и мы увидим это в конфигурации Babel ниже
  • @babel/preset-env: преобразование с обратной совместимостью из нашего многофункционального JavaScript в версии, поддерживаемые в браузерах и средах Node, которые будут использовать наши потребители (иногда называемое целью преобразования)

Если вы надеетесь, что другие разработчики с открытым исходным кодом внесут свой вклад в ваш проект, вы захотите сделать им одолжение, гарантируя, что сценарии разработки, которые мы собираемся написать, будут запускаться в командной строке на кросс-платформенной платформе (Mac и Windows). Для этого в проекте можно установить несколько служебных двоичных файлов:

  • cross-env: общий вариант использования этого двоичного файла — запуск скриптов в контексте переменных среды, указанных в командной строке, что мы собираемся сделать, чтобы сообщить Babel и Rollup, какой формат модуля (ESM, CJS, UMD, в комплекте или отдельно) мы строим. Это делается с использованием различных синтаксисов в разных операционных системах, так что это уравнивает игровое поле.
  • rimraf: то же самое. Команда Unix rm -rf для рекурсивного удаления каталогов работает не во всех ОС, и мы хотим, чтобы она начиналась с чистого листа после того, как мы предварительно создали нашу библиотеку.

Примечание. Мы используем pnpm, который предлагает единый магазин, альтернативный npm и yarn, который значительно быстрее, чем эти менеджеры пакетов, но вы можете выбрать то, к чему вы привыкли. На момент написания pnpm был довольно новым и имел открытые проблемы с созданием символических ссылок, поэтому мы будем использовать yarn позже для этой цели, если вы хотите сохранить согласованность и использовать yarn на всем пути.

Установите эти пакеты с флагом -D, чтобы установить их как devDependencies — мы не хотим, чтобы они проникали в сборки наших потребителей.

# Command line
pnpm add -D typescript @babel/core @babel/cli @babel/preset-typescript @babel/preset-env cross-env rimraf

Создайте файл .babelrc.js (это дословно взято из Статьи Матиаса Ремшардта, но мы собираемся немного распаковать его и устранить некоторые расхождения, которые вы, возможно, захотите сделать):

# Command line
touch .babelrc.js
// .babelrc.js
/**
 * Config fragments to be used by all module
 * format environments
 */
const sharedPresets = ['@babel/preset-typescript']
const sharedIgnoredFiles = ['src/**/*.test.ts']
const sharedConfig = {
  ignore: sharedIgnoredFiles,
  presets: sharedPresets,
}
/**
 * Shared configs for bundles (ESM and UMD)
 */
const bundlePresets = [
  [
    '@babel/preset-env',
    {
      targets: '> 0.25%, not dead',
    },
  ],
  ...sharedPresets,
]
const bundleConfig = {
  ...sharedConfig,
  presets: bundlePresets,
}
/**
 * Babel Config
 */
module.exports = {
  env: {
    esmUnbundled: sharedConfig,
    esmBundled: bundleConfig,
    umdBundled: bundleConfig,
    cjs: {
      ignore: sharedIgnoredFiles,
      presets: [
        [
          '@babel/preset-env',
          {
            modules: 'commonjs',
          },
        ],
        ...sharedPresets,
      ],
    },
    test: {
      presets: ['@babel/preset-env', ...sharedPresets],
    },
  },
}

На высоком уровне это экспортирует объект конфигурации для использования Babel, который определяет четыре возможные среды, в которых наш код может работать после транспиляции:

  1. ЕСМ, отдельно
  2. ЭСМ в комплекте,
  3. UMD, в комплекте и
  4. CJS (обособленно)

Мы хотим скомпилировать TypeScript и игнорировать тестовые файлы нашей библиотеки для каждой цели компиляции, поэтому имеет смысл абстрагировать эту конфигурацию в объект, который может использовать каждая среда (также была добавлена ​​среда test, которая не игнорирует тестовые файлы).

Для esmUnbundled компиляция TS и игнорирование тестов — это все, что нам нужно.

Для esmBundled мы создаем очень широкую сеть поддержки браузерами через запрос browserslist ('> 0.25%, not dead'), который является зависимостью, поставляемой вместе с Babel. Для наших целей приведение широкой сети поддержки браузера — это нормально, но, вспоминая предыдущий пример с библиотекой контейнерных запросов, если вы полагаетесь на новые функции JavaScript или дополняете их, вы можете оказать своим потребителям медвежью услугу, приведя тоже далеко от сети и транспиляция слишком далеко назад — транспиляция, в конце концов, в значительной степени представляет собой полифилл-код, добавленный для того, чтобы ваш код работал в старых браузерах. Для чего-то подобного, возможно, имеет смысл использовать минимальные версии определенных браузеров, например единственную версию Chrome, которая поддерживает контейнерные запросы (на момент написания статьи).

Хотя настройка целей транспиляции с помощью browserslist (которое, в двух словах, объединяет Могу ли я использовать в запрашиваемый пакет) гораздо более гибкая, чем указание определенной версии ES, она может быть собственной проблемной банкой червей, потому что она по своей сути чувствительна ко времени: как выпускаются новые версии браузеров, по мере распространения новых версий браузеров и по мере того, как поставщики браузеров прекращают поддержку браузеров и их самых старых версий, browserslist данные устаревают. И по мере старения вашей библиотеки, поскольку browserslist является зависимостью от Babel, вам необходимо постоянно обновлять Babel, чтобы убедиться, что ваш запрос на поддержку браузера актуален.

См. https://github.com/browserslist/browserslist#full-list для получения полного списка browserslist запросов, которые вы можете сделать для точной настройки поддержки браузером ваших связанных целей транспиляции.

Это точно такая же (сложная) история для umdBundled — мы просто будем полагаться на то, что Rollup сделает что-то немного другое с этим выводом на следующем шаге.

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

Для cjs нам нужно фактически преобразовать синтаксис импорта/экспорта ESM, который мы используем с TypeScript, в синтаксис CommonJS require() и module.exports, и мы можем сделать это с module: 'cjs' или module: 'commonjs' (оба работают одинаково).

Речь идет только об учетных записях для компиляции TypeScript и переноса в целевые браузеры JavaScript (и версии Node до 12.0.0). Однако мы потратили много времени на обсуждение файлов объявлений TypeScript — как их получить? Мы собираемся использовать двоичный файл tsc (компилятор TypeScript) из typescript для автоматического написания этих объявлений для нас, и мы указываем это, добавляя его в качестве параметра компилятора в файл tsconfig.json в корне нашего проекта:

# Command Line
touch tsconfig.json
// tsconfig.json
{
  "compilerOptions": {
    "outDir": "lib",
    "target": "es6",
    "module": "esnext",
    "moduleResolution": "node",
    "lib": [
      "es2015",
      "dom"
    ],
    "sourceMap": true,
    "strict": true,
    "strictFunctionTypes": true,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "alwaysStrict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "skipLibCheck": true,
    "declaration": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "lib", "**/*.test.ts"]
}

"declaration": true — самое важное свойство, которое нам здесь понадобится. Также важно отметить наш "outDir", который установлен в нашу папку lib, которая будет создана Rollup, и наши реквизиты "include" и "exclude". Нам не нужно компилировать ни наши тесты, ни наши собственные node_modules, и как только мы создадим нашу папку lib/, запустив нашу цепочку сборки в первый раз, мы не хотим, чтобы компилятор TS начал компилировать это тоже непреднамеренно!

Объединение и сборка выходных данных

Теперь, когда у нас есть некоторые инструкции по компиляции TS в JS и транспиляции нового, блестящего JS в более узнаваемый JS, мы должны вывести наш транспилированный JavaScript в файловую систему, чтобы приложения/библиотеки наших потребителей могли получить доступ и получить его. Некоторые выходные данные (одна из наших сборок ESM и наша сборка UMD) должны быть объединены (конкатенированы и минимизированы), чтобы обеспечить быструю загрузку в браузере.

Все это будет происходить в файле конфигурации нашего сборщика, который будет состоять из инструкций для сборщика под названием rollup, который мы будем запускать через командную строку, чтобы автоматически записывать наши выходные данные сборки в каталог /lib в нашем проекте.

Нам нужно установить rollup и несколько плагинов его экосистемы как devDependencies:

  • rollup: упаковщик. Rollup — это в значительной степени стандарт экосистемы JS для объединения библиотек. Подобные сборщики, такие как Webpack, Snowpack и Vite, более или менее ориентированы на объединение приложений.
  • rollup-plugin-terser: плагин-минимизатор, который берет наш транспилированный объединенный код и делает его как можно меньше для передачи по сети.
  • @rollup/plugin-babel: помните, что здесь есть два всеобъемлющих шага. Этот плагин управляет сложностью хранения вывода первого шага где-то в нашей файловой системе до того, как окончательный вывод попадет в свое окончательное место в нашей сборке.
  • @rollup/plugin-node-resolve: этот пакет сообщает Rollup, как разрешать импорт из нашей собственной библиотеки node_modules. Возможно, вам это не понадобится, если вашей библиотеке не нужны никакие dependencies или peerDependencies, но, зная JS, это маловероятно, и к этому стоит обратиться. Кроме того, если вы используете разрешения файлов в стиле Node (где ссылка на имя каталога разрешает файл index.js этой папки, например, в файле «barrel»), вам также понадобится этот плагин.
# Command Line
pnpm add -D rollup rollup-plugin-terser @rollup/plugin-babel @rollup/plugin-node-resolve
// rollup.config.js
import babel from '@rollup/plugin-babel'
import { nodeResolve } from '@rollup/plugin-node-resolve'
import { terser } from 'rollup-plugin-terser'
console.log(`
-------------------------------------
Rollup building bundle for ${process.env.BABEL_ENV}
-------------------------------------
`)
const extensions = ['.js', '.ts']
export default {
  input: 'src/index.ts',
  output: [
    ...(process.env.BABEL_ENV === 'esmBundled'
      ? [
          {
            file: 'lib/bundles/bundle.esm.min.js',
            format: 'esm',
            sourcemap: true,
          },
        ]
      : []),
    ...(process.env.BABEL_ENV === 'umdBundled'
      ? [
          {
            file: 'lib/bundles/bundle.umd.min.js',
            format: 'umd',
            name: 'my-lib',
            sourcemap: true,
          },
        ]
      : []),
  ],
  plugins: [
    nodeResolve({ extensions }),
    babel({
      babelHelpers: 'bundled',
      include: ['src/**/*.ts'],
      extensions,
      exclude: './node_modules/**',
    }),
    terser(),
  ],
}

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

Вы заметите, во-первых, что мы включаем сюда только те среды формата модуля, для которых мы создаем пакеты (UMD и ESM). Мы не собираемся связывать CJS, потому что он в основном предназначен для использования на сервере, и наш код не будет загружаться по сети. А для ESM мы хотим включить одну версию без пакетов, чтобы нижестоящие сборщики могли встряхивать биты кода в нашей библиотеке, которые не используют потребители.

Вы также заметите, что мы делаем это условно, основываясь на переменной среды, которая предположительно где-то установлена ​​(мы поговорим о том, где на следующем шаге). Мы используем троичное выражение в тандеме с оператором расширения для условного добавления данных в массив output, если этого требует среда; и наоборот, если переменная среды не совпадает, распространение по пустому массиву гарантирует, что эта конфигурация не будет добавлена ​​в файл output. Таким образом, мы можем запускать сборки определенного формата модуля с одним и тем же файлом конфигурации, просто переключая переменную среды.

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

Мы добавили sourcemap: true к каждому пакету, так что если в нашем пакетном коде возникнет ошибка, трассировка будет включать наш фактический код, а не место в минимизированном JavaScript, где произошла ошибка.

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

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

Скрипты сборки

Теперь мы вернемся к package.json, чтобы настроить скрипты, которые сообщат Babel, Rollup и tsc для запуска с нашей настройкой, в зависимости от того, собираем ли мы пакет.

Очистка между сборками

Давайте добавим скрипт, который поможет нам очистить список между сборками:

// package.json
{
// ...
"scripts": {
    "clean": "rimraf lib"
  },
// ...
}

Это очищает весь каталог lib. В любое время, когда нам нужно увидеть выходные данные сборки для определенного формата модуля, будет полезно сначала очистить lib/ out, потому что, хотя наша текущая сборка может перезаписывать файлы желательным образом, файлы цепочки сборки не перезаписываются. касание из-за изменений, которые вы вносите в конфигурацию, останется на месте — это смехотворно сложно, и будет легче рассуждать, если каждый раз это будет с чистого листа.

Вы можете проверить это, вручную создав каталог lib/ в корне вашего проекта, запустив:

# Command Line
pnpm clean

и убедитесь, что созданная вами папка lib/ была удалена.

ESM (отдельно)

Начнем с самой простой среды сборки. Мы пишем на ESM и TypeScript, поэтому единственное, что нужно сделать, — это компиляция TS и транспиляция в целевой JS.

// package.json
{
// ...
"scripts": {
    "clean": "rimraf lib", // (note: new comma delimit a new script)
    "build:esm": "cross-env BABEL_ENV=esmUnbundled babel src --extensions '.ts' --out-dir 'lib'"
  },
// ...
}

Итак, раньше, когда я говорил о различиях сред сборки, я имел в виду это. BABEL_ENV=esmUnbundled — это то, как устанавливается эта переменная среды. Мы добавляем к нему префикс cross-env для кроссплатформенной поддержки env var на Mac и Windows.

Сущность команды — babel src. Бинарный файл babel по умолчанию знает, что мы хотим вызвать его с нашим корневым .babelrc.js. Если вы откроете его снова, вы помните, что мы разделили конфигурацию Babel на несколько сред.

BABEL_ENV — это специальная переменная среды, которую Babel использует для доступа к свойствам объекта env, который мы написали, поэтому в данном случае будет использоваться env.esmUnbundled, потому что esmUnbundled — это значение, которое мы передали переменной среды в нашем сценарии npm. Имея все это в виду, вот производная конфигурация Babel:

/**
 *  [ Derived code for demonstration purposes (don't copy) ]
 *  The resulting Babel config for BABEL_ENV=esmUnbundled
 */
{
  ignore: ['src/**/*.test.ts'],
  presets: ['@babel/preset-typescript'],
}

Так что на самом деле все, что мы говорим, это скомпилировать TypeScript с плагином Babel и игнорировать тесты. Никакой конкатенации, никакой минификации — вот и все. Мы берем все с расширением .ts (--extensions '.ts') в наши npm-скрипты, отправляем через Babel с приведенной выше производной конфигурацией, а затем выводим в lib/ (--out-dir 'lib).

Давайте запустим его и посмотрим, что произойдет!

pnpm clean && pnpm build:esm

Посмотрите на весь этот ванильный JavaScript! Если вы откроете только что созданный файл lib/arithmetic/index.js, вы увидите, что типы отсутствуют — мы учтем их позже! В остальном наш код почти не поврежден! Это версия кодовой базы, которую будут использовать потребители, использующие свои собственные сборщики. Поскольку ESM является стандартом, одно из решений, которое мы приняли, заключается в том, что несвязанная сборка ESM выводится в корневом каталоге lib/.

CJS (отдельно)

Для нашего другого формата несвязанных модулей это очень похожая история:

// package.json
{
  // ...
  "scripts": {
    "clean": "rimraf lib",
    "build:esm": "cross-env BABEL_ENV=esmUnbundled babel src --extensions '.ts' --out-dir 'lib'", // (note: new comma!)
    "build:cjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts' --out-dir 'lib/cjs'"
  },
	
  // ...
}

Мы запускаем babel в контексте переменной cjs BABEL_ENV, что составляет следующую производную конфигурацию:

/**
 *  [ Derived code for demonstration purposes (don't copy) ]
 *  The resulting Babel config for BABEL_ENV=cjs
 */
{
  ignore: ['src/**/*.test.ts'],
  presets: [
    [
      '@babel/preset-env',
      {
        modules: 'commonjs',
      },
    ],
    '@babel/preset-typescript',
  ],
}

С этой конфигурацией мы обрабатываем все файлы с расширением *.ts и на этот раз выводим их в собственный каталог lib/cjs/ с --out-dir 'lib/cjs'.

Давайте запустим это. Очистите предыдущую сборку и запустите сборку CJS:

# Command Line
pnpm clean && pnpm build:cjs

Как видите, здесь происходит довольно много полифиллинга транспиляции! Все наши именованные экспорты были преобразованы в вызовы Object.defineProperty() объекта exports. Однако есть одна вещь, которую следует отметить по этому поводу.

Все это будет стерто, как только мы запустим pnpm clean, но создадим файл test.js в lib/arithmetic/ :

# Command Line
touch lib/arithmetic/test.js

Если вы используете VS Code, обратите особое внимание на параметры автозавершения, которые вы получаете при попытке require литерала объекта arithmetic, который наша библиотека экспортирует как default:

Это не совсем то, чего мы ожидали, не так ли? Что случилось с default в качестве раскрывающегося списка здесь? Одно важное несоответствие между ESM и CJS заключается в том, что CJS на самом деле не имеет концепцию экспорта default, и поэтому, когда мы транспилируем, экспорт CJS, который мы выводим, представляет собой объект module.exports со свойством default, и это почему VSCode говорит нам, что мы можем сделать arithmetic.default.

Если бы мы удалили именованные экспорты из этого файла, выпадающий список не заполнил бы sum, difference, product и quotient напрямую — вы бы просто получили default. Почему? Потому что в этот момент, после преобразования из ESM в CJS, вы экспортируете объект со свойством default и все. Чтобы использовать эти функции, вам нужно будет выполнить arithmetic.default.sum и так далее.

Итак, что мы можем сделать по этому поводу?

  • Мы можем написать нашу библиотеку на CJS, чтобы получить API типа const arithmetic = require('my-lib') Это не рекомендуется! Экосистема переходит на ESM, поэтому вы и ваши сотрудники захотите писать в ESM.
  • Мы можем реорганизовать наш файл, чтобы использовать исключительно именованные экспорты и получить такой API: const { arithmetic } = require('my-lib'), и задокументировать наш API как таковой.
  • Мы можем реорганизовать наш файл, чтобы использовать исключительно экспорт по умолчанию и получить такой API: const { default: arithmetic } = require('my-lib'), и задокументировать наш API как таковой.
  • Оставьте как есть. Если вы это сделаете, вы получите лучшее из обоих миров, имея возможность импортировать литерал объекта, например:
const { default: arithmetic } = require('my-lib')
// - OR -
const arithmetic = require('my-lib').default

… и названный экспорт, например:

const { sum, difference, product, quotient } = require('my-lib')

Хотя получившийся в результате API не имеет родного ощущения от CommonJS, ландшафт в любом случае смещается в сторону стандарта ES Modules, и поддержка идиоматических функций по умолчанию и именованного экспорта для этого формата модуля, вероятно, более желательна в долгосрочной перспективе, чем поддержка идиоматического импорта. синтаксис CommonJS.

За исключением любых изменений API, которые вы хотели бы внести после этого откровения, давайте перейдем к нашим объединенным форматам.

ESM (в комплекте)

Здесь на помощь приходит Роллап! Для отдельных форматов модулей мы использовали интерфейс командной строки Babel для загрузки в нашу конфигурацию. На этот раз мы будем использовать JavaScript API Babel, который он предоставляет в своем плагине Rollup. Итак, хотя мы не будем вызывать babel из нашего скрипта сборки, наша конфигурация Rollup будет. Вот почему по-прежнему важно определить переменную BABEL_ENV:

// package.json
{
  // ...
  "scripts": {
    "clean": "rimraf lib",
    "build:esm": "cross-env BABEL_ENV=esmUnbundled babel src --extensions '.ts' --out-dir 'lib'",
    "build:cjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts' --out-dir 'lib/cjs'", // (note: new comma!)
    "build:esmBundled": "cross-env BABEL_ENV=esmBundled rollup -c rollup.config.ts"
  },
  // ...
}

Итак, мы просто запускаем rollup с флагом конфигурации -c, указывающим на наш файл конфигурации Rollup. Откройте этот файл и посмотрите, что произойдет, когда мы запустим его с BABEL_ENV=esmBundled. Свойство output в конечном итоге только распространяет конфигурацию ESM, и это сводится к следующему:

/**
 *  [ Derived code for demonstration purposes (don't copy) ]
 *  The resulting Rollup config for BABEL_ENV=esmBundled
 */
export default {
  input: 'src/index.ts',
  output: [
    {
      file: 'lib/bundles/bundle.esm.min.js',
      format: 'esm',
      sourcemap: true,
    },
  ],
  plugins: [
    nodeResolve({ extensions }),
    babel({
      babelHelpers: 'bundled',
      include: ['src/**/*.ts'],
      extensions,
      exclude: './node_modules/**',
    }),
    terser(),
  ],
}

Вы увидите, что в output мы указали один file для размещения всех наших модулей. Rollup следует графу зависимостей нашей библиотеки от нашей точки входа (input: 'src/index.ts'), чтобы надлежащим образом объединить все наши файлы. Вызов terser() дополнительно минимизирует вывод, поэтому также был установлен sourcemap: true, что позволяет проверять любые ошибки из нашей библиотеки более полезным способом, чем распечатывать минимизированные искажения из одной строки одного файла.

Здесь не так очевидно то, что вызов babel(...) в plugins запускается в контексте среды BABEL_ENV=esmBundled, поэтому babel загружает наш .babelrc.js и обращается к свойству esmBundled env. Давайте проследим, что там происходит, убрав часть абстракции; env.esmBundled был установлен на bundleConfig, который определен ранее в файле. Если вы распакуете эту переменную, конфигурация для нашего связанного ESM будет выглядеть так:

/**
 *  [ Derived code for demonstration purposes (don't copy) ]
 *  The resulting Babel config for BABEL_ENV=esmBundled
 */
{
  ignore: ['src/**/*.test.ts'],
  presets: [
    [
      '@babel/preset-env',
      {
        targets: '> 0.25%, not dead',
      },
    ],
    '@babel/preset-typescript'
  ],
}

Итак, как и раньше, мы компилируем TypeScript и игнорируем любые тесты, которые есть в нашей библиотеке. Но на этот раз есть шаг для преобразования скомпилированного JS в желаемую цель транспиляции.

Давайте посмотрим все это в действии!

# Command line
pnpm clean && pnpm build:esmBundled

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

UMD (в комплекте)

На наш последний модульный формат! Мы собираемся немного поэкспериментировать, прежде чем придем к окончательному сценарию сборки UMD. Мы собираемся отказаться от связывания, чтобы убедиться, что наша конфигурация создает вывод UMD, аналогичный UMD IIFE, который мы видели, когда впервые представили его.

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

Добавьте реквизит umdUnbundled в .babelrc.js:

// .babelrc.js
module.exports = {
	env: {
		// ...
		umdUnbundled: {
      ...sharedConfig,
      presets: [
        ...sharedPresets,
        [
          '@babel/preset-env',
          {
            targets: '> 0.25%, not dead',
            modules: 'umd',
          },
        ],
      ],
    },
		// ...
	}
}

Переключитесь на package.json и добавьте скрипт "build:umdUnbundled":

// package.json
{
  // ...
  "scripts": {
    "clean": "rimraf lib",
    "build:esm": "cross-env BABEL_ENV=esmUnbundled babel src --extensions '.ts' --out-dir 'lib'",
    "build:cjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts' --out-dir 'lib/cjs'",
    "build:esmBundled": "cross-env BABEL_ENV=esmBundled rollup -c rollup.config.ts", // (note: new comma to delimit multiple scripts)
    "build:umdUnbundled": "cross-env BABEL_ENV=umdUnbundled babel src --extensions '.ts' --out-dir 'lib/umd'"
  },
  // ...
}

Запустить его:

pnpm clean && pnpm build:umdUnbundled

Вы увидите, что мы получили каталог lib/umd/, и если вы проверите все наши модули, вы увидите, что наши модули ESM были перенесены в шаблон UMD IIFE if/else if/else, который мы видели ранее!

Вы можете безопасно удалить несвязанный скрипт UMD, который мы добавили в package.json, и пару ключ/значение umdUnbundled, которую мы добавили в .babelrc.js. Мы решили, что нам нужна только версия UMD в комплекте.

// package.json
{
  // ...
  "scripts": {
    "clean": "rimraf lib",
    "build:esm": "cross-env BABEL_ENV=esmUnbundled babel src --extensions '.ts' --out-dir 'lib'",
    "build:cjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts' --out-dir 'lib/cjs'",
    "build:esmBundled": "cross-env BABEL_ENV=esmBundled rollup -c rollup.config.ts",
    "build:umdBundled": "cross-env BABEL_ENV=umdBundled rollup -c rollup.config.ts"
  }
  // ...
}

Как и в случае с пакетом ESM, мы просто запускаем rollup, а он запускает babel от нашего имени со следующей конфигурацией, полученной из контекста среды, созданного BABEL_ENV=umdBundled:

/**
 *  [ Derived code for demonstration purposes (don't copy) ]
 *  The resulting Babel config for BABEL_ENV=umdBundled
 */
{
  ignore: ['src/**/*.test.ts'],
  presets: [
    [
      '@babel/preset-env',
        {
	  targets: '> 0.25%, not dead',
	},
    ],
    '@babel/preset-typescript'
  ],
}

Запустите этот скрипт:

# Command Line
pnpm clean && pnpm build:umdBundled

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

Файлы объявлений TypeScript

У нас есть все наши форматы! Но мы еще не совсем там! Нам все еще нужно сгенерировать наши файлы объявлений TypeScript. Это довольно легко. Мы собираемся использовать tsc для компиляции нашего TypeScript, генерируем только наши файлы объявлений (поскольку Babel уже обрабатывает компиляцию TS с помощью своего плагина TS) и помещаем их в нашу папку lib/types/:

// package.json
{
  // ...
  "scripts": {
    "clean": "rimraf lib",
    "build:esm": "cross-env BABEL_ENV=esmUnbundled babel src --extensions '.ts' --out-dir 'lib'",
    "build:cjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts' --out-dir 'lib/cjs'",
    "build:esmBundled": "cross-env BABEL_ENV=esmBundled rollup -c rollup.config.ts",
    "build:umdBundled": "cross-env BABEL_ENV=umdBundled rollup -c rollup.config.ts", // (note: new comma!)
    "build:declarations": "tsc -p tsconfig.json"
  }
  // ...
}

А затем запустите его:

# Command Line
pnpm clean && pnpm build:declarations

Вы увидите, что сгенерированная папка lib/types/ имитирует форму нашего проекта, но создает только файлы *.d.ts и файлы их исходных карт.

Один сценарий сборки, чтобы управлять ими всеми

Четыре несовместимых скрипта вряд ли подходят для повседневной разработки и непрерывного развертывания: мы не можем просто забыть поставлять UMD в версии 4! Итак, давайте создадим скрипт, который будет запускать все это и обеспечит одновременную сборку всех наших форматов. Это очень просто. Мы можем просто использовать возможность компоновки сценариев npm для создания одного всеобъемлющего сценария build.

Но сначала! Нам нужен двоичный файл, который поможет нам запустить несколько сценариев npm, как последовательно, так и параллельно:

# Command Line 
pnpm add -D npm-run-all
// package.json
{
	
  // ...
  "scripts": {
    "clean": "rimraf lib",
    "build:esm": "cross-env BABEL_ENV=esmUnbundled babel src --extensions '.ts' --out-dir 'lib'",
    "build:cjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts' --out-dir 'lib/cjs'",
    "build:esmBundled": "cross-env BABEL_ENV=esmBundled rollup -c rollup.config.ts",
    "build:umdBundled": "cross-env BABEL_ENV=umdBundled rollup -c rollup.config.ts",
    "build:declarations": "tsc -p tsconfig.json", // (note: new comma to delimit multiple scripts)
    "build": "npm-run-all -l clean -p build:esm build:cjs build:esmBundled build:umdBundled build:declarations"
  }
  // ...
}

-l — это просто флаг, который поможет визуализировать, какой скрипт в данный момент выполняется в командной строке. Первый скрипт, который мы запускаем, это clean, который запускает наш rimraf lib точно так же, как и раньше. Это выполняется последовательно и должно завершиться перед последовательностью параллельных (-p) сценариев. Любые дополнительные сценарии сборки, которые вы хотите включить, должны быть включены сюда.

Вы можете вызвать его так:

# Command line
pnpm build

Вы увидите очень красочный вывод, который запускает наш скрипт clean, а затем все наши сценарии build: параллельно. После его завершения папка lib/ будет состоять из корня вашего пакета в ESM для потребителей, которые будут использовать свои собственные сборщики, пакетов для ESM и UMD, сборки CJS и всех наших объявлений типов. Все сходится!

Режим просмотра

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

Мы также собираемся использовать nodemon.

# Command Line
pnpm add -D nodemon

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

# Command Line
touch nodemon.json
// nodemon.json
{
  "ignore": ["**/*.test.*", "node_modules", "lib"]
}

Каталога "lib" достаточно, чтобы избежать сценария бесконечного перезапуска, но не помешает игнорировать наши тестовые файлы (у таких исполнителей тестов, как Jest, часто есть свои собственные наблюдатели) и нашу папку node_modules.

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

// package.json
{
  // ...
  "scripts": {
    "clean": "rimraf lib",
    "build:esm": "cross-env BABEL_ENV=esmUnbundled babel src --extensions '.ts' --out-dir 'lib'",
    "build:cjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts' --out-dir 'lib/cjs'",
    "build:esmBundled": "cross-env BABEL_ENV=esmBundled rollup -c rollup.config.ts",
    "build:umdBundled": "cross-env BABEL_ENV=umdBundled rollup -c rollup.config.ts",
    "build:declarations": "tsc -p tsconfig.json", 
    "build": "npm-run-all -l clean -p build:esm build:cjs build:esmBundled build:umdBundled build:declarations", // (note: new comma to delimit multiple scripts)
    "build:watch": "nodemon -L -e ts,json,js --config ./nodemon.json --exec \\"pnpm build\\""
  }
 
  // ...
}

Самое главное здесь — это флаг --exec, который сообщает nodemon, что он на самом деле постоянно перезапускает наш скрипт npm вместо запуска файла Node. Мы указываем на конфигурацию, которую мы написали с --config ./nodemon.json, мы смотрим расширения файлов (-e) *.ts, *.json и *.js, и я избавлю вас от некоторых ошибок по поводу необходимости запуска nodemon в «устаревшем режиме» с -L.

Итак, когда вы запускаете:

# Command Line
pnpm build:watch

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

Симлинкинг

Этот скрипт build:watch довольно изящный, но его истинная мощь раскрывается только тогда, когда вы можете использовать свою собственную библиотеку на своем локальном компьютере в другом проекте, и вы можете немедленно принять участие в изменениях, которые вы вносите в библиотеку в другом проекте, как если бы это было опубликовано. Какая альтернатива? Публикуете и увеличиваете номер версии вашего демонстрационного проекта? Даже не думай об этом! Пожалуйста, не публикуйте непроверенный код!

Симлинкинг или создание так называемой «символической ссылки» между двумя местами в файловой системе звучит намного страшнее, чем есть на самом деле. Концептуально это очень просто. Вместо того, чтобы устанавливать свою библиотеку из реестра npm через npm install, yarn add или pnpm add, вы предоставляете свою библиотеку потребляющему проекту на своем компьютере, по сути создавая портал для своей библиотеки.

Лучший инструмент для этого — yarn link. К сожалению, на момент написания статьи у pnpm link были открытые проблемы. Если вам не нужно мгновенное удовлетворение от возможности немедленно увидеть изменения, которые вы сделали в своей библиотеке, установка глобального пакета yalc, вероятно, будет вашим лучшим выбором.

Первым шагом из корня вашего проекта является сборка библиотеки в режиме разработки:

# Command Line
pnpm build:watch

Откройте другую вкладку/окно в вашем терминале, чтобы nodemon оставался активным, и из корня проекта запустите:

# Command Line
yarn link

Затем cd в корень любого демонстрационного проекта, в котором вы хотите протестировать API вашей библиотеки, и запустите то, что предлагает вывод yarn link:

# Command Line
yarn link "my-lib"

Вы должны увидеть, что my-lib добавлено к node_modules другого проекта, и вы должны иметь возможность импортировать любые модули, которые вы предоставили в этом проекте, как если бы библиотека была установлена ​​из реестра.

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

Символические ссылки

При символической ссылке с yarn link вам может понадобиться воссоздать ссылку в какой-то момент (например, вы запустили yarn link в неправильном месте или вам нужно переместить каталоги).

Вы можете попробовать yarn link в новом каталоге проекта, но yarn будет жаловаться, что для вашей библиотеки уже существует ссылка, указывающая на старое местоположение. Чтобы это исправить, вы можете удалить ссылку из конфигурации Yarn. Для этой команды я рекомендую вводить полный путь к файлу перед rm -rf (или эквивалентной командой Windows) — мы бы не хотели запускать rm -rf ~ или rm -rf ~/.config!

# Command Line
rm -rf ~/.config/yarn/my-lib

Это удалит символическую ссылку из конфигурации пряжи, и вы сможете запускать yarn link из нужного места!

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

Цели выполнены!

Это все для этой статьи! Надеюсь, вы лучше понимаете состояние модулей в экосистеме JavaScript, проект-катализатор для создания вашей идеальной системы сборки библиотек и рабочий процесс для тестирования API вашей библиотеки, реагирующего на локальные изменения.

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

Спасибо за кодирование вместе!