项目排期终于给到了 Angular V21 升级,说实话接到这个任务的第一反应不是兴奋,是心虚。这个系列写到第十八期,眼看着 Angular 从模块化一路走向信号化,每次升级都像一次外科手术,V21 这刀要切在哪里,如果心里没数,光靠一路ng update回车,大概率要跪。这篇我不聊 PPT 上的新特性,只讲从 V20 往 V21 搬家的实操思路、踩过的坑、以及出现问题时的排查路径。适合手头正在做迁移预算、又不想把生产环境搞得鸡飞狗跳的开发者。
1. 为什么值得升级:V21 的关键变化
1.1 升级不是追新,是解决旧债
很多团队对升级的第一反应是“能跑就不动”。但说句实在话,Angular 的版本迭代节奏这些年已经非常稳定,每个大版本不是给你加几个 API 完事,而是在做技术债务的清理。V21 这个版本最明显的信号是:旧的响应式体系逐渐退场,信号(Signal)成为状态管理的主干;模板控制流语法全面接管*ngIf、*ngFor;构建管线进一步精简,开发服务器和构建缓存都有实质性的性能提升。
我这次升级的项目是公司内部的某跨平台后台系统,模块数量接近两百,模板中旧结构指令的用量非常大,直接依赖的第三方库有二十多个。之所以选在这个时间点升级,是因为项目里积压了两个久拖未决的性能问题:一个是列表页大数据量渲染卡顿,另一个是状态变更时变更检测的无效刷新太多。V21 的信号与按需派发机制,加上新的控制流语法对可变动检查的友好度,正好能解决这两类问题。升级不是给自己找事,是在用短期切换成本换后续两年的维护舒适度。
1.2 破坏性变更清单先行
升级前必须把变更清单拉出来,不然后面每一步都是雷。V21 相对 V20 的破坏性变更大致可以归成四类:第一类是废弃 API 的移除,尤其是@angular/common里一些长期标废弃的管道和指令,会在编译期直接报错;第二类是控制流语法的默认化,旧模板如果还没迁移,会收到大量弃用告警,某些严格模式下直接失败;第三类是 TypeScript 版本基线提高,类型定义更严格,之前能蒙混过关的any写法开始报错;第四类是运行环境要求变化,Node.js 版本低于某个阈值的会直接无法启动构建。
我的建议是别凭记忆猜,直接在官方 changelog 里拉出 V20 到 V21 的 breaking changes 清单,然后逐条对照自己的项目代码做标记。这一步很枯燥,但能省掉后面一半的定位时间。实际操作时可以用一个简单的表格,把每条变更对应到项目里的模块,标记影响面是“核心代码”还是“测试代码”。目的是让团队心里有数,哪些改动是要单独排期的,哪些可以顺手改完。
2. 升级前的准备与依赖体检
2.1 版本兼容性矩阵与依赖盘点
在敲任何命令之前,先把环境版本理清楚。Angular 的依赖树很敏感,@angular/core、@angular/common、@angular/compiler这些核心包必须同版本,哪怕差一个 patch,生产构建也可能出稀奇古怪的报错。
我通常会维护一份版本兼容性清单,列在这个项目里。V21 升级的基础要求,我用表格整理给大家参考(这是基于本次实操环境的经验值,具体以官方文档为准):
| 项目 | 最低要求 | 推荐配置 |
|---|---|---|
| Node.js | 20.x 以上 | 22.x LTS |
| TypeScript | 5.6 以上 | 5.8 左右 |
| RxJS | 7.8 以上 | 7.8.x |
| Zone.js | 0.14.x 或迁移到 zoneless | 按实际路由复杂度决定 |
| Angular CLI | 21.x | 最新 patch |
依赖盘点这一步很容易被忽视,但它往往是升级失败的“头号种子”。我这次打开package.json逐个检查所有@angular/*依赖,确保没有用latest这种浮动版本,因为浮动版本在升级瞬间可能把你带到不同的 minor 版本上,锁定你的环境版本能保证升级可控。然后用npm ls @angular/core检查是否有重复安装的情况,如果出现多个版本嵌套,先通过overrides或升级中间层依赖来收敛,否则后续升级的报错信息会非常混乱。
2.2 用官方迁移工具做预检
Angular 官方提供的升级工具链是ng update,但在执行之前,我强烈建议先跑一次ng update @angular/core --from 20 --to 21 --dry-run。这个命令不会真正改动文件,只会输出一份迁移影响报告,展示哪些包会被更新、哪些源文件会被改写、哪些命令会被执行。这是整场升级里性价比最高的一次“预演”。
我这次跑完 dry-run 的输出让我心里踏实了不少,迁移器会自动处理模板中旧控制流指令到新语法的转换,但同时也提示有些自定义指令和管道需要手动检查。还有一个重要信息是,V21 的迁移工具会把providedIn的写法统一规范化,某些测试文件的注入方式也要跟着变。拿到这份报告后,不要急着真正更新,先在版本控制里建一个备份分支,确认当前工作区是干净的,再继续下一步。
2.3 锁文件与分支策略
升级前的重要性排序里,锁文件一定要排在前列。使用npm的话就是package-lock.json,使用yarn就是yarn.lock,这些文件记录了当前依赖树的确切状态。我见过不少项目升级失败后想回滚,结果发现锁文件已经跟着乱了,回滚之后依赖树依旧残缺。
我的做法是:先把锁文件哈希记录下来,或者直接在版本控制里打一个升级前 tag。然后新建一个专门的分支,比如chore/upgrade-ng21,所有升级改动都在这条分支上完成。好处是升级过程会被完整保留,即使中途想放弃,也可以干净地切回主分支。一旦真开始ng update,锁文件会被自动重写,这是正常现象,不要慌,只要你的原始分支在,就随时有后悔药。
3. 从 V20 到 V21 的分步实操
3.1 分步执行 ng update 命令
执行ng update不是一条命令一键到底,正确的姿势是分步走。先更新全局 Angular CLI,再更新项目里的 CLI 相关包,然后依次更新核心包、工具链包、相关生态包。
我在这次升级里按下面这个顺序执行,每一步之间都停下来跑一次构建或者测试:
# 全局 CLI 更新(如果有全局依赖) npm install -g @angular/cli@21 # 项目本地 CLI 更新 ng update @angular/cli@21 --from 20 --to 21 --migrate-only # 核心框架更新 ng update @angular/core@21 --from 20 --to 21 # 其他相关包 ng update @angular/material@21 @angular/cdk@21为什么要有这个顺序?因为 Angular CLI 是升级过程的执行引擎,引擎不先动,后面的迁移器跑不起来。核心包更新的时候,会提示是否运行代码迁移器,这里建议选择y,比如模板控制流迁移、独立组件迁移,它们能自动处理大部分机械性的重复修改。
我遇到过有人直接执行ng update @angular/core@21,结果 CLI 还是旧的,迁移器无法驱动,报错说“需要先更新 @angular/cli”。这种问题本质上就是顺序错了。升级讲的是依赖链:CLI 依赖 compiler,compiler 依赖 core,core 依赖 TypeScript,所以每一步之后都要用npm ls和ng version确认版本状态没跑偏。
3.2 处理破坏性变更的典型场景
升级过程中最耗时间的就是破坏性变更。我这里举两个这次项目里处理得比较典型的场景,可以给大家参考。
第一个是模板控制流迁移。虽然迁移器能自动把*ngIf改为@if,把*ngFor改为@for,但还是有漏网之鱼。例如*ngFor里用了index变量,迁移器可能把它变成普通变量,但如果你在循环里嵌套了*ngIf,这层关系可能需要人工调整。迁移后的代码大致长这样:
<!-- 迁移前 --> <div *ngFor="let item of items; let i = index; trackBy: trackByFn"> <span *ngIf="item.visible">{{ i }}: {{ item.name }}</span> </div> <!-- 迁移后 --> @for (item of items; track item.id) { @if (item.visible) { <span>{{ $index }}: {{ item.name }}</span> } }注意trackBy的处理。新语法里track直接接受表达式,不用再写完整的函数名,但如果你的trackBy函数里有关键逻辑,需要迁移器帮它转换成track item.id之类的形式,转换后一定要复查一遍,因为如果你的业务需要按复合字段跟踪,简单转换可能会丢掉逻辑。
第二个是模块导入路径的变化。V21 对公共 API 做了收敛,不少深层路径的导入被收进主入口。升级后构建报错,说找不到@angular/common/http/testing的某个导出,这种一般是路径改了。我的排查方式是查官方 API 列表,把旧的深层路径替换成新的主入口导入。这类改动虽然机械,但涉及面广,适合让迁移器配合搜索替换一起处理,然后靠 TypeScript 编译错误一项项消灭。
3.3 信号状态的渐进式接入
升级到 V21 不等于要把所有状态管理都改成信号模式,但如果你想让升级后的性能收益落到实处,信号化是绕不开的。我这次的做法不是重写,而是渐进式接入:先挑一个高频更新、旧变更检测压力最大的列表页来试点,把原来的BehaviorSubject状态替换为signal和computed。
下面是一个简化的对比,左侧是旧写法,右侧是信号写法:
// 旧写法 private itemsSubject = new BehaviorSubject<Item[]>([]); readonly items$ = this.itemsSubject.asObservable(); loadItems() { this.http.get<Item[]>('/api/items') .subscribe(data => this.itemsSubject.next(data)); }// 信号写法 private itemsSignal = signal<Item[]>([]); readonly items = itemsSignal.asReadonly(); loadItems() { this.http.get<Item[]>('/api/items') .subscribe(data => itemsSignal.set(data)); }模板中旧写法要配合async管道,信号写法直接就可以在模板中读取。这里的关键点是,如果组件里还有别的响应式依赖,不要立刻把async管道删掉,先让信号和旧响应式共存,观察变更检测和渲染时间的变化,确认稳定之后再逐步切换。我见过激进迁移的团队,一夜之间把几十个组件改成信号,结果下拉刷新、表单联动各种隐性 bug 一起爆发。信号化是长期工程,升级当天能切一两个核心场景,已经够让团队体验到收益了。
4. 升级过程中的常见问题排查实录
4.1 构建阶段的两类高频报错
每次大版本升级,构建报错都是最多的问题。第一类是 TypeScript 类型错误,特别是一些第三方库的类型定义没有跟上 V21 的类型收紧,报错信息往往显示在node_modules内部,看似与你无关,实际是你的代码引用了被移除的导出。解决方式不是去改第三方库,而是在你的业务代码里添加类型收窄或者显式类型标注。
第二类是编译器模板错误,常见的是@for循环里要求必须指定track表达式,旧代码里如果之前没有trackBy,迁移器可能会生成一个简单的track $index或track item。如果元素是基本类型,比如字符串数组,直接track $index就够了;如果是对象数组,建议直接用唯一标识字段,否则列表变更时可能出现渲染异常。有时候这个错误不会立刻爆,而是在某些操作后列表排序乱了,后台检索数据时才发现。
4.2 运行时白屏与路由问题
升级后如果页面直接白屏,优先检查浏览器控制台和网络面板。我这次遇到的白屏是因为懒加载路由的模块拆分路径变了,V21 默认的 chunk 命名规则和输出目录有调整,导致生产环境中旧的 chunk 文件名被缓存,构建后部署到服务器上,浏览器还在请求旧资源名。解决方式很简单:服务器配置里关闭对*.js的强缓存,或者部署时清一次 CDN 缓存。开发环境下如果白屏,那多半是 Zone.js 被移除或替换导致的事件触发异常。
V21 把 zoneless 模式进一步前置,如果你在升级前已经启用了provideZonelessChangeDetection,升级后要检查所有第三方库对 Zone 的隐性依赖。判断方法是在控制台搜索NgZone相关的告警,如果第三方组件库还在内部调用NgZone.onMicrotaskEmpty,就需要用兼容模式过渡,或者升级该组件库的版本。
4.3 第三方库版本冲突
在升级第三方库时,最容易出现的是 peer dependency 冲突。比如某个图表库声明支持@angular/core ^20.0.0,而你升级到了 21,npm install会直接报错。这时候不要急着加--force或--legacy-peer-deps强闯,先查这个库是否有适配 V21 的版本,如果有,直接更新库本身;如果没有,就要评估代码里对它的依赖有多深。
我这次遇到一个老旧的工具库,作者已经停止维护,peer dependency 还锁在 Angular 20。最终方案是把库的代码用本地封装的方式抽进来,移除对node_modules中该库的依赖,然后用 V21 重新编译。这个过程比较费精力,但比强闯依赖关系、在运行时产生隐蔽 bug 要安全得多。如果你的项目里有类似的长期不维护库,建议在升级前就列一个“黑名单”,优先处理。
4.4 包体积与性能回退
升级后如果发现首屏包体积不降反升,先别慌。V21 在默认配置里增加了更细粒度的构建缓存,第一次构建会比以前慢一些,这是缓存冷启动,第二次构建就会恢复正常。但如果你对比过构建产物,确实发现 main bundle 变大,通常是新的控制流语法在编译时使用了额外的模板运行时,或者是独立组件迁移把原本共享的模块代码拆进了各自的路由块里。
我的排查工具是source-map-explorer,直接看产物体积分布。如果发现某个共享模块被多个懒加载块重复打包,检查@angular/compiler的配置和optimization选项,必要时使用inject配合路由懒加载的配置,把重复的代码抽到公共 chunk。V21 对独立组件的支持更完善,理论上经过合理的模块拆分,包体会比 V20 更小。如果你反而变大了,多半是迁移器把某些组件标记为独立,但依赖没有正确共享,这种问题需要结合路由代码逐一确认。
5. 升级后的收尾与维护建议
5.1 上线前的回归清单
升级完成、构建通过,不代表事情结束了。在我这次的经验里,真正的问题往往在回归测试阶段暴露。我为自己总结了一个上线前回归清单,分享给大家参考:
- 核心业务链路:登录、授权、路由守卫、超时跳转全流程走一遍。
- 列表页:重点测大数据量分页、排序、过滤,观察是否出现卡顿或数据错乱。
- 表单页:双向绑定、校验提示、异步校验,确认响应式表单的事件触发正常。
- 懒加载模块:逐个访问对应路由,确认 chunk 加载正常,没有 404。
- 错误上报:确认全局错误处理器还能捕获到模板错误和异步错误。
回归时用 Chrome DevTools 的 Performance 面板记录几个关键页面的渲染时长,和升级前的同场景数据对比。如果某个页面的性能明显下降,优先看是不是变更检测范围变大了,V21 下可以使用信号来缩小更新范围,但前提是你真的切了信号模式。
5.2 我踩过的几个坑
最后分享几个这次升级中我亲自踩过、差点把我劝退的坑。
第一个是ng update中途终端断网导致依赖半更新状态。修复方式是把node_modules整个删掉,重新用锁文件安装,再继续升级命令。所以升级前一定要确认网络稳定,最好用本地缓存仓库。
第二个是迁移器把模板里*ngIf改成@if后,原来用ng-template包裹的else分支会被重构成@else,但如果原来的else模板里又引用了外层循环变量,重构后作用域可能不对,需要手动调整。这个问题不报错,但跑起来会出现未定义变量,非常隐蔽。
第三个是测试代码里的fakeAsync与tick。V21 由于变更检测调度的调整,依赖fakeAsync的时序测试可能会失败,需要把tick次数或异步处理方式重新校准。如果你的项目测试量很大,这部分我建议放在升级后的第二天再做,因为核心功能回归比测试修复优先级更高。
Angular V21 升级不是一次性的命令执行,而是一次对项目代码健康度的全面体检。你会在这个过程中发现很多以前被隐藏的坏味道,也会逐步理解 Angular 官方为什么会做这样的设计演进。我个人在实际操作中的体会是:分步走、勤验证、少用蛮力强改,比什么都重要。升级之后回头再看,那些繁琐的迁移工作,其实是重新梳理项目结构的好机会。