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

в 11:16, , рубрики: angular, Angular 6, ISPsystem, TypeScript, webpack 4, Блог компании ISPsystem, Программирование, Разработка веб-сайтов

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

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

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

Предпосылки

У всего есть причины, и наши текущие трудности получили свое начало полтора года назад. Тогда только появилась 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 = {
  context: PATHS.root,
  target: 'web',
  entry,
  resolve: {
    extensions: ['.ts', '.js', '.json'],
    modules: [PATHS.src, PATHS.node_modules],
  },
  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. Но тут мы видим, что инструмент, которым будет собираться наш проект, «из коробки» не идет.

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

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

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

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

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

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

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

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

В обновлении предыдущих версий такого не было, а теперь, получается, без 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 и смежные пакеты до последних версий. Рассказ про обновление Webpack тянет на отдельную статью, поэтому здесь напишу только, что если вы используете extract-text-webpack-plugin, замените его на mini-css-extract-plugin, и это сэкономит вам нервы и силы. Почитать про то, как хорош четвертый webpack, можно здесь, ну и, собственно, статья по миграции.

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

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

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

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

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

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

Автор: Денис Сумбаев

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js