image


Работая в крупной компании довелось перехватить внутренний проект, который нужно было сдать вчера. Народ кочевал и топтался по нему, развивая культуру Святого Копипаста. А package.json рос не по дням, а по часам. Спустя только год удалось приступить к рефакторингу. В этой статье речь пойдет об одной конкретной оптимизации, о которой на просторах рунета я ничего не слышал.


А началось все...


… с моего удивления. Неминифицированный бандл приложения весил 44 Мб, включал в себя около 4 тыс. модулей, на сборку уходило порядка 8 мин. С еще большим удивлением я смотрел на одновременно подключенные lodash и lodash-es. Пляски с бубном, вырезание "мертвого" кода, удаление модулей, которые практически дублируют друг друга (например, все те же lodash и lodash-es) и как результат — 3 тыс. модулей, 30 Мб, 5 мин. Затем были еще искания на Хабре и нехабре, применение найденной инфы, обрезание лишних локалей moment'а, переход на TypeScript (пока используется только как транспайлер, но это совсем другая история) — 2,5 тыс модулей, 1 мин. 30 сек., 20 Мб.
Вполне достойно.


Но однажды исследуя beta-версию material-ui наткнулся на интересную страничку. Если вы перешли по ссылке, прочитали и все поняли, то дальше вам будет не интересно.


Для тех кто, еще с нами


Допустим, мы имеем небольшой проект, в корневой директории которого находятся:


  • package.json

{
  "name": "webpack-bundle-example",
  "version": "1.0.0",
  "scripts": {
    "start": "webpack"
  },
  "dependencies": {
    "lodash-es": "^4.17.4",
    "webpack": "^3.6.0",
    "webpack-bundle-analyzer": "^2.9.0"
  }
}

  • Конфиг сборщика webpack.config.js

const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer');
const path = require('path');

module.exports = {
    entry: './index',
    output: {
        filename: 'index.bundle.js',
        path: path.resolve(__dirname, 'build')
    },
    plugins: [
        new BundleAnalyzerPlugin({
            analyzerMode: 'static'
        })
    ]
};

  • Entry point нашего приложения — index.js (совершенно не важно что происходит в скрипте, нас интересует только импорт)

import {mapValues} from 'lodash-es';

console.log(mapValues({a: 0, b: 1}, (...args) => args));

В корне выполняем npm i и после установки зависимостей запускаем сборку npm start.


Плагин BundleAnalyzerPlugin сгенерирует для нас карту, по которой будет понятно из чего же состоит бандл.
И по этой карте мы видим, что в бандле содержатся файлы из директории node_modules общим весом в 608 Kb(!).


А мы всего лишь импортировали одну функцию из lodash-es. Жирновато, не правда ли?


Не очевидно очевидное решение


Решение простое, как дважды два. Для меня оно оказалось таким очевидным, что я до сих пор считаю его абсолютно не очевидным.


You can import directly from lodash-es/ to avoid pulling in unused modules. For instance, instead of:


import {mapValues} from 'lodash-es';

use:


import mapValues from 'lodash-es/mapValues';

А после сборки получаем следующую карту наших модулей и удивляемся — 82 Кб:


Поменяв всего одну строку в проекте мы получили чуть ли не 8-ми кратное уменьшение размера бандла.


Вы еще помните про проект из начала статьи? Так вот… 11 Мб, 40 сек, 1,7 тыс. модулей

Комментарии (12)


  1. vtvz_ru
    03.10.2017 01:18
    +5

    Серьезно? Я, наверное, раз 5 на том же самом хабре читал, что tree shaking за рамками es6 почти не работает и в случае с lodash (и не только) он будет грузить всю библиотеку целиком и полностью. Сообщество это знает, и поэтому тот же самый lodash специально разбит на мелкие модули, которые можно подключать независимо, что позволит сократить размеры бандла в разы.


  1. justboris
    03.10.2017 02:38
    +2

    То есть вы считаете что 80Кб ради несчастного mapValues — это нормально?


    6 строк ванильного JS делают то же самое: https://jsfiddle.net/npnqxp86/


    P.S. очень долгое время пользовался lodash, но, кажется, сейчас развитие библиотеки повернуло не туда.


    1. justboris
      03.10.2017 02:45

      В качестве альтернативы могу предложить семейство модулей just-* с той же самой функциональностью, но объемом всего в 600 байт для тех же mapValues


    1. shybovycha
      03.10.2017 03:26
      +1

      Подозреваю, что фраза


      совершенно не важно что происходит в скрипте, нас интересует только импорт

      явно декларирует непричастность оптимизации к lodash и всем прочим "да я это в пять строк напишу!"


    1. faiwer
      03.10.2017 18:44

      80 KiB это как-то слишком много. Я не поверил. Пошёл проверять. С -p флагом там 23 KiB. Но эта штука webpack-bundle-analyzer несмотря ни на что пишет свои 80+. Решил проверить детальнее, выполнил ./node_modules/.bin/webpack -p --json > stats.json. Посмотрел здесь. Всё верно — 22 KiB. Никакие не 80. Скорее всего, webpack-bundle-analyzer срабывает не на той стадии, на какой нужно. До uglifyJS и подобных мер, видимо. Иначе как можно в 23 KiB файле насчитать 80, я не знаю. 22 KiB тоже не мало, но с увеличением числа используемых методов из lodash, это число почти не будет расти. А подключать такую библиотеку ради 2-3 методов бессмысленно.


      P.S. большая часть веса lodash это jsDoc, который срезается на стадии сборки. Из оставшегося, я подозреваю, немало отъедает import-export обёртки webpack-а, но тут не уверен.


      1. justboris
        03.10.2017 21:17
        +1

        Я тоже посмотрел. Webpack bundle analyzer пишет 3 цифры


        Stat size: 84.6 KB
        Parsed size: 20.06 KB
        Gzipped size: 5.83 KB

        • Stat size — исходники
        • Parsed size — результат
        • Gzipped size — результат после применения gzip к нему.

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


      1. justboris
        03.10.2017 21:34
        +1

        А насчет большого веса lodash, он берется из двух вещей.


        1. Подержка экзотических сценариев. Например, вместо обычного obj[key] = value там используется специальный метод для поддержки свойства __proto__, который в свою очередь тянет специальный defineProperty. (Надо отдать должное, в версии 5 будет нативный defineProperty вместо кастомного)


        2. Поддержка сокращенных операций: _.mapValues(obj, 'prop') вместо _.mapValues(obj, item => item.prop). В эпоху короткой записи стрелочных функций это уже не так необходимо, но килобайты логики тянутся.

        Кстати, второй пункт можно исправить при помощи волшебной строчки в конфиге Webpack:


        Магия
            resolve: {
                alias: {
                    './_baseIteratee': require.resolve('lodash/identity')
                }
            },


  1. Klimashkin
    03.10.2017 09:23
    +1

    Можно просто использовать lodash-webpack-plugin и babel-plugin-lodash


    1. hacke151
      04.10.2017 19:45

      а они работают если используется chain и нет явных импортов функций?


      1. Klimashkin
        04.10.2017 20:44

        нет явных импортов функций

        Это как раз то что делает babel-plugin-lodash — преобразует импорт всего лодэша в импорт конкретных функций

        а они работают если используется chain

        _.chain — это антипаттерн и может быть заменен:
        Why using `_.chain` is a mistake


  1. DanilaMaster
    04.10.2017 11:56

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


  1. parakhod
    04.10.2017 19:45
    -1

    Хм, увидел вдруг название lodash и подумал, что последний раз этой библиотекой пользовался года два назад.
    С появлением синтаксиса ES6 и всех его мапов, редюсов, сетов, спредов и прочая у меня всё реже и реже возникала в ней необходимость — да и код стал читабельнее.
    А где-то год назад на immutable перешёл, так что уж и вообще применять её больше и негде...