Управление зависимостями в Javascript заходит на новый виток? Работа с ES модулями без сборщиков
Изначально эта статья задумывалась, как рассказ о различиях и назначении полей dependencies
, devDependencies
и peerDependencies
в package.json
. Эту тему выбрали ребята в моем телеграм-канале, кстати подписывайтесь, если еще не. Однако, когда я посмотрел количество контента на эту тему, то понял, что его достаточно даже в русском сегменте. При этом я прочитал одну статью, которая показалась мне очень хорошей, а также там были мысли на тему будущего управления зависимостями.
В итоге, я решил кратко пересказать вышеупомянутую статью, чтобы лучше самому усвоить тему, а также набросать проект по управлению зависимостями прямо на клиенте, через ES Modules. Так что вы можете прочитать либо оригинальную и полную статью у автора, либо сокращенную версию в первой половине этой статьи. А разбор работы ESM будет во второй половине.
История развития управления зависимостями
В далекие времена, которые, я полагаю, уже многие забыли, не было NodeJS, поэтому библиотеки или скрипты подключали напрямую в HTML с помощью тэга script
:
На место
необходимо поставить ссылку на js
файл. Как правило, это была ссылка на CDN:
В этом случае, мы полностью полагаемся на CDN-провайдера, так как не имеем контроля над тем, что загружаем. Но раньше при таком способе был бонус в виде кросс-доменного кэша, но это больше не актуально по причинам безопасности.
Чтобы иметь полный контроль, необходимо было скачать код библиотеки и хранить его в том же репозитории, что и приложение. Однако библиотек со временем становилось все больше, а управлять этим становилось все сложнее.
Здесь на свет вышел Bower — пакетный менеджер, который автоматизирует загрузку библиотек. При этом все, что с его появлением требовалось хранить в репозитории с приложением -bower.json
, который выглядит так:
{
"name": "my-app",
"dependencies": {
"react": "^16.1.0"
}
}
Здесь уже видно сходство с тем, что используется сейчас.
Достаточно было выполнить команду bower install
и Bower установит все зависимости, что есть в dependencies
. При этом использовать их в проекте можно было, как с помощью различных менеджеров задач по типу Grunt или Gulp, так и по старинке, через тег script
:
Также стоит отметить, что с появлением Bower на сцену вышло версионирование в том виде, к которому мы все привыкли. Так как Bower имеет свой собственный реестр пакетов, то возникла необходимость как-то в этом ориентироваться. Теперь достаточно было указать диапазон версий согласно SemVer для загрузки той или иной библиотеки.
Данная формализация и автоматизация в управлении зависимостями и появление различных модульных систем позволило разработчикам библиотек использовать третьи библиотеки, тем самым создав такой термин, как транзитивные зависимости.
Транзитивные зависимости
Ключевым моментом в понимании работы любого пакетного менеджера является понимание работы разрешения (resolution) зависимостей. В момент установки Bower подбирает подходящие зависимости согласно полю dependencies, но так как появляются и транзитивные зависимости, то процесс разрешения становится рекурсивным и представляет собой обход дерева.
Тут же стоит отметить, что помимо dependencies
с появлением Bower появилось и поле devDependencies
. По префиксу dev понятно, что здесь указываются все зависимости, которые помогают нам в разработке, но не нужны в самом коде приложения, то есть различные библиотеки для тестирования, форматирования и т.п. Пакетный менеджер при установке загрузит только прямые devDependencies
, а транзитивные проигнорирует:
Установка зависимостей с devDependencies
При этом в Bower все загруженные зависимости будут лежать в одной директории в плоском виде:
Пример плоской установки зависимостей в Bower
При такой структуре растет риск конфликтов, которые могут возникнуть, когда зависимости проекта зависят от разных версий одной и той же библиотеки:
Конфликт версий зависимости
Так как установка идет в одну директорию, то Bower не может установить несколько версий одного и того же пакета. В таком случае разработчику необходимо вручную выбрать какую версию необходимо использовать, что влечет дополнительные риски, если речь идет о мажорных версиях.
Для этой цели появилось поле resolutions
:
{
"resolutions": {
"library-d": "2.0.0"
}
}
Эта проблема выступала сильным ограничителем, поэтому не удивительно, что в скором времени нашли решение. И пришло оно из NodeJS, когда для этой платформы разрабатывался свой пакетный менеджер — NPM.
NPM изначально имел nested
модель разрешения зависимостей, то есть для каждой зависимости создается своя директория node_modules
, где хранятся ее собственные зависимости, что позволяет избежать конфликтов.
Пример вложенной установки зависимостей из NPM 1 и 2 версии
Однако такой подход также имеет свои недостатки, а именно проблемы с дублями пакетов и глубиной иерархии. Поэтому при безответственном подходе вес директории node_modules
мог стать колоссальным, а также можно было нарваться на ограничение максимальной длины путей на Windows.
В итоге в NPM 3 перешли на hoisted
модель разрешения, в которой менеджер пакетов старается расположить все пакеты на верхнем уровне. И только когда возникает конфликт версий, то создается отдельная вложенная директория node_modules
для конкретного пакета, где и располагается конфликтующая зависимость.
Пример установки зависимостей со всплытием из NPM 3
Принцип этой модели заключается в том, что NodeJS при поиске зависимости проходит по всей директории node_modules
снизу вверх, то есть «всплывает».
Разрешение модулей в NodeJS
Когда шла речь о Bower были представлены dependencies и devDependencies, которые также присутствуют и в NPM. Однако в NPM есть еще ряд полей с зависимостями, которые также подробно описаны в оригинальной статье, что я упомянул в начале, и еще здесь. Поэтому я пробегусь кратко. К dependencies
и devDependencies
добавляются:
peerDependencies — этот тип зависимостей чаще всего используется для разработки библиотек. Яркий пример — это
react-dom
— это библиотека для работы с DOM, которая подразумевает, что вы в своем проекте будете использоватьreact
. То естьreact-dom
не указывает явно, что ей нуженreact
, но почему? React может применяться в разных средах. Для front end разработки привычна связкаreact
иreact-dom
, однако для для мобильной разработкиreact-dom
не нужен, а нуженreact-native
. Таким образом peerDependencies указывает на связь, но не жестко, перекладывая ответственность за наличие нужной зависимости на разработчика.bundledDependencies — предназначены для тех случаев, когда вы упаковываете свой проект в один файл. Это делается с помощью команды
npm pack
, которая превращает вашу папку в тарбол (tarball-файл). Таким образом все зависимости идут сразу с пакетом и менеджер пакетов уже не резолвит их.optionalDependencies — эту директорию обычно используют для установки зависимостей, которые зависят от контекста. Например при различных сценариях CI/CD или операционной системы.
Однако про peerDependencies
хочется добавить еще пару моментов. NPM 7 и выше уже автоматически будет устанавливать недостающие peerDependencies
. При этом если версии буду конфликтовать, то установка упадет с ошибкой. Например следующая ошибка возникнет при попытке установить Storybook 6-ой версии в приложение с React 18:
Ошибка установки peerDependencies
Если запустить установку с флагом --force
или --legacy-peer-deps
, как подсказывает сам текст ошибки, то NPM будет работать как до NPM 7, но это может привести к проблемам с дубликатами.
Для решения подобных проблем в NPM по аналогии с Bower есть поле overrides, где можно решить эту проблему:
{
"dependencies": {
"react": "18.2.0"
},
"devDependencies": {
"@storybook/react": "6.3.13"
},
"overrides": {
"@storybook/react": {
"react": "18.2.0"
}
}
}
Как я уже писал ранее peerDependencies
, как правило, используются для разработки библиотек, которые требуют хост-библиотеку (в примере с react-dom
react
выступает хост-библиотекой). Однако некоторые библиотеки могут работать и без хост-библиотеки, то есть работать без нее в одном ключе, а с ней в другом. В таком случае зависимость от хост-библиотеки является опциональной и это также можно указать в package.json
через поле peerDependenciesMeta
:
{
"peerDependencies": {
"react": ">= 16"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
}
}
Однако не только NPM играет весомую роль в области управления зависимостями. Со временем появились аналоги: Yarn и PNPM. Они также внесли свой вклад и подтолкнули тот же NPM к развитию. Например, Yarn решил проблему возможности загрузить разные версии зависимостей в разный момент времени.
Обычно зависимости указывают не строго, ограничивая только мажорную версию, тем самым перекладывая ответственность на пакетный менеджер. В этом случае возникает риск того, что при разработке будет загружена одна зависимость, а в момент поставки кода на прод выйдет более свежая версия и именно она будет установлена, что может повлиять на поведение приложения. Конечно такая ситуация маловероятна, но все же возможна.
Для решения этой проблемы Yarn добавил lock-файл, который сохраняет результат процесса разрешения зависимостей, то есть сохраняет версии пакетов, что были установлены. В последствии, используя yarn.lock
, Yarn просто установит зависимости по списку, пропустив этап их разрешения. Это делает этап установки предсказуемым и более быстрым.
Установка при наличии yarn.lock
NPM также использовал этот метод и добавил свой lock-файл, чтобы учитывать его как основной источник, необходимо провести установку через команду npm ci
.
А также Yarn ускорил установку пакетов за счет локального кэша, который позволяет создать на своей машине собственный реестр пакетов, чтобы в процессе установки заменять сетевой запрос на копирование папок в файловой системе.
Yarn Cache
В свою очередь PNPM также решил еще одну проблему NPM, а именно проблему фантомных зависимостей. NPM использует hoisted
модель разрешения зависимостей, когда все пакеты «всплывают» на самый верх, а только дубли пакетов с другими версиями остаются на месте. Это поведение дает не очевидный на первый взгляд эффект. Все пакеты, что «всплыли» становятся доступными для импорта в приложении, хотя они могут быть не указаны как dependencies
в package.json
.
Использование транзитивной зависимости
А теперь представьте ситуацию, что вышел патч library-a
, который уже не использует library-b
. В этом случае приложение упадет, так как импорт library-b
закончится ошибкой.
Фантомная зависимость
В NPM следить за этим можно с помощью ESLint-плагина, а PNPM в отличие от NPM и Yarn не пытается сделать структуру node_modules
как можно более плоской, вместо этого он скорее нормализует граф зависимостей.
Пока что все, что мы видели больше напоминало дерево нежели граф. И действительно «nested» модель наиболее близка к структуре дерева, но по факту она просто дублирует зависимости, которые можно расположить в ориентированный ациклический граф.
Ромбовидные зависимости
В файловых структурах также была подобная дилемма, которую решили симлинки. Они позволяют создать ссылку на файл или директорию, вместо дублирования содержимого. Именно эту идею и использует PNPM.
После установки PNPM создаёт в node_modules директорию .pnpm, которая концептуально представляет собой хранилище ключ-значение. В этом файле ключом является название пакета и его версия, а значением — содержимое этой версии пакета.
Такая структура данных исключает возможность возникновения дубликатов. Структура самой директории node_modules будет подобна «nested»-модели из NPM, но вместо физических файлов там будут симлинки, которые ведут в то самое хранилище пакетов.
Структура node_modules с PNPM
В node_modules
каждого пакета будут находиться только симлинки на те пакеты, которые указаны у него в package.json
, что полностью избавляет от проблемы фантомных зависимостей и потребность в наличии ESLint-плагина отпадает.
В версии NPM 9 появился флаг install-strategy, значение «linked» в нём включает подобную PNPM модель установки с симликами, но на текущий момент вышел уже NPM 10, а эта фича остается экспериментальной.
Будущее развития управления зависимостями
Сейчас все больше библиотек переходят с CommonJS-модулей на EcmaScript-модули. В частности моя предыдущая статья о переходе с Webstorm на Cursor появилась благодаря тому, что msw второй версии имеет одну проблему с Jest. Из-за этого я начал переход на Vitest, что в свою очередь вызвало переход с Webstorm на Cursor, так старый Webstorm не поддерживал Vitest.
Как вы надеюсь помните, что первая часть этой статьи — это более менее краткая выдержка из другой статьи, в которой автор также поделился тем, что ESM — это, на его взгляд, будущее управления зависимостями. С момента написания той статьи прошло уже больше года, поэтому мне стало интересно попробовать реализовать приложение не используя NodeJS и сборщики. И сейчас я поделюсь тем, что у меня вышло.
Раньше интерактивность приложению мы добавляли, подключая скрипты прямо в HTML. Поэтому, на мой взгляд, иронично, что постепенно все возвращается к тому, от чего давно ушли. Но не буду забегать вперед и начну рассказ по порядку.
Прежде всего я начал искать информацию о полноценных SPA на ESM и ничего не нашел за исключением ряда статей в зарубежном сегменте:
How to use ESM on the web and in Node.js
Building a TODO app without a bundler
Developing Without a Build (1): Introduction
Вторая статья вызвала у меня наибольший интерес, так как там уже есть реализация приложения на ESM — классическое ToDo App, которое я и использовал, как пример. Также там вы найдете доклад 2019 года от Фреда Шотта, где он поднял вопрос почему нужны сборщики и нужны ли они вообще.
Основной посыл этого доклада, даже можно сказать ответ на вопрос: «Почему было бы здорово использовать ESM прямо на клиенте?» — это отказ от огромного инструментария. Только вспомните сколько часов за свою жизнь вы потратили на настройки того или иного сборщика или менеджера задач. К тому же не придется тратить время на саму сборку, что порой бывает утомительно. А также достигается идентичность окружения для разработки и прода.
С момента публикации этого доклада до выхода статьи прошло 5 лет. Изменилось ли что-то глобально за это время?
Нет.
NPM был создан для Node, Web нашел в свое время выход в виде сборщиков и развернуть эту машину очень трудно. Все библиотеки пишутся на CJS, а поддержку ESM добавляют не все. К тому же остались открыты еще ряд критических моментов:
Теряется возможность использовать такие инструменты как Typescript;
Нет возможности минифицировать код и использовать Tree-shaking;
Нет возможности использовать алиасы для импортов.
И на мой взгляд минусы пока перевешивают плюсы.
В любой случае, давайте рассмотрим как использовать ESM на примере старого доброго Lodash. Мы можем импортировать нужную нам функцию или всю библиотеку напрямую в любом js
файле:
import get from "https://esm.sh/lodash-es@4.17.21/get.js";
Но вставлять подобный путь каждый раз накладно, поэтому рекомендуется использовать тэг script
с типом importmap
:
После чего в любом js
файле можно будет использовать следующую нотацию:
import get from "get";
Рассмотрев, как работать с ESM, осталось выбрать ряд инструментов, которые потребуются для разработки. Я выбрал следующие:
preact — так как я пишу в основном на React;
htm — так как люблю jsx.
Эти две библиотеки могут работать в связке друг с другом, приближая разработку к тому, к чему я привык, работая с React.
import { h, render } from 'https://esm.sh/preact';
import htm from 'https://esm.sh/htm';
// Initialize htm with Preact
const html = htm.bind(h);
const MyComponent = (props, state) => html`
`;
render(htm`<${MyComponent} />`, container);
Также здесь вы можете посмотреть еще больше инструментов, которые можно использовать прямо на клиенте без сборщика.
С инструментами все, теперь разберем проект, что я набросал специально для этой статьи.
Чтобы запустить его локально установите serve и выполните команду npx serve
, находясь в директории проекта.
Начнем с index.html
:
Agify
Здесь подключаем готовые bootstrap-стили, нужные библиотеки и модуль App.mjs
, о котором и пойдет дальнейший рассказ.
import { render } from "preact";
import html from "./render.mjs";
import { Agify } from "./Agify/index.mjs";
import { Footer } from "./Footer/index.mjs";
import { Header } from "./Header/index.mjs";
import { Layout } from "./Layout/index.mjs";
const App = () => {
return html`
<${Layout} Header=${Header} Content=${Agify} Footer=${Footer} />
`;
};
render(html`<${App} />`, document.body);
Здесь вызывается render
из preact
, который отрисует приложение. Обратите внимание на компонент App
. Я набросал его схематично, чтобы показать синтаксис и способ передачи свойств в дочерние компоненты. Также отдельно стоит отметить функцию html
, если перейдете в файл render.mjs
, то увидите ту связку preact
и htm
, о которой я писал выше:
import { h } from 'preact';
import htm from 'htm';
export default htm.bind(h);
Все действительно очень близко к React. Правда так как jsx передается в функцию html jsx в виде строки, то в IDE нет подсветки. Для VS Code я обошел это через плагин lit-html. Lit — это еще одна библиотека, с помощью которой можно реализовать подобную задачу.
В модуле App.mjs
отрисовывается компонент Layout
, куда через свойства передаются ряд других компонентов: Header
, Agify
и Footer
. Все кроме Agify
отвечают за отображение соответствующих секций, а с Agify
все немного интереснее.
Для тех кто не в курсе, я скопировал приложение Agify из моей другой статьи о Typescript Generics. Так что, если кому интересна реализация этого же приложения на React, то добро пожаловать в песочницу. А здесь давайте посмотрим на код компонента Agify
:
import { useState } from "preact/compat";
import get from "get";
import html from "../render.mjs";
export const Agify = () => {
const [value, setValue] = useState("");
const [age, setAge] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
setLoading(true);
fetch(`https://api.agify.io?name=${value}`)
.then((res) => {
return res.json();
})
.then((data) => {
setAge(get(data, "age"));
})
.finally(() => {
setLoading(false);
});
};
const handleReset = () => {
setValue("");
setAge("");
};
return html`
${loading
? html`
Loading...
`
: ""}
Estimate your age based on your first name
`;
};
Agify представляет из себя обычное поле, куда мы должны ввести свое имя, кнопку поиска предполагаемого возраста по имени, текст с ответом и кнопку сброса. Ничего сложного.
Но интересно, что preact
предоставляет нам возможность использовать уже знакомый многим API React, в данном случае useState
, а также интересны примеры вложенного в условные операторы рендера html:
${age ? age : html``}
Подводя итоги, скажу, что, как и в 2019, как и в 2022, так и в 2024 году, сообщество не накопило достаточное количество удобных инструментов и подходов для реализации серьезных проектов без сборщиков. Приходится жертвовать слишком многим в пользу малого. Выбор инструментов — это компромисс между сложностью процесса сборки и производительностью + оптимизацией. Использование сторонних библиотек в приложении без сборки может оказаться затруднительным, если нет доступной версии ESM. К тому же я слишком люблю Typescript, чтобы отказаться от него.
Но в целом мне очень интересна мысль о том, что все ходит по спирали. И такой концепт приводит нас к тому, с чего все начиналось. Очень интересно узнать приведет ли.
Всем спасибо за уделенное время. Если вам понравилась статья, то подписывайтесь на мой телеграм-канал, где вы можете влиять на выбор темы для следующих статей, а также на мой YouTube-канал.