Внутреннее устройство и оптимизация бандла webpack

    Webpack фактически стал стандартом для сборки крупных приложений на JS. Его используют практически все. Для разработчика webpack выглядит как магический черный ящик: если забросить в него файлы и небольшой конфиг, на выходе автоматически появится бандл.

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


    В основе материала — доклад Алексея Иванова на конференции HolyJS 2017, проходившей в Санкт-Петербурге 2-3 июня.

    В компании Злые марсиане я занимаюсь сервисом «eBay for business». eBay выдвигает довольно жесткие требования, например по тому, сколько должен весить сайт, первая страница, сколько она должна грузиться. Чтобы не выйти за пределы ограничений, мы регулярно смотрим на содержимое наших бандлов: что туда попадает и что делать, чтобы туда не попадало всякого странного.

    Для этого мы используем различные инструменты: webpack bundle analyzer, webpack runtime analyzer и другие. Выглядит это примерно так:



    Он смотрит за вашим билдом. Когда вы закончили собирать бандл, он берет результат сборки, парсит его и показывает три красивые мапы. И когда вы занимаетесь этим постоянно, то начинаете замечать странности.

    • React в бандле весит больше чем lib/react.js
    • Несколько версий lodash или underscore
    • Moment.js грузит 100+ локалей
    • Непонятные полифилы
    • Не работает tree shaking
    • Изменяется кеш для не изменившихся чанков
    • И так далее

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

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

    После этого мне пришлось провести серию экспериментов: запустить webpack с пустым бандлом, с одним файликом и пустым js, с одним import и т.д. И мне это помогло, все возникшие проблемы я исправил. И тут я подумал, что я, наверное, не один такой, и с таким проблемами сталкивались многие. И поэтому решил поделиться своим опытом.

    Содержание


    • CommonJS
    • Резолв путей до файлов
    • Устройство бандла изнутри
    • Глобальные константы и DefinePlugin
    • UglifyJS dead code elimination
    • ES6 modules и tree shaking
    • Выделение чанков и асинхронная подгрузка
    • Анализ результатов сборки

    Начнем с CommonJS-модулей. CommonJS-модули — это такая штука, когда в одних файлах вы пишите require, а в других — exports или module.exports.

    Про CommonJS-модули необходимо помнить следующее: они появились в node, и когда вы пользуетесь webpack, на самом деле используете не просто CommonJS-модули, а штуку, которая позволяет обеспечить совместимость с node. В общем CommonJS необходима:

    • JS-файл с переменными require, exports, module
    • this равно exports
    • exports по умолчанию равно {}
    • Чтобы мы могли использовать npm-модули в браузере, нам нужно эмулировать в браузере это поведение.

    Вторая очевидная вещь — ее, наверное, все знают, но я все же расскажу. Что такое резолв путей? Вот у нас есть require(), внутри него пишем путь, по которому говорим, что нужно зарекуарить. Есть простые варианты, когда мы пишем черточку либо черточку с точками. И он идет либо в текущую папку, либо в корневую систему и т.д. Тут никаких нюансов нет, здесь все просто.

    Дальше, если вы не написали расширение, то он сначала попытается добавить расширение js. Если его нет, он попытается найти папку, где лежит index.js. Если мы говорим про node, то она ищет по умолчанию не только js, а еще JSON, файлы с расширением node и другое. В webpack все это не используется, поэтому об этом можно пока забыть. Но это и так все знают.

    Самое интересное — это то, что происходит, когда мы пытаемся сделать import модуля. Если мы написали module, что делает node и, соответственно, webpack? Она идет в текущую папку node, ищет папку node_modules, заходит в нее и ищет папку с названием модуля. Если она нашла ее, то она ее берет, и дальше все нормально.

    Если она ее не нашла, она идет в папку на уровень выше, после этого — еще на уровень выше, и так пока не дойдет до корня файловой системы. И вот тут у нас есть первый нюанс, на который можно хорошо попасться. Он выглядит вот так:

    Вы поставили какую-то библиотеку из npm, и в ней прописано, что ей нужна зависимость lodash 1.0.0, а вы в своем проекте используете lodash 5.0.0. И вот, если так произошло, npm создаст свою папку node_modules и поставит свою версию lodash. Если таких библиотек несколько, то в каждой может оказаться версия lodash, никак не связанная со всеми остальными. Если вы используете node, никаких проблем, а вот если webpack, то все версии lodash подгружаются в браузер. Это такие базовые вещи, которые webpack’у необходимо делать, чтобы хорошо работать.

    Базовое устройство бандла


    Базовое устройство бандла выглядит примерно так:

    Вот у нас есть файл, и вроде бы там все хорошо, но есть нюансы. Браузер ни про require, ни про exports, ни про module ничего не знает. Ему надо об этом как-то рассказать. Самый простой способ — взять содержимое файла, обернуть его в функцию, в которой все эти штуки передать в параметрах, а потом в какой-то момент ее выполнить. На самом деле webpack примерно так и делает, но с небольшим изменением.

    Первое изменение: мы меняем require на __webpack_require__. Зачем это нужно?

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

    Во-вторых, из-за способа webpack помечать функции, которые он портирует внутрь бандла. Соответственно, мы можем делать всякие оптимизации.

    Покажу еще раз. Вот тут у нас написан path:

    Вот тут у нас в скобочках 0:

    Почему так? В браузерах файловой системы нет. Поэтому когда webpack собирает модули в один файлик, он на самом деле кладет их в массив. Цифра в скобочках — по умолчанию индекс в этом массиве. Это нам аукнется еще в будущем. Почему массив? Потому что по сравнению со всякими объектами с ключами это самый компактный вариант и он будет меньше всего весить.

    То есть пришел webpack, обернул все модули в функции, положил все функции в массив модулей и добавил в начало анонимную функцию, которая это все подгружает.

    Первая строчка, которая нам важна, — это installedModules. То есть когда webpack грузит массив, он не инициализирует то, что в нем находится, автоматически — оно продолжает лежать в массиве мертвым кодом.

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

    Дальше у нас есть функция __webpack_require__, которую мы будем передавать внутрь. И есть такое корневое место, которое вызывает ваш файлик, и вы начинаете строить ваш корневой бандл. То есть мы подгрузили все в массив, вызвали функцию, объявили функцию __webpack_require__ и вызываем корневой файл.

    Что делает __webpack_require__


    1. Ищет инициированный модуль в кэше
    2. Создает заглушку и добавляет ее в массив
    3. Выполняет код модуля с this равным module.exports
    4. Возвращает module.exports

    Как именно работает __webpack_require__, что он вообще делает? Опять, как я уже и говорил, смотрим, нет ли у нас в модуле кэша. Если нет, идем дальше, а если есть, возвращаем из него. Дальше, если его нет, мы создаем заглушку и добавляем ее в массив. Заглушка выглядит примерно так:

    У нас есть айдишники (в основном цифры, но иногда — нет) и функция exports. По умолчанию у нее ставится пустой объект, поэтому когда мы эту штуку будем вызывать внутри модуля webpack, по умолчанию она там будет пустым объектом.

    Дальше происходит следующее. Мы берем наш код и вызываем то, что у нас было в массиве, примерно в таком виде:

    То есть мы вызываем не просто функцию, а именно через call, чтобы exports тоже попал в первый объект. Это нужно для обратной совместимости с node. В результате получается примерно следующее:

    С этого момента у нашего модуля, который живет в installedModules, в exports уже не пустой объект, а то, что мы ему назначили и вернули. Почему это интересно и важно? Потому что, так как мы делаем все вот таким способом, у нас в бандле есть один рабочий экземпляр нашего модуля, и мы можем его использовать как замыкание.

    То есть если мы объявляем в модуле какую-то переменную, она будет общей для всех instance’ов. Если мы в exports экспортируем какой-то метод, который позволяет, например, инкрементировать эту переменную внутри замыкания, то эта переменная тоже будет доступна внутри. Если вы тут объявляете какую-то библиотеку и назначаете ей некоторые плагины, то instance-библиотеки со всеми плагинами тоже будут общими. Соответственно, с помощью этой штуки можно делиться информацией со всеми модулями и делать другие интересные вещи.

    После того как мы все это инициализировали, создали экземпляр модуля, мы возвращаем то, что находится внутри exports, и на этом успокаиваемся.

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

    То есть когда вы указываете внутри require не полный путь файла, а какую-то регулярку, webpack сможет это собирать. При этом так как он не занимается анализом кода в живую, то он не может знать, что у вас на самом деле используется, и на всякий случай тащит туда все, что вообще может быть. То есть как это все работает? Webpack в данном случае создаст в массиве новый модуль, в котором запишет логику про resolve путей.

    Внутри самого модуля живет карта, в которой описаны все возможные пути. То есть если у вас лежит 20 файлов, то он все сюда положит и сделает 40 вариантов имени, если вы указываете, например, с js или без js. Дальше он сделает функцию, которая будет проводить эволюцию выражения, переданного внутрь функции, и сравнивать то, что есть в массиве. Если она найдет совпадение, то вернет тело, если нет, выкинет ошибку. В этом месте тоже может возникнуть проблема. Я думаю, многие с ней сталкивались.

    Проблема следующая. У нас есть библиотека moment.js, которая позволяет делать различные операции с датами. Когда вы ее используете через node, есть небольшой нюанс. Внутри корневого файла moment.js есть строчка require(‘./locale/’ + name). Соответственно, webpack идет внутрь папки locale, находит там 118 локалей, подгружает их все в bundle и создает карту примерно из 250 ключей. Наверное, это не совсем то, что хотелось бы видеть.

    Для webpack есть ContextReplacementPlugin. Он проверяет первую часть по маске. Если маска совпала с тем, что написано в первом аргументе, он, вместо того чтобы возвращать то, что нашел в файловой системе, возвращает то, что вы ему передали вторым параметром.

    Глобальные константы и DefinePlugin



    У нас есть бандл, в нем все хорошо. Он подключает файлы, разруливает пути и так далее. Иногда нам нужно сделать так, чтобы он жил не на основе тех данных, которые у нас есть, а получал какую-то информацию снаружи.

    Допустим, у вас есть dev и production-версии, у которых разный путь, и вы хотите, чтобы webpack при dev разработке работал с одним путем, а при production — с другим. Или есть разные номера версий. Еще один вариант использования, который применяется во многих библиотеках, — задание process.env.NODE_ENV. Данная переменная является эдаким общим шаблоном, который говорит, что эти функции не надо использовать в режиме разработки, а, например, функции для дебага — в режиме production.

    Возникает вопрос, как нам передавать эти переменные? Существует DefinePlugin, где можно объявить данные переменные и, следовательно, они попадут внутрь бандла.

    DefinePlugin берет строку слева, в нашем случае это VERSION, далее берет строку справа, идет в файлик, и регулярка заменяет старую строку на новую. В итоге результат выглядит примерно так:

    JSON.stringify добавляет кавычки. Если бы кавычек не было, у нас была бы просто цифра 1.0.1 и все бы сломалось. Что здесь произошло? Если после замены какой-либо строки или числа webpack может понять, что это условие if, и он понимает, что левая часть сравнивается с правой и обе — константы, то он заменяет их на true либо на false. Так происходит для того, чтобы мог прийти UglifyPlugin и прибраться.

    Вторая интересная вещь: как вы могли заметить, require осталась require. Она не заменилась на __webpack_require__. Соответственно, require в бандл не попала и в сборке ее не будет. Если же вы хотите отключить какую-то часть функционала, вот один из способов это сделать. Но, как и везде, тут есть нюансы. Проблема выглядит вот так:

    Если вы хотите быть модным и использовать, например, babel, и у него есть деструктуринг, то вы можете написать NODE_ENV. К сожалению, это все поломает. Но почему? Вот так выглядит код после преобразования:

    То есть одна переменная ссылается на вторую, которой тоже что-то приходит. И внутри ваше условие будет выглядеть так: NODE_ENV !== «production».

    На самом деле если выкатить этот код на production, все будет работать так, как вы бы и хотели. Потому что переменной приходит false, и то, что внутри if, не выполнится. Но так как webpack не знает, что такое переменные, и он не делает полный анализ кода и не выясняет, что в этой переменной будет, то он не может понять, что то, что находится внутри, грузить не нужно. В этой ситуации модуль, который находится внутри и не должен грузиться, на самом деле подгрузится.

    Поэтому, еще раз, если вы используете DefinePlugin, то обязательно используйте замену строк, т.е. полная строка заменяется на полную строку, никаких сокращений.

    Что будет, если заменить process.env?


    Webpack пытается эмулировать node, потому что большинство модулей, которые лежат в папке node_modules, могут быть и чисто node. Стандартная переменная в node — process. Поэтому когда вы не указали process.env, а какая-то из библиотек, которая в импорте, использует process.env или просто process, webpack думает, что это node-модуль, и добавляет полифил. В итоге мало того, что код не уменьшился, так еще и полифил добавился. То есть любую переменную, которая по умолчанию есть внутри node, если она используется в вашем файле и не заменена и не объявлена, при сборке webpack заменит полифилом.

    Функции отладки в библиотеках

    Redux




    Если вы не добавите process для React, Redux и пр., будет много полифилов.

    Сжатие кода


    Что же делает Uglify со всем нашим кодом?

    UglifyPlugin

    • Удаляет пробелы
    • Переименовывает переменные короткими именами
    • Делает dead code elimination

    Сначала приходит UglifyPlugin и убирает лишние пробелы, переносы, заменяет длинные названия переменных короткими, но делает это внутри функции.


    Но если переменные объявлены вне функции, глобально, то доступ к ним имеют и другие функции. Поэтому когда мы отдадим такой код Uglify,


    то все переменные останутся. Теперь мы подошли к самому интересному, к нашему true и false.

    Что здесь происходит? Мы каким-то образом внутри условия сделали значение, которое стало константой и гарантированно не поменяется. Когда сюда придет Uglify и увидит это, то оставит вот так:

    Если в условии живет переменная, которая является не статической, Uglify не сможет понять, что происходит, и ничего не удалит.

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

    ES6 modules



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

    В чем радость от использования import и export? Она выглядит примерно вот так:

    Tree shaking


    В теории webpack может точно определить, что используется у нас в приложении, и помечать их соответственно. На практике все несколько сложнее.

    Одна из главных фич, которая есть в webpack 2, — понимание import и умение делать Tree shaking. Было бы замечательно, если бы все работало, но есть проблема.

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

    Webpack очень сильно печется об обратной совместимости. Поэтому он пытается сделать так, чтобы никакое удаление не сломало билды. Поэтому он делает следующее:

    Что произойдет после того, как webpack прочтет это? Сначала место, которое он импортирует, превращает в CommonJS-модуль.

    Тут уже интереснее. Что происходит в файле, с которого ушел export? Во-первых, убрали export перед словом const. Во-вторых, у той константы, которая экспортируется, мы вручную написали __webpack_exports__ с каким-то ключом. И все вроде бы хорошо. Когда Uglify приходит сюда и видит неиспользованную константу 2, он ее удаляет.

    Но существуют нюансы.

    Если у одного из экспортов использовалась переменная, которую раньше импортировали из какого-то import, то в этом месте webpack и Uglify уже ничего не удалят. Точнее, const method удалится, а import останется и его содержимое тоже добавится в бандл. Почему? Во-первых, потому что webpack не знает, будет ли он использоваться или нет, есть ли там сайд-эффекты или нет, поэтому он его оставил. Во-вторых, используется модуль, поэтому он его тоже оставил. После этого к нам пришел Uglify, увидел метод и удалил его, а import оставил, потому что это на самом деле вызов из массива, там могут быть сайд-эффекты и на самом деле Uglify про него ничего не знает. Поэтому он эту переменную оставит, и она будет жить внутри массива.

    Например, мы решили использовать lodash-es, который написан с import и export. Мы импортируем из него метод и надеемся, что все остальное не попадет, но на самом деле так не сработает.

    В строке, где from, мы сделали импорт всех модулей, которые есть в lodash, и теперь все они попадут к вам в бандл. Здесь от этой проблемы не уйти. Необходимо использовать какой-нибудь babel-плагин, который будет заменять lodash на конкретные методы, либо вручную записать вплоть до метода все, что необходимо.

    И еще очень важно: по умолчанию, если вы используете babel с дефолтными настройками, то он на самом деле транспилирует все ваши красивые импорты и экспорты в обычный require. Поэтому если у вас стоит babel с дефолтными настройками, то во внутрь webpack’a у вас никакие импорты не попадут, а будут лишь только старые require. Соответственно, если вы хотите, чтобы они работали, необходимо в babel заменить транспилинг import и export.

    Чанки


    Чанк — это кусок кода, который можно загружать синхронно или асинхронно. Для того чтобы загрузить чанк, необходимо немного поправить код, который занимается инициализацией. Он правится примерно так — добавляется функция window[«webpackJsonp»].

    Чанки
    • Синхронные и асинхронные
    • В первый файл добавляется функция window[«webpackJsonp»]
    • В следующих файлах вызывается функция webpackJsonp со списком модулей и id модулей, которые надо запустить
    • Все модули попадают в общий массив и используются оттуда

    У нас есть какой-то файл, который первым грузится синхронно, вы добавляете его в шапку. В этот файл добавили функцию. Все остальные файлы состоят из вызова этой функции, на вход которой приходит объект, в котором есть, например, id чанка, список модулей и т.д.

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

    С синхронными чанками все просто. У нас есть два файла: сначала мы загрузили файл, в котором есть код подгрузки, потом после него добавили еще один файл, в котором подгружаем следующий кусок модулей. Здесь необходимо понимать: если мы грузим его так, то нам важен порядок и, соответственно, мы не можем добавлять асинхронных подгрузок или проводить параллельную загрузку, потому что вторая функция выполнится, ничего не найдет и все сломает.

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

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

    У нас добавляется функция __webpack_require__.e, которая асинхронно загружает другие файлы. В ней нам важен один момент, который выглядит вот так:

    Если бы у нас чанки назывались цифрами, то ничего страшного бы не было. Мы бы передавали туда цифры, и все было бы хорошо. Но для того чтобы использовать кэш или удобнее разбираться с ним, мы обычно их именуем или добавляем какую-то хитрую строчку в url, чтобы этот url был уникальным. Когда webpack’у необходимо подгрузить этот файл, ему нужно знать имя.

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

    CoomonsChunkPlugin


    Собственно, как создаются чанки? Самый простой способ — при помощи CommonChunkPlugin.

    Работает очень просто. Мы добавляем плагин, говорим ему, что «minChunks: 2» — это значит, что если в двух чанках используется какой-то общий плагин, то давайте создадим отдельный чанк, который будет грузиться синхронно, и в нем будет лежать общая часть. Но при этом есть пара нюансов.

    Первый нюанс — когда мы вот так написали, и у нас есть чанки, которые создаются через import, то он не будет с ними работать, так как данные чанки считаются детьми, и у них отдельная логика.

    Например, вы работаете с React и, чтобы у вас было не пять копий, а одна, вам необходимо добавить children: true, тогда он будет выносить общий модуль и из детей.

    Второй нюанс: когда у нас есть код, который берет все, что есть в папке node_modules, и выносит в отдельный чанк. Какая здесь логика? Мы обновляем модули редко, они у нас лежат в отдельном файле, и мы хотим закэшировать их. Свой код меняем часто, кэшировать его хотим отдельно, и нужно, чтобы обновлялся только он, а не все заново. Так большая часть станет как бы константой и не будет грузиться каждый раз, а меньшая часть будет обновляться.

    Вот такой код позволяет нам это сделать. Но с ним есть один нюанс. Он не работает.

    Изменяющиеся индексы


    Пример с node_modules не работает для кэша:

    1. При добавлении файлов меняются индексы
    2. Код загрузки и инициализации живет в первом файле:

    • меняется стартовый индекс
    • меняются ссылки на чанки

    Он не работает по двум причинам. Первая выглядит так:

    В __webpack_require__ индекс не всегда первый. Там может быть другой, случайный индекс из массива. Соответственно, если вы удаляете или добавляете какой-то файл, индекс первого объекта может меняться. Код первого чанка меняется всегда и, соответственно, у него меняется хэш, сумма и все остальное.

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

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

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

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

    Здесь «minChunks: Infinity» означает, что будет только код загрузки и 0 своих чанков. Соответственно, у вас получится не два файла, а три: первый — с кодом загрузки, второй — с node_modules, третий — с вашим кодом. Конечно, кода станет больше, но зато будет работать кэширование.

    Вот так можно подключить два плагина подряд:

    Анализ бандла


    Для анализа бандла есть два полезных плагина:

    webpack-bundle-analyzer

    Строит treemap бандлов. Удобно проверять, не попали ли в бандл:

    • Две версии одной библиотеки
    • Копии библиотеки в разных чанках
    • Библиотеки, которые должны были вырезаться по условию
    • Непредвиденные зависимости у библиотек
    • Просто большие файлы

    И второй, более удобный плагин:

    webpack-runtime-analyzer

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

    • Кто именно использует файл
    • Кто именно подключил библиотеку

    Итого


    • Сделайте пустой бандл и посмотрите содержимое, там 40 строчек
    • Не бойтесь ходить в исходники и смотреть, что получилось в коде
    • После добавления библиотек всегда запускайте анализатор банда и смотрите, что он с собой притащил
    • После добавления чанков проверяйте их содержимое



    Если вы любите JS так же, как мы, и с удовольствием копаетесь во всей его нутрянке, вам могут быть интересные вот эти доклады на нашей декабрьской конференции HolyJS 2017 Moscow:

    JUG.ru Group 693,46
    Конференции для взрослых. Java, .NET, JS и др. 18+
    Поделиться публикацией
    Комментарии 23
    • +2

      А ещё у Злых Марсиан есть специальная утилита для контроля за размером бандла: size-limit.


      Про неё даже целую статью написали: Size Limit: Make the Web lighter.

      • 0
        А еще есть встроенный конфиг performance который работает прямо фактически из коробки (и вроде бы с недавних пор — по-умолчанию):
        WARNING in asset size limit: The following asset(s) exceed the recommended size limit (250 kB).
        This can impact web performance.
        Assets:
          firebase@d02e70a5ce3f.js (370 kB)
          app@665998113356.js (874 kB)
          demo@68048cf0bc91.js (982 kB)
          vendor@6d9cbf3751f5.js (1.12 MB)
        
        WARNING in entrypoint size limit: The following entrypoint(s) combined asset size exceeds the recommended limit (300 kB). This can impact web performance.
        Entrypoints:
          app (2 MB)
              vendor@6d9cbf3751f5.js
              app@665998113356.js
          demo (2.11 MB)
              vendor@6d9cbf3751f5.js
              demo@68048cf0bc91.js
        
      • +1
        Шикарно, спасибо большое. С удовольствием посмотрел доклад. Я тоже выталкиваю библиотеки в отдельный vendor файл, но никогда не обращал внимания, что хеш действительно меняется. Меня интересует один вопрос: как решить проблему одной библиотеки разных версий? Допустим, если в бандл попадет два lodash? Только сменой библиотеки, у которой зависимость?
        • 0

          Можно задать для неё resolve.alias секцию в webpack.config.js, и тогда она будет принудительно грузиться оттуда, откуда укажете. Я столкнулся с более странной но схожей проблемой. У меня приложение разбито на несколько npm-пакетов. Каждый из них имеет свой список зависимостей в dependencies в package.json. И некоторые используются как дочерние в разных родительских пакетах. Всё бы ничего, но все дублированные в родителе и ребёнке пакеты грузятся дважды. Несмотря на полностью идентичную версию. Webpack просто видит, что ближайший node_modules/%lib% не пустой и берёт его. Игнорируя то, что такой же уже есть на вышестоящих уровнях и даже уже загружен. В общем годы идут а webpackbabel) всё также имеет ряд грабель с npm link и симлинками в целом. Спасся всё тем же resolve.alias-ом.

          • 0

            Если я правильно понял проблему, то вам сюда: externals.

            • 0

              Про externals я в курсе. Но не понимаю причём тут оно? Вроде как это для возможности подключения каких-либо библиотек, которые пришли в обход webpack-а. Скажем доступная извне jQuery. Такая возможность сказать webpack-у откуда её взять. К тому же каждую такую библиотеку нужно туда руками прописать. Как это связано с моей проблемой не понимаю :)


              Опишу её детальнее. У меня есть 4 репозитория. Пусть будет так: A, B, C, D. D используется в A, B, C. Это такой common-пакет, всякие вспомогательные штуки и всё что нужно для обмена. A и B это два отдельных проекта, друг друга не используют, связаны лишь логически. И A и B опираются на пакет C, который содержит всё нужно для A и B, а также может самостоятельно тестироваться. В общем не особо хитрый клубок. Главное тут то, что все 4 пакета могут тестироваться и каким-либо образом использоваться самостоятельно. IoC вполне применим и даже используется. Однако большая часть зависимостей в пакетах общая. Но не вся. В директориях node_modules каждого из пакетов эти зависимости установлены. По сути они дублируют друг друга. И webpack не понимает этого грузит их по многу раз. Скажем он когда собирает модуль A который зависит от C не проверяет есть ли A/node_modules/react, а просто увидев его в C/node_modules/react его подключает. И получается что подключены и A/node_modules/react и C/node_modules/react. И похоже никаких штатных возможностей не грузить одни и те же зависимости одних и тех же версий дважды у webpack-а нет. Это создаёт проблемы. Все найденные и придуманные мною варианты мне не нравятся, но что ж поделать.

              • 0
                Вроде как это для возможности подключения каких-либо библиотек, которые пришли в обход webpack-а

                ну да, в C подключаете D как внешнюю зависимость, а в A и B externals не используете, D попадёт в бандл пакета A|B один раз хоть используется и в C и в A|B. У меня куда более спутанные клубки и ничего два раза не грузится.

                • 0

                  Ничего не понял, если честно. Скажем пусть react используется во всех 4-х репозиториях. Каким образом вы избегаете его троекратной загрузки? A/node_modules/react, B/node_modules/react и C/node_modules/react. Пакет react "физически" присутствует в этих директориях, т.к. они могут быть использованы независимо друг от друга (к примеру для теста или в рамках IoC). Куда и что вы прописываете, чтобы использовался только, скажем A/node_modules/react?


                  Плюс сама идея рвать свой проект на якобы несвязанные части за счёт externals мне очень не симпатизирует.

                  • 0

                    Ааа, я кажется вас понял. Вы видимо думаете, что у меня мои же пакеты дублируются. Нет, они не дублируются, т.к. symlink-и. Это как раз webpack6 умеет. Дублируются ИХ внутренние зависимости.


                    P.S. вы уверены что вам нужен externals? мне кажется вы себе сильно усложнили жизнь почём зря. Достаточно просто npm link. Стандартный механизм. А externals это костыль на случай когда ваш бандл встраивается уже в какие-то готовые условия, которые вам не подвластны.

                    • 0

                      У вас в результате получается несколько бандлов которые грузятся на одну страницу?

                      • 0

                        У меня 2 бандла. Первый для A, второй для B. Они не грузятся на одной странице, они предназначены для совсем разных задач, A это, условно, админ-панель, а B это уже для потребителей. Немалая часть кодовой базы общая. Ну по сути это не важно, это нюансы проекта. Тут ключевое в том, что есть такой подход — не монолитный проект-репозиторий, а множество мелких, каждый из которых npm-пакет. Я решил попробовать это в деле. Честно говоря намучался настрадался уже.


                        Одна из многих проблем это как раз ^. Webpack резолвит зависимости слишком уж просто, снизу вверх, оттого и дубляж. Он не смотрит на version-ы. Не смотрит на названия пакетов. Не проверяет загружена ли такая либа уже или нет. Увидел — подключил. Не увидел — пошёл на уровень выше. Похоже что единственное, что там сделано, это то, что библиотеку по одному и тому же пути (файловому) webpack дважды не подключает.


                        Что делать? Ну либо патчить его resolve, либо готовить alias-ы. Я выбрал второй путь как менее костыльный.


                        Получилось как-то так:
                        exports.genSubAliases = (pkg, dir) =>
                        {
                            const nodeDir = `${dir}/node_modules`;
                            const { dependencies } = require(`${nodeDir}/${pkg}/package.json`);
                            const set = new Set(Object.keys(dependencies));
                            const own = require(`${dir}/package.json`).dependencies;
                        
                            return Object
                                .keys(own)
                                .filter(m => set.has(m))
                                .reduce((hash, m) =>
                                {
                                    hash[m] = `${nodeDir}/${m}`;
                                    return hash;
                                }, {});
                        };
                        • 0

                          Если у вас общая зависимость ваших зависимостей одной и той же версии, то какой-нибудь yarn, делая плоский список зависимостей, вынесет общую зависимость в рутовый node_modules.
                          И тогда у вас будет 1 версия общей зависимости в одном месте и вебпак не должен это дублировать.

                          • 0
                            И тогда у вас будет 1 версия общей зависимости в одном месте и вебпак не должен это дублировать

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

                            • 0

                              Давайте еще раз, я видимо чего-то не понял.
                              Вот как я это вижу.
                              У вас есть пакет Main и есть его зависимости dep-a и dep-b.
                              И у пакета Main и у его зависимостей dep-a, dep-b есть third-party зависимость not-react.


                              Если этот not-react и в Main и в dep-a и в dep-b одинаковой версии, то при установке зависимостей Main с помощью yarnа not-react будет только в рутовом node_modules.
                              И при сборке webpack будет тянуть один и тот же not-react в Main, dep-a, dep-b => нет дублирования.
                              При этом ваши пакеты dep-a, dep-b разрабатывайте и тестируйте как хотите в их собственных репозиториях\папках.

                              • 0
                                При этом ваши пакеты dep-a, dep-b разрабатывайте и тестируйте как хотите в их собственных репозиториях\папках.

                                Что будет в dep-a/node_modules/not-react? Будет symlink на Main/node_modules/not-react? Или вообще ничего не будет?


                                Вариант 1-ый:


                                1. будет symlink — тогда я могу обособленно работать с dep-a, из dep-a игнорируя существование Main. Вроде всё ок. Но в следующий раз когда я захочу добавить другую зависимость, и я не буду заранее знать, будет ли она в Main тоже нужна или нет, я встану перед проблемой. Если я выполню npm install some-dep из dep-a, то когда эта же либа потребуется в Main и я выполню npm install some-dep уже из Main — у меня снова будет две копии. И снова те же проблемы. И чтобы этого избежать, я вынужден весь этот граф зависимостей держать в голове, следить за дубликатами и прочая муть.
                                2. Вариант два. Нет symlink-а. Ничего нет. В этом случае код из dep-a выполняемый не из Main, а сам по себе, не запустится вовсе. Даже тесты. Самый плохой вариант.
                                • 0
                                  Вот видимо про симлинки я и упустил.
                                  А как вы используете симлинки?
                                  У нас есть workflow работы с симлинками во время разработки. Мы линкуем также *dep-a*, *dep-b* и там действительно будут дубликаты *not-react*, но это не важно во время разработки.
                                  Во время сборки для выкладки нет никаких симлинков до *dep-a*, *dep-b*, они ставятся из *registry* и дубликатов *not-react* нет.

                                  Хотя в целом проблем с симлинками достаточно.
                                  • 0

                                    Я использую симлинки в рамках npm link (вроде как это самый, что ни на есть, стандартный механизм работы с собственными модулями). Но выше я веду речь про то, как, возможно, работает yarn.


                                    но это не важно во время разработки

                                    Это как сказать. Как сказать. Если вы динамически меняете что-нибудь во внешней зависимости, то если их будет две, то у вас будет разное поведение на проде и деве :) Помимо прочего это ещё и замедлит время сборки (одни и те же вещи грузятся дважды, трижды, четырежды...), и столько же watcher-ов для пересборки. Плюс не все библиотеки вообще смогут работать в таком мульти-режиме. Скажем если у них какие-нибудь "глобальные" пулы чего-нибудь, то это всё может затрещать по швам.


                                    Хотя в целом проблем с симлинками достаточно.

                                    Это да. Прямо беда. Сразу по всем фронтам. Была даже мысль забить на симлинки и npm link и использовать /etc/fstab для этих целей. Пока спасаюсь "костылями".


                                    Я думаю, что подход "разбить проект на множество npm-модулей" в моём случае показал себя просто отвратительно. Проблемы прямо по всем фронтам, начиная от npm, babel, webpack, заканчивая mocha.

                                    • 0

                                      Если вы динамически меняете что-нибудь во внешней зависимости
                                      Так и думал что дело не чисто и тут есть зависимость со стейтом :)


                                      Да, если в деве дубликаты критичны то вы правы. Все очень плохо :)


                                      Мы сейчас переходим с несколько npm-модулей на множество npm-модулей, но npm link используем только если нужно одновременно разрабатывать и слинкованный пакет и тот пакет, к которому линковка исполняется.

                                      • 0
                                        Так и думал что дело не чисто и тут есть зависимость со стейтом :)

                                        У меня нет такой. Надеюсь :) Просто глаза зацепились за "не важно" :)


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

                                        А бывает по-другому? Это как? Когда слинкованный пакет уже "готов"? Или когда он "библиотечный", и к проекту относится опосредованно? У меня просто это большие куски проекта. Для примера — часто я делаю push-ы сразу из 4 репозиториев. Одна задача затрагивает сразу все 4. Прямо регулярное явление. Честно говоря очень неудобно, хотя и вроде как эти части логически друг от друга отвязаны и можно даже инверсию управления использовать.


                                        И в итоге я столкнулся с тем, что собирается сразу 4 react, 4 redux, 4 всех остальных общих либ. 4 lodash-а. Бомбит от этого знатно :) Как меня, так и бандл.


                                        А лечится, казалось бы, просто элементарно — смотреть на номер версии в package.json, и если таковая уже загружена, — не грузить второй раз. Зарегистрировано множество issue с 15-го года, а воз и поныне там.

                • 0

                  И такая проблема возникает неизбежно если вы пишете проект не в моно-репозитории, а во многих сразу, используя npm link. Я нашёл много issues чуть ли не с 2015г и ничего не изменилось. Советуют явным образом указывать все такие библиотеки руками в alias. Ужасно :)

            • 0
              Для vendor файлов есть отличная штука, которая называется DLL, но про неё мало кто пишет. Ускоряет сборку, потому что не нужно держать в памяти vender код, он редко обновляется и тп.
              • +1
                обещали секреты, а на выходе краткий пересказ скринкаста по webpack
                • 0
                  Только злые марсиане могут в этом во всем разобраться… :)

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

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