Angular CDK 与 Angular Material 的 ng-update 迁移原理与实战指南
【免费下载链接】componentsComponent infrastructure and Material Design components for Angular项目地址: https://gitcode.com/GitHub_Trending/co/components
ng-update schematic 是 Angular CDK(以及以其为基础的 Angular Material)提供的自动升级工具:当开发者运行ng update升级到新的主版本时,它能自动改写受破坏性变更(breaking changes)影响的 TypeScript、HTML 模板与样式文件,并对无法自动迁移的变更给出明确提示。本文以本仓库 src/cdk/schematics/ng-update/update-schematic.md 为主干,结合仓库源码深入讲解其多版本入口设计、update-tool迁移框架、升级数据(upgrade data)的组织方式,以及如何为新的破坏性变更添加迁移数据与测试用例。
一、ng-update 的整体架构
1.1 多个迁移入口点(Migration Entry-Points)
ng-updateschematic 由多个迁移入口点构成,每个入口点针对一个具体的 Angular CDK / Angular Material 主版本。根据原文档,当前仓库维护着如下目标版本入口:
| 目标版本 | 说明 |
|---|---|
| V6 | 从任意版本升级到 v6.0.0 |
| V7 | 从任意版本升级到 v7.0.0 |
| V8 | 从任意版本升级到 v8.0.0 |
| V9 | 从任意版本升级到 v9.0.0 |
原文档写作时以 V6~V9 为例;本仓库当前代码中,TargetVersion 枚举 已演进为只包含最新版本(如
V22),而 Angular Material 侧对应提供了 updateToV22 入口,并由createMigrationSchematicRule组装。历史的迁移规则仍按需保留,以保证“从任意旧版本升级”的链路完整。
1.2 迁移按顺序执行
关键设计点是:如果一次升级隐式跨越多个主版本,迁移会按版本顺序依次运行。
文档给出的典型场景:
假设应用正在使用 Angular Material v5.0.0,开发者执行
ng update,Angular CLI只安装最新的 V7,然后会按顺序运行 V6 与 V7 的迁移。
这意味着仓库中必须保留过去所有主版本的迁移代码:CLI 通常只安装最新版包,却期望旧版本对应的迁移全部在场。因此“所有历史迁移都保留在代码库中”是 ng-update 的硬性约束。
顺序执行还有一个重要原因——升级数据的隔离。文档举了一个非常典型的例子:
- 在 V6 中:
onChange被重命名为changed - 在 V7 中:
changed被重命名为onValueChange
如果迁移不按顺序执行、或升级数据不按版本隔离,那么一个从 5.0.0 只想升到 6.0.0 的用户,会错误地得到onValueChange(因为非隔离的数据里只记录了onChange => onValueChange这一条映射)。按版本隔离数据 + 按顺序执行迁移,才能让每个版本段的破坏性变更被正确处理。
1.3 升级概念(Update Concept)
ng-update 的目标是自动迁移受目标版本破坏性变更影响的代码。大多数破坏性变更可以通过升级数据自动改写,但也存在少量无法自动迁移的变更——此时的目标是明确通知开发者该破坏性变更需要人工关注。
这在源码中体现为MigrationFailure机制:基类 Migration 中维护了failures: MigrationFailure[],并通过createFailureAtNode(node, message)在指定节点位置记录失败信息(包含文件路径、行/列位置与消息),供上层汇总并报告给开发者。
二、update-tool:TypeScript 文件的转换框架
2.1 为什么放弃 tslint 自研 update-tool
为了自动迁移 TypeScript 源文件,项目利用了 TypeScript Compiler API 解析并操作项目源文件的 AST,并在此之上构建了一个名为update-tool的小型框架。之所以必须自研,是因为最初的ng update实现基于tslint,存在一系列严重问题:
- 不支持 HTML 模板与样式表,只能靠 workaround 处理;
- 文件更新后会重跑全部升级 lint 规则,对文件众多的项目性能损耗显著;
- 每次源文件更新都会重建 TypeScript program,对大型 TypeScript 项目造成严重内存压力,甚至引发 OOM;
- tslint 会为每条升级规则递归访问所有源文件的节点,性能差;
- CLI 项目中不保证安装 tslint(对应 angular-cli issue #14555);
- tslint 的 replacement 因保留 TypeScript 节点导致内存泄漏;
- lint 规则只能逐个访问源文件,无法进行全局分析(global analysis)阶段;
- 灵活性不足,例如无法保证源文件只被分析一次、无法实现进度条、难以扩展对 HTML 模板和样式表的支持。
2.2 update-tool 相比 tslint 的关键改进
文档列出了update-tool相比 tslint 的差异,结合源码可以逐条印证:
- 文件系统抽象化,迁移可编程化运行:迁移既能在 CLI 中运行,也能在 google3 内运行,还能脱离
ng update独立运行——对应 file-system.ts 与 devkit 适配层(devkit-file-system.ts、devkit-migration-rule.ts); - 原生支持 HTML 模板与样式表:
Migration基类提供了 visitTemplate / visitStylesheet 回调,配合 component-resource-collector.ts 收集组件资源; - 每个源文件只迁移一次:即使该文件属于多个 TypeScript 项目也不会重复处理;
- 每个 TypeScript 项目只创建一次 program,type checker 也只获取一次;
- 迁移失败不会保留
ts.Node实例,避免 tslint 常见的内存泄漏; - 替换操作在虚拟文件系统中进行(schematics 的最佳实践),通过 update-recorder.ts 实现;
- TypeScript program 只被递归访问一次;
- 完全灵活:例如可以实现进度条;
- 支持全局分析阶段:
Migration基类中的 init() 用于对 program 做全局分析,postAnalysis() 在所有节点、模板和样式表访问完毕后调用——这是 tslint 无法实现的。
2.3 备选方案及其评估
文档也讨论了其他 TypeScript 转换思路及其结论:
| 方案 | 评估结论 |
|---|---|
| 正则表达式(Regular Expressions) | 过于脆弱,无法做类型检查;只能在配合真实 AST 遍历时局部使用 |
| TypeScript transforms(不 emit) | 思路不错,但缺少把转换后的 AST 序列化回源码的 API(ts.Printer虽可序列化,却会破坏格式与代码风格),对迁移而言不可接受 |
因此update-tool采用“遍历 AST + 在虚拟文件系统上做替换(replacement)”的方案,兼顾了正确性与对原文件格式的保持。
三、升级数据(Upgrade Data):按版本与代码类型组织
3.1 双重隔离:按目标版本 + 按受影响的代码类型
升级数据首先按目标版本隔离,这是顺序迁移的前提(见 1.2 的onChange/changed/onValueChange例子)。其次,数据还按受影响的代码类型拆分。文档给出的参考是src/material/schematics/ng-update/material/data目录(在原文档中为 GitHub 链接,本仓库内对应 src/material/schematics/ng-update/data)。
本仓库中 CDK 侧的升级数据类型定义在 src/cdk/schematics/ng-update/data,包含:
attribute-selectors:HTML 属性选择器重命名class-names:CSS 类名重命名constructor-checks:构造函数签名变更检查css-selectors:CSS 选择器重命名css-tokens:CSS token 变更element-selectors:元素选择器重命名input-names:输入属性重命名method-call-checks:方法调用变更检查output-names:输出事件重命名property-names:属性重命名symbol-removal:符号移除
每种数据都对应 ng-update/migrations 下同名的一个迁移实现(如input-names.ts、output-names.ts、property-names.ts等),以及 typescript 下的辅助工具(如imports.ts、literal.ts、module-specifiers.ts)。
Angular Material 侧通过 materialUpgradeData 把这些数据聚合为一个UpgradeData对象,统一注入迁移规则。
3.2 数据类型与版本的数据结构
升级数据的数据结构定义在 version-changes.ts:
export type VersionChanges<T> = { [target in TargetVersion]?: ReadableChange<T>[]; }; export type ReadableChange<T> = { pr: string; // 关联的 Pull Request 链接,便于追溯破坏性变更 changes: T[]; // 该 PR 涉及的具体变更列表 };配套提供了两个读取辅助函数:
getChangesForTarget(target, data):取指定目标版本的变更,并剔除pr字段、展平为易迭代的数组——文档注释说明,pr链接是为了可读性和破坏性变更总览,升级执行时并不需要;getAllChanges(data):取所有版本的变更(用于不区分目标版本的迁移规则,但数据仍按版本分隔以保持可读性)。
3.3 从数据到迁移:以属性重命名为例
以 PropertyNamesMigration 为例,看升级数据如何驱动迁移:
export class PropertyNamesMigration extends Migration<UpgradeData> { data: PropertyNameUpgradeData[] = getVersionUpgradeData(this, 'propertyNames'); enabled = this.data.length !== 0; // 没有数据时自动禁用该迁移 override visitNode(node: ts.Node): void { if (ts.isPropertyAccessExpression(node)) { this._visitPropertyAccessExpression(node); } } // ... }其核心逻辑是:对每个属性访问表达式,用 TypeScripttypeChecker解析宿主类型(若为交叉类型则展开所有成员类型),当属性名匹配data.replace且满足limitedTo.classes的类型限制时,就在虚拟文件系统中删除旧名并插入新名。enabled = this.data.length !== 0说明:迁移是否启用,取决于目标版本是否存在对应升级数据——这正是文档“迁移规则与升级数据解耦、按数据驱动”的体现。
四、为破坏性变更添加升级数据(实战)
文档强调:在把破坏性变更合并进上游(upstream)之前,添加升级数据是强制步骤。对于简单常见的破坏性变更,通常已有对应的升级数据文件,只需插入新条目即可;如果某个破坏性变更没有现成数据,则需要评估:是写一个与该变更绑定的misc迁移,还是新建一个接受升级数据的可配置迁移。
4.1 属性重命名的完整示例
文档场景:在 Angular Material V7.0.0 中,将MatRipple#color重命名为MatRipple#newColor。
第一步:找到现有的同类升级数据文件。属性重命名对应property-names数据文件,在VersionTarget(即TargetVersion.V7)下插入新变更:
// src/material/schematics/ng-update/material/data/property-names.ts export const propertyNames: VersionChanges<MaterialPropertyNameData> = { [TargetVersion.V7]: [ { pr: '{PULL_REQUEST_LINK_FOR_BREAKING_CHANGE}', changes: [ { replace: 'color', replaceWith: 'newColor', limitedTo: { classes: ['MatRipple'] } } ] } ], // ... };各字段含义:
| 字段 | 说明 |
|---|---|
pr | 该破坏性变更对应的 Pull Request 链接占位符,便于追溯 |
replace | 需要被替换的旧名称(如color) |
replaceWith | 替换后的新名称(如newColor) |
limitedTo.classes | 限定作用范围:仅当宿主类型为MatRipple时才改写,避免误伤同名属性 |
数据插入后,开发者升级到 Angular Material V7.0.0 时,MatRipple#color就会被自动迁移为MatRipple#newColor。(本仓库当前版本中该数据文件为 src/material/schematics/ng-update/data/property-names.ts,结构上与示例一致。)
4.2 向已有测试用例添加破坏性变更
为新增迁移数据补充测试用例是强烈推荐的。属性重命名场景已有property-names迁移的测试用例,因此只需把新变更加入既有测试文件,无需新建。
输入文件property-names_input.ts(会被 V7 迁移转换):
/** * Mock definitions. This test case does not have access to @angular/material. */ class MatRipple { color: string; } class A implements OnInit { constructor(private a: MatRipple) {} ngOnInit() { this.a.color = 'primary'; } }期望输出文件property-names_expected_output.ts(迁移后应与之一致):
/** * Mock definitions. This test case does not have access to @angular/material. */ class MatRipple { color: string; } class A implements OnInit { constructor(private a: MatRipple) {} ngOnInit() { this.a.newColor = 'primary'; } }注意事项:_input.ts只会被 V7 迁移转换,然后与_expected_output.ts逐字比对。因此仍然有效的 mock 声明也必须保留在期望输出文件中——即 mock 的MatRipple类定义本身不会被改写(它只是测试用的占位),所以color: string会原样出现在期望输出里,而使用处this.a.color则改写为this.a.newColor。
文档中给出的测试用例位于src/material/schematics/ng-update/test-cases/v7/目录;本仓库中测试的入口与数据对应关系见 src/material/schematics/ng-update/test-cases/index.spec.ts。
五、总结:ng-update 的设计要点
- 多入口、按版本顺序迁移:每个主版本一个迁移入口,升级时按旧版本 → 新版本的顺序依次执行,全部历史迁移必须保留在仓库中,因为 CLI 通常只安装最新版本包。
- 升级数据按目标版本 + 代码类型双重隔离:这是顺序迁移正确性的前提,也让每个版本段的破坏性变更互不干扰。
- update-tool 框架替代 tslint:借助 TypeScript Compiler API,实现“program 只创建一次、源码只遍历一次、替换在虚拟文件系统中完成、支持模板/样式表与全局分析阶段”的高性能迁移管线。
- 数据驱动、可配置的迁移规则:迁移是否启用由升级数据决定(
enabled = data.length !== 0),新增破坏性变更的标准流程是“插入升级数据 → 补充测试用例”。 - 无法自动迁移的变更明确上报:通过
MigrationFailure机制把文件、位置与消息反馈给开发者,确保破坏性变更不被静默遗漏。
Angular CDK 的 ng-update 是 Angular Material ng-update 的基础,这套“升级数据 + 迁移框架”的设计同样适用于任何希望为 Angular 生态提供可复用自动迁移能力的组件库或应用。
【免费下载链接】componentsComponent infrastructure and Material Design components for Angular项目地址: https://gitcode.com/GitHub_Trending/co/components
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考