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

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


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

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

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



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


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

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

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

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


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



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

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

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

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

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

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




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

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

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


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

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

    При этом 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 мы смогли изящно избежать дублирования имени модуля при объявлении компонента.

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

    HTML-шаблоны


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

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

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

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

    Чтобы легко выявлять пропущенные подключения шаблонов, мы добавили в 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 заворачивала их в angular-модуль и подключала к проекту на этапе сборки, уменьшая прозрачность чтения и отладки. Использование DefinePlugin сделало это гораздо удобнее и проще.

    Юнит-тесты


    • Чтобы не замедлять тестовую сборку, для подключения всего, кроме js, был использован ignore-loader.
    • В юнит-тестах пришлось напрямую обращаться к angular.mock из-за webpack.
    • Начали массово падать тесты, использующие 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, сейчас перешли на UglifyJsPlugin. Несмотря на одинаковые настройки, при переходе возникла проблема с тем, что одна из библиотек начала считать русские символы в HTML-шаблонах небезопасными и превращала их в дважды экранированные HTML entities. Логическому объяснению подобные случаи не поддаются, зато иллюстрируют пользу частого тестирования кода, собранного с настройками, используемыми для production.

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


    • Мы всегда создавали два бандла – код сторонних библиотек и наш. С webpack разделение происходит через СommonsChunksPlugin. Сначала мы держали две точки входа, на которые применяли С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, ускоряя пересборку. Плюс при достаточно частых релизах (а мы стремимся увеличить их частоту) список зависимостей не успевает поменяться, сохраняя тот же hash. Это позволяет пользователю не загружать лишний файл, а просто брать его из кеша бразуера.

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

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


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


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

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

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

    Hot module replacement


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

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


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

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

    О цифрах


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



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

    Время сборки


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

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

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



    Выводы




    Минусы:

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

    Плюсы:

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

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

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

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

    Было бы обидно прочитать такую длинную статью и не получить в конце бонус! Мы прикладываем для вас готовые конфиги для webpack и karma!
    Поделиться публикацией
    Комментарии 10
    • НЛО прилетело и опубликовало эту надпись здесь
      • 0
        ИМХО, для существующих проектов бонусов от миграции webpack 1 -> webpack 2 не так много, чтобы срочно переходить. Там где webpack'а ещё нет, стоит использовать последнюю версию.
      • +1
        Было бы обидно прочитать такую длинную статью и не получить в конце бонус! Мы прикладываем для вас готовые конфиги для webpack и karma!


        ВАУУ, СПАСИБО!!! ВАШЕ КРУТО!!!
      • +1
        Не так давно решал похожую задачу, правда переходили с Gulp :)

        Много идей подчерпнул из отличного шаблона NG6 starter, рекомендую.

        По поводу экспорта имени модуля, вы это имели в виду?
        export default angular.module('app.core', [
          ...
        ]).name;
        


        Если под автозагрузкой вы имели в виду загрузку модулей по требованию, у нас используется такая схема:
        // bundle-loader автоматически оборачивает контент модуля в require.ensure
        const handler = require('bundle-loader?lazy!app/components/' + moduleName + '/index.js');
        
        handler((module) => {
            // плагин ocLazyLoad регистрирует модуль и его зависимости в angular
            $ocLazyLoad.load({ name: module.default });
        });
        

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

        Так же сегодня мигрировали на Webpack 2, трудности возникли только с ngInject — ng-annotate-loader перестал работать, но вместо него отлично подошел babel плагин angularjs-annotate.
        Production сборка стала значительно быстрее, а вот от tree shaking эффекта не заметили :)
        • 0
          Мы также столкнулись с тем, что замена ng-annotate-loader на angularjs-annotate необходима, чтобы начала работать поддержка ES2017 синтаксиса в babel-loader
        • 0
          Если писать модули в формате экспорта функции, необходимо упоминание @ngInject. Если это не сделано, минифицированная версия не работает, а линтерами эту ситуацию не отследить.

          Для этого есть ngStrictDi, обязательно включите. https://docs.angularjs.org/api/ng/directive/ngApp

          • 0
            легко ориентироваться по зависимостям модуля при помощи ctr+click

            А что меншает допустим также и например сервисы запрашивать в явном виде?


            import './careers.component.scss';
            
            import {Component, BaseClass} from '_global_common_angular';
            import Crud from './../shared/crud.service';
            import Profile from './../profile/profile.service';
            
            @Component({
                __filename
            })
            export default class extends BaseClass {
                static $inject = [
                    Crud.$name,
                    Profile.$name,
            ...
                constructor(Crud, Profile, ...) {
                    super({Crud, Profile, ...});
            ...
            

            PS Я предпочитаю использовать TypeScript пусть даже только как трайспайлер вместо Babel. Он работает немного быстрее, код генерирует более красивый (для меня), тянет меньше npm зависимостей.

            • 0

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


              • зависимости описываются только один раз и это просто массив $inject без необходимости соблюдать порядок элементов в массиве
              • использование сервисов в более явном/унифицированном виде — по импортированной зависисимости (this.$i[GroupsService.$name])

              import './groups-favorite-page.component.scss';
              
              import {Component, BaseClass2Scoped} from '_global_common_angular';
              import {GroupsService} from './../groups/groups.service';
              
              @Component({
                  __filename,
                  bindings: {
                      _input: '< input'
                  }
              })
              export class GroupsFavoritePageComponent extends BaseClass2Scoped {
                  static $inject = [
                      GroupsService.$name,
                      ...BaseClass2Scoped.$inject
                  ];
              
                  constructor(...$injections) {
                      super(...$injections);
              
                      this.$s.groups = [];
              
                      this.$i[GroupsService.$name]
                          .requestFavoritePage({sort: {orders: [{property: 'group.title'}]}})
            • 0
              Похоже настало время написать о нашей сборке :) Мы пошли еще дальше. Теперь убейте karma, заменив ее на mocha и тестируйте все как самый обычный JS код. Все прямые зависимости от angular прекрасно заменяются парой lodash + $q (можно только для тестов). Если про существование angular знает исключительно точка входа в модуль, а все остальное существует как самые обычные функции — жизнь упрощается в разы. И тесты проходят моментально, а не как карма запустится. Осталось сделать пулл-реквест в angular чтобы убрать абзац про «Testability Built-in» :)

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

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