вторник, 4 апреля 2017 г.

Angular 2. SystemJS для разработки, WebPack для продакшена. Проблемы с LazyLoading и относительными путями вообще


Введение 

Начинал я делать проект на каком-то там ReleaseCandidate Angular2 или даже на beta. И, соответственно, во всю там применялся SytemJS для подгрузки частей приложения. Сейчас команда Angular постепенно мигрирует на Webpack, но SystemJS еще пока есть в гайдах, полно примеров как делать именно с его помощью, короче - помирать пока не собирается. И, откровенно говоря, с ним удобнее разрабатывать.
Как выглядит разработка с использованием SystemJS? Вы правите свой ts-файлик, сохраняете, он компилируется в js-файлик. Ручками или, что удобнее, с помощью запущенного через npm typescript компилятора в режиме ожидания изменений, или, что еще удобнее, IDE вам сама как-то там запускает typescript-компилятор. Все это не столь важно. Важно что через секунду после сохранения ts-файла у вас новый js-файл. Можно в браузере нажать обновить и все - новый код в работе.
Кроме того, SystemJS по умолчанию грузит каждый файл отдельным запросом. Да - для продакшена это плохо, но для разработки это удобно.

Что происходит, если перейти на WebPack? После того как вы поменяли и сохранили свой ts-файлик, вам надо перебилдить bundle с помощью webpack. На большом проекте это время. Да, для webpack есть некий webpack-dev-server, который можно запустить в режиме отслеживания изменений, он будет на лету как-то там посылать измененные js прямо в браузер. Но у меня эта схема не заработала, подозреваю из-за того что у меня тут же крутится и backend на PHP. А вот для продакшена webpack классный - твои сотни js- и html- файлов собираются в несколько bundle.js (как настроишь, хоть все в один) и запросов на сервак уходит на порядки меньше. Причем там еще все это можно сжать, минифицировать и так далее. Таким образом работает все намного быстрее.

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

Проблема

Я попробовал перевести проект на рельсы Webpack. После некоторых танцев с бубном, все получилось. Но выявилась проблема с лениво загружаемыми модулями. В Angular для таких в роутере указывается параметр loadChildren и его значение либо строка, либо функция типа LoadChildrenCallback:
export declare type LoadChildrenCallback = () => Type<any> | NgModuleFactory<any> | Promise<Type<any>> | Observable<Type<any>>;
Для Webpack c этим ничего сложного нет - подключаете там нужный loader (e.g., angular-router-loader), и он делает так, что ваши LazyLoaded модули превращаются в отдельные xx.chunk.js и грузятся когда надо. Статей про это полно в инете полно. В конфиге webpack у меня например написано так:
...
module: {
        rules: [
            {
                test: /\.ts$/,
                loaders: ['awesome-typescript-loader', 'angular2-template-loader','angular-router-loader']
            },
  ...
}

Более того, в webpack пути в loadChildren указываются относительные, что удобнее и логичнее. А в том же пресловутом SystemJS, пути от корня приложения. И приходилось либо реально писать фактически абсолютные пути, либо изобретать велосипеды в виде собственного загрузчика ну или генератора абсолютного пути по относительному. Ну, например, типа таких: Первым параметром там передается moduleId. Вопрос откуда он берется. А это ключевая штука.Дело в том что в момент загрузки скрипта и его интерпретации поле module.id равно абсолютному пути этого скрипта. То есть если, скажем, ваши корневые маршруты лежат в app/app.route.ts, то в момент интерпретации app.route.js module.id будет равен "http://blabla.bla/app/app.route.js". Таким образом в app.route.ts можно написать что-то типа
loadChildren: Helpers.getAbsolutePath(module.id, './dash/dash.module#DashboardModule')
и в loadChildren будет абсолютный путь. Но все это не прокатывает для WebPack. Проблем а в том, что его loader такую конструкцию не поймет и наш lazy-loaded модуль просто не попадет в сборку. Это происходит поскольку загрузчик просто ищет строку в ts-файле по регулярному выражению и наши Helpers.blablabla ему ни о чем не говорят.

Решение

 Вообще можно, наверно, было форкнуть загрузчик webpack и заставлять его понимать такие извращения, но, во-первых, мне это показалось еще пока сложноватым, а во-вторых, больше хотелось заставить SystemJS понимать относительные пути.
И я начал ползать по дебрям Angular, Observable и Promise пытаясь понять где, как и в какой момент это все грузится и можно ли туда внедриться.
Грузится это все, кстати, классом RouterConfigLoader. На вход ему подается абстрактный NgModuleFactoryLoader, а реально там SystemJsNgModuleLoader.
RouterConfigLoader подменить не вышло (Angular его не экспортирует, проинжектить вместо него что-то другое тоже), подменять SystemJsNgModuleLoader смысла нет, потому что его методы на вход приходит уже просто строка пути. А идея была строку не трогать никак, чтобы была совместимость с webpack.
И я заметил что RouterConfigLoader перед загрузкой дергает callback onLoadStartListener и пошел искать откуда это берется и куда ведет. Оказалось, что это метод onLoadStart класса Router и передается он в RouterConfigLoader при его создании. Метод в свою очередь вызывает метод triggerEvent с новым экземпляром RouteConfigLoadStart, внутри которого маршрут route, который мы будем грузить. А triggerEvent просто вызывает next на routerEvents, которые есть Observable и на них можно подписаться через router.events.

И возникла следующая схема. В маршруты в поле data мы добавляем значение module.id. Таким образом, в момент интерпретации файла с маршрутами у нас в нем будет сохранен статический относительный путь от него до требуемого lazy-loading модуля и динамический абсолютный путь до самого фала маршрута. Далее в корневом (чтобы как можно раньше) компоненте app.component (который мы bootstrap'им) мы получаем экземпляр класса Router с помощью DI, подписываемся на его события и, если приходит RouteConfigLoadStart, то вычисляем абсолютный путь до модуля и подменяем его в маршруте.

Получился вот такой код:
export class AppComponent {
    constructor(private router: Router) {
        router.events.filter((event) => event instanceof RouteConfigLoadStart).subscribe((event: RouteConfigLoadStart) => {
            if (event.route && event.route.loadChildren && event.route.data && event.route.data.moduleId && !event.route.data.absolute) {
                if (typeof event.route.loadChildren === "string") {
                    let relPath = event.route.loadChildren;
                    if (relPath.substr(0, 1) == ".") {
                        event.route.loadChildren = Helpers.getAbsolutePath(event.route.data.moduleId, relPath);
                        event.route.data.absolute = true;
                    }
                }
            }
        })
    }
}

А в маршрутах пишем примерно так:
export const routes: Routes = [
    {
        path: '',
        loadChildren: './main/main.module#MainModule',
        data: {
            moduleId: module.id,
        }
    }

Тут я в route.data.absolute прописываю true, чтобы второй раз не срабатывало, но вообще загрузка происходит 1 раз и второго раза не будет для маршрута. Просто перестраховка.
Также я собирался проверять в runtime используем ли мы systemjs, но оказалось это не надо и под webpack тоже все работает чудесным образом.

Комментариев нет:

Отправить комментарий