Хабрахабр

Собираем бандл мечты с помощью Webpack

JS-приложения, сайты и другие ресурсы становятся сложнее и инструменты сборки — это реальность веб-разработки. Бандлеры помогают упаковывать, компилировать и организовывать библиотеки. Один из мощных и гибких инструментов с открытым исходным кодом, который можно идеально настроить для сборки клиентского приложения — Webpack.

RU внедрил Webpack в несколько больших проектов, на которых до этого была своя кастомная сборка, и контрибьютил с ним несколько проектов. Максим Соснов (crazymax11) — Frontend Lead в N1. Максим знает, как с Webpack собрать бандл мечты, сделать это быстро и конфигурировать так, чтобы конфиг оставался чистым, поддерживаемым и модульным.

Расшифровка отличается от доклада — это сильно усовершенствованная пруфлинками версия. По всей расшифровке рассыпаны пасхалочки на статьи, плагины, минификаторы, опции, транспайлеры и пруфы слов докладчика, ссылки на которые просто не поставить в выступление. Если собрать все, то откроется бонусный уровень в Webpack 🙂

Интеграция Webpack в типичный проект

Обычно порядок внедрения такой: разработчик где-то прочитал статью про Webpack, решает его подключить, начинает встраивать, как-то это получается, все заводится, и какое-то время webpack-config работает — полгода, год, два. Локально все хорошо — солнце, радуга и бабочки. А потом приходят реальные пользователи:

Локально все хорошо! — С мобильных устройств ваш сайт не загружается.
— У нас все работает.

Это никого не устраивает и разработчик начинает искать, как решить проблему — может подключить лоадер или найти волшебный плагин, который решит все проблемы. На всякий случай разработчик идет все профилировать и видит, что для мобильных устройств бандл весит 7 Мбайт и грузится 30 секунд. Наш разработчик идет в webpack-config, пытается установить, но мешает строчка кода: Чудесным образом такой плагин находится.

if (process.env.NODE_ENV === ’production’)

Строчка переводится так: «Если config собирается для production, то возьми седьмое правило, и поставь там опцию magic = true». Разработчик не знает, что с этим делать и как решать. Это ситуация, когда нужен бандл мечты.

Как собрать бандл мечты?

Для начала определим, что это такое. Прежде всего, у бандла мечты две основные характеристики:

  • Мало весит. Чем меньше вес — тем быстрее пользователь получит работающее приложение. Вы же не хотите, чтобы ваш сайт открывался 15 секунд.
  • Пользователь загружает только то, что нужно загрузить для показа текущей страницы сайта, и ни байтом больше!

А чтобы уменьшать размер бандла, нужно сначала оценить его размер.

Оценить размер бандла

Самое популярное решение — это плагин WebpackBundleAnalyzer. Он собирает статистику сборки приложения и рендерит интерактивную страничку, на которой можно посмотреть расположение и вес каждого модуля.

image

Если этого мало, можно построить граф зависимостей с помощью другого плагина.

image

Или круговую диаграмму.

image

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

image

Есть опция в конфиге Webpack, которая рушит сборку, если бандл слишком много весит, например. Инструментов, которые оценивают размер бандла и следят за ним, очень много. 15 и Lodash 4. Есть плагин duplicate-package-checker-webpack-plugin который не даст собрать бандл, если у вас 2 npm-пакета разных версий, например, Lodash 4. 14.

Как уменьшить бандл

  • Самое очевидное — подключить UglifyJS, чтобы он заминифицировал JavaScript.
  • Использовать специальные лоадеры и плагины, которые сжимают и оптимизируют определенный ресурс. Например, css-nano для css, или SVGO, который оптимизирует SVG.
  • Сжимать все файлы прямо в Webpack через gzip/brotli плагины.
  • Другие инструменты.

Теперь поймем как выкинуть лишнее из бандла.

Выкинуть лишнее

Рассмотрим это на популярном примере с moment.js: import moment from 'moment'. Если вы возьмете пустое приложение, импортируете в него moment.js и ReactDOM, и потом пропустите это через WebpackBundleAnalyzer, то увидите следующую картину.

image

Почему так происходит и как это решается? Оказывается, когда вы добавляете в дате день, час или просто хотите поставить ссылку «через 15 минут» с помощью moment.js, вы подключаете целых 230 Кбайт кода!

Загрузка локали в moment

В moment.js есть функция, которая устанавливает локали:

function setLocale(locale) { const localePath = ’locale/’ + locale + ’.js’; this._currentLocale = require(localePath);
}

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

image

Решение очень простое — берем стандартный плагин из Webpack и говорим ему: «Если увидишь, что кто-то хочет загрузить много локалей, потому что не может определить какую, — возьми только русскую!»

image

Webpack возьмет только русскую, а WebpackBundleAnalyzer покажет 54 Кb, что уже на 200 Kb легче.

Dead code elimination

Следующая оптимизация, которая нас интересует — Dead code elimination. Рассмотрим следующий код.

const cond = true; if (!cond) { return false; } return true; someFunction(42);

Большинство строк из этого кода не нужны в финальном бандле — блок с условием не выполнится, функция после return — тоже. Все, что нужно оставить, это return true. Это как раз и есть Dead code elimination: инструмент сборки обнаруживает код, который не может быть выполнен, и вырезает его. Есть приятная особенность, что UglifyJS умеет это делать.

Теперь перейдем к более продвинутому способу Dead code elimination — Tree shaking.

Tree shaking

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

Это и есть Tree shaking. Когда мы делаем Tree shaking, мы хотим от Webpack, чтобы он «потряс» ненужные модули и выкинул их, а у нас остались только необходимые.

Как работает Tree shaking в Webpack

Допустим, у вас есть такой код:

import { a } from ’./a.js’; console.log(a);

Код очень простой: из какого-то модуля импортируете переменную a и выводите ее. Но в этом модуле есть две переменные: a и b. Переменная b нам не нужна, и мы хотим ее убрать.

export const a = 3 export const b = 4

Когда придет Webpack, он преобразует код с импортом в такой:

var d = require(0);
console.log(d["a"]);

Наш import превратился в require, а console.log не изменился.

Зависимость Webpack преобразует в следующий код:

var a = 3; module.exports["a«] = a; /* unused harmony export b */ var b = 4;

В преобразованном коде переменная b не используется, и UglifyJS может ее удалить. Webpack оставил экспорт переменной a, и убрал экспорт переменной b, но саму переменную оставил, пометив её специальным комментарием.

Tree shaking в Webpack работает, только если у вас есть какой-нибудь минификатор кода, например, UglifyJS или babel-minify.

Рассмотрим случаи интереснее — когда Tree shaking не работает.

Когда Tree shaking не работает

Кейс № 1. Вы пишете код:

module.exports.a = 3; module.exports.b = 4;

Прогоняете код через Webpack, и он остается таким же. Все потому, что бандлер организует Tree shaking, только если вы используете ES6 модули. Если применяете CommonJS модули, то Tree shaking работать не будет.

Вы пишете код с ES6 модулями и именованными экспортами. Кейс № 2.

export const a = 3 export const b = 4

Если ваш код прогоняется через Babel и вы не выставили опцию modules в false, то Babel приведет ваши модули к CommonJS, и Webpack опять же не сможет выполнить Tree shaking, ведь он работает только с ES6 модулями.

module.exports.a = 3; module.exports.b = 4;

Соответственно, нам нужно быть уверенными, что никто в нашем пайплане сборки не будет транспайлить ES6 модули.

Допустим, у нас есть такой бесполезный класс, который ничего не делает: export class ShakeMe {}. Кейс № 3. Когда Webpack будет проходить по импортам и экспортам, Babel превратит класс в функцию, а бандлер пометит, что функция не используется: Более того, мы его еще и не используем.

/* unused harmony e[port b */
var ShakeMe = function () { function ShakeMe() { babelHelpers.classCallCheck(this, ShakeMe); } return ShakeMe; }();

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

Как это исправляется? Когда вы пишете классы и прогоняете их через Babel, они никогда не вырезаются. Есть стандартизованный хак — добавить коммент /*#__PURE__*/ перед функцией:

/* unused harmony export b */ var ShakeMe = /*#__PURE__*/ function () { function ShakeMe() { babelHelpers.classCallCheck(this, ShakeMe); } return ShakeMe; }();

Тогда UglifyJS поверит на слово, что следующая функция чистая. К счастью, сейчас это делает Babel 7, а в Babel 6 до сих пор ничего не удаляется.

Правило: если у вас где-то есть сайд-эффект, то UglifyJS ничего не сделает.

Подведем итоги:

  • Tree shaking не работает для большинства библиотек из npm, потому что они все из CommonJS и собираются старым Babel’ем.
  • Скорее всего, Tree shaking будет адекватно работать для тех библиотек, которые уже к этому подготовлены, например, Lodash-es, Date-fns и ваш код или библиотеки.
  • В сборке участвует UglifyJS.
  • Используются ES6-модули.
  • Нет сайд-эффектов.

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

Загружаем только нужный функционал

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

Загружаем только необходимый код

Рассмотрим структуру воображаемого приложения. В нем есть:

  • Entry-point — APP.
  • Три страницы: главная, поиск и карточка.

image

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

image

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

image

Плагин выносит код приложения или код node modules, который используется несколькими чанками в отдельный чанк, при этом гарантирует, что чанк с общим кодом будет больше 30 Kb, а для загрузки страницы требуется загрузить не больше 5 чанков. Хорошо, что в Webpack 4 уже есть встроенный плагин, который это делает за нас — SplitChunksPlugin. Чтобы повторить такое поведение на 2 или 3 версии Webpack, приходилось писать 20–30 строк с не документированными фичами. Стратегия оптимальна: слишком маленькие чанки загружать невыгодно, а загрузка слишком большого количества чанков — долго и не так эффективно, как загрузка меньшего количества чанков даже на http2. Сейчас это решается одной строкой.

Вынос CSS

Было бы прекрасно, если бы мы еще вынесли CSS для каждого чанка в отдельный файл. Для этого есть готовое решение — Mini-Css-Extract-Plugin. Плагин появился только в Webpack 4, а до него не было адекватных решений для такой задачи — только хаки, боль и простреленные ноги. Плагин выносит CSS из асинхронных чанков и создан специально для этой задачи, которую выполняет идеально.

Минимально возможная перезагрузка ресурсов

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

Вот у нас главная страница версии N, а после релиза промо-блока — версии N+1. Если бы у нас было версионирование — всё было бы хорошо. После того, как Webpack соберет все ассеты, — в данном случае app.js, — то посчитает его контент-хэш, и добавит его к имени файла, чтобы получилось app.[hash].js. Webpack предоставляет подобный механизм прямо из коробки с помощью хэширования. Это и есть версионирование, которое нам нужно.

image

Включим хэши, внесем правки на главной странице, и посмотрим — действительно ли изменился код только главной страницы.Мы увидим, что изменились два файла: main и app.js. Давайте теперь проверим как это работает.

image

Чтобы понять почему, давайте разберем app.js. Почему так произошло, ведь это нелогично? Он состоит из трех частей:

  • код приложения;
  • webpack runtime;
  • ссылки на асинхронные чанки.

Когда мы меняем код в main, меняется его контент и хэш, а значит, в app меняется и ссылка на него. Сам app тоже поменяется и его нужно перезагрузить. Решение этой проблемы — разделить app.js на два чанка: код приложения и webpack runtime и ссылки на асинхронные чанки. Webpack 4 делает все за нас одной опцией runtimeChunk, которая весит очень мало — меньше 2 Кбайта в gzip. Перезагрузить его для пользователя — практически ничего не стоит. RuntimeChunk включается всего одной опцией:

optimization: { runtimeChunk: true }

В Webpack 3 и 2 мы бы написали 5-6 строк, вместо одной. Это не сильно больше, но все равно лишнее неудобство.

image

Давайте напишем новый модуль в main, зарелизим, и — оп! Все здорово, мы научились выносить ссылки и рантайм! — теперь вообще все перезагружается.

image

Давайте разберемся, как работают модули в webpack. Почему так?

Модули в webpack

Допустим, есть такой код, в котором вы добавляете модули a, b, d и e:

import a from ’a’;
import b from ’b’;
import d from ’d’;
import e from ’e’;

Webpack преобразует импорты в require: a, b, d и e заменились на require(0), require (1), require (2) и require (3).

var a = require(0); var b = require(1);
var d = require(2); var e = require(3);

Представим картину, которая очень часто случается: вы пишете новый модуль c import c from 'c'; и вставляете его где-то посередине:

import a from ’a’; import b from ’b’; import c from ’c’;
import d from ’d’; import e from ’e’;

Когда Webpack будет все обрабатывать, то преобразует импорт нового модуля в require(2):

var a = require(0); var b = require(1);
var c = require(2);
var d = require(3); var e = require(4);

Модули d и e, которые были 2 и 3, получат цифры 3 и 4 — новые id. Из этого следует простой вывод: использовать порядковые номера как id немного глупо, но Webpack это делает.

Не используйте порядковый номер как уникальный id

Для исправления проблемы есть встроенное решение Webpack — HashedModuleIdsPlugin:

new webpack.HashedModuleIdsPlugin({ hashFunction: ’md4′, hashDigest:’base64′, hashDigestLength: 4,
}),

Этот плагин вместо цифровых id использует 4 символа md4-хэша от абсолютного пути до файла. С ним наши require превратятся в такие:

var a = require(’YmRl’);
var b = require(’N2Fl’);
var c = require(’OWE4′); var d = require(’NWQz’);
var e = require(’YWVj’);

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

Мы теперь знаем, как собирать бандл мечты.

  • Минифицировать.
  • Использовать код-сплиттинг.
  • Настроить хэши.

Собирать научились, а теперь поработаем над скоростью.

Как собрать бандл мечты быстро?

У нас в N1.RU самое большое приложение состоит из 10 000 модулей и без оптимизаций собирается 28 минут. Мы смогли ускорить сборку до двух минут! Как же мы это сделали? Существует 3 способа ускорения любых вычислений, и все три применимы к Webpack.

Параллелизация сборки

Первое, что мы сделали — распараллелили сборку. Для этого у нас есть:

  • HappyPackPlugin, который оборачивает ваши лоадеры в другие лоадеры, и выносит все вычисления, которые обернули, в отдельные процессы. Это позволяет, например, распараллелить Babel и node-sass.
  • thread-loader. Выполняет примерно то же, что и HappyPackPlugin, только использует не процессы, а thread pool. Переключение на отдельный тред — затратная операция, используйте осторожно, и только если хотите обернуть ресурсоемкие и тяжелые операции, типа babel или node-sass. Для загрузки json, например, параллелизация не нужна, потому что он грузится быстро.
  • В используемых вами плагинах и лоадерах, скорее всего, уже есть встроенные инструменты параллелизации — стоит только посмотреть. Например, эта опция есть в UglifyJS.

Кэширование результатов сборки

Кэшировать результаты сборки — наиболее эффективный способ ускорения сборки Webpack.

Это лоадер, который встает в цепочку лоадеров и сохраняет на файловую систему результат сборки конкретного файла для конкретной цепочки лоадеров. Первое решение, которое у нас есть — cache-loader. При следующей сборке бандла, если этот файл есть на файловой системе и уже обрабатывался с этой цепочкой, cache-loader возьмет результаты и не будет вызывать те лоадеры, которые стоят за ними, например, Babel-loader или node-sass.

Синий столбик — 100% время сборки, без cache-loader, а с ним — на 7% медленнее. На графике представлено время сборки. Уже на а второй сборке мы получили ощутимый профит — сборка прошла в 2 раза быстрее. Это происходит потому что cache-loader тратит дополнительное время на сохранение кэшей на файловую систему.

image

Основное отличие: cache-loader — это просто лоадер, который может оперировать только в цепочке лоадеров кодом или файлами, а HardSourcePlugin имеет почти полный доступ к экосистеме Webpack, умеет оперировать другими плагинами и лоадерами, и сам немного расширяет экосистему для кэширования. Второе решение более навороченное — HardSourcePlugin. На графике выше видно, что на первом запуске время сборки увеличилось на 37%, но ко второму запуску со всеми кэшами мы ускорились в 5 раз.

image

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

Например, в babel-loader очень эффективная система кэширования, но почему-то по умолчанию она выключена. В уже используемых вами плагинах/лоадерах могут быть встроенные механизмы кэширования. В UglifyJS плагине тоже есть кэширование, которое замечательно работает. Такой же функционал есть в awesome-typeScript-loader. Нас он ускорил на несколько минут.

А теперь проблемы.

Проблемы кэширования

  • Кэш может неправильно валидироваться.
  • Примененные решения могут не работать с подключенными плагинами, лоадерами, вашим кодом или друг с другом. В этом плане cache-loader — простое и беспроблемное решение. А вот с HardSourcePlugin нужно быть внимательнее.
  • Сложно дебажить, если всё сломалось. Когда кэширование сработает неправильно и произойдет непонятная ошибка, будет очень сложно разобраться, в чем же проблема.

На чем сэкономить в production?

Последний способ ускорить какой-либо процесс — не делать какие-то части процесса. Давайте подумаем, на чем можно сэкономить в production? Что мы можем не делать? Ответ короткий — мы ничего не можем не делать! Мы не вправе отказаться от чего-то в production, но можем хорошо сэкономить в dev.

На чем экономить:

  • Не собирать source map, пока они нам не понадобятся.
  • Использовать style-loader вместо крутой схемы с выносом css и с обработкой через css-лоадеры. Style-loader сам по себе очень быстрый, потому что он берет строчку css и загоняет ее в функцию, которая вставляет эту строчку в тэг style.
  • Можно оставить в browserlist только используемый конкретно вами браузер — скорее всего это last chrome. Это позволит сильно ускориться.
  • Полностью отказаться от какой-либо оптимизации ресурсов: от UglifyJS, css-nano, gzip/brotli.

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

Как конфигурировать Webpack?

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

Эволюция конфига в проекте

Типичный путь webpack-конфиг в проекте начинается с простого конфига. Сначала вы просто вставляете Webpack, Babel-loader, sass-loader и все хорошо. Потом, неожиданно, появляются какие-то условия на process.env, и вы вставляете условия. Одно, второе, третье, все больше и больше, пока не добавляется условие с «магической» опцией. Вы понимаете, что все уже совсем плохо, и лучше просто продублировать конфиги для dev и production, и сделать правки два раза. Все будет понятнее. Если у вас мелькнула мысль: «Что-то здесь не так?», то единственный работающий совет — держать конфиг в порядке. Расскажу, как мы это делаем.

Держать конфиг в порядке

Мы используем пакет webpack-merge. Это npm-пакет, который создан, чтобы объединять несколько конфигов в один. Если вас не устраивает стратегия объединения по умолчанию, то можно кастомизировать.

Структура проекта с конфигом

У нас есть 4 основные папки:

  • Loaders.
  • Plugins.
  • Presets.
  • Parts.

Расскажу про каждую отдельно.

Plugin/Loader

Это папки, которые содержат файлы для каждого лоадера и плагина, с подробной документацией и более человеческим API, чем тот, что предоставляют разработчики плагинов и лоадеров.

Выглядит это примерно так:

/** * Подробный JSdoc * @param {Object} options * @see ссылка на доки */ module.exports = function createPlugin(options) { return new Plugin(options); };

Есть модуль, он экспортирует функцию, которая имеет опции, и есть документация. На словах выглядит хорошо, а в реальности наши доки к url-loader выглядят так:

/** * url-loader это надстройка над file-loader. Он позволяет учитывать ассеты во время бандлинга * * @example * Какой-то ресурс запросил some-image.png. Если для загрузки нужен url-loader, то url-loader проверит размер файла * 1. если он меньше лимита, то url-loader вернет ресурс как base64 строку * 2. иначе, url-loader сложит файл в outputPath + name и вернёт вместо ресурса ссылку, по которой его можно загрузить. * В случае с some-image.png, он может сохраниться в outputPath/images/some-image.12345678hash.png, а url-loader вернет * publicPath/images/some-image.12345678hash.png
* * @param {string} prefix префикс имён файлов
* @param {number} limit если ресурс меньше лимита, он будет заинлайнен * @return {Object} loader конфиг лоадера * @see https://www.npmjs.com/package/url-loader
*/

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

function urlLoader(prefix = ’assets’, limit = 100) { return { loader: ’url-loader’, options: { limit, name: `${prefix}/[name].[hash].[ext]` } }; };

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

Preset

Это набор опций webpack. Они отвечают за одну функциональность, при этом оперируют лоадерами и плагинами, которые мы уже описали, и настройками webpack, которые у него есть. Самый простой пример — это пресет, который говорит, как правильно загружать scss-файлы:

{ test: /\.scss$/, use: [cssLoader, postCssLoader, scssLoader] }

Он использует уже преподготовленные лоадеры.

Part

Части — это то, что уже лежит в самом приложении. Они настраивают точку входа и выхода вашего приложения, и могут регулировать или подключать специфичные плагины, лоадеры и опции. Типичный пример, где мы объявляем точку входа и выхода:

entry: { app: ’./src/Frontend/app.js’ }, output: { publicPath: ’/static/cabinet/app/’, path: path.resolve(’www/static/app’) },

В своей практике мы используем:

  • Базовый пресет, в котором описываем, как загружать шаблоны, json, какие плагины нужно использовать всегда, например, splitChunks.
  • Пресет для dev, где описано, как правильно загружать js/css и плагины на оптимизацию
  • Part, который описывает output, publicPath, entry-point и некоторые специфичные правила, например, как отдельно переписываются source map.

image

С этим подходом у нас всегда есть документация к конфигурации, в которой достаточно просто разобраться. Webpack-merge просто выдает нам готовый конфиг. А еще интуитивно понятно, где делать правку. С webpack-merge мы не лазим по 3-7 конфигам, чтобы поправить везде Babel-loader, потому что у нас есть консистентная конфигурация отдельных частей по всему проекту.

Управление конфигом

Подведем итоги. Используйте готовые инструменты, а не стройте велосипеды. Документируйте решения, потому что webpack конфиги правятся редко и разными людьми — поэтому документация там очень важна. Разделяйте и переиспользуйте то, что пишете.

Теперь вы знаете, как собирать бандл мечты!

Понравилось, и хотите больше — подпишитесь на рассылку, в которой мы собираем новые материалы и даем доступ к видео, и приходите на Frontend Conf РИТ++ в мае. Это доклад — один из лучших на Frontend Conf.

Подавайте доклады на FrontenConf РИТ++, который пройдет 27 и 28 мая в Сколково. Хотите рассказать миру что-то крутое по фронтенду из своего опыта? Мы ждем ваш опыт — откликайтесь! Присылайте тезисы до 27 марта, а до 15 апреля ПК примет решение о включении доклада в программу конференции.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть