Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение13 мин
Количество просмотров7.1K

Изначально эта статья задумывалась, как рассказ о различиях и назначении полей 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-канал.

Теги:
Хабы:
+10
Комментарии11

Публикации