最近刚把一个维护了三年的中后台管理平台从 Angular 18 一路升到了 V21,整个过程花了两周多一点。说实话,一开始我也觉得跨了三个大版本会很痛苦,但真正跑下来之后发现,只要把升级思路理清楚,绝大多数坑都是可以提前避开的。这篇帖子就把我实际操作的全过程、踩过的坑、以及我对 V21 的一些个人理解写下来,给后面准备升级的同学做个参考。
如果你也在 Angular 16 到 Angular 19 之间徘徊,或者正准备从老版本跳到 V21,那这篇内容会比较适合你。我不讲空泛的概念,只讲怎么做、为什么这么做、以及哪些地方容易翻车。
1. 为什么要跨三大版本硬升 V21:我的选择理由
1.1 旧版本留下的三个长期痛点
我接手那个项目的时候,它停在 Angular 18。说实话 Angular 18 本身不算老,完全还可以继续跑,但对于一个持续迭代的工程来说,停留在老版本会慢慢积累三类问题。
第一是依赖生态的跟进越来越吃力。我们项目里用了不少第三方组件库和工具库,这些库的维护版本逐渐放弃对 Angular 18 的兼容声明。虽然跑起来大概率没问题,但每次升级一个小版本都要提心吊胆,生怕某个间接依赖偷偷把 Angular 版本范围给改了。
第二是构建速度和使用体验开始落后。老版本默认的构建器在处理大型模块时明显比新版本慢,尤其是冷启动和执行增量构建的时候。我们项目光业务模块就上百个,开发机上每次改完代码等待编译的时间越来越长,已经影响到每天提交代码的节奏。
第三是新特性带来的开发效率差异太大。像信号(signal)、新的控制流 @if / @for、可延迟视图等特性,在 Angular 18 里已经开始引入,但很多核心 API 到了 V21 才真正确立下来。我评估了一下,如果现在不升级,再过半年再升,代码里的历史债只会更多。
1.2 V21 实际值得迁移的几个关键变化
V21 我重点关注了四个方面的变化,它们直接决定了我愿不愿意付出升级成本。
第一个是信号作为变更检测核心的进一步深化。V21 里很多原先基于 Zone.js 的触发机制被信号替代,尤其是在组件内部状态管理上,可以做到更细粒度的更新。这意味着某些高频更新的界面不再需要整棵视图树从头检查,我实测下来在表格和图表密集的页面上帧率有明显改观。
第二个是模板语法的收口。新控制流语法从 V17 开始引入,到 V20 时老语法虽然还保留,但官方在很多示例里已经不再展示。V21 版本中,像 ngIf、ngFor 这类老指令在类型推导和编辑器提示上已经明显不如新语法。升级到 V21 之后,我彻底清除掉旧控制流代码,整个模板层干净了很多。
第三个是构建管线的重新整合。V21 对 esbuild 和 Vite 的集成比 V18 时代成熟得多。生产构建的压缩率、CSS 抽取策略、以及开发服务器的模块热替换(HMR)速度都上了一个台阶。后面我会专门对比一组构建数据。
第四个是长期支持的承诺更清晰。Angular 从某个版本开始把支持期拉长了,V21 作为当前主版本,能在未来一年内持续获得安全和功能更新。对于企业级项目来说,这意味着升级后可以有一段时间不用再频繁考虑大版本跳跃。
1.3 但是:不是所有项目都适合立刻升级
我要先说个实在话。如果你的项目只有几个页面,没有复杂的依赖,也没有遇到性能瓶颈,那升级 V21 并不急。Angular 的升级成本是和项目的模块数、自定义指令数、以及第三方依赖的复杂度强相关的。
我看到的很多升级失败案例,都不是因为 Angular 本身的 API 变化,而是因为某些核心依赖不兼容,导致应用直接跑不起来。所以如果你目前稳得很,建议先列一个依赖清单,把每个依赖对 Angular 版本的范围要求查清楚,再决定是否启动升级。后面我会讲怎么查。
2. 升级前的准备工作:把依赖边界摸清楚
2.1 版本基线的硬性要求
升级前第一步不是改代码,而是确认本机工具链。我整理了一份基线版本表,是我这次升级过程中实际使用的环境,不一定是最新,但足够稳定。
| 工具 | 升级前版本 | 升级后版本 | 备注 |
|---|---|---|---|
| Node.js | 18.x | 20.x | V21 要求 Node 20 以上,低于这个版本装依赖必失败 |
| npm | 9.x | 10.x | 使用 npm 10 配合 lockfile 处理更稳 |
| Angular CLI | 18.x | 21.x | 必须先升 CLI,再动业务代码 |
| TypeScript | 5.2 | 5.8 | 官方要求根据目标版本匹配,不可贪高 |
| RxJS | 7.8 | 7.8 | RxJS 7 是兼容的,暂不需要升 8 |
这里要注意,TypeScript 版本不是越高越好,得看 Angular 官方对 TS 的版本支持范围。V21 会锁定某个 TS 主版本区间,你要是直接用最新的 TS 6.x,会出现类型定义不匹配的问题。我建议以官方文档的兼容表为准,别自己去试最新版。
2.2 依赖清单的遍历方法
我们需要把项目里所有带 Angular 关联的包列出来,逐个核对它们对版本的声明。我一般用两步来遍历。
第一步是读 package.json,把 dependencies 和 devDependencies 里所有以 @angular 开头,以及我们已知的第三方组件库、通用工具库逐一列出。这里容易漏的是那些不直接以 @angular 开头但有 peerDependencies 引用的库,比如某些图表库、拖拽库、表单增强库。
第二步是使用 npm 提供的依赖检查能力。执行npm ls @angular/core可以看当前树里的版本冲突;也可以借助工具扫描 peerDependencies 的 range。实际操作中我发现,直接从 node_modules 里翻包里的 package.json 更直观,虽然笨,但最准确。
我整理了一个简单的核对表模板,你们升级的时候可以参考:
| 包名 | 当前版本 | 是否 peerDep Angular | 声明版本范围 | 兼容 V21? | | --- | --- | --- | --- | --- | | @angular/core | 18.x | - | - | 需升级 | | 某表格组件库 | 13.x | 是 | ^18.0.0 | 需升级/替换 | | 某工具库 | 3.2.1 | 是 | >=16.0.0 <19.0.0 | 不兼容,需找替代 |把不兼容的库标红,升级前要决定是升级库本身,还是换一个类库,或者是用 AST 工具把用到该库的地方整体改写。这一步是最耗时间的,但也最值得花时间。
2.3 官方迁移工具的真实使用体验
Angular 官方提供了一套自动化升级工具,通过ng update命令来走。我实际用下来的感受是:它能解决 70% 的机械性问题,但剩下的 30% 必须靠人。
执行的顺序很重要,必须按照主版本一层一层来,即从 18 升到 19,再到 20,最后到 21。如果你在 CLI 里直接运行ng update @angular/core@21想跨越多个版本,工具会报错或者直接跳过很多中间转换。我一开始想偷懒,直接指定目标版本运行,结果它提示需要先升级到中间版本,所以老老实实分步走。
每一步ng update会做的事情大致是:
- 更新所有 @angular/* 包到目标版本
- 更新 TypeScript 版本范围
- 自动重写一部分模板和 TS 代码
- 输出一份变更说明,告诉你哪些问题需要手动处理
比较有意思的是,V20 到 V21 这步的自动迁移比我预期的少,更多是清理一些 V18 时代遗留下来的旧装饰器写法。V18 到 V19 那步反而是重头,因为涉及很多 Zone.js 相关代码的替换。
3. 升级过程中最容易翻车的五个环节
3.1 从老式依赖注入到新的 inject 函数式写法
Angular 一直在推函数式 inject,到 V21 阶段,构造器里通过constructor(private service: MyService) {}声明依赖的方式虽然还能用,但很多库代码已经改成readonly service = inject(MyService)。你可能会问,这不就是写法不同吗?实际影响在于两者在类型推断、继承时的处理、以及测试时的 mock 方式都不一样。
我升级时遇到的具体问题是,有一个基类组件通过构造器注入了多个服务,子类继承时又通过super()手动传递。到了 V21,构造器注入的时序和 inject 函数执行顺序在某些场景下会出现差异,导致子类在调用super()之前访问了未初始化的服务。修复方式很简单:把基类的依赖全部改为字段声明加inject(),而不是在构造器里取。
排查这类问题有一个技巧:编译时如果出现类似Cannot read properties of undefined,而且错误发生在组件初始化阶段,优先怀疑注入时序问题,而不是业务逻辑问题。
3.2 信号模式对 Zone.js 的冲击比想象中更大
V21 最大的架构变化是很多默认行为从 Zone.js 转向信号。这听起来是性能优化,但对应用代码的冲击在于:以前写在 NgZone 里的逻辑、以及依赖自动变更检测的异步操作,现在很多不再被自动追踪。
比如我们项目里有一个通过 WebSocket 推送消息的模块,旧版只要用this.zone.run()包一层更新就可以,但升级后由于组件树的变更检测策略已经变了,某些页面收不到消息刷新。最后排查下来,是因为代码里在NgZone.run()里手动调用了信号更新,结果两套机制互相干扰。
正确的做法是:如果用信号管理状态,就完全绕开 NgZone,直接在回调里state.set(value)即可;如果你还在用 RxJS Subject 推数据,确保订阅更新链路里没有同时依赖两套机制。V21 里这种"混合模式"是坑最多的地方,要么全信号,要么全 Zone,尽量不要掺着用。
另外,NgZone本身的 API 还在,但已经标记为 legacy。我建议逐步清理所有对NgZone.run()的显式调用,把状态更新全部收敛到信号或者 RxJS 的async管道中。
3.3 表单相关 API 的调整
我们项目大量使用响应式表单,V21 里表单相关的类型定义和部分方法有了变化。
比较明显的是FormControl在类型收窄上变得更严格。旧版允许你把FormControl直接赋值给一个期望string类型的表单值,新版本会在泛型推导上更严格,导致大量编译错误。解决办法是把表单控件的泛型参数写清楚,比如new FormControl<string | null>(null),尽量不要省略泛型。
另一个适配点是表单校验器的类型签名变更。某些自定义校验器返回类型从ValidationErrors | null变成了更具体的联合类型。我遇到的情况是,因为函数返回值类型不匹配,所有自定义校验器全被编译器标红。这个没有捷径,只能逐个更新函数签名。
ControlValueAccessor这个接口在 V21 里也有微调,主要是setDisabledState的方法签名中参数类型更加严格。如果你们有封装自定义表单控件,升级时要重点关注这部分。
3.4 模板老控制流与新控制流的混用问题
我在升级过程中没有一次性删除所有ngIf和ngFor,而是采取了逐步替换的方式。问题来了,在 V21 里,当你新建一个组件的时候,CLI 默认生成的是@if/@for语法,但老组件里还留着ngIf,两边混用的时候,编辑器有时会给出错误的语法建议。
更麻烦的是,某些模板片段里同时使用*ngIf和@if时,作用域内变量的类型推断会变得不可预测。比如用@if (user$ | async; as user)时,user的类型是明确的;但换成*ngIf="user$ | async as user"时,在某些复杂表达式里会出现类型丢失。
我的策略是一口气清除所有旧控制流指令,不留混用状态。Angular 官方提供了迁移器,但迁移器在表达式嵌套特别深的时候容易漏改,需要自己复查。
3.5 构建器配置项的移除与替换
V21 的构建器在angular.json里的配置项比 V18 少了很多。原先很多应用自己在配置optimization、buildOptimizer或者vendorChunk的地方,新版直接默认最优,配置项反而变得不可用。
我遇到的问题是,项目里设置了vendorChunk: true来拆分第三方库,但升级后这个配置被构建器忽略了,并且开发服务器启动时报了一个未知配置项的警告。后来我把这些废弃项全部删除,改用 V21 推荐的方式:通过injector和outputHashing等新字段来控制产物。
如果你在升级后发现构建行为变化,不要急着加配置去还原旧行为,先看官方文档里的 breaking changes 列表,再决定是否真的需要新配置。很多时候旧配置本身就是拖慢性能的原因。
4. 一次真实升级案例的全流程记录
4.1 分支与回退策略
升级这件事最忌讳直接在主分支上做。我的习惯是开一个独立的chore/upgrade-angular-v21分支,并且从开始每一步都打 tag。这样即使某一步失败,也能快速回到上一个可用状态。
具体来说,我从基础分支切出一个功能分支,然后在这个分支上按版本顺序提交:
# 1. 先升级 CLI npx @angular/cli@21 update @angular/core@19 @angular/cli@19 # 2. 升级到 20 npx @angular/cli@21 update @angular/core@20 @angular/cli@20 # 3. 升级到 21 npx @angular/cli@21 update @angular/core@21 @angular/cli@21每一步之后,我都执行一次完整构建和简单跑测,确认通过后再提交。如果某一步出现大量错误且短时间内无法处理,我会git revert到该步骤之前,避免带着错误继续往下走。
4.2 逐模块迁移的推进方式
当自动更新完成后,编译运行时通常会冒出一大片错误。我见过有人试图一次性把所有错误修完,结果越修越乱。我的做法是先把应用跑起来,看看是全局错误还是局部错误,然后按照依赖的顶层到底层顺序去改。
比如先处理app.module.ts、路由配置、全局服务等核心文件,再处理公共组件库,最后处理具体业务页面。这有点像拆炸弹,优先拆影响面的那根线。实际操作中,我把代码分成了四批:
第一批是全局基础设施:入口模块、路由守卫、全局拦截器、HTTP 服务。 第二批是公共组件:表格封装、弹窗封装、表单控件。 第三批是核心业务模块:用户管理、权限管理、数据台账。 第四批是辅助页面:设置、帮助、统计报表。
每一批改完,都启动一次开发服务器验证。用这种切片的方式,错误范围会越缩越小,不会出现到最后都不知道错误是从哪冒出来的情况。
4.3 我遇到的三个印象深刻的修复细节
第一个是和第三方表格库的兼容问题。那个库版本比较旧,直接声明不支持 Angular 20 以上,但它只在模板层面用了老语法,本质上并没有调用任何被删除的 API。我试过在很多场景里会出现编译警告,但没有阻断运行。后来我找到了这个库的 fork 版本,它把 peerDependencies 范围放宽了,换装后问题消失。如果你们也遇到类似情况,可以先确认库是否真的有实质性调用,而不仅仅是声明文件不兼容。
第二个是路由懒加载在 V21 里的行为变化。旧版通过loadChildren: () => import('./module').then(m => m.Module)可以正常工作,V21 里推荐直接写成loadChildren: () => import('./module'),因为默认导出的模块已经被识别。我把项目里所有手动.then都去掉了,同时发现路由本身的预加载策略也更智能了。
第三个是ngc的增量编译导致类型错误不报的问题。升级后,第一次编译正常,第二次修改代码后错误突然出现,后来发现是 V21 的缓存机制和旧的tsconfig.tsbuildinfo冲突。清掉node_modules/.cache和tsconfig.tsbuildinfo后,问题就消失了。这个坑很隐蔽,我建议在任何升级后都先把缓存目录干净地删一次。
5. V21 环境下的构建优化与性能实测
5.1 构建配置对比
升级前后我对同一套业务代码分别做了生产构建,用同一台机器、同样的网络条件,数据如下:
| 指标 | Angular 18 | Angular V21 | 变化幅度 |
|---|---|---|---|
| 首次冷构建耗时 | 3分25秒 | 1分58秒 | 下降约 43% |
| 增量构建耗时 | 40秒 | 12秒 | 下降约 70% |
| 主包体积(gzip) | 486 KB | 398 KB | 下降约 18% |
| 首屏 LCP(本地模拟) | 2.8s | 2.1s | 下降约 25% |
这个数据其实很说明问题。V21 默认开启了更激进的 tree shaking 和事件内置微优化。我没有改任何业务代码,单靠构建器切换就拿到了这些收益。
唯一要注意的是,升级后还需要检查一下自己的构建配置有没有覆盖掉新默认值。比如旧配置里为了兼容老浏览器,强制设置了较高target,这可能导致新语法被降级,失去优化效果。V21 对现代浏览器默认使用较新的 target,如果你不需要支持很老的浏览器,建议直接用默认。
5.2 包体积拆解与实际收益
我在升级后特意看了产物里每个 chunk 的占比。最大的变化是 Angular runtime 部分小了很多,原因是很多模块不再打包进主 bundle,而是通过可延迟块按需加载。
我习惯用source-map-explorer这类工具来检查包体积。V21 里组件级懒加载更自然,也就是说,只要在模板中使用了新语法@defer,相关组件产物就会自动切到独立 chunk,无需手动搞loadChildren。我们项目里一些帮助弹窗、异常日志面板、低频统计图表都换成了@defer后,首屏请求体积肉眼可见地下降。
5.3 开发服务器与热更新的体验
开发阶段的体验提升也很明显。Angular 18 时代,每改一次代码,整个页面经常白屏几秒钟,尤其改动公共组件的时候。V21 搭配 Vite 驱动的开发服务器,热更新变成了真正意义上的局部刷新,我改一个按钮组件,页面上只有那个按钮位置闪了一下,状态和数据都还在。
不过这也要付出一点代价。V21 的开发服务器默认对文件监听更敏感,有些目录(比如后端生成的接口文档目录)如果放在项目根目录下,文件变化会触发不必要的重新编译。解决办法是在angular.json的开发服务器配置里加入对某些目录的ignore规则,或者干脆把这类目录移出项目根。
6. 升级完成后的收尾检查表与长期维护建议
6.1 升级后的验证清单
构建通过只是开始,我建议在正式切到 V21 之前,逐项确认下面这些事情:
- 路由懒加载后的页面是否都能正常跳转,特别是深层链接刷新场景。
- 所有的自定义表单控件是否都能双向绑定,校验信息是否正确展示。
- WebSocket 或轮询消息能否触发表单更新,是否出现“状态变了但界面没变”的情况。
- 第三方库的弹窗、拖拽等交互是否和升级前表现一致。
- 国际化插件的切换是否正常,特别是翻译管道在信号环境下的触发机制。
- 生产环境构建后的 sourcemap 是否正常定位,方便线上排错。
不要只靠自动测试。升级过程中最容易遗漏的是那些“碰巧能跑但依赖旧行为”的代码,这类问题只有在特定的用户操作路径上才会暴露。
6.2 依赖治理的长期方案
升级完成之后,如果不做依赖治理,过半年又会重蹈覆辙。我用的方案是直接在 package.json 的overrides字段里锁定关键依赖的 peerDep 范围,防止某个嵌套依赖把 Angular 版本悄悄升级到不兼容的区间。
另外建议设置自动化检查。我们每次提交代码后,跑一条脚本读取所有第三方库的peerDependencies,凡是声明不支持当前 Angular 版本的一律列出警告。这样能第一时间发现哪些库是潜在的雷。
6.3 我自己的踩坑速查表
最后给一份我这次升级过程中整理的问题对照表,按类型排了序,方便遇到类似问题时快速查阅。
| 现象 | 根因方向 | 处理建议 |
|---|---|---|
| 编译报 Cannot find namespace | TypeScript 版本区间不对 | 调整 TS 版本到官方兼容范围 |
| 运行时报 Injector 报错 | 构造器注入与 inject 混用 | 统一改为字段 + inject() |
| 页面无响应但无报错 | Zone.js 与信号变更检测冲突 | 去掉 NgZone.run,改用信号 set |
| 模板里变量未定义 | 老控制流作用域丢失 | 重写为新控制流语法 |
| 构建时报警告 Unknown option | angular.json 包含废弃配置 | 删除旧配置项,改用默认值 |
| 升级后热更新频繁失效 | 缓存文件冲突 | 清除 node_modules/.cache 和 tsbuildinfo |
| 表单校验不触发 | 自定义校验器签名不兼容 | 更新校验器函数返回类型 |
| 第三方库报版本冲突 | peerDependencies 范围窄 | 升级库/换 fork/加 overrides 锁定 |
说实话,Angular V21 这次升级,我最大的体会是:架构层面的东西没有伤筋动骨,真正磨人的全是细节适配。只要前期把依赖调研做扎实、按版本顺序一步步走、过程中用分支和切片的方式控制风险,绝大多数人都能在一个排期内完成迁移。最后顺便提一句,升级完后记得把项目里的ng update日志和变更记录留档,后面再维护的时候,翻文档比翻聊天记录靠谱多了。