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

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

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

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

Стоимость обработки при разборе/компиляции 170 КБ JavaScript по сравнению со временем декодирования JPEG эквивалентного размера. ( источник ).

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

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

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

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

Image for: Что такое тряска деревьев?

Tree shake — это форма устранения мертвого кода. Термин был популяризирован Rollup , но ко��цепция устранения мертвого кода существует уже некоторое время. Эта ко��цепция ��ак��е нашла применение в webpack , что демонстрируется в этой статье с помощью примера приложения.

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

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

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

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

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

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

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

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

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

Скриншот примера приложения.

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

Два пакета JavaScript приложения. Это несжатые размеры.

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

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

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

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

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

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

Пространство имен 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.

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

Не позволяем Babel транспилировать модули ES6 в модули CommonJS

Image for: Не позволяем Babel транспилировать модули ES6 в модули CommonJS

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

Поскольку tree shake сложнее для модулей CommonJS, webpack не будет знать, что обрезать из ��акетов, если вы решите их использовать. Решение состоит в том, чтобы настроить @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 вести себя так, как требуется, что позволяет Webpack анализировать дерево зависимостей и избавляться от неиспользуемых зависимостей.

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

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

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

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, и это имеет значение в контексте tree shake. Модули, которые принимают предсказуемые входные данные и производят столь же предсказуемые выходные данные, не изменяя ничего за пределами своей области действия, являются зависимостями, которые ��ож��о ��езопасно отбросить, если мы их не используем. Это самодостаточные, модульные фрагменты кода. Отсюда и «модули».

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

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

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

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

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

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

Image for: Импорт только того, что необходимо

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

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

Поскольку импортируется только simpleSort вместо всего модуля utils , каждый экземпляр 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);
}

Это должно быть все, что нужно для работы tree shake в этом примере. Это вывод webpack перед shake дерева зависимостей:

                 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%. Это не только сокращает время, необходимое скрипту для загрузки, но и время обработки.

Пойди, потряси деревья!

Image for: Пойди, потряси деревья!

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

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

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