Хабрахабр

Обновляем Angular до 6-ой версии в проекте без использования CLI

В этой статье я расскажу о тернистом пути обновления Angular с кастомным Webpack конфигом, который нашей команде пришлось пройти неделю назад. Возможно, наш опыт будет полезен тем, кто использует Angular со своим Webpack конфигом, а остальным — интересен как иллюстрация того, куда может завести современный frontend и как с этим жить.

Дабы вы имели общее представление о проекте до обновления, сообщу, что количество файлов в нем уже перевалило за 67 тысяч. Наша команда работает над интерфейсом BILLmanager 6. По технологиям основу составляют компоненты, директивы и модули Angular, написанные на TypeScript. Архитектурно можно выделить два подпроекта: модуль регистрации и основной пользовательский интерфейс. Для стилизации мы используем SASS/SCSS и применяем CSS variables, чтобы темизировать приложение без перекомпиляции.
Есть несколько компонентов на Web components.

Предпосылки

У всего есть причины, и наши текущие трудности получили свое начало полтора года назад. Тогда только появилась beta Angular 2. У программистов в компании был опыт создания приложений на Angular 1, ReactJS и собственном небольшом фреймворке. Angular 2 на тот момент вобрал в себя плюсы из первой версии и ReactJS. Поэтому и был выбран в силу своей перспективности (как никак Google), успешности Angular 1 и формализации, которую дает TypeScript. Мы не пишем небольшие SPA сайты, которые можно отдать заказчику и забыть, наши приложения живут долго и им нужна постоянная поддержка и развитие. BILLmanager используют провайдеры для продажи хостинга и работы с клиентами. Поэтому и его, и другие продукты ISPsystem, нужно постоянно поддерживать и развивать. В принципе, команда Angular 2 уже тогда везде писала, что теперь будет просто Angular и развитие фреймворка будет происходить эволюционно, что подходит для наших внутренних процессов.

У них сложные конфиги с гибкими настройками под отдельные сборки. Как я уже писал, проекты у нас большие и долгоживущие. А Webpack давно является своего рода стандартом для сборки больших и маленьких проектов в мире frontend, поэтому здесь выбор был однозначным.

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

содержимое файла webpack.config.common.js перед обновлением

module.exports = , module: { rules: [{ test: /\.ts$/, loaders: [{ loader: 'awesome-typescript-loader', options: { transpileOnly: !process.env.NODE_ENV === 'production' } }, 'angular2-template-loader', 'angular2-router-loader' ], exclude: [/\.(spec|e2e)\.ts$/], }, { test: /\.ts$/, include: [/\.(spec|e2e)\.ts$/], loaders: ['awesome-typescript-loader', 'angular2-template-loader'] }, { test: /\.json$/, use: 'json-loader' }, { test: /\.html$/, use: [{ loader: 'html-loader', }], }, { test: /\.(eot|woff|woff2|ttf|png|jpg|gif|svg|ico)(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader', options: { context: PATHS.assets, name: '[path][name].[ext]' }, }, { test: /\.css$/, loader: extractSASS.extract({ fallback: 'style-loader', use: 'css-loader?sourceMap' }), exclude: [path.join(PATHS.projectPath), path.join(PATHS.src, 'common'), path.join(PATHS.src, 'common-bill')], }, { test: /\.css$/, include: [path.join(PATHS.projectPath), path.join(PATHS.src, 'common'), path.join(PATHS.src, 'common-bill')], use: [{ loader: "raw-loader" // creates style nodes from JS strings }], }, { test: /\.(scss|sass)$/, loader: extractSASS.extract({ use: [{ loader: "css-loader", }, { loader: "sass-loader", options: { sourceMap: true, } } ], // use style-loader in development fallback: "style-loader" }), exclude: [path.join(PATHS.projectPath), path.join(PATHS.src, 'common'), path.join(PATHS.src, 'common-bill')], }, { test: /\.(scss|sass)$/, use: [{ loader: "raw-loader" // creates style nodes from JS strings }, { loader: "sass-loader", // compiles Sass to CSS options: { sourceMap: true } } ], include: [path.join(PATHS.projectPath), path.join(PATHS.src, 'common'), path.join(PATHS.src, 'common-bill')], } ] }, plugins: [ extractSASS, new webpack.IgnorePlugin(/vertx/), new webpack.ContextReplacementPlugin( // The (\\|\/) piece accounts for path separators in *nix and Windows /\@angular(\\|\/)core(\\|\/)esm5/, PATHS.projectPath, // location of your src {} // a map of your routes ), new webpack.optimize.CommonsChunkPlugin({ name: ['vendor', 'polyfills'], // minChunks: Infinity }), ]
};

Это очень похоже на то, что описано сейчас в документации Angular angular.io/guide/webpack. Наиболее интересной из этого является часть про компиляцию .ts файлов.

{ test: /\.ts$/, loaders: [{ loader: 'awesome-typescript-loader', options: { transpileOnly: !process.env.NODE_ENV === 'production' } }, 'angular2-template-loader', 'angular2-router-loader' ], exclude: [/\.(spec|e2e)\.ts$/],
},
{ test: /\.ts$/, include: [/\.(spec|e2e)\.ts$/], loaders: ['awesome-typescript-loader', 'angular2-template-loader']
},

Как видите, мы используем лоадер angular2-template-loader и angular2-router-loader для сборки наших компонентов Angular. В официальной документации так и написано. И это крайне странно, так как оба лоадера написаны не командой Angular и лежат в пользовательских репозиториях на GitHub. Одной из причин выбора Angular в качестве основного фреймворка было как раз то, что он работает как комбайн — всё идет «из коробки», в отличие от того же ReactJS. Но тут мы видим, что инструмент, которым будет собираться наш проект, «из коробки» не идет.

Хотя нет, была одна. Ну да ладно, такой конфиг работал со второй по пятую версию, и причин для беспокойства не было. Кто работал с большими проектами на Angular, тот поймет меня — сборки проходят очень долго. На ng-conf 2017 Brad Green рассказал о попытке сборки Angular приложения с помощью Bazel и Closure. И стремление разработчиков фреймворка сделать сборки быстрее вполне обосновано. Наша первая сборка development режима на пятой версии Angular со вторым webpack занимает больше 4 минут. Как сказал мой коллега:
Хотя существует и другой взгляд на эту ситуацию.

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

Обновление Angular до 6-ой версии

В нашей компании хорошо понимают, что происходит, когда не обновляешь инструменты. Мы знаем, к чему это может в итоге привести. Революции не проходят безболезненно, и лучше их предвидеть двигаясь эволюционным путем. Поэтому при появлении новости о выходе стабильной версии Angular 6, мы решили обновить наш проект. Но безболезненно сделать это не получилось.

Здесь нас ждал первый сюрприз. Как и при обновлении предыдущей версии, мы перешли на сайт с руководством по обновлению update.angular.io.

Если не указать пункт “I use ngUpgrade”, то руководство всё равно предложит выполнить команду ng update @angular/core.

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

Для начала нужно определиться с направлением: Если как и мы, вы все же хотите продолжить обновлять проект, то именно тут начинается наш тернистый путь.

  1. Установить CLI и обновлять по шагам официального руководства.
  2. Обновлять пакеты отдельно и редактировать конфиги самостоятельно.

Первый показался нам проще и мы пошли по нему.
Но после установки, обновления CLI и выполнения команды ng update @angular/core нас ожидало разочарование.

$ ng update @angular/core
Package "@angular/compiler-cli" has an incompatible peer dependency to "typescript" (requires ">=2.7.2 <2.8", would install "2.8.3")
Invalid range: ">=2.3.0 <3.0.0||>=4.0.0"

В issues на GitHub можно найти github.com/angular/angular-cli/issues/10621. На сегодняшний день эту ошибку вроде бы поправили (судя по github.com/angular/devkit/pull/901), но на тот момент мы решили не лезть в дебри утилиты обновления и обновили пакеты вручную.

Angular 6 использует Webpack 4 (это можно увидеть, если вы поставите его через CLI). После обновления пакетов проект перестал запускаться, что, собственно, было ожидаемо. Рассказ про обновление Webpack тянет на отдельную статью, поэтому здесь напишу только, что если вы используете extract-text-webpack-plugin, замените его на mini-css-extract-plugin, и это сэкономит вам нервы и силы. Поэтому на следующем шаге мы обновили Webpack и смежные пакеты до последних версий. Почитать про то, как хорош четвертый webpack, можно здесь, ну и, собственно, статья по миграции.

Это обязательное условие и выполнить его несложно, нужно просто следовать документации по миграции. Помимо обновления Angular и Webpack, нужно обновить RxJS до шестой версии, иначе проект просто не запустится. Существенных трудностей возникнуть не должно, RxJS предоставляет утилиту, которая самостоятельно вносит необходимые изменения в проект.

Проект по-прежнему не собирается и выдает массу невразумительных ошибок. Тем временем мы возвращаемся к обновлению до Angular 6. У нас используется связка angular2-template-loader и angular2-router-loader. Здесь самое время обратить внимание на лоадер, которым обрабатываются .ts файлы. Если зайти в репозиторий angular2-template-loader, то видно, что он не обновлялся уже как год (странно, что в официальной документации нам все еще предлагают использовать его).

Мы начали искать замену и нашли плагин для Ahead-of-Time (AoT) компиляции @ngtools/webpack. Похоже, что проблема в том как этот лоадер обрабатывает наш код. Но, с другой стороны, команда Angular уже давно говорит о планах сделать AoT-компиляцию по умолчанию. Это не равнозначная замена, так как до этого нами использовалась только JIT-компиляция. Для справедливости замечу, что можно собрать Angular 6 проект и с плагинами angular2-template-loader и angular2-router-loader. @ngtools/webpack — официальный инструмент из Angular DevKit, он обновляется постоянно и был переработан под шестую версию фреймворка. Именно это не позволило нам легко сразу же отловить все необходимые исправления для перехода на шестую версию. Связка этих плагинов может подойти для разработки, но для production сборок лучше их не использовать из-за отсутствия дополнительных проверок исполняемого кода.

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

Для ее выполнения придется переделать большую часть проекта, потому что AOT предъявляет очень строгие требования к коду и если сразу вы их не соблюдали, то будет сложно. Переход на AOT-компиляцию проекта — это отдельная большая задача. Можно использовать плагин @ngtools/webpack с JIT-компиляцией. Но есть выход. Для этого нужно добавить параметр skipCodeGeneration=true в настройки плагина.

Обозначу основные моменты, которые пришлось исправить при переходе на @ngtools/webpack плагин:

  1. В шаблонах все private переменные заменены на public.
  2. При наследовании нежелательно наследовать один компонент от другого (с директивами то же самое). В принципе, это логично, но angular2-template-loader — пропускал, а @ngtools/webpack стал ругаться на неправильно созданные модули.
  3. Если пренебречь рекомендацией выше, то можно получить ошибку при использовании в конструкторе компонента переменных простых типов. Это наиболее странная ошибка. Компонент выглядит следующим образом:

@Component({ selector: '[form-component]', template: ''
})
export class FormComponent extends BaseComponent implements OnInit { constructor( public formService: FormService, public formFunc: string, public formParams: Array<any> = [] ) { super(); }
...

В логах видим примерно следующее:

ERROR in : Can't resolve all parameters for FormComponent in form.component.ts: ( [object Object], ?, ?)

Рекомендую все же выполнить правило второе, но если по каким-либо причинам это не получается, вы можете сделать небольшой хак https://stackoverflow.com/a/48748942/4778628, заменив код выше на:

@Component({ selector: '[form-component]', template: ''
})
export class FormComponent extends BaseComponent implements OnInit { constructor( public formService: FormService, @Inject('') public formFunc: string, @Inject('') public formParams: Array<any> = [] ) { super(); }
...

К сожалению, на этом ошибки не закончились, и мы получили её в самом плагине компилятора Angular:

текст ошибки

[0] building modules 「wds」: Project is running at http://localhost:8080/ 「wds」: webpack output is served from / 「wds」: Content not from webpack is served from /home/dsumbaev/DEVELOPMENT/bill-client-front/dist 「wds」: 404s will fallback to /index.html
[0] building modules/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/@ngtools/webpack/src/angular_compiler_plugin.js:509 if (this.done && (request.request.endsWith('.ts') ^ TypeError: Cannot read property 'request' of null at nmf.hooks.beforeResolve.tapAsync (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/@ngtools/webpack/src/angular_compiler_plugin.js:509:47) at _fn1 (eval at create (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/webpack/node_modules/tapable/lib/HookCodeFactory.js:24:12), <anonymous>:27:1) at Object.resolveWithPaths (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/@ngtools/webpack/src/paths-plugin.js:14:9) at nmf.hooks.beforeResolve.tapAsync (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/@ngtools/webpack/src/angular_compiler_plugin.js:521:32) at AsyncSeriesWaterfallHook.eval [as callAsync] (eval at create (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/webpack/node_modules/tapable/lib/HookCodeFactory.js:24:12), <anonymous>:19:1) at NormalModuleFactory.create (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/webpack/lib/NormalModuleFactory.js:338:28) at semaphore.acquire (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/webpack/lib/Compilation.js:494:14) at Semaphore.acquire (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/webpack/lib/util/Semaphore.js:17:4) at asyncLib.forEach (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/webpack/lib/Compilation.js:492:15) at arrayEach (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/neo-async/async.js:2400:9) at Object.each (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/neo-async/async.js:2835:9) at Compilation.addModuleDependencies (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/webpack/lib/Compilation.js:471:12) at Compilation.processModuleDependencies (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/webpack/lib/Compilation.js:450:8) at afterBuild (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/webpack/lib/Compilation.js:556:15) at buildModule.err (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/webpack/lib/Compilation.js:600:11) at callback (/home/dsumbaev/DEVELOPMENT/bill-client-front/node_modules/webpack/lib/Compilation.js:358:35)

Сначала подумали, что отдаем компилятору пакеты из node_modules, а он не может их обработать, но добавление исключений никак не отразилось на ошибке. Деваться было некуда и поворачивать поздно, поэтому появился небольшой PR в @ngtools/webpack. Эти изменения вошли в версию пакета 6.0.1. После этого сборка прошла успешно и проект запустился!

Оказалось, что не подтянулись все модули кроме основного. НО! Давайте посмотрим на настройку @ngtools/webpack плагина.

new AngularCompilerPlugin({ platform: 0, sourceMap: true, tsConfigPath: path.join(PATHS.root, 'tsconfig.json'), skipCodeGeneration: true, })

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

new AngularCompilerPlugin({ platform: 0, entryModule: path.join(PATHS.src, 'apps/client/app/app.module#AppModule'), sourceMap: true, tsConfigPath: path.join(PATHS.root, 'tsconfig.json'), skipCodeGeneration: true, })

Если помните, в начале я писал, что в проекте у нас два подпроекта, но в entryModule можно указать только один. Здесь нам повезло, так как второе приложение не содержит вложенных модулей. Если же у вас другая ситуация: несколько сложных проектов внутри одного, то вам придется сделать для каждого отдельные конфиги или дождаться прохождения этого PR в репозитории Angular DevKit.

В результате общая часть конфига в проекте стала следующей:

итоговое содержимое файла webpack.config.common.js

const path = require('path');
const merge = require('webpack-merge');
const webpack = require('webpack');
const ProgressPlugin = require('webpack/lib/ProgressPlugin');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const {AngularCompilerPlugin} = require('@ngtools/webpack'); const { PATHS, PARAMS
} = require('./helpers.js');
const devMode = process.env.NODE_ENV === 'development'; let entry = { 'polyfills': path.join(PATHS.src, 'polyfills.browser.ts'), 'main': path.join(PATHS.projectPath, 'main.ts'), 'extform': path.join(PATHS.apps, 'extform/main.ts'), 'style': path.join(PATHS.assets, 'sass', 'app.sass')
}; PARAMS.themes.forEach(theme => { entry['themes/' + theme + '/theme'] = path.join(PATHS.themes, theme, 'theme.scss')
}); module.exports = { context: PATHS.root, target: 'web', entry, resolve: { extensions: ['.ts', '.js', '.json'], modules: [PATHS.src, PATHS.node_modules], }, mode: process.env.NODE_ENV, stats: 'errors-only', module: { rules: [{ test: /\.ts$/, loader: '@ngtools/webpack', exclude: [/\.(spec|e2e)\.ts$/, /node_modules/], }, { test: /\.ts$/, loader: 'null-loader', include: [/\.(spec|e2e)\.ts$/], }, { test: /\.json$/, use: 'json-loader' }, { test: /\.html$/, use: [{ loader: 'html-loader', }], }, { test: /\.(eot|woff|woff2|ttf|png|jpg|gif|svg|ico)(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader', options: { context: PATHS.assets, name: '[path][name].[ext]' }, }, { test: /\.css$/, use: [ MiniCssExtractPlugin.loader, "css-loader" ], exclude: [path.join(PATHS.projectPath), path.join(PATHS.src, 'common'), path.join(PATHS.src, 'common-bill')], }, { test: /\.css$/, include: [path.join(PATHS.projectPath), path.join(PATHS.src, 'common'), path.join(PATHS.src, 'common-bill')], use: [{ loader: "raw-loader" // creates style nodes from JS strings }], }, { test: /\.(scss|sass)$/, use: [ devMode ? 'style-loader' : MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader', ], exclude: [path.join(PATHS.projectPath), path.join(PATHS.src, 'common'), path.join(PATHS.src, 'common-bill')], }, { test: /\.(scss|sass)$/, use: [{ loader: "raw-loader" // creates style nodes from JS strings }, { loader: "sass-loader", // compiles Sass to CSS options: { sourceMap: true } } ], include: [path.join(PATHS.projectPath), path.join(PATHS.src, 'common'), path.join(PATHS.src, 'common-bill')], } ] }, optimization: { splitChunks: { cacheGroups: { commons: { test: /[\\/]node_modules[\\/]/, name: "vendors", chunks: "all" } } } }, plugins: [ new MiniCssExtractPlugin({ filename: '[name].[hash].css', }), new webpack.IgnorePlugin(/vertx/), new ProgressPlugin(), new AngularCompilerPlugin({ platform: 0, entryModule: path.join(PATHS.src, 'apps/client/app/app.module#AppModule'), sourceMap: true, tsConfigPath: path.join(PATHS.root, 'tsconfig.json'), skipCodeGeneration: true, }) ]
};

Заключение

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

Теперь не только дополнительные инструменты в виде роутера или библиотеки HTTP-запросов, но и инструменты сборки идут «из коробки». Angular — монолитный фреймворк, и с приходом шестой версии это становится видно еще больше. Только в этом случае вы сможете легко обновлять проект и, возможно, не будете сталкиваться с необходимостью менять сотни файлов после обновлений. И лучше их не трогать, не вносить изменения, которые не были задуманы разработчиками Angular. Иначе, вас ожидает нелегкий путь и придется лайкать негативные отзывы по новой версии в море позитива и общей радости окружающих.

Если вы будете работать с ним через CLI, собирать проект ng утилитой, ей же тестировать и создавать модули и компоненты, то будет вам счастье. Это не значит, что Angular плохой или хороший, просто он требует особого обращения и подойдет не всем. Как говорится в хорошей русской пословице: «Знал бы, где упадешь, — соломки бы подстелил». В своей команде мы бы тоже хотели так, но, увы и ах, слишком много уже завязано на наш конфиг Webpack. Еще год назад CLI не был столь обязательным инструментом в Angular проектах, ну а сегодня даже в документации по обновлению нет руководства как обновлять без него.

Показать больше

Похожие публикации

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

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

Кнопка «Наверх»