Управление зависимостями в Javascript заходит на новый виток? Работа с ES модулями без сборщиков

от автора

Изначально эта статья задумывалась, как рассказ о различиях и назначении полей dependencies, devDependencies и peerDependencies в package.json. Эту тему выбрали ребята в моем телеграм-канале, кстати подписывайтесь, если еще не. Однако, когда я посмотрел количество контента на эту тему, то понял, что его достаточно даже в русском сегменте. При этом я прочитал одну статью, которая показалась мне очень хорошей, а также там были мысли на тему будущего управления зависимостями.

В итоге, я решил кратко пересказать вышеупомянутую статью, чтобы лучше самому усвоить тему, а также набросать проект по управлению зависимостями прямо на клиенте, через ES Modules. Так что вы можете прочитать либо оригинальную и полную статью у автора, либо сокращенную версию в первой половине этой статьи. А разбор работы ESM будет во второй половине.

История развития управления зависимостями

В далекие времена, которые, я полагаю, уже многие забыли, не было NodeJS, поэтому библиотеки или скрипты подключали напрямую в HTML с помощью тэга script:

<script src="<URL>"></script>

На место <URL> необходимо поставить ссылку на js файл. Как правило, это была ссылка на CDN:

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js"></script>

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

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

Здесь на свет вышел Bower — пакетный менеджер, который автоматизирует загрузку библиотек. При этом все, что с его появлением требовалось хранить в репозитории с приложением —bower.json, который выглядит так:

{   "name": "my-app",   "dependencies": {     "react": "^16.1.0"   } }

Здесь уже видно сходство с тем, что используется сейчас.

Достаточно было выполнить команду bower install и Bower установит все зависимости, что есть в dependencies. При этом использовать их в проекте можно было, как с помощью различных менеджеров задач по типу Grunt или Gulp, так и по старинке, через тег script:

<script src="bower_components/jquery/dist/jquery.min.js"></script>

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

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

Транзитивные зависимости

Транзитивные зависимости

Ключевым моментом в понимании работы любого пакетного менеджера является понимание работы разрешения (resolution) зависимостей. В момент установки Bower подбирает подходящие зависимости согласно полю dependencies, но так как появляются и транзитивные зависимости, то процесс разрешения становится рекурсивным и представляет собой обход дерева.

Тут же стоит отметить, что помимо dependencies с появлением Bower появилось и поле devDependencies. По префиксу dev понятно, что здесь указываются все зависимости, которые помогают нам в разработке, но не нужны в самом коде приложения, то есть различные библиотеки для тестирования, форматирования и т.п. Пакетный менеджер при установке загрузит только прямые devDependencies, а транзитивные проигнорирует:

Установка зависимостей с devDependencies

Установка зависимостей с devDependencies

При этом в Bower все загруженные зависимости будут лежать в одной директории в плоском виде:

Пример плоской установки зависимостей в Bower

Пример плоской установки зависимостей в Bower

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

Конфликт версий зависимости

Конфликт версий зависимости

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

Для этой цели появилось поле resolutions:

{   "resolutions": {     "library-d": "2.0.0"   } } 

Эта проблема выступала сильным ограничителем, поэтому не удивительно, что в скором времени нашли решение. И пришло оно из NodeJS, когда для этой платформы разрабатывался свой пакетный менеджер — NPM.

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

Пример вложенной установки зависимостей из NPM 1 и 2 версии

Пример вложенной установки зависимостей из NPM 1 и 2 версии

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

В итоге в NPM 3 перешли на hoisted модель разрешения, в которой менеджер пакетов старается расположить все пакеты на верхнем уровне. И только когда возникает конфликт версий, то создается отдельная вложенная директория node_modules для конкретного пакета, где и располагается конфликтующая зависимость.

Пример установки зависимостей со всплытием из NPM 3

Пример установки зависимостей со всплытием из NPM 3

Принцип этой модели заключается в том, что NodeJS при поиске зависимости проходит по всей директории node_modules снизу вверх, то есть «всплывает».

Разрешение модулей в NodeJS

Разрешение модулей в NodeJS

Когда шла речь о Bower были представлены dependencies и devDependencies, которые также присутствуют и в NPM. Однако в NPM есть еще ряд полей с зависимостями, которые также подробно описаны в оригинальной статье, что я упомянул в начале, и еще здесь. Поэтому я пробегусь кратко. К dependencies и devDependencies добавляются:

  1. peerDependencies — этот тип зависимостей чаще всего используется для разработки библиотек. Яркий пример — это react-dom — это библиотека для работы с DOM, которая подразумевает, что вы в своем проекте будете использовать react. То есть react-dom не указывает явно, что ей нужен react, но почему? React может применяться в разных средах. Для front end разработки привычна связка react и react-dom, однако для для мобильной разработки react-dom не нужен, а нужен react-native. Таким образом peerDependencies указывает на связь, но не жестко, перекладывая ответственность за наличие нужной зависимости на разработчика.

  2. bundledDependencies — предназначены для тех случаев, когда вы упаковываете свой проект в один файл. Это делается с помощью команды npm pack, которая превращает вашу папку в тарбол (tarball-файл). Таким образом все зависимости идут сразу с пакетом и менеджер пакетов уже не резолвит их.

  3. optionalDependencies — эту директорию обычно используют для установки зависимостей, которые зависят от контекста. Например при различных сценариях CI/CD или операционной системы.

Однако про peerDependencies хочется добавить еще пару моментов. NPM 7 и выше уже автоматически будет устанавливать недостающие peerDependencies. При этом если версии буду конфликтовать, то установка упадет с ошибкой. Например следующая ошибка возникнет при попытке установить Storybook 6-ой версии в приложение с React 18:

Ошибка установки peerDependencies

Ошибка установки 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

Установка при наличии yarn.lock

NPM также использовал этот метод и добавил свой lock-файл, чтобы учитывать его как основной источник, необходимо провести установку через команду npm ci.

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

Yarn Cache

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 с 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 и ничего не нашел за исключением ряда статей в зарубежном сегменте:

  1. How to use ESM on the web and in Node.js

  2. Building a TODO app without a bundler

  3. Developing Without a Build (1): Introduction

Вторая статья вызвала у меня наибольший интерес, так как там уже есть реализация приложения на ESM — классическое ToDo App, которое я и использовал, как пример. Также там вы найдете доклад 2019 года от Фреда Шотта, где он поднял вопрос почему нужны сборщики и нужны ли они вообще.

Основной посыл этого доклада, даже можно сказать ответ на вопрос: «Почему было бы здорово использовать ESM прямо на клиенте?» — это отказ от огромного инструментария. Только вспомните сколько часов за свою жизнь вы потратили на настройки того или иного сборщика или менеджера задач. К тому же не придется тратить время на саму сборку, что порой бывает утомительно. А также достигается идентичность окружения для разработки и прода.

С момента публикации этого доклада до выхода статьи прошло 5 лет. Изменилось ли что-то глобально за это время?

Нет.

NPM был создан для Node, Web нашел в свое время выход в виде сборщиков и развернуть эту машину очень трудно. Все библиотеки пишутся на CJS, а поддержку ESM добавляют не все. К тому же остались открыты еще ряд критических моментов:

  1. Теряется возможность использовать такие инструменты как Typescript;

  2. Нет возможности минифицировать код и использовать Tree-shaking;

  3. Нет возможности использовать алиасы для импортов.

И на мой взгляд минусы пока перевешивают плюсы.

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

import get from "https://esm.sh/lodash-es@4.17.21/get.js";

Но вставлять подобный путь каждый раз накладно, поэтому рекомендуется использовать тэг script с типом importmap:

 <script type="importmap">   {     "imports": {       "get": "https://esm.sh/lodash-es@4.17.21/get.js"     }   } </script>

После чего в любом js файле можно будет использовать следующую нотацию:

import get from "get";

Рассмотрев, как работать с ESM, осталось выбрать ряд инструментов, которые потребуются для разработки. Я выбрал следующие:

  1. preact — так как я пишу в основном на React;

  2. 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`<div ...${props} class=bar>${foo}</div>`; render(htm`<${MyComponent} />`, container);

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

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

Чтобы запустить его локально установите serve и выполните команду npx serve, находясь в директории проекта.

Начнем с index.html :

<!DOCTYPE html> <html lang="en">   <head>     <title>Agify</title>     <link       href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"       rel="stylesheet"       integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"       crossorigin="anonymous"     />     <link       rel="stylesheet"       href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"     />     <link href="styles/styles.css" rel="stylesheet" />     <link rel="manifest" href="./manifest.json" />     <meta charset="utf-8" />     <meta name="viewport" content="width=device-width, initial-scale=1" />     <script type="importmap">       {         "imports": {           "preact": "https://esm.sh/preact",           "preact/": "https://esm.sh/preact/",           "htm": "https://esm.sh/htm",           "bootstrap": "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.esm.min.js"         }       }     </script>   </head>   <body>     <script type="module" src="js/App.mjs"></script>   </body> </html>

Здесь подключаем готовые 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`     <div class="h-100 d-flex justify-content-center align-items-center agify">       ${loading         ? html`             <div               class="h-100 w-100 d-flex justify-content-center align-items-center agify-spinner-container"             >               <div class="spinner-border" role="status">                 <span class="visually-hidden">Loading...</span>               </div>             </div>           `         : ""}       <div class="w-50 d-flex flex-column gap-3 agify-content">         <h2 class="text-center agify-title">           Estimate your age based on your first name         </h2>         <form class="agify-form" onSubmit=${handleSubmit}>           <div class="input-group mb-3">             <input               aria-describedby="button-addon2"               aria-label="Enter your first name"               class="form-control"               onChange=${(e) => setValue(e.target.value)}               placeholder="Enter your first name"               type="text"               value=${value}             />             <button               class="btn btn-outline-secondary"               disabled=${!value}               type="submit"               id="button-addon2"             >               <i class="bi bi-search"></i>             </button>           </div>           <div             class="d-flex flex-column justify-content-center align-items-center gap-3 agify-result"           >             <h3 class="agify-result-title">               Your age is:               ${age ? age : html`<i class="bi bi-question-circle"></i>`}             </h3>             <button               class="btn btn-secondary"               disabled=${!age}               type="button"               onClick=${handleReset}             >               Reset             </button>           </div>         </form>       </div>     </div>   `; };

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

Но интересно, что preact предоставляет нам возможность использовать уже знакомый многим API React, в данном случае useState, а также интересны примеры вложенного в условные операторы рендера html:

${age ? age : html`<i class="bi bi-question-circle"></i>`}

Подводя итоги, скажу, что, как и в 2019, как и в 2022, так и в 2024 году, сообщество не накопило достаточное количество удобных инструментов и подходов для реализации серьезных проектов без сборщиков. Приходится жертвовать слишком многим в пользу малого. Выбор инструментов — это компромисс между сложностью процесса сборки и производительностью + оптимизацией. Использование сторонних библиотек в приложении без сборки может оказаться затруднительным, если нет доступной версии ESM. К тому же я слишком люблю Typescript, чтобы отказаться от него.

Но в целом мне очень интересна мысль о том, что все ходит по спирали. И такой концепт приводит нас к тому, с чего все начиналось. Очень интересно узнать приведет ли.

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


ссылка на оригинал статьи https://habr.com/ru/articles/825424/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *