До появления ES-модулей JavaScript-проекты в основном использовали
формат CommonJS. Модули подключались через require,
экспортировались через module.exports, а структура
зависимостей определялась динамически во время выполнения кода.
Пример CommonJS:
const math = require('./math')
console.log(math.sum(1, 2))
Файл math.js:
module.exports = {
sum(a, b) {
return a + b
},
multiply(a, b) {
return a * b
}
}
Сборщик видел только факт подключения объекта math, но
не мог гарантированно определить, какие именно свойства реально
используются. Из-за этого в итоговый bundle попадал весь модуль
целиком.
С ростом размеров приложений это стало серьёзной проблемой:
Появилась необходимость в механизме удаления неиспользуемого кода ещё на этапе сборки.
Так возникла философия tree-shaking.
Tree-shaking — это процесс удаления кода, который никогда не используется в приложении.
Название происходит от аналогии с деревом зависимостей:
Пример:
export function sum(a, b) {
return a + b
}
export function multiply(a, b) {
return a * b
}
Импорт:
import { sum } from './math.js'
console.log(sum(1, 2))
При корректном tree-shaking функция multiply не попадёт
в сборку.
Главная причина — статическая структура ES-модулей.
ESM проектировался так, чтобы связи между модулями можно было анализировать до выполнения кода.
Импорт всегда располагается на верхнем уровне:
import { sum } from './math.js'
Экспорт также статичен:
export function sum() {}
Сборщик может заранее определить:
Это делает возможным точный анализ зависимостей.
CommonJS допускает динамическое поведение.
const moduleName = getModuleName()
const module = require(moduleName)
Сборщик не способен заранее определить, какой файл будет подключён.
module.exports[someKey] = value
Структура экспортов становится неизвестной во время сборки.
if (condition) {
require('./feature')
}
Зависимости определяются во время выполнения.
Из-за этого безопасное удаление кода становится невозможным.
Rollup изначально создавался вокруг идеи максимально эффективного tree-shaking.
В отличие от ранних версий Webpack, которые долгое время концентрировались на универсальности, Rollup делал ставку на:
Главная философия Rollup:
Итоговый bundle должен содержать только реально используемый код.
Rollup строит граф модулей.
Например:
import { foo } from './foo.js'
foo()
foo.js:
export function foo() {
console.log('foo')
}
export function bar() {
console.log('bar')
}
Rollup определяет:
foo используется;bar нигде не импортируется;bar можно удалить.В итоговом bundle останется только:
function foo() {
console.log('foo')
}
foo()
ES-модули используют механизм live bindings.
Это означает:
Пример:
// counter.js
export let counter = 0
export function increment() {
counter++
}
// app.js
import { counter, increment } from './counter.js'
console.log(counter)
increment()
console.log(counter)
Значение counter обновится автоматически.
Tree-shaking при этом остаётся возможным, потому что структура зависимостей всё ещё статична.
Rollup работает не как простой конкатенатор файлов.
Он:
Фактически Rollup рассматривает программу как систему связей между символами.
Tree-shaking тесно связан с понятием dead code elimination.
Dead code — это код, который:
Пример:
function used() {
console.log('used')
}
function unused() {
console.log('unused')
}
used()
После tree-shaking:
function used() {
console.log('used')
}
used()
Не весь неиспользуемый код можно удалить.
Причина — side effects.
Side effect — это любое действие, влияющее на внешнее состояние:
console.log('module loaded')
export const value = 10
Даже если value нигде не используется, удалять модуль
нельзя, потому что исчезнет вызов console.log.
Tree-shaking особенно эффективен для чистых функций.
Чистая функция:
Пример:
export function square(x) {
return x * x
}
Такой код безопасно удаляется, если не используется.
Barrel-файлы переэкспортируют сущности из других модулей.
Пример:
export * from './math.js'
export * from './string.js'
Они упрощают API библиотеки:
import { sum } from './utils'
Однако чрезмерное использование barrel-файлов иногда усложняет анализ зависимостей и может ухудшать tree-shaking в некоторых сборщиках.
Rollup обычно справляется с этим лучше многих других bundler’ов благодаря глубокому анализу ESM.
Именованные экспорты анализируются лучше.
export function foo() {}
export function bar() {}
Импорт:
import { foo } from './utils.js'
Rollup легко удалит bar.
export default {
foo,
bar
}
Импорт:
import utils from './utils.js'
utils.foo()
Теперь сборщик видит объект целиком.
Без дополнительного анализа определить использование отдельных свойств становится значительно сложнее.
Поэтому философия tree-shaking тесно связана с использованием named exports.
Rollup стал одним из первых bundler’ов, активно использующих scope hoisting.
Суть:
Вместо:
(function() {
function foo() {}
})()
Rollup старается генерировать:
function foo() {}
Это делает output ближе к обычному «ручному» JavaScript-коду.
Полностью точный анализ JavaScript невозможен.
Язык слишком динамичен.
Некоторые конструкции мешают оптимизации.
eval(code)
Сборщик не знает, какой код будет выполнен.
obj[prop] = value
Невозможно гарантировать последствия.
window.foo = 123
Такие действия создают side effects.
import(`./${name}.js`)
Анализ становится ограниченным.
Современный ecosystem вокруг Rollup и ES-модулей формирует важную идею:
Код должен быть не только читаемым человеком, но и анализируемым инструментами сборки.
Отсюда появляются рекомендации:
Для библиотек tree-shaking особенно важен.
Плохой пример:
import _ from 'lodash'
В bundle может попасть огромная часть библиотеки.
Лучше:
import debounce from 'lodash/debounce'
Или ESM-вариант:
import { debounce } from 'lodash-es'
Библиотеки, ориентированные на Rollup и ESM, проектируются так, чтобы их части можно было удалять независимо.
Многие bundler’ы используют поле:
{
"sideEffects": false
}
Это сообщает сборщику:
Но это опасное утверждение.
Если side effects всё же присутствуют, приложение может сломаться после tree-shaking.
Tree-shaking и минификация — разные процессы.
Удаляет:
Сжимает код:
Пример минификации:
function sum(a,b){return a+b}
Tree-shaking уменьшает объём логически.
Минификация уменьшает объём синтаксически.
ES-модули создавались не только ради удобного синтаксиса.
Их архитектура ориентирована на:
Rollup стал одним из инструментов, который наиболее полно раскрыл преимущества этой модели.
Tree-shaking повлиял на всю экосистему JavaScript.
Изменились подходы к:
Появились:
Размер bundle стал одним из ключевых факторов качества frontend-приложений.
Rollup исходит из простой идеи:
Поэтому Rollup особенно хорошо подходит для: