Angular 6 → 22: путь обновления и на чём спотыкаешься
На этой странице
Правило номер один: по одной версии
Angular выпускает мажорную версию примерно раз в полгода. Команда обновления:
npx ng update @angular/core@17 @angular/cli@17ng update не просто меняет номер в package.json. Он запускает миграции — скрипты, которые переписывают твой код под новую версию. Миграции написаны для перехода с N на N+1. Если прыгнуть с 12 на 17, миграции 13, 14, 15 и 16 не выполнятся, и разбираться придётся руками.
Поэтому цикл такой:
- Обновить на одну версию.
- Собрать, прогнать тесты, открыть приложение.
- Закоммитить отдельным коммитом.
- Следующая версия.
Отдельный коммит на версию — это возможность откатиться ровно на одну ступень и через git bisect найти, на какой из них что-то сломалось.
Для монорепозитория на Nx вместо ng update — nx migrate: он обновляет и Angular, и сам Nx, и плагины согласованно.
npx nx migrate 21 # пишет новые версии в package.json и migrations.json
pnpm install
npx nx migrate --run-migrationsИ сверяйся с официальным Angular Update Guide: выбираешь «с какой» и «на какую», и он показывает, что сделать руками.
Карта пути: где большие ступени
Большинство обновлений — это «обновил, поправил пару предупреждений, поехал». Но есть несколько ступеней, где меняется что-то фундаментальное.
| Версия | Что меняется | Чем опасно |
|---|---|---|
| 6 | ng update, RxJS 6 | старые импорты RxJS (rxjs/add/operator/...) нужно переписать на pipe() |
| 8 | ленивая загрузка через import() | строки вида './module#Module' в маршрутах устарели |
| 9 | движок Ivy по умолчанию | старые библиотеки, собранные под View Engine; более строгие проверки шаблонов |
| 12–13 | View Engine удалён, IE11 больше не поддерживается | библиотеки без поддержки Ivy перестают работать совсем |
| 14–15 | standalone-компоненты, типизированные формы | можно жить по-старому, но новый код пишется иначе |
| 16–17 | сигналы, новый control flow (@if, @for), сборщик на esbuild | огромный, но механический рефакторинг шаблонов |
| 19 | standalone: true по умолчанию | компоненты с NgModule требуют явного standalone: false |
| 20–22 | смена билдера, zone.js уходит из умолчаний, CDK меняет оверлеи | подробно ниже |
Самая болезненная ступень на практике — 9 и 13: проблема обычно не в твоём коде, а в зависимостях, которые никто не обновил под Ivy. Решение одно: заменить библиотеку или убрать её.
Как это было у нас
До монорепозитория у нас было больше десяти копий приложения, и каждая обновлялась отдельно — то есть, по сути, не обновлялась. Переход в монорепо (подробно — nx-monorepo-from-copies) начался уже на Angular 16. Дальше:
- 16 → 17 — через пару месяцев после старта, отдельным коммитом.
- 17 → 19 — через полтора года. Это ошибка: чем дольше стоишь, тем больше накапливается.
- 19 → 20 → 21 → 22 — за один день, тремя отдельными коммитами, вместе с Nx 20 → 23 и TypeScript 5.7 → 6.0.
Последний прыжок быстрый именно потому, что код общий: миграции прошли по всем приложениям сразу. Но «за один день» — это только обновление. Последствия разбирали ещё две недели.
Ступень 20: новый билдер
Старый билдер @angular-devkit/build-angular:browser (на webpack) заменён на @angular/build:application (на esbuild). В project.json каждого приложения:
// было
"executor": "@angular-devkit/build-angular:browser",
"options": { "main": "src/main.ts", "buildOptimizer": true, "vendorChunk": true }
// стало
"executor": "@angular/build:application",
"options": { "browser": "src/main.ts" }Часть опций просто исчезла (buildOptimizer, vendorChunk, namedChunks) — новому сборщику они не нужны. Изменилась и структура результата: файлы теперь лежат в dist/<app>/browser/. Если деплой копирует dist/<app>/ — на сервер уедет лишняя папка. Мы это поймали уже в CI.
Новый сборщик заметно быстрее. Но он строже к порядку модулей — и это приводит к следующему пункту.
Грабли: кольца импортов
После обновления часть страниц начала падать в рантайме с ошибкой NG0919 («Cannot read @Component metadata»). Сборка проходила.
Причина — циклический импорт. Пример:
// list.page.ts
import { EditDialog } from './edit.dialog'; // страница открывает диалог
// edit.dialog.ts
import { ListPage } from './list.page'; // диалог берёт тип у страницы
Модули в кольце загружаются в непредсказуемом порядке. Когда Angular читает imports: [EditDialog], класс EditDialog может быть ещё undefined. Старый сборщик случайно грузил их в «удачном» порядке. Новый — нет.
Как разрывать кольцо, от лучшего к худшему:
- Вынести общее в третий файл. Константы, токены, интерфейсы — в
*.constants.tsили*.interface.ts, от которого зависят оба. - Импорт только типа. Если нужен лишь тип,
import type { ListPage } from './list.page'. TypeScript удаляет такой импорт при компиляции, и в рантайме кольца нет. - Ослабить тип. Например,
MatDialogRef<any>вместоMatDialogRef<EditDialog>. Работает, но теряет проверку типов.
Честно: в нескольких местах мы сделали третий вариант, чтобы быстрее выпустить обновление. Это долг, и он записан. Правильно — второй.
А чтобы кольца не появлялись между библиотеками, нужны жёсткие границы: зависимости только в одну сторону, с проверкой линтером.
Грабли: тосты под диалогами
После обновления Angular CDK до 22 всплывающие уведомления (тосты) стали появляться под модальными окнами.
Причина: CDK 22 по умолчанию рендерит оверлеи (диалоги, выпадающие списки, тултипы) через Popover API — в так называемом top layer браузера. Top layer рисуется поверх всего документа, и z-index на него не влияет. Тост с z-index: 1001 в обычном документе оказывается под диалогом в top layer, как бы ты ни поднимал число.
Возвращаем старое поведение одним провайдером:
import { OVERLAY_DEFAULT_CONFIG } from '@angular/cdk/overlay';
providers: [
{ provide: OVERLAY_DEFAULT_CONFIG, useValue: { usePopover: false } },
]Урок: читай changelog не только Angular, но и CDK и Material. Они обновляются вместе, а меняют поведение отдельно.
Ступень 21: zone.js и HttpClient
zone.js — библиотека, которая перехватывает все асинхронные события (клики, таймеры, HTTP) и говорит Angular: «что-то произошло, проверь экран». Angular уходит от неё к сигналам, и новые приложения создаются без zone.js.
Для существующего приложения миграция при обновлении добавила явный провайдер, чтобы ничего не изменилось:
bootstrapApplication(AppComponent, {
...appConfig,
providers: [provideZoneChangeDetection(), ...appConfig.providers],
});Это правильное поведение миграции: обновление не должно менять поведение незаметно. Переход на zoneless — отдельная задача, с отдельным тестированием.
Так же миграция добавила withXhr() в provideHttpClient(...), чтобы HTTP-клиент остался на XMLHttpRequest. Если у тебя есть загрузка файлов с прогрессом — проверь её после обновления.
После обновления: доводка
Обновление версии — не конец. Новый Angular предлагает новые способы писать код, и стоит постепенно переходить на них, иначе через пару лет снова будет «старый код»:
inject()вместо внедрения через конструктор;- сигналы (
signal,computed) для состояния компонента; @if/@forвместо*ngIf/*ngFor;takeUntilDestroyed()для подписок.
Мы делали переход на inject() отдельной задачей: один коммит на одну библиотеку. Так ревью остаётся маленьким, а если что-то сломалось — понятно где. Для большей части есть официальные автоматические миграции (ng generate @angular/core:inject и аналоги) — запускай их, а не переписывай руками.
Чек-лист
- Одна мажорная версия за раз, отдельный коммит.
ng update/nx migrate, а не ручная правкаpackage.json.- После каждой: сборка, тесты, приложение глазами.
- Перед стартом — список зависимостей: какие не поддерживают целевую версию.
- Changelog Angular и CDK/Material.
- Новые ошибки рантайма (NG0919 и т. п.) — сначала искать кольца импортов.
- Не стоять на одной версии полтора года: два обновления в год дешевле, чем одно раз в три года.
Связанные посты: nx-monorepo-from-copies, tailwind-v3-to-v4-monorepo, ci-nx-affected.