Внутреннее устройство и оптимизация бандла 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__
- Ищет инициированный модуль в кэше
- Создает заглушку и добавляет ее в массив
- Выполняет код модуля с this равным module.exports
- Возвращает 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 не работает для кэша:
- При добавлении файлов меняются индексы
- Код загрузки и инициализации живет в первом файле:
- меняется стартовый индекс
- меняются ссылки на чанки
Он не работает по двум причинам. Первая выглядит так:
В __webpack_require__ индекс не всегда первый. Там может быть другой, случайный индекс из массива. Соответственно, если вы удаляете или добавляете какой-то файл, индекс первого объекта может меняться. Код первого чанка меняется всегда и, соответственно, у него меняется хэш, сумма и все остальное.
Вторая проблема: чтобы асинхронно грузить чанки, необходима карта их имен. Соответственно, если содержимое какого-либо чанка меняется, то карта, которая нужна для их подгрузки, тоже меняется.
Первый файл, где живет функция webpack, которая загружает все это, гарантированно будет меняться всегда при каждом изменении любого файла. Что с этим нужно сделать?
Необходимо сделать две вещи. Во-первых, зафиксировать имена файлов. Для этого в webpack есть два встроенных плагина. Первый позволяет оставлять те имена, которые вы использовали раньше внутри webpack«а, но это не очень удобно для production, так как имена становятся очень длинными. Второй позволяет менять имена на четырехбуквенные хэши.
Во-вторых, необходимо ту часть кода, которая отвечает за подгрузку новых модулей и чанков, вынести в отдельный чанк. Соответственно, это можно сделать примерно так:
Здесь «minChunks: Infinity» означает, что будет только код загрузки и 0 своих чанков. Соответственно, у вас получится не два файла, а три: первый — с кодом загрузки, второй — с node_modules, третий — с вашим кодом. Конечно, кода станет больше, но зато будет работать кэширование.
Вот так можно подключить два плагина подряд:
Анализ бандла
Для анализа бандла есть два полезных плагина:
webpack-bundle-analyzer
Строит treemap бандлов. Удобно проверять, не попали ли в бандл:
- Две версии одной библиотеки
- Копии библиотеки в разных чанках
- Библиотеки, которые должны были вырезаться по условию
- Непредвиденные зависимости у библиотек
- Просто большие файлы
И второй, более удобный плагин:
webpack-runtime-analyzer
Показывает отношения между файлами в графе — кто на кого ссылается, кто кого добавил в сборку. Удобно использовать, чтобы понять:
- Кто именно использует файл
- Кто именно подключил библиотеку
Итого
- Сделайте пустой бандл и посмотрите содержимое, там 40 строчек
- Не бойтесь ходить в исходники и смотреть, что получилось в коде
- После добавления библиотек всегда запускайте анализатор банда и смотрите, что он с собой притащил
- После добавления чанков проверяйте их содержимое
Если вы любите JS так же, как мы, и с удовольствием копаетесь во всей его нутрянке, вам могут быть интересные вот эти доклады на нашей декабрьской конференции HolyJS 2017 Moscow:
- Better, faster, stronger — getting more from the web platform (Martin Splitt, Archilogic)
- The Post JavaScript Apocalypse (Douglas Crockford)
- Testing serverless applications (Slobodan Stojanovic, Cloud Horizon)