Оптимизация фронтенда. Часть 1. Почему я не люблю слово treeshaking или где вас обманывает webpack


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


    Предыстория


    В статье одного из авторов rollup рассмотрены две оптимизации, одна называется dead code elimination, а вторая tree-shaking.  Автор показывает, что у tree-shaking намного больше возможностей по сжатию кода.  И в доказательство приводит несколько соображений о рецептах пирога и разбившихся яйцах. Ох уж эти метафоры!


    Эту идею (про tree-shaking, не про пирог и яйца) подхватила команда разработчиков webpack и с версии 2.0 стала официально поддерживать.


    Проблема


    Я не стал бы писать, если бы на реальных проектах технология приносила хоть какой-то результат.  На практике размеры итоговой сборки либо не уменьшаются вовсе, либо уменьшаются на размер статистической погрешности.


    Некоторые, конечно, догадываются о подвохе и даже пишут статьи на Хабр. Но любителей порассуждать о преимуществах tree-shaking над dead code illumination в webpack вокруг меньше не становится, по крайней мере, среди посетителей конференций и среди моих коллег.


    Как это должно было работать?


    Идея проста, как танк.


    1. Сборщик проходит по дереву модулей и помечает неиспользуемые импорты специальными комментариями. Вот так:
      ...
      /* unused harmony export square */
      function square(x) { return x * x;}
      ...
    2. Следующим этапом UglifyJS, который по умолчанию изолентой примотан к webpack, помимо всего того, что мы от него ждем, нещадно выпиливает код, который помечен этими самыми комментариями.
    3. PROFIT!

    Когда это не работает?


    Допустим, у нас все как в документации. Два файла index.js и module.js


    // index.js
    
    import {cube} from './module'
    
    console.log(cube(x))

    // module.js
    
    export function square(x) {
      return x * x;
    }
    
    export function cube(x) {
      return x * x * x;
    }

    Если мы сейчас запустим webpack в режиме оптимизации и минимизации, то все заработает как и ожидалось. (код)


    webpack --optimize-minimize index.js out.js

    Но если только в файл с модулями добавится любой, даже самый маленький класс с export при наличии babel-loader, то пиши пропало. Класс попадет в итоговую сборку в виде функции, которую выплюнул babel. (код)


    //module.js
    
    export function square(x) {
      return x * x;
    }
    
    export function cube(x) {
      return x * x * x;
    }
    
    export class MyClass {
        print(){
            console.log('find me');
        }
    }

    Как же так получилось?


    Все дело в том, что UglifyJS боится выкинуть что-то лишнее. Оно и понятно: пусть лучше на пару сотен байт больше, только бы не сломалось.


    И вот, представьте себе, что UglifyJS получает на вход следующий код:


    /* unused harmony export MyClass */
    var MyClass = function () {
      function MyClass() {
        babelHelpers.classCallCheck(this, MyClass);
      }
    
      MyClass.prototype.turn = function print() {
        console.log('find me');
      };
    
      return MyClass;
    }();

    MyClass после компиляции babel как-то выбрался за осознания себя как класса. И вообще UglifyJS мало что знает про то, как команда babel видит реализацию классов на ES5. Вот и пасует, оставляя это неведомое никем не используемое безобразие в вашей итоговой сборке.
    На это даже есть баг в репозитории webpack, и ребята обещают починить все в 4й версии.
    Rollup, кстати, не так давно тоже работал только на примерах с математикой, но в последних версиях ребята починили баг. (сломанный пример, работающий пример).


    Мораль


    Вот так, купив webpack в том числе и за tree-shaking, я получил околонулевую выгоду в этом направлении. И слово tree-shaking теперь вызывает у меня нервный смех, икоту и неконтролируемый сарказм.


    И как теперь быть?


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


    Извините, не удержался.


    Если серьезно, есть очень простой способ починить ситуацию:


    нужно, чтобы webpack добавлял специальную директиву /*#__PURE__*/, которая говорила бы UglifyJS, что вот это неведомое чудище в виде функции вполне себе можно выпиливать.


    Ох уж эти костыли.


    А выглядит это как-то так:


    /* unused harmony export MyClass */
    var MyClass  =  /*#__PURE__*/ function () {
      function MyClass() {
        babelHelpers.classCallCheck(this, MyClass);
      }
    
      MyClass.prototype.turn = function print() {
        console.log('find me');
      };
    
      return MyClass;
    }();

    Еще пара итераций и мы изобретем статическую типизацию ;)


    Работающий пример


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


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


    Выводы


    1. Сомневайтесь во всем. Проверяйте информацию. Даже эту статью. Такая практика сэкономит вам кучу нервов и времени.
    2. Экосистема javascript иногда допускает сбои на стыках технологий. Вот и здесь из-за того, что babel ничего не сказал UglifyJS через webpack о свем формате классов для ES5, получилось недопонимание. При этом в babel его уже почти исправили, о чем, конечно же, не знают ребята из webpack и хотят исправить в следующем релизе.

    P.S. Напишите в комментариях, если тоже попадались на удочку маркетологов и выбирали модную технологию вместо решения проблем. 


    Оптимизация фронтенда. Часть 2

    Wrike 239,70
    Wrike делает совместную работу над проектами проще
    Поделиться публикацией
    Комментарии 17
    • +1
      Больше всего, мне не нравится выхлоп в сравнении с тем же rollup, в этом issue это описано
      • 0
        Human-readable код делается с помощью sourcemaps.
        • 0
          Если сравнивать по состоянию на текущий день, то Rollup генерирует меньше кода и это факт. Насчет понятности тут каждый сам решает. Лично мне важно понимать, что происходит под капотом.
          • +2
            Абсолютно согласен с размером бандлов. Однако стоит учитывать, что у webpack и rollup немного разные ниши.
            • 0
              Какие у них ниши? Как раз сейчас активно разбираюсь во всём этом зоопарке (раньше кодовая база не имела модулей), поэтому очень интересно.
              • 0
                Rollup для микро-приложений (несколько простых скриптов, никаких стилей/svg..) и библиотек. Webpack уже для полноценных SPA.
      • +2
        Хочу вставить пару копеечек.

        Предположим, мы имеем следующий код (почти как у автора в статье):
        // foo.js
        import { getBar } from './bar';
        getBar();
        // bar.js
        export function getBar() {}
        export function setBar() {}
        

        И почему же webpack нам отдает даже те экспорты, которые не используются? А делает он это потому, что webpack молодец, следует стандартам EcmaScript. Он обязан загрузить полностью весь модуль, выполнить его, и только потом выполнять свой код. Именно поэтому в результирующем коде и появляется весь модуль.

        Так же, все импорты «хоистятся» (не знаю, как это правильно на русском написать). Поэтому их надо писать в начале файла, и ни в коем случае не в каких-нибудь if. Они должны быть статическими. Сейчас комитетом ТС39 ведется работа над динамическим импортом, который уже в Stage 3.

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

        Правильная конфигурация webpack, в частности, плагинов (код-сплиттинг, минификация, итд) позволяет писать весьма компактные бандлы. Поэтому не надо тут на него клеветать.

        import на MDN, там же есть ссылки сопутствующие спекификации.
        • +2
          Я не против webpack, как инструмента. Гораздо больше меня смущают поверхностные мнения и суждения. Как сейчас все выглядит: webpack поддерживает tree-shaking, и rollup поддерживает tree-shaking – значит они жмут код примерно одинаково. На самом деле это далеко не так. И те, кто думает, что у них прямо сейчас работает tree-shaking, скорее всего ошибаются.
          • +2
            Да, всё верно. Я считаю, что tree-shaking еще совсем «зеленый», поэтому чудес от него ожидать не стоит. Надо писать нормальный код и правильно конфигурировать сборщик.
        • +1
          Если проблема в tree-shaking после babel, то нельзя ли просто babel запускать после tree-shaking?
          • +2
            Хороший вопрос, если коротко, то нет
            Если длинно, то UglifyJS падает, когда видит ES2015+ код, но есть версия UglifyJS для ES. Очень сомневаюсь в ее качестве по сравнению с оригиналом. Но я не об этом. Поскольку Babel генерирует неоптимальный код, придется прогонять UglifyJS еще раз или оставлять все так, как нагенерировал Babel. Оба варианта мне не очень нравятся.
            • 0
              На самом деле обо всем об этом напишу немного попозже, решений несколько.
            • +2
              P.S. Напишите в комментариях, если тоже попадались на удочку маркетологов и выбирали модную технологию вместо решения проблем.

              Про удочку маркетологов смешно получилось. Сразу представил офис Webpack, Inc. и совещание на тему "Чем будем ловить пользователей в следующем году?" Поддержка WASM? Нанооптимизации через машинное обучение?

              • 0
                Согласен, маркетинг должен быть в кавычках )) Но при всей моей любви к Rollup, статья про него и про tree-shaking рекламная. Рекламирует сборщик.
              • +1
                выкидываем углифай, ставим closure compiler, радуемся жизни.
                • 0
                  А чем он поможет. Чтоб он эффективно работал в advanced mode нужно писать код по всем правилам (расставлять везде комментарии с типами) и если где-то ошибёшься то он тебе сломает код. А в более простом режиме он работает примерно как uglify.

                  Пишу по опыту где-то 4-х лет назад. Что-то изменилось?
                  • 0
                    ага, в advanced оптимизации он всё ломает, чтобы не ломал надо слишком много сделать и всегда останется вероятность, что в процессе сборки код перестанет работать
                    если не использовать advanced, то он проиграет по сжатию углифаю и бабель-минифаю

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое