Tree-Shaking: справочное руководство
Опубликовано: 2022-03-10Прежде чем начать наше путешествие, чтобы узнать, что такое встряхивание деревьев и как настроить себя на успех с ним, нам нужно понять, какие модули есть в экосистеме JavaScript.
С самого начала программы на JavaScript выросли по сложности и по количеству выполняемых задач. Стала очевидной необходимость разделения таких задач на закрытые области выполнения. Эти части задач, или ценности, мы называем модулями . Их основная цель - предотвратить повторение и использовать повторное использование. Таким образом, архитектуры были разработаны, чтобы допускать такие специальные виды области действия, раскрывать их значения и задачи и потреблять внешние значения и задачи.
Чтобы глубже понять, что такое модули и как они работают, я рекомендую «ES Modules: A Cartoon Deep-Dive». Но чтобы понять нюансы древовидной тряски и потребления модулей, приведенного выше определения должно быть достаточно.
Что на самом деле означает Tree-Shaking?
Проще говоря, встряхивание дерева означает удаление недостижимого кода (также известного как мертвый код) из пакета. Как указано в документации Webpack версии 3:
«Вы можете представить свое приложение в виде дерева. Исходный код и библиотеки, которые вы фактически используете, представляют собой зеленые, живые листья дерева. Мертвый код представляет собой коричневые мертвые листья дерева, которые съедает осень. Чтобы избавиться от опавших листьев, нужно встряхнуть дерево, чтобы они упали».
Этот термин был впервые популяризирован в сообществе интерфейсных разработчиков командой Rollup. Но авторы всех динамических языков боролись с этой проблемой гораздо раньше. Идея алгоритма встряхивания дерева восходит как минимум к началу 1990-х годов.
В области JavaScript древовидная тряска стала возможной со времен спецификации модуля ECMAScript (ESM) в ES2015, ранее известной как ES6. С тех пор древовидная тряска включена по умолчанию в большинстве сборщиков, потому что они уменьшают размер вывода без изменения поведения программы.
Основная причина этого заключается в том, что ESM являются статическими по своей природе. Давайте разберем, что это значит.
Модули ES против CommonJS
CommonJS предшествует спецификации ESM на несколько лет. Это произошло из-за отсутствия поддержки повторно используемых модулей в экосистеме JavaScript. В CommonJS есть функция require()
, которая извлекает внешний модуль на основе предоставленного пути и добавляет его в область видимости во время выполнения.
Это require
является такой же function
, как и любая другая в программе, что делает ее достаточно сложной для оценки результата вызова во время компиляции. Кроме того, возможно добавление вызовов require
в любом месте кода — в вызове другой функции, в операторах if/else, в операторах switch и т. д.
Благодаря обучению и трудностям, возникшим в результате широкого внедрения архитектуры CommonJS, спецификация ESM остановилась на этой новой архитектуре, в которой модули импортируются и экспортируются с помощью соответствующих ключевых слов import
и export
. Поэтому больше никаких функциональных вызовов. ESM также разрешены только как объявления верхнего уровня — их вложение в какую-либо другую структуру невозможно, поскольку они являются статическими : ESM не зависят от выполнения во время выполнения.
Область применения и побочные эффекты
Есть, однако, еще одно препятствие, которое нужно преодолеть, чтобы избежать раздувания дерева: побочные эффекты. Считается, что функция имеет побочные эффекты, когда она изменяет или зависит от факторов, внешних по отношению к области выполнения. Функция с побочными эффектами считается нечистой . Чистая функция всегда будет давать один и тот же результат, независимо от контекста или среды, в которой она была запущена.
const pure = (a:number, b:number) => a + b const impure = (c:number) => window.foo.number + c
Сборщики служат своей цели, максимально оценивая предоставленный код, чтобы определить, является ли модуль чистым. Но оценка кода во время компиляции или во время связывания может зайти слишком далеко. Поэтому предполагается, что пакеты с побочными эффектами не могут быть должным образом устранены, даже если они полностью недоступны.
Из-за этого сборщики теперь принимают ключ внутри файла package.json
модуля, который позволяет разработчику заявить, не имеет ли модуль побочных эффектов. Таким образом, разработчик может отказаться от оценки кода и намекнуть сборщику; код в конкретном пакете может быть исключен, если нет доступного оператора import или require
, связывающего его. Это не только делает пакет более компактным, но также может ускорить время компиляции.
{ "name": "my-package", "sideEffects": false }
Итак, если вы разработчик пакета, добросовестно используйте sideEffects
перед публикацией и, конечно же, пересматривайте его при каждом выпуске, чтобы избежать каких-либо неожиданных критических изменений.
В дополнение к корневому sideEffects
также можно определить чистоту для каждого файла, добавив встроенный комментарий /*@__PURE__*/
к вызову метода.
const x = */@__PURE__*/eliminated_if_not_called()
Я считаю эту встроенную аннотацию спасательным люком для разработчика-потребителя, который должен быть сделан в случае, если пакет не объявил sideEffects: false
или в случае, если библиотека действительно представляет побочный эффект для определенного метода.
Оптимизация веб-пакета
Начиная с версии 4, Webpack требует все меньше конфигураций, чтобы обеспечить работу лучших практик. Функциональность для нескольких плагинов была включена в ядро. И поскольку команда разработчиков очень серьезно относится к размеру пакета, они упростили встряхивание деревьев.
Если вы не большой мастер или если ваше приложение не имеет особых случаев, то встряхивание дерева ваших зависимостей — это вопрос всего одной строки.
Файл webpack.config.js
имеет корневое свойство с именем mode
. Всякий раз, когда значением этого свойства является production
, оно полностью оптимизирует ваши модули. Помимо устранения мертвого кода с помощью TerserPlugin
, mode: 'production'
включит детерминированные искаженные имена для модулей и фрагментов, а также активирует следующие плагины:
- флаг использования зависимостей,
- флаг включает куски,
- объединение модулей,
- не испускать ошибки.
Не случайно триггерным значением является production
. Вы не захотите, чтобы ваши зависимости были полностью оптимизированы в среде разработки, потому что это значительно усложнит отладку проблем. Поэтому я бы предложил использовать один из двух подходов.
С одной стороны, вы можете передать флаг mode
в интерфейс командной строки Webpack:
# This will override the setting in your webpack.config.js webpack --mode=production
В качестве альтернативы вы можете использовать переменную process.env.NODE_ENV
в webpack.config.js
:
mode: process.env.NODE_ENV === 'production' ? 'production' : development
В этом случае вы должны не забыть передать --NODE_ENV=production
в конвейере развертывания.
Оба подхода являются абстракцией поверх известного definePlugin
из Webpack версии 3 и ниже. Какой вариант вы выберете, абсолютно не имеет значения.
Webpack версии 3 и ниже
Стоит отметить, что сценарии и примеры в этом разделе могут не применяться к последним версиям Webpack и других сборщиков. В этом разделе рассматривается использование UglifyJS версии 2 вместо Terser. UglifyJS — это пакет, из которого был разветвлен Terser, поэтому оценка кода может различаться между ними.
Поскольку Webpack версии 3 и ниже не поддерживает свойство sideEffects
в package.json
, все пакеты должны быть полностью оценены, прежде чем код будет удален. Уже одно это делает подход менее эффективным, но также необходимо учитывать несколько предостережений.
Как упоминалось выше, компилятор не может сам определить, когда пакет вмешивается в глобальную область. Но это не единственная ситуация, в которой он пропускает встряхивание дерева. Есть более размытые сценарии.
Возьмите этот пример пакета из документации Webpack:
// transform.js import * as mylib from 'mylib'; export const someVar = mylib.transform({ // ... }); export const someOtherVar = mylib.transform({ // ... });
А вот и точка входа потребительского комплекта:
// index.js import { someVar } from './transforms.js'; // Use `someVar`...
Невозможно определить, вызывает ли mylib.transform
побочные эффекты. Таким образом, никакой код не будет устранен.
Вот другие ситуации с аналогичным исходом:
- вызов функции из стороннего модуля, которую компилятор не может проверить,
- реэкспорт функций, импортированных из сторонних модулей.
Инструмент, который может помочь компилятору заставить дерево работать, называется babel-plugin-transform-imports. Он разделит все элементы и именованные экспорты на экспорты по умолчанию, что позволит оценивать модули по отдельности.
// before transformation import { Row, Grid as MyGrid } from 'react-bootstrap'; import { merge } from 'lodash'; // after transformation import Row from 'react-bootstrap/lib/Row'; import MyGrid from 'react-bootstrap/lib/Grid'; import merge from 'lodash/merge';
Он также имеет свойство конфигурации, которое предупреждает разработчика, чтобы он избегал проблемных операторов импорта. Если вы используете Webpack версии 3 или более поздней и тщательно проверили базовую конфигурацию и добавили рекомендуемые плагины, но ваш пакет все еще выглядит раздутым, то я рекомендую попробовать этот пакет.
Подъем области действия и время компиляции
Во времена CommonJS большинство упаковщиков просто заключали каждый модуль в объявление другой функции и отображали их внутри объекта. Это ничем не отличается от любого объекта карты:
(function (modulesMap, entry) { // provided CommonJS runtime })({ "index.js": function (require, module, exports) { let { foo } = require('./foo.js') foo.doStuff() }, "foo.js": function(require, module, exports) { module.exports.foo = { doStuff: () => { console.log('I am foo') } } } }, "index.js")
Помимо того, что это трудно анализировать статически, это принципиально несовместимо с ESM, потому что мы видели, что мы не можем обернуть операторы import
и export
. Итак, в настоящее время сборщики поднимают каждый модуль на верхний уровень:
// moduleA.js let $moduleA$export$doStuff = () => ({ doStuff: () => {} }) // index.js $moduleA$export$doStuff()
Этот подход полностью совместим с ESM; кроме того, это позволяет оценке кода легко обнаруживать модули, которые не вызываются, и удалять их. Недостаток этого подхода заключается в том, что во время компиляции требуется значительно больше времени, потому что он касается каждого оператора и сохраняет пакет в памяти во время процесса. Это главная причина, по которой производительность пакетов стала еще более серьезной проблемой для всех и почему скомпилированные языки используются в инструментах для веб-разработки. Например, esbuild — это сборщик, написанный на Go, а SWC — это компилятор TypeScript, написанный на Rust, который интегрируется со Spark, сборщиком, также написанным на Rust.
Чтобы лучше понять подъем области действия, я настоятельно рекомендую документацию по Parcel версии 2.
Избегайте преждевременной транспиляции
Есть одна конкретная проблема, которая, к сожалению, довольно распространена и может иметь разрушительные последствия для встряхивания деревьев. Короче говоря, это происходит, когда вы работаете со специальными загрузчиками, интегрируя различные компиляторы в свой сборщик. Распространенными комбинациями являются TypeScript, Babel и Webpack — во всех возможных сочетаниях.
И у Babel, и у TypeScript есть собственные компиляторы, и соответствующие загрузчики позволяют разработчику использовать их для упрощения интеграции. И в этом кроется скрытая угроза.
Эти компиляторы достигают вашего кода до оптимизации кода. И по умолчанию или из-за неправильной настройки эти компиляторы часто выводят модули CommonJS вместо ESM. Как упоминалось в предыдущем разделе, модули CommonJS являются динамическими и, следовательно, не могут быть должным образом оценены для устранения мертвого кода.
Этот сценарий становится все более распространенным в настоящее время с ростом «изоморфных» приложений (т. е. приложений, которые выполняют один и тот же код как на стороне сервера, так и на стороне клиента). Поскольку Node.js еще не имеет стандартной поддержки ESM, когда компиляторы нацелены на среду node
, они выводят CommonJS.
Так что обязательно проверяйте код, который получает ваш алгоритм оптимизации .
Контрольный список встряхивания деревьев
Теперь, когда вы знаете все тонкости работы объединения и встряхивания деревьев, давайте нарисуем контрольный список, который вы сможете распечатать где-нибудь под рукой, когда будете пересматривать текущую реализацию и кодовую базу. Надеюсь, это сэкономит вам время и позволит оптимизировать не только воспринимаемую производительность вашего кода, но, возможно, даже время сборки конвейера!
- Используйте ESM, и не только в своей собственной кодовой базе, но также отдавайте предпочтение пакетам, которые выводят ESM в качестве расходных материалов.
- Убедитесь, что вы точно знаете, какие из ваших зависимостей (если таковые имеются) не объявили
sideEffects
или установили их какtrue
. - Используйте встроенную аннотацию для объявления вызовов методов, которые являются чистыми при использовании пакетов с побочными эффектами.
- Если вы выводите модули CommonJS, обязательно оптимизируйте пакет, прежде чем преобразовывать операторы импорта и экспорта.
Создание пакетов
Надеюсь, к этому моменту мы все согласимся с тем, что ESM — это путь вперед в экосистеме JavaScript. Однако, как всегда в разработке программного обеспечения, переходы могут быть сложными. К счастью, авторы пакетов могут принять надежные меры, чтобы облегчить своим пользователям быструю и беспрепятственную миграцию.
С некоторыми небольшими дополнениями к package.json
ваш пакет сможет сообщать сборщикам среды, которые поддерживает пакет, и как они поддерживаются лучше всего. Вот контрольный список от Skypack:
- Включите экспорт ESM.
- Добавьте
"type": "module"
. - Указать точку входа через
"module": "./path/entry.js"
(соглашение сообщества).
И вот пример, который получается, когда соблюдаются все лучшие практики и вы хотите поддерживать как веб-среду, так и среду Node.js:
{ // ... "main": "./index-cjs.js", "module": "./index-esm.js", "exports": { "require": "./index-cjs.js", "import": "./index-esm.js" } // ... }
В дополнение к этому команда Skypack ввела показатель качества пакета в качестве эталона, чтобы определить, настроен ли данный пакет на долговечность и лучшие практики. Инструмент с открытым исходным кодом на GitHub и может быть добавлен как devDependency
в ваш пакет, чтобы легко выполнять проверки перед каждым выпуском.
Подведение итогов
Надеюсь, эта статья была вам полезна. Если это так, подумайте о том, чтобы поделиться им со своей сетью. Я с нетерпением жду возможности пообщаться с вами в комментариях или в Twitter.
Полезные ресурсы
Статьи и документация
- «ES Modules: A Cartoon Deep Dive», Лин Кларк, Mozilla Hacks
- «Встряхивание дерева», Webpack
- «Конфигурация», веб-пакет
- «Оптимизация», Вебпак
- «Scope Hoisting», документация Parcel версии 2.
Проекты и инструменты
- Терсер
- Babel-плагин-преобразование-импорт
- Скайпак
- Вебпак
- Пакет
- Свернуть
- строить
- СВК
- Проверка пакета