Изначально эта статья задумывалась, как рассказ о различиях и назначении полей 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 для загрузки той или иной библиотеки.
Данная формализация и автоматизация в управлении зависимостями и появление различных модульных систем позволило разработчикам библиотек использовать третьи библиотеки, тем самым создав такой термин, как транзитивные зависимости.
![Транзитивные зависимости Транзитивные зависимости](https://habrastorage.org/getpro/habr/upload_files/860/d59/a94/860d59a94c61173a4810e5d83a5b8c93.png)
Ключевым моментом в понимании работы любого пакетного менеджера является понимание работы разрешения (resolution) зависимостей. В момент установки Bower подбирает подходящие зависимости согласно полю dependencies, но так как появляются и транзитивные зависимости, то процесс разрешения становится рекурсивным и представляет собой обход дерева.
Тут же стоит отметить, что помимо dependencies
с появлением Bower появилось и поле devDependencies
. По префиксу dev понятно, что здесь указываются все зависимости, которые помогают нам в разработке, но не нужны в самом коде приложения, то есть различные библиотеки для тестирования, форматирования и т.п. Пакетный менеджер при установке загрузит только прямые devDependencies
, а транзитивные проигнорирует:
![Установка зависимостей с devDependencies Установка зависимостей с devDependencies](https://habrastorage.org/getpro/habr/upload_files/52f/e3e/0aa/52fe3e0aa9cb9fab8b91c395b03b1efa.png)
При этом в Bower все загруженные зависимости будут лежать в одной директории в плоском виде:
![Пример плоской установки зависимостей в Bower Пример плоской установки зависимостей в Bower](https://habrastorage.org/getpro/habr/upload_files/ee8/50e/8c0/ee850e8c0574e2345bfe5b0b9517f092.png)
При такой структуре растет риск конфликтов, которые могут возникнуть, когда зависимости проекта зависят от разных версий одной и той же библиотеки:
![Конфликт версий зависимости Конфликт версий зависимости](https://habrastorage.org/getpro/habr/upload_files/92f/b31/b3f/92fb31b3f50f909abfad08b628adfa59.png)
Так как установка идет в одну директорию, то Bower не может установить несколько версий одного и того же пакета. В таком случае разработчику необходимо вручную выбрать какую версию необходимо использовать, что влечет дополнительные риски, если речь идет о мажорных версиях.
Для этой цели появилось поле resolutions
:
{
"resolutions": {
"library-d": "2.0.0"
}
}
Эта проблема выступала сильным ограничителем, поэтому не удивительно, что в скором времени нашли решение. И пришло оно из NodeJS, когда для этой платформы разрабатывался свой пакетный менеджер - NPM.
NPM изначально имел nested
модель разрешения зависимостей, то есть для каждой зависимости создается своя директория node_modules
, где хранятся ее собственные зависимости, что позволяет избежать конфликтов.
![Пример вложенной установки зависимостей из NPM 1 и 2 версии Пример вложенной установки зависимостей из NPM 1 и 2 версии](https://habrastorage.org/getpro/habr/upload_files/35b/d71/11d/35bd7111dde184c86869bf409cd07841.png)
Однако такой подход также имеет свои недостатки, а именно проблемы с дублями пакетов и глубиной иерархии. Поэтому при безответственном подходе вес директории node_modules
мог стать колоссальным, а также можно было нарваться на ограничение максимальной длины путей на Windows.
В итоге в NPM 3 перешли на hoisted
модель разрешения, в которой менеджер пакетов старается расположить все пакеты на верхнем уровне. И только когда возникает конфликт версий, то создается отдельная вложенная директория node_modules
для конкретного пакета, где и располагается конфликтующая зависимость.
![Пример установки зависимостей со всплытием из NPM 3 Пример установки зависимостей со всплытием из NPM 3](https://habrastorage.org/getpro/habr/upload_files/5b5/2ce/12d/5b52ce12d990afd59a8ee7f13ec3ee1b.png)
Принцип этой модели заключается в том, что NodeJS при поиске зависимости проходит по всей директории node_modules
снизу вверх, то есть «всплывает».
![Разрешение модулей в NodeJS Разрешение модулей в NodeJS](https://habrastorage.org/getpro/habr/upload_files/949/a82/252/949a82252aa4bf5b574eff48c070d376.png)
Когда шла речь о 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 Ошибка установки peerDependencies](https://habrastorage.org/getpro/habr/upload_files/2b0/715/06b/2b071506bc3c3a2e2d01378fc187e878.png)
Если запустить установку с флагом --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](https://habrastorage.org/getpro/habr/upload_files/55f/9f4/2ad/55f9f42ada3aa8fa758a59e47f9c9951.png)
NPM также использовал этот метод и добавил свой lock-файл, чтобы учитывать его как основной источник, необходимо провести установку через команду npm ci
.
А также Yarn ускорил установку пакетов за счет локального кэша, который позволяет создать на своей машине собственный реестр пакетов, чтобы в процессе установки заменять сетевой запрос на копирование папок в файловой системе.
![Yarn Cache Yarn Cache](https://habrastorage.org/getpro/habr/upload_files/d85/3eb/672/d853eb672c0fc76c857e5b81d7c20cf3.png)
В свою очередь PNPM также решил еще одну проблему NPM, а именно проблему фантомных зависимостей. NPM использует hoisted
модель разрешения зависимостей, когда все пакеты «всплывают» на самый верх, а только дубли пакетов с другими версиями остаются на месте. Это поведение дает не очевидный на первый взгляд эффект. Все пакеты, что «всплыли» становятся доступными для импорта в приложении, хотя они могут быть не указаны как dependencies
в package.json
.
![Использование транзитивной зависимости Использование транзитивной зависимости](https://habrastorage.org/getpro/habr/upload_files/c49/906/c1c/c49906c1c14a18644c49d6624086aa60.png)
А теперь представьте ситуацию, что вышел патч library-a
, который уже не использует library-b
. В этом случае приложение упадет, так как импорт library-b
закончится ошибкой.
![Фантомная зависимость Фантомная зависимость](https://habrastorage.org/getpro/habr/upload_files/633/0aa/782/6330aa782aa326ce92eb3792defc0435.png)
В NPM следить за этим можно с помощью ESLint-плагина, а PNPM в отличие от NPM и Yarn не пытается сделать структуру node_modules
как можно более плоской, вместо этого он скорее нормализует граф зависимостей.
Пока что все, что мы видели больше напоминало дерево нежели граф. И действительно «nested» модель наиболее близка к структуре дерева, но по факту она просто дублирует зависимости, которые можно расположить в ориентированный ациклический граф.
![Ромбовидные зависимости Ромбовидные зависимости](https://habrastorage.org/getpro/habr/upload_files/cd7/94e/c82/cd794ec8258dc116d928ddd884fab68f.png)
В файловых структурах также была подобная дилемма, которую решили симлинки. Они позволяют создать ссылку на файл или директорию, вместо дублирования содержимого. Именно эту идею и использует PNPM.
После установки PNPM создаёт в node_modules директорию .pnpm, которая концептуально представляет собой хранилище ключ-значение. В этом файле ключом является название пакета и его версия, а значением — содержимое этой версии пакета.
Такая структура данных исключает возможность возникновения дубликатов. Структура самой директории node_modules будет подобна "nested"-модели из NPM, но вместо физических файлов там будут симлинки, которые ведут в то самое хранилище пакетов.
![Структура node_modules с PNPM Структура node_modules с PNPM](https://habrastorage.org/getpro/habr/upload_files/948/10a/be6/94810abe612f6e8df9e7b3750e74d315.png)
В 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 и ничего не нашел за исключением ряда статей в зарубежном сегменте:
Вторая статья вызвала у меня наибольший интерес, так как там уже есть реализация приложения на 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
:
<script type="importmap">
{
"imports": {
"get": "https://esm.sh/lodash-es@4.17.21/get.js"
}
}
</script>
После чего в любом js
файле можно будет использовать следующую нотацию:
import get from "get";
Рассмотрев, как работать с ESM, осталось выбрать ряд инструментов, которые потребуются для разработки. Я выбрал следующие:
Эти две библиотеки могут работать в связке друг с другом, приближая разработку к тому, к чему я привык, работая с 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-канал.
i360u
Теряется возможность использовать такие инструменты как Typescript;
Нет возможности минифицировать код и использовать Tree-shaking;
Нет возможности использовать алиасы для импортов.
- все эти три пункта - не соответствуют действительности. Но, зато есть реклама ТГ-канала... конечно.
Советую всем не ориентироваться на устаревшие и поверхностные статьи, а самим лучше разобраться в вопросе. С ESM и TypeScript прекрасно работает (как и все остальные важные тулзы), нет проблем со сборкой/минификацией/тришейкингом, и вы можете использовать самый обычный резолвинг модулей node-стайл, со всеми плюшками + importmap.
ЗЫ: насчет "спирали" еще очень странная мысль, ибо как-то я упустил момент, когда скрипты к сайту можно было подключить как-то иначе, чем через тег script...
denis_voronin_habr Автор
C ESM весь инструментарий действительно работает. Но посыл был про то, что ESM открывает дорогу для разработки вне зависимости от среды исполнения, опуская шаги транспиляции и компиляции. Для типизации в этом случае также можно использовать typescript, но уже в ручную описывая d.ts. Или же использовать jsDoc.
P.S. Очевидно, что все скрипты подключаются через тэг script, но мы используем сборщики для этих целей и не пишем это руками.
nin-jin
Что, даже тришейкинг работает? Это каким таким чудесным образом?