Философия tree-shaking и ES-модулей

До появления 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 попадал весь модуль целиком.

С ростом размеров приложений это стало серьёзной проблемой:

  • увеличивался размер бандлов;
  • возрастало время загрузки;
  • замедлялся парсинг JavaScript;
  • ухудшалась производительность мобильных устройств;
  • усложнялась оптимизация библиотек.

Появилась необходимость в механизме удаления неиспользуемого кода ещё на этапе сборки.

Так возникла философия tree-shaking.


Суть tree-shaking

Tree-shaking — это процесс удаления кода, который никогда не используется в приложении.

Название происходит от аналогии с деревом зависимостей:

  • модули образуют дерево импортов;
  • неиспользуемые ветви «стряхиваются»;
  • в финальный bundle попадает только реально задействованный код.

Пример:

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 не попадёт в сборку.


Почему tree-shaking работает именно с ES-модулями

Главная причина — статическая структура ES-модулей.

ESM проектировался так, чтобы связи между модулями можно было анализировать до выполнения кода.

Статичность import/export

Импорт всегда располагается на верхнем уровне:

import { sum } from './math.js'

Экспорт также статичен:

export function sum() {}

Сборщик может заранее определить:

  • какие сущности экспортируются;
  • какие импортируются;
  • какие используются;
  • какие никогда не вызываются.

Это делает возможным точный анализ зависимостей.


Почему CommonJS мешает tree-shaking

CommonJS допускает динамическое поведение.

Динамический require

const moduleName = getModuleName()

const module = require(moduleName)

Сборщик не способен заранее определить, какой файл будет подключён.


Динамический экспорт

module.exports[someKey] = value

Структура экспортов становится неизвестной во время сборки.


Выполнение модулей как функций

if (condition) {
    require('./feature')
}

Зависимости определяются во время выполнения.

Из-за этого безопасное удаление кода становится невозможным.


Rollup и философия минимального bundle

Rollup изначально создавался вокруг идеи максимально эффективного tree-shaking.

В отличие от ранних версий Webpack, которые долгое время концентрировались на универсальности, Rollup делал ставку на:

  • ES-модули;
  • статический анализ;
  • минимальный output;
  • оптимизацию библиотек;
  • удаление мёртвого кода.

Главная философия Rollup:

Итоговый bundle должен содержать только реально используемый код.


Как Rollup анализирует зависимости

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()

Концепция live bindings

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 работает не как простой конкатенатор файлов.

Он:

  1. Парсит AST каждого модуля.
  2. Анализирует импорты и экспорты.
  3. Строит граф зависимостей.
  4. Определяет используемые символы.
  5. Удаляет неиспользуемые ветви.

Фактически Rollup рассматривает программу как систему связей между символами.


Dead code elimination

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 effects.

Side effect — это любое действие, влияющее на внешнее состояние:

  • изменение глобальных переменных;
  • запись в DOM;
  • сетевые запросы;
  • логирование;
  • модификация объектов;
  • регистрация обработчиков.

Пример side effect

console.log('module loaded')

export const value = 10

Даже если value нигде не используется, удалять модуль нельзя, потому что исчезнет вызов console.log.


Pure functions

Tree-shaking особенно эффективен для чистых функций.

Чистая функция:

  • не меняет внешнее состояние;
  • зависит только от входных данных;
  • не имеет side effects.

Пример:

export function square(x) {
    return x * x
}

Такой код безопасно удаляется, если не используется.


Barrel-файлы и влияние на tree-shaking

Barrel-файлы переэкспортируют сущности из других модулей.

Пример:

export * from './math.js'
export * from './string.js'

Они упрощают API библиотеки:

import { sum } from './utils'

Однако чрезмерное использование barrel-файлов иногда усложняет анализ зависимостей и может ухудшать tree-shaking в некоторых сборщиках.

Rollup обычно справляется с этим лучше многих других bundler’ов благодаря глубокому анализу ESM.


Tree-shaking и default export

Именованные экспорты анализируются лучше.

Named export

export function foo() {}
export function bar() {}

Импорт:

import { foo } from './utils.js'

Rollup легко удалит bar.


Default export

export default {
    foo,
    bar
}

Импорт:

import utils from './utils.js'

utils.foo()

Теперь сборщик видит объект целиком.

Без дополнительного анализа определить использование отдельных свойств становится значительно сложнее.

Поэтому философия tree-shaking тесно связана с использованием named exports.


Scope hoisting

Rollup стал одним из первых bundler’ов, активно использующих scope hoisting.

Суть:

  • модули объединяются в единый scope;
  • исчезают лишние обёртки;
  • уменьшается overhead;
  • улучшается производительность.

Вместо:

(function() {
    function foo() {}
})()

Rollup старается генерировать:

function foo() {}

Это делает output ближе к обычному «ручному» JavaScript-коду.


Почему tree-shaking не всегда работает идеально

Полностью точный анализ JavaScript невозможен.

Язык слишком динамичен.

Некоторые конструкции мешают оптимизации.


Использование eval

eval(code)

Сборщик не знает, какой код будет выполнен.


Мутация объектов

obj[prop] = value

Невозможно гарантировать последствия.


Глобальные изменения

window.foo = 123

Такие действия создают side effects.


Динамические импорты сложной структуры

import(`./${name}.js`)

Анализ становится ограниченным.


Философия «пишите код для анализа»

Современный ecosystem вокруг Rollup и ES-модулей формирует важную идею:

Код должен быть не только читаемым человеком, но и анализируемым инструментами сборки.

Отсюда появляются рекомендации:

  • использовать ES-модули;
  • избегать динамических require;
  • минимизировать side effects;
  • использовать named exports;
  • писать чистые функции;
  • избегать глобальных мутаций;
  • разделять ответственность модулей.

Tree-shaking в библиотеках

Для библиотек tree-shaking особенно важен.

Плохой пример:

import _ from 'lodash'

В bundle может попасть огромная часть библиотеки.

Лучше:

import debounce from 'lodash/debounce'

Или ESM-вариант:

import { debounce } from 'lodash-es'

Библиотеки, ориентированные на Rollup и ESM, проектируются так, чтобы их части можно было удалять независимо.


package.json и sideEffects

Многие bundler’ы используют поле:

{
    "sideEffects": false
}

Это сообщает сборщику:

  • модули не имеют побочных эффектов;
  • неиспользуемые импорты можно безопасно удалять.

Но это опасное утверждение.

Если side effects всё же присутствуют, приложение может сломаться после tree-shaking.


Разница между tree-shaking и минификацией

Tree-shaking и минификация — разные процессы.

Tree-shaking

Удаляет:

  • неиспользуемые функции;
  • неиспользуемые импорты;
  • лишние модули.

Минификация

Сжимает код:

  • удаляет пробелы;
  • сокращает имена переменных;
  • оптимизирует выражения.

Пример минификации:

function sum(a,b){return a+b}

Tree-shaking уменьшает объём логически.

Минификация уменьшает объём синтаксически.


Архитектура ESM как долгосрочная стратегия

ES-модули создавались не только ради удобного синтаксиса.

Их архитектура ориентирована на:

  • статический анализ;
  • оптимизацию;
  • ленивую загрузку;
  • tree-shaking;
  • эффективную доставку кода;
  • компоновку зависимостей.

Rollup стал одним из инструментов, который наиболее полно раскрыл преимущества этой модели.


Эволюция frontend-индустрии под влиянием tree-shaking

Tree-shaking повлиял на всю экосистему JavaScript.

Изменились подходы к:

  • проектированию библиотек;
  • структуре модулей;
  • публикации npm-пакетов;
  • организации API;
  • форматам сборки.

Появились:

  • ESM-first библиотеки;
  • dual packages;
  • exports maps;
  • модульные runtime;
  • оптимизированные design systems.

Размер bundle стал одним из ключевых факторов качества frontend-приложений.


Философия Rollup: меньше кода — быстрее приложение

Rollup исходит из простой идеи:

  • лучший код — код, который не был отправлен пользователю;
  • неиспользуемый JavaScript не должен существовать в bundle;
  • статический анализ важнее магии runtime;
  • модульная архитектура должна помогать оптимизации.

Поэтому Rollup особенно хорошо подходит для:

  • библиотек;
  • SDK;
  • UI-kit;
  • utility-пакетов;
  • ESM-first проектов;
  • приложений с критичным размером bundle.