Три причины раздувания JavaScript
- вторник, 24 марта 2026 г. в 00:00:03

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

Показанный выше граф похож на многие деревья зависимостей — небольшая вспомогательная функция, выполняющая действие, которое вроде бы должно быть доступно нативно; за ним следует множество столь же малых глубоких зависимостей.
Почему же возникает такая ситуация? Зачем нам нужна is-string вместо проверок typeof? Зачем нам нужна hasown вместо Object.hasOwn (или Object.prototype.hasOwnProperty)? На то есть три причины:
Поддержка очень старых движков
Защита от изменения глобального пространства имён
Значения между разными realm
Очевидно, в нашем мире есть люди, которым необходима поддержка ES3 — можно вспомнить про IE6/7 или очень ранние версии Node.js1.
Для таких людей многое из того, что мы воспринимаем как само собой разумеющееся, не существует. Например, им недоступно ни одно из нижеперечисленного:
Array.prototype.forEach
Array.prototype.reduce
Object.keys
Object.defineProperty
Всё это фичи ES5, а значит, их нет в движках ES3.
Те несчастные, кто всё ещё работают со старыми движками, должны реализовывать всё сами или использовать полифилы.
Или же было бы совсем неплохо, если бы они проапгрейдились.
Вторая причина использования части таких пакетов — это «безопасность».
Внутри самого Node отсутствует концепция «прародителей» (primordial). По сути, это просто глобальные объекты, оборачиваемые при запуске и в дальнейшем импортируемые Node, чтобы сам Node не поломался из-за чего-то, изменяющего глобальное пространство имён.
Например, если сам Node использует Map, а мы переопределим смысл Map, то можем поломать Node. Чтобы избежать этого, Node хранит ссылку на исходную Map, которую импортирует вместо того, чтобы получать доступ к глобальной.
Подробнее об этом можно почитать в репозитории Node.
Это очень логично для движка, потому что он не должен вываливаться, если скрипт изменит глобальное пространство имён.
Кроме того, некоторые мейнтейнеры считают, что это корректный способ и для сборки пакетов. Именно поэтому возникают зависимости наподобие math-intrinsics из графа выше, которая повторно экспортирует различные функции Math.*, чтобы избежать изменений.
Значения между разными realm — это, по сути, значения, передаваемые из одной realm в другую, например, с веб-страницы в дочерний <iframe> или наоборот.
В этой ситуации new RegExp(pattern) в iframe — это не тот же класс RegExp, что на родительской странице. Это значит, что window.RegExp !== iframeWindow.RegExp, а это, разумеется, означает, что val instanceof RegExp будет false , если берётся из iframe (другой realm).
Например, я мейнтейнер chai, и у нас возникла именно такая проблема. Нам нужна поддержка assertion, выполняемых между realm (так как при прогоне тестов они могут выполняться в VM или iframe), поэтому мы не можем полагаться на проверки instanceof. Поэтому мы используем Object.prototype.toString.call(val) === '[object RegExp]' для проверки на regex; такая схема работает и между realm, потому что не зависит от конструктора.
В графе выше is-string, по сути, выполняет ту же работу в случае, если мы передаём new String(val) из одной realm в другую.
Всё это полезно только для очень небольшой группы людей. Если вы поддерживаете очень старые движки, передаёте значения между realm или вам нужно защититься от изменения кем-то окружения, то именно такие пакеты вам и нужны.
Проблема в том, что подавляющему большинству разработчиков всё это не нужно. Мы работаем с версией Node не старше десяти лет или пользуемся актуальным браузером. Нам не нужно поддерживать окружения до ES5, мы не передаём значения между фреймами и мы удаляем пакеты, ломающие окружение2.
Эти слои нишевой совместимости каким-то образом попали на «горячий путь» часто используемых пакетов. Искать специальные пакеты под свои требования должна была маленькая группа людей, которой действительно это нужно. Однако всё получилось наоборот, и расплачиваемся за это все мы.
Некоторые люди считают, что пакеты должны разбиваться почти до атомарного уровня, создавая коллекцию маленьких строительных блоков, которые можно многократно использовать для сборки более высокоуровневых систем.
При такой архитектуре у нас получаются следующие графы:

Как видите, самые мелкие блоки кода имеют собственные пакеты. Например, shebang-regex на момент написания этого поста выглядел так:
const shebangRegex = /^#!(.*)/; export default shebangRegex;
Разбивая код до атомарного уровня, теоретически, можно создавать высокоуровневые пакеты, просто соединяя множество мелких.
Вот примеры таких атомарных пакетов, позволяющие понять степень разбиения:
arrify — преобразует значение в массив (Array.isArray(val) ? val : [val])
slash — заменяет обратные косые черты в пути файловой системы на /
cli-boxes — файл JSON, содержащий границы поля
path-key — получает ключ переменной среды PATH для текущей платформы (PATH в Unix, Path в Windows)
onetime — гарантирует, что функция вызывается только один раз
is-wsl — проверяет, равно ли process.platform значению linux, и содержит ли os.release() значение microsoft
is-windows — проверяет, содержит ли process.platform значение win32
Если бы мы, например, захотели создать новое CLI, то могли бы подтянуть часть таких пакетов, не беспокоясь о реализации. Нам не нужно для этого самостоятельно выполнять env['PATH'] || env['Path'], для этого можно просто подтянуть пакет.
В реальности большинство таких пакетов не оказывается многократно используемыми строительными блоками, как это задумывалось. Они или многократно дублируются среди разных версий в дереве, или оказываются одноразовыми пакетами, которые использует только один пакет.
Давайте рассмотрим некоторые из самых мелких пакетов:
shebang-regex используется почти исключительно пакетом shebang-command от того же мейнтейнера
cli-boxes используется почти исключительно пакетами boxen и ink от того же мейнтейнера
onetime используется почти исключительно пакетом restore-cursor от того же мейнтейнера
Каждый из них имеет всего одного потребителя, то есть они эквивалентны встроенному коду, но для их получения нам требуется больше затрат (запросы npm, извлечение tar, пропускная способность и так далее).
Взглянув на дерево зависимостей nuxt, можно увидеть, что часть из этих строительных блоков дублируется:
is-docker (2 версии)
is-stream (2 версии)
is-wsl (2 версии)
isexe (2 версии)
npm-run-path (2 версии)
path-key (2 версии)
path-scurry (2 версии)
Если мы просто встроим этот код в свой, это не значит, что он не будет дублироваться, но благодаря этому мы избавимся от таких затрат, как разрешение версий, конфликты, затраты на получение и так далее.
Встраивание кода делает дублирование практически бесплатным, а использование пакетов становится дорогостоящим.
Чем больше у нас пакетов, тем больше площадь поверхности цепочки поставок. Каждый пакет — потенциальная точка отказа поддержки, безопасности и так далее.
Например, мейнтейнер многих из этих пакетов в прошлом году был скомпрометирован. Из-за этого оказались скомпрометированными сотни строительных блоков, а значит, и высокоуровневые пакеты, которые мы устанавливаем.
Простая логика наподобие Array.isArray(val) ? val : [val], вероятно, не требует собственного пакета, обеспечения безопасности, поддержки и так далее. Её можно просто встроить в код, избежав таким образом риска компрометации.
Аналогично первой причине, эта философия тоже попала на «горячий путь» разработки, хотя, вероятно, этого не должно было произойти. Мы снова терпим затраты, не получая никакой реальной выгоды.

Если вы создаёте приложение, то можете захотеть использовать некие «будущие» фичи, которые пока не поддерживаются выбранным движком. В такой ситуации может пригодиться полифил — он обеспечивает резервную реализацию там, где должна была существовать фича, чтобы вы могли использовать её, как будто она поддерживается нативно.
Например, temporal-polyfill создаёт полифил нового Temporal API, поэтому мы можем использовать Temporal вне зависимости от того, поддерживает ли его движок.
Но как нам поступить, если мы создаём не приложение, а библиотеку?
В общем случае, никакая библиотека не должна загружать полифил, потому что это задача потребителя, а библиотека не должна изменять окружение, в котором находится. В качестве альтернативы некоторые мейнтейнеры решили использовать так называемый понифил (ponyfill, продолжая тему единорогов, искр и радуги).
Понифил — это, по сути, полифил, который импортируется, а не изменяет окружение.
И это вполне работает, потому что при этом библиотека может использовать реализованную в будущем технологию, импортировав её реализацию, которая обращается к нативной версии, если она существует, а в противном случае работает с резервной. В обоих случаях окружение не изменяется, поэтому библиотеки могут использовать такую систему безопасно.
Например, fastly предоставляет @fastly/performance-observer-polyfill, содержащий и полифил, и понифил для PerformanceObserver.
Такие понифилы выполняли свою задачу в нужное время — они позволяли автору библиотеки использовать будущую технологию без изменения окружения и не заставляя потребителя разбираться, какие полифилы устанавливать.
Проблема возникает, когда понифилы остаются с нами слишком долго. Когда заменяемая ими фича начинает поддерживаться всеми интересующими нас движками, от понифила следует избавиться. Однако такого часто не происходит, и понифил ещё долго остаётся на месте после того, как необходимость в нём исчезла.
В итоге у нас появляется множество пакетов, реализующих в понифилах фичи, доступные уже с десяток лет.
Например:
globalthis — понифил для globalThis (широко поддерживается с 2019 года, 49 миллионов скачиваний в неделю)
indexof — понифил для Array.prototype.indexOf (широко поддерживается с 2010 года, 2,3 миллиона скачиваний в неделю)
object.entries — понифил для Object.entries (широко поддерживается с 2017 года, 35 миллионов скачиваний в неделю)
Если только эти пакеты не остаются живыми из-за Причины 1, их обычно всё равно используют, потому что никто никогда не задумывается об их удалении.
Когда фича появляется во всех долговременно поддерживаемых версиях движков, понифил следует удалить4.
Большая доля раздувания сегодня насколько глубоко встроена в деревья зависимостей, что распутывание их становится довольно масштабной задачей. Потребуется много времени и усилий со стороны мейнтейнеров и потребителей.
Тем не менее, я считаю, что если взяться всем вместе, то мы можем добиться существенного прогресса на этом фронте.
Начните задаваться вопросами: «Зачем у меня есть этот пакет?» и «Нужен ли он мне на самом деле?».
Если вы найдёте что-то, кажущееся избыточным, то создайте issue для мейнтейнера и спросите, можно ли это удалить.
При обнаружении прямой зависимости, имеющей множество таких issue, рассмотрите альтернативу, у которой их нет. Неплохим началом для этого станет проект module-replacements.
knip — отличный проект, который может помочь в нахождении и удалении неиспользуемых зависимостей, мёртвого кода и многого другого. В данном случае, он может быть прекрасным инструментом для нахождения и удаления зависимостей, которые вы больше не используете.
Это может и не решить все перечисленные выше проблемы, но с этого будет удобно начать очищать дерево зависимостей, а уже потом приступать к более сложной работе.
Подробнее о том, как knip работает с неиспользуемыми зависимостями, можно прочитать в его документации.
В CLI e18e есть крайне полезный режим analyze, позволяющий определять, какие зависимости больше не нужны или имеют рекомендуемые сообществом замены.
Допустим, вы получаете примерно такое уведомление:
$ npx @e18e/cli analyze ... │ Warnings: │ • Module "chalk" can be replaced with native functionality. You can read more at │ https://nodejs.org/docs/latest/api/util.html#utilstyletextformat-text-options. See more at │ https://github.com/es-tooling/module-replacements/blob/main/docs/modules/chalk.md. ...
С его помощью быстро обнаруживаются прямые зависимости, которые можно подчистить. Также после этого можно применить команду migrate, выполняющую автоматическую миграцию части таких зависимостей:
$ npx @e18e/cli migrate --all e18e (cli v0.0.1) ┌ Migrating packages... │ │ Targets: chalk │ ◆ /code/main.js (1 migrated) │ └ Migration complete - 1 files migrated.
В этом случае она выполнит миграцию с chalk на picocolors — гораздо меньший пакет, обеспечивающий такую же функциональность.
В будущем этот CLI даже будет давать рекомендации на основании окружения пользователя — например, если вы пользуетесь достаточно новым Node, она может посоветовать нативную styleText вместо библиотеки работы с цветами.
npmgraph — отличный инструмент для визуализации дерева зависимостей и выявления причин раздувания.
Например, давайте взглянем на нижнюю половину графа зависимостей ESLint на момент написания этого поста:

В этом графе мы видим, что ветвь find-up изолирована в том смысле, что её глубокие зависимости больше ничто не использует. Вероятно, для такой простой задачи, как обход файловой системы снизу вверх, шесть пакетов — это перебор. Можно поискать альтернативы, например, empathic, имеющий гораздо меньший граф зависимостей и реализующий те же возможности.
Проект module replacements используется сообществом в качестве центрального датасета для документации пакетов, которые можно заменить нативной функциональностью или более высокопроизводительными альтернативами.
Если вам понадобится найти альтернативу или просто проверить свои зависимости, то этот датасет идеально вам подойдёт.
Аналогично, если вы найдёте в своём дереве пакеты, ставшие избыточным благодаря нативной функциональности или имеющие более качественные альтернативы, то вам стоит внести вклад в этот проект, чтобы помочь другим разработчикам.
Также существует проект codemods, который наряду с предоставлением данных содержит разные codemod для автоматической миграции части таких пакетов на рекомендованные замены.
Все мы расплачиваемся за то, чтобы невероятно малая группа людей могла работать с необычной архитектурой или обеспечивала себе нужный уровень обратной совместимости.
Но это необязательно вина разработчиков таких пакетов, потому что каждый человек должен иметь возможность выполнять сборку так, как ему хочется. Многие из них относятся к старому поколению влиятельных разработчиков на JavaScript: они создавали пакеты в тёмные времена, когда современных удобных API и перекрёстной совместимости ещё не существовало. Их создавали так, потому что, вероятно, на тот момент такой способ был наилучшим.
Проблема в том, что мы зациклились на них. Мы продолжаем скачивать всю эту гору зависимостей, хотя их фичи реализованы уже много лет назад.
Я думаю, можно решить эту проблему, перевернув ситуацию. Платить должна эта маленькая группа — ей необходимо выделить собственный специальный стек, которым пользуются практически только они. Все остальные получат современный, легковесный и широко поддерживаемый код.
Надеюсь, проекты наподобие e18e и npmx помогут в этом благодаря документации, инструментарию и так далее. Вы сами можете внести свой вклад, внимательнее приглядевшись к своим зависимостям и задав вопрос «Зачем?». Создавайте issue о своих зависимостях, спрашивайте, по-прежнему ли нужны эти пакеты.
Мы можем исправить эту ситуацию.
Думаю, есть люди, которым нужны такие старые движки, но хотел бы увидеть какие-нибудь примеры.
Основная часть раздувания возникла в те времена, когда оно, вероятно, было необходимо, потому что у платформы имелось не так много фич. Думаю, на тот момент это было правильное решение/архитектура.
Большинство указанных годов поддержки взято из MDN или, если они предшествовали MDN, из данных compat.
Тема «понифилов» в целом ещё не урегулирована. Думаю, нам следует отказаться от них, как только будет достигнута LTS, но есть и несогласные с этим люди, желающие, чтобы они сохранялись «навечно».