- PVSM.RU - https://www.pvsm.ru -

Angular 1.x: крадущийся webpack, затаившийся grunt

История о том, как мы поменяли сборку проекта с grunt на webpack

Приходишь на работу, открываешь IDE, пишешь npm start, запуская систему сборки, начинаешь работать. Тебе удобно ориентироваться в структуре проекта, удобно отлаживать код и стили, очевидно, как именно и в каком порядке собирается проект.

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

Как выяснилось, такое случается, если не модернизировать систему сборки вместе с ростом проекта. Хорошая новость в том, что это успешно лечится! Летом мы подтвердили это в бою и хотим поделиться опытом.

Angular 1.x: крадущийся webpack, затаившийся grunt - 1

Исходная ситуация

Мы разрабатываем пакет офисных приложений МойОфис с 2013 года, web-версию (о которой и пойдет речь) – с 2014-го.

Имеется несколько смежных проектов (файловый менеджер, авторизация и профиль, веб-редактор документов) с общими сабрепозиториями, каждый из которых представляет собой SPA-часть большого приложения МойОфис. Разработка ведется на angular 1.5, для continuous integration используется jenkins.

Исходная система сборки на grunt, состоящая из сложносочиненных взаимозависимых задач, была создана на заре проекта и мало менялась с тех пор. Чтобы обозначить масштабы: dev-сборка запускала около 30 grunt-тасок, 30% из которых собирали модули и стили, 70% – перекладывали изображения и шрифты, обновляя ссылки на них. Порядок выполнения был критически важен, однако информацию о взаимозависимости можно было получить только от коллег.

Зачем мигрировать и почему webpack

Собирать angular-проект на самом деле не так уж и сложно: нужно всего лишь сконкатенировать все исходные файлы в один, не забывая, чтобы модуль был объявлен раньше его контроллера. Мы делали еще проще: собирали вообще все файлы из папки src (используя `_` в начале имени файла для обеспечения правильного порядка подключения), добавляли массив внешних пакетов, а дальше подключали файлы прямо в head (конечно, только для dev-сборки, для production код конкатенировался в бандл с последующей обфускацией и минификацией).

Angular 1.x: крадущийся webpack, затаившийся grunt - 2

Сборка очевидно устарела морально. Последние критические изменения были датированы 2015 годом, что в условиях современного фронтенда можно приравнивать к оплетенному паутиной углу, в котором уже, откровенно говоря, немодный grunt хранит свои промежуточные файлы.
Плюс у нее был только один: пересборка проекта была условно бесплатной ввиду прямого подключения файлов в head.

Минусов же гораздо больше:

  • Собирая файлы по маске, мы не могли разрабатывать независимые подключаемые модули.
  • Количество HTTP-запросов в dev-режиме измерялось сотнями, а время перезагрузки страницы в таком режиме было на несколько секунд больше, чем в собранном приложении.
  • Из-за подключения по *.js маске в проект попадали неиспользуемые модули.
  • При добавлении нового js-файла приходилось перезапускать всю сборку.
  • Для подключения сторонних зависимостей мы хранили отдельный json c именами модулей.
  • Grunt вынуждал создавать большое количество промежуточных и конфиг-файлов, из-за которых наш .hgignore содержал в себе более 50 строк.

И чем больше расширялся наш проект, тем сильнее мешали недостатки билд-системы.

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

Организация работ

Angular 1.x: крадущийся webpack, затаившийся grunt - 3

Главный секрет успешного рефакторинга – заранее четко определить шаги и составить план.

  1. Составить список всех требований.
  2. Реализовать proof of concept [1] на небольшом участке проекта. Идея в том, чтобы собрать все возможные грабли дешево и в фоновом режиме, без рисков для основной разработки.
  3. Осуществить полный переход с учетом всех тонких мест, выявленных в п. 2. Зная все проблемы и имея опыт перевода части кода на новые рельсы, можно довольно точно оценить трудоемкость.

Реверс-инжиниринг требований

Новая система должна была не только решить существующие проблемы сборки, но и поддержать несколько новых и давно желаемых фич (и конечно же, не лишиться старых). Составили список того, что должен уметь webpack в готовом виде:

  • Инкрементальный билд [2].
  • Watch mode.
  • Поддержка source map (по флагу).
  • Минификация (по флагу).
  • Hot module replacement.
  • Поддержка Babel.
  • Dead code elimination. [3]
  • Разделить вендорный код и наш на два бандла.
  • Добавлять хеш к именам файлов.

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

Proof of concept

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

По-хорошему вся работа разбивалась на три части:

1. Cоздать конфиг для webpack.
2. Подготовить файлы для такой сборки. Форматы файлов:

  • html,
  • js,
  • css,
  • media (картинки и шрифты),
  • большое количество конфигов, хранящихся в json и интегрирующихся в сборку где-то посередине.

3. Переписать юнит-тесты.

Css вместе с media временно отложили, так как они не интегрированы в angular и могут продолжать жить своей жизнью.

js-модули

Для тех, кто уже давно не заглядывал в angular, напомним, как он выглядит изнутри:

// module.js
angular.module('moduleName',[
   'dependencyOneName',
   'dependencyTwoName'  
])
.controller('SomeController', function(){…})
.directive('someDirecive', function(){});

// someService.js
angular.module('moduleName')
.service('SomeService', function(){…});

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

Со временем образовался такой план:

//module.js
module.exports = angular.module(‘moduleName’, [ 
    require('path/to/firstDependency'),    
    require('path/to/secondDependency')
])
.controller(...require(‘controller.js’))  //es6 spread syntax feature yay!
.name;

//controller.js
module.exports = ['SomeController', function(){}];

За счет использования es6 spread syntax [4] мы смогли изящно избежать дублирования имени модуля при объявлении компонента.

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

HTML-шаблоны

Шаблоны делятся на две категории: index.html и все остальные. Собирать index.html несложно при помощи html-webpack-plugin [5]. Всеми остальными раньше занимался grunt-ng-template [6]. Пришлось поискать webpack-плагин для работы с шаблонами. Требований к нему было всего два:

  • Чтобы все шаблоны, упомянутые в модулях, тут же попадали в $templateCache.
  • Чтобы все внутренние подключения шаблонов (ng-include) тоже обрабатывались.

С первым пунктом справиться было легко [7], а со вторым возникли проблемы. До сих пор не существует подходящего решения, и, хотя написать его несложно, для нас подключить все такие шаблоны руками в js было быстрее. В будущем мы хотим разработать webpack-лоадер для этих целей. Если вы уже написали такой сами – поделитесь с нами в комментариях ссылкой на github.

C попаданием в $templateCache интересный нюанс: если делать require внутри директивы или контроллера, то в кеш он попытается добавиться только в рантайме, не попадая заранее в бандл. С появлением angular-компонентов [8] это было исправлено, в остальных местах приходилось подключать шаблоны до объявления контроллеров.

Чтобы легко выявлять пропущенные подключения шаблонов, мы добавили в webpack-dev-middleware прослойку, запрещающую загрузку любых вложенных html.

function blockLocalTemplatesMiddleware(req, res, next) {
        var urlPath = parseUrl(req).pathname;
        if (/[^/]+/[^/]+.html$/g.test(urlPath)) {
            res.statusCode = 404;
            res.end('Request to .html template denied');
        } else {
            next();
        }
    }

Конфиги

У каждого из наших проектов есть конфигурации, зашиваемые в проект на этапе сборки. Раньше все конфиги хранились в нескольких json-файлах, grunt-ng-constant [9] заворачивала их в angular-модуль и подключала к проекту на этапе сборки, уменьшая прозрачность чтения и отладки. Использование DefinePlugin [10] сделало это гораздо удобнее и проще.

Юнит-тесты

  • Чтобы не замедлять тестовую сборку, для подключения всего, кроме js, был использован ignore-loader [11].
  • В юнит-тестах пришлось напрямую обращаться к angular.mock из-за webpack [12].
  • Начали массово падать тесты, использующие angular.element. Знатно поломав голову, мы вспомнили, что angular.element использует jQuery, но не тянет его с собой, поэтому библиотеку надо подключать отдельно в karma.config.js.

Окончательная миграция

Три недели аккуратного POC спустя мы были готовы к окончательной миграции всего приложения целиком.

image

Работа с css

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

И хотя эта статья изначально предполагала рассказ только о первом этапе миграции, мы не можем не поделиться опытом отказа от grunt-sass.

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

Как работала сборка раньше? Подобно сборке js-модулей. По маске собирались все *.scss и импортились в одном файле. Далее sass отрабатывал на нем одном, все подключенные один раз миксины и хелперы были доступны везде, перекрестных импортов практически не наблюдалось.

Для реализации модульной структуры мы начали в каждом файле стилей импортить переменные, миксины, node-bourbon, лунапарки, блекджек. Из-за этого случилось две беды:

  • Из-за отсутсвия import-once (в нем не было особой нужды раньше) наши итоговые .css распухли настолько, что IE (в Chrome, Firefox и даже Safari таких проблем, конечно же, не было) не был в силах их распарсить. То есть страница загружалась, .css файл загружался, но осознать, что он полон стилей, IE был не в состоянии. Эта проблема разрешилась простым добавлением import-once.
  • sass-loader, не обладающий инкрементальным билдом, пересобирал проект всякий раз заново и из-за обилия точек входа и импортов в них тратил на пересборку около 5 секунд. Справиться с этим не меняя архитектуры не представлялось возможным.

Однако обновление до недавно вышедшего node-sass@4.0.0 ускорило пересборку примерно в 1,5 раза, и мы решили отложить массовую переработку стилей.

Отладка и тестирование

Главное в разработке – не написать, а отладить, по результатам отладки мы составили шпаргалку для тех, кто решит повторить путь миграции (кстати, тут совершенно неважно, с чего слезать – с gulp или grunt). В основном все встреченные на пути дефекты и их диагностика выглядели как:

  • “Ничего не собирается, в консоли IDE полно букв”: пытаемся подключить зависимость, которой нет (неверный путь или некорректный экспорт).
  • “Собралось, но ничего не работает, в консоли браузера очень длинные ошибки”: модулю не хватает зависимости. В старой сборке такой проблемы не было, потому что все модули попадали в сборку и легко можно не упоминать необходимую зависимость, не получив побочных эффектов. Теперь же не упомянутые нигде файлы в бандл не попадают.
  • “Все загрузилось, но при переходе или клике – падает”: на выбор три варианта – предыдущий, нехватка шаблона в $templateCache или же забыли добавить в сборку webworker.
  • “Приложение выглядит не так“: из-за смены порядка стилей мы еще довольно долго находили маленькие дефекты, вызванные изначальным расчетом на определенный (алфавитный) порядок подключения файлов.

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

Технические моменты

Возможностей оптимизировать процесс работы angular при помощи webpack очень много. На github можно обнаружить десятки лоадеров (любопытно, что с того момента, как мы завершили миграцию, до дня, когда был начат этот абзац, там уже появились некоторые плагины, которых нам не хватало тогда). Однако треть из них не имеет документации и содержит только минифицированный код, поэтому пользоваться ими не представляется возможным, вторая треть работает неоднозначно (например, существует три лоадера для шаблонов, делают вроде одно и то же, а заработал корректно у нас только один).

Неизбежные трудности

  • В этом разделе расскажем про трудности, встреченные нами на пути работы с выбранными инструментами.
  • Неудобно экспортировать имя модуля, непонятно, как решить эту проблему на angular 1.x.
  • Можно не подключить необходимую зависимость и остаться непойманным, если она же используется в другом модуле. Это выявляют юнит-тесты, запускающиеся изолированно, но общая тенденция не кажется здоровой.
  • У некоторых внешних angular-модулей из зависимостей нет экспорта, это причиняет страдание и убавляет прозрачность.

Например:

require('ng-file-upload');
angular.module('app', ['ngFileUpload'])

Справиться с этим можно при помощи батареи пулл-реквестов на github, в свободную минутку мы порой отправляем их.

  • Если писать модули в формате экспорта функции, необходимо упоминание @ngInject. Если это не сделано, минифицированная версия не работает, а линтерами эту ситуацию не отследить.
  • Webpack, оказывается, впадает в панику, когда у него есть два способа отрезолвить файл.

Например, структура проекта такова:

├── src
│   └── module.js
└── common
    └── src
        └── module.js

// webpack.config.js
 resolve: {
    root: ['src','common/src']
}

//app.js
require('module.js');

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

  • Не все способы минификации работают на 100% одинаково. Раньше мы использовали grunt-contrib-uglify [13], сейчас перешли на UglifyJsPlugin [14]. Несмотря на одинаковые настройки, при переходе возникла проблема с тем, что одна из библиотек начала считать русские символы в HTML-шаблонах небезопасными и превращала их в дважды экранированные HTML entities. Логическому объяснению подобные случаи не поддаются, зато иллюстрируют пользу частого тестирования кода, собранного с настройками, используемыми для production.

Неожиданные бенефиты

  • Мы всегда создавали два бандла – код сторонних библиотек и наш. С webpack разделение происходит через СommonsChunksPlugin [15]. Сначала мы держали две точки входа, на которые применяли СommonsChunkPlugin, но нашли отличное хитрое решение.

new webpack.optimize.CommonsChunkPlugin({
        name: 'vendor',
        chunks: ['app'],
        filename: 'vendor.[hash].js',
        minChunks: function minChunks(module) {
            return module.resource
                && module.resource.indexOf('node_modules') > 0;

        }
    })

Зачем разделять код на две части? Чтобы использовать DLL [16], ускоряя пересборку. Плюс при достаточно частых релизах (а мы стремимся увеличить их частоту) список зависимостей не успевает поменяться, сохраняя тот же hash. Это позволяет пользователю не загружать лишний файл, а просто брать его из кеша бразуера.

  • Используя opensource-библиотеки, мы обязаны указывать имена их авторов. С webpack стало очень удобно собирать эту информацию при помощи license-webpack-plugin [17], ориентирующегося по пути к подключаемому модулю.

Не пошло в работу

Автозагрузка модулей

Конечно, переписывать все зависимости модулей со строк на require нам не очень хотелось. Круто было бы прикрутить loader, который бы анализировал код и сам подставлял нужные require!

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

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

Hot module replacement

К сожалению, от HMR для js-кода мы вынуждены были отказаться. Существует два плагина [18], но оба они требуют не только очень строгой структуры проекта, но и точного формата экспорта, а также работают только с контроллерами, но не с директивами. Даже при подходящей структуре пользоваться обновлением только для части кода совершенно неудобно. Однако для стилей HMR работает корректно.

Советы себе в прошлое

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

  • Вместо того, чтобы вручную заменять все строковые имена зависимостей на require(), проще написать одноразовый nodejs-скрипт, анализирующий текущую кодовую базу и сам заменяющий имена модулей на пути к ним.
  • Непроверенный совет! Возможно, имеет смысл сначала переписать код с использованием browserify [19], а потом уже приделывать к нему webpack, чтобы не разбираться, в чем именно из тонны внесенных изменений, проблема – в неправильных путях подключаемых файлов или же в самом сборщике.

О цифрах

Самое интересное это, конечно же, цифры. Интересные логи по задачам:

Angular 1.x: крадущийся webpack, затаившийся grunt - 5

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

Время сборки

На старой сборке при обнаружении изменений в js страница начинала перезагрузку сразу же. Если были внесены изменения в стили, пересборка css занимала около 3,5 секунд.
После переезда пересборка происходит за 5 секунд независимо от того, где были внесены изменения.

Время загрузки страницы в dev-версии на старой сборке занимало около 1,5 секунд из-за большого количества подключаемых js-файлов. После перехода на webpack оно сократилось до 0,8 секунды. При изменении стилей, как тогда, так и сейчас, перезагрузки не требуется.

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

Angular 1.x: крадущийся webpack, затаившийся grunt - 6

Выводы

Angular 1.x: крадущийся webpack, затаившийся grunt - 7
Минусы:

  • время от внесения изменений до перезагрузки страницы увеличилось

Плюсы:

  • масштабируемость проекта выросла – теперь добавить новый плагин или loader (подключить babel или postcss) гораздо проще
  • переход на модульную структуру наконец стал возможным
  • легко ориентироваться по зависимостям модуля при помощи ctr+click
  • в бандл не попадают лишние файлы
  • стало удобнее собирать информацию о сторонних лицензиях и отделять opensource-код от нашего
  • при добавлении новых файлов не нужно перезапускать всю сборку заново
  • избавились от длинного запутанного списка grunt-тасок, заменив его на список webpack-плагинов, пользоваться которыми куда удобнее
  • можно переключаться с ветки на ветку, не перезапуская работающую сборку

Планы на будущее:

  • ускорить сборку
  • научиться собирать assets, используемые в стилях, при помощи postcss-плагинов, а остальные – webpack’ом
  • принять меры по поддержанию HMR для любых изменений

В целом ориентироваться в проекте стало проще, порог вхождения для нового сотрудника стал ниже, рефакторинг – доступнее, отслеживание зависимостей – удобнее. Теперь можно разрабатывать отдельные модули и не опасаться, что часть кода или css попадет в общий бандл.

Было бы обидно прочитать такую длинную статью и не получить в конце бонус! Мы прикладываем для вас готовые конфиги для webpack и karma [20]!

Автор: Новые Облачные Технологии

Источник [21]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/243129

Ссылки в тексте:

[1] proof of concept: https://en.wikipedia.org/wiki/Proof_of_concept

[2] Инкрементальный билд: https://en.wikipedia.org/wiki/Incremental_build_model

[3] Dead code elimination.: https://en.wikipedia.org/wiki/Dead_code_elimination

[4] spread syntax: https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Spread_operator

[5] html-webpack-plugin: https://github.com/jantimon/html-webpack-plugin

[6] grunt-ng-template: https://www.npmjs.com/package/grunt-ng-template

[7] легко: https://github.com/WearyMonkey/ngtemplate-loader

[8] angular-компонентов: https://docs.angularjs.org/guide/component

[9] grunt-ng-constant: https://github.com/werk85/grunt-ng-constant

[10] DefinePlugin: https://webpack.github.io/docs/list-of-plugins.html#defineplugin

[11] ignore-loader: https://www.npmjs.com/package/ignore-loader

[12] webpack: http://stackoverflow.com/questions/32499108/karma-jasmine-webpack-module-is-not-a-function/32584798#32584798

[13] grunt-contrib-uglify: https://github.com/gruntjs/grunt-contrib-uglify

[14] UglifyJsPlugin: https://webpack.github.io/docs/list-of-plugins.html#uglifyjsplugin

[15] СommonsChunksPlugin: https://webpack.github.io/docs/list-of-plugins.html#commonschunkplugin

[16] DLL: http://robertknight.github.io/posts/webpack-dll-plugins/

[17] license-webpack-plugin: https://www.npmjs.com/package/license-webpack-plugin

[18] два плагина: https://github.com/yargalot/Angular-HMR

[19] browserify: http://browserify.org

[20] конфиги для webpack и karma: https://gist.github.com/newcloudtech/b5001f993cb2e2974725ff58962bf01b

[21] Источник: https://habrahabr.ru/post/321584/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best