Уменьшите полезную нагрузку JavaScript с помощью встряхивания дерева

Сегодняшние веб-приложения могут стать довольно большими, особенно их часть JavaScript. По состоянию на середину 2018 года HTTP Archive средний размер передачи JavaScript на мобильные устройства составляет примерно 350 КБ. И это всего лишь размер перевода! JavaScript часто сжимается при отправке по сети, а это означает, что фактический объем JavaScript становится немного больше после того, как браузер его распакует. Это важно отметить, поскольку с точки зрения обработки ресурсов сжатие не имеет значения. 900 КБ распакованного JavaScript по-прежнему составляют 900 КБ для анализатора и компилятора, хотя в сжатом виде они могут составлять примерно 300 КБ.

Диаграмма, иллюстрирующая процесс загрузки, распаковки, анализа, компиляции и выполнения JavaScript.
Процесс загрузки и запуска JavaScript. Обратите внимание, что даже несмотря на то, что размер передаваемого сценария составляет 300 КБ в сжатом виде, это все равно составляет 900 КБ JavaScript, который необходимо проанализировать, скомпилировать и выполнить.

JavaScript — дорогой ресурс для обработки. В отличие от изображений, декодирование которых требует относительно незначительного времени после загрузки, JavaScript необходимо проанализировать, скомпилировать и затем, наконец, выполнить. Байт за байтом, это делает JavaScript дороже, чем другие типы ресурсов.

Диаграмма, сравнивающая время обработки JavaScript размером 170 КБ с изображением JPEG эквивалентного размера. Ресурс JavaScript гораздо более ресурсоемкий, чем JPEG.
Затраты на обработку синтаксического анализа/компиляции 170 КБ JavaScript по сравнению со временем декодирования JPEG эквивалентного размера. ( источник ).

Несмотря на то, что для повышения эффективности движков JavaScript постоянно вносятся улучшения , повышение производительности JavaScript, как всегда, является задачей разработчиков.

С этой целью существуют методы повышения производительности JavaScript. Разделение кода — это один из таких методов, который повышает производительность за счет разделения JavaScript приложения на фрагменты и предоставления этих фрагментов только тем маршрутам приложения, которые в них нуждаются.

Хотя этот метод работает, он не решает распространенную проблему приложений с большим количеством JavaScript, а именно включение кода, который никогда не используется. Встряхивание деревьев пытается решить эту проблему.

Что такое тряска дерева?

Встряхивание дерева — это форма устранения мертвого кода. Этот термин был популяризирован компанией Rollup , но концепция устранения мертвого кода существует уже некоторое время. Эта концепция также нашла применение в веб-пакете , который продемонстрирован в этой статье в виде примера приложения.

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

// Import all the array utilities!
import arrayUtils from "array-utils";

Когда приложение молодое — саженец, если хотите, — у него может быть мало зависимостей. Он также использует большинство, если не все, добавляемых вами зависимостей. Однако по мере развития вашего приложения могут добавляться новые зависимости. Ситуация усугубляется тем, что старые зависимости выходят из употребления, но не могут быть удалены из вашей кодовой базы. Конечным результатом является то, что приложение поставляется с большим количеством неиспользуемого JavaScript . Tree Shaking решает эту проблему, используя преимущества того, как статические операторы import извлекают определенные части модулей ES6:

// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";

Разница между этим примером import и предыдущим заключается в том, что вместо импорта всего из модуля "array-utils" (а это может быть много кода) этот пример импортирует только определенные его части. В дев-сборках это ничего не меняет, так как все равно импортируется весь модуль. В производственных сборках веб-пакет можно настроить так, чтобы он «отбрасывал» экспорт из модулей ES6, которые не были импортированы явно, что делает эти производственные сборки меньше. В этом руководстве вы узнаете, как это сделать!

Поиск возможностей потрясти дерево

В иллюстративных целях доступен пример одностраничного приложения , демонстрирующего, как работает встряхивание деревьев. Вы можете клонировать его и следовать инструкциям, если хотите, но в этом руководстве мы вместе рассмотрим каждый шаг, поэтому клонирование не требуется (если только вам не нравится практическое обучение).

Пример приложения представляет собой базу данных гитарных педалей эффектов с возможностью поиска. Вы вводите запрос, и появляется список педалей эффектов.

Скриншот примера одностраничного приложения для поиска в базе данных гитарных педалей эффектов.
Скриншот примера приложения.

Поведение, управляющее этим приложением, разделено на пакеты кода поставщика (т. е. Preact и Emotion ) и пакеты кода, специфичные для приложения (или «куски», как их называет веб-пакет):

Снимок экрана двух пакетов (или фрагментов) кода приложения, показанных на сетевой панели инструментов разработчика Chrome.
Два пакета JavaScript приложения. Это несжатые размеры.

Пакеты JavaScript, показанные на рисунке выше, являются производственными сборками, то есть они оптимизированы посредством укрупнения. 21,1 КБ для пакета конкретного приложения — это неплохо, но следует отметить, что никакого дрожания дерева не происходит. Давайте посмотрим на код приложения и посмотрим, что можно сделать, чтобы это исправить.

В любом приложении поиск возможностей встряхивания дерева потребует поиска статических операторов import . В верхней части файла основного компонента вы увидите такую ​​строку:

import * as utils from "../../utils/utils";

Вы можете импортировать модули ES6 разными способами , но такие, как этот, должны привлечь ваше внимание. В этой конкретной строке говорится: « import все из модуля utils и поместите это в пространство имен под названием utils ». Здесь следует задать большой вопрос: «Сколько всего содержимого в этом модуле?»

Если вы посмотрите исходный код модуля utils , вы обнаружите около 1300 строк кода.

Вам нужны все эти вещи? Давайте дважды проверим, выполнив поиск в файле основного компонента , который импортирует модуль utils , чтобы увидеть, сколько экземпляров этого пространства имен встречается.

Скриншот поиска в текстовом редакторе по запросу «utils.», возвращающего только 3 результата.
Пространство utils , из которого мы импортировали множество модулей, вызывается только три раза в файле основного компонента.

Как оказалось, пространство имен utils присутствует только в трёх местах нашего приложения — но для каких функций? Если вы еще раз посмотрите на файл основного компонента, то увидите, что это только одна функция — utils.simpleSort , которая используется для сортировки списка результатов поиска по ряду критериев при изменении раскрывающихся списков сортировки:

if (this.state.sortBy === "model") {
  // `simpleSort` gets used here...
  json = utils.simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  // ..and here...
  json = utils.simpleSort(json, "type", this.state.sortOrder);
} else {
  // ..and here.
  json = utils.simpleSort(json, "manufacturer", this.state.sortOrder);
}

Из файла в 1300 строк с кучей экспортов используется только один из них. Это приводит к отправке большого количества неиспользуемого JavaScript.

Хотя этот пример приложения, по общему признанию, немного надуман, это не меняет того факта, что этот синтетический сценарий напоминает реальные возможности оптимизации, с которыми вы можете столкнуться в рабочем веб-приложении. Теперь, когда вы определили, что встряхивание деревьев может быть полезным, как это на самом деле делается?

Удержание Babel от переноса модулей ES6 в модули CommonJS

Babel — незаменимый инструмент, но он может затруднить наблюдение за эффектом сотрясения деревьев. Если вы используете @babel/preset-env , Babel может преобразовать модули ES6 в более широко совместимые модули CommonJS, то есть модули, которые вам require вместо import .

Поскольку встряхивание дерева сложнее выполнить для модулей CommonJS, веб-пакет не будет знать, что удалять из пакетов, если вы решите их использовать. Решение состоит в том, чтобы настроить @babel/preset-env так, чтобы он явно оставлял модули ES6 в покое. Где бы вы ни настраивали Babel — будь то в babel.config.js или package.json — это предполагает добавление чего-то дополнительного:

// babel.config.js
export default {
  presets: [
    [
      "@babel/preset-env", {
        modules: false
      }
    ]
  ]
}

Указание modules: false в вашей конфигурации @babel/preset-env заставляет Babel вести себя так, как нужно, что позволяет веб-пакету анализировать ваше дерево зависимостей и избавляться от неиспользуемых зависимостей.

Помните о побочных эффектах

Еще один аспект, который следует учитывать при удалении зависимостей из вашего приложения, — имеют ли модули вашего проекта побочные эффекты. Примером побочного эффекта является ситуация, когда функция изменяет что-то за пределами своей области действия, что является побочным эффектом ее выполнения:

let fruits = ["apple", "orange", "pear"];

console.log(fruits); // (3) ["apple", "orange", "pear"]

const addFruit = function(fruit) {
  fruits.push(fruit);
};

addFruit("kiwi");

console.log(fruits); // (4) ["apple", "orange", "pear", "kiwi"]

В этом примере addFruit создает побочный эффект, когда изменяет массив fruits , выходящий за рамки его области действия.

Побочные эффекты также применимы к модулям ES6, и это важно в контексте встряхивания дерева. Модули, которые принимают предсказуемые входные данные и производят столь же предсказуемые выходные данные, не изменяя ничего за пределами своей области действия, — это зависимости, от которых можно безопасно отказаться, если мы их не используем. Это автономные модульные фрагменты кода. Отсюда и «модули».

В случае с веб-пакетом можно использовать подсказку, чтобы указать, что пакет и его зависимости не содержат побочных эффектов, указав "sideEffects": false в файле package.json проекта:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": false
}

В качестве альтернативы вы можете указать веб-пакету, какие конкретные файлы не свободны от побочных эффектов:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": [
    "./src/utils/utils.js"
  ]
}

В последнем примере любой файл, который не указан, будет считаться свободным от побочных эффектов. Если вы не хотите добавлять это в свой файл package.json , вы также можете указать этот флаг в конфигурации вашего веб-пакета через module.rules .

Импортируем только то, что необходимо

После указания Babel оставить модули ES6 в покое, потребуется небольшая корректировка синтаксиса import , чтобы использовать только те функции, которые необходимы из модуля utils . В примере этого руководства все, что нужно, — это функция simpleSort :

import { simpleSort } from "../../utils/utils";

Поскольку вместо всего модуля utils импортируется только simpleSort , каждый экземпляр utils.simpleSort необходимо будет изменить на simpleSort :

if (this.state.sortBy === "model") {
  json = simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  json = simpleSort(json, "type", this.state.sortOrder);
} else {
  json = simpleSort(json, "manufacturer", this.state.sortOrder);
}

Это должно быть все, что нужно для того, чтобы встряхивание деревьев работало в этом примере. Это вывод веб-пакета перед встряхиванием дерева зависимостей:

                 Asset      Size  Chunks             Chunk Names
js/vendors.16262743.js  37.1 KiB       0  [emitted]  vendors
   js/main.797ebb8b.js  20.8 KiB       1  [emitted]  main

Это результат успешного встряхивания дерева:

                 Asset      Size  Chunks             Chunk Names
js/vendors.45ce9b64.js  36.9 KiB       0  [emitted]  vendors
   js/main.559652be.js  8.46 KiB       1  [emitted]  main

Хотя оба пакета сократились, на самом деле наибольшую выгоду приносит main пакет. Если стряхнуть неиспользуемые части utils модуля, то main комплект уменьшится примерно на 60%. Это не только сокращает время загрузки скрипта, но и время обработки.

Иди потряси деревья!

Какой бы результат вы ни получили от тряски деревьев, зависит от вашего приложения, его зависимостей и архитектуры. Попробуй это! Если вы точно знаете, что не настроили сборщик модулей для выполнения этой оптимизации, нет ничего плохого в том, чтобы попробовать и посмотреть, какую пользу это принесет вашему приложению.

Вы можете получить значительный прирост производительности от встряхивания дерева или не получить вообще никакого. Но настроив свою систему сборки на использование преимуществ этой оптимизации в производственных сборках и выборочно импортируя только то, что нужно вашему приложению, вы заранее будете сохранять как можно меньше пакетов приложений.

Особая благодарность Кристоферу Бакстеру, Джейсону Миллеру , Адди Османи , Джеффу Поснику , Сэму Сакконе и Филиппу Уолтону за их ценные отзывы, которые значительно улучшили качество этой статьи.