12k
All articles

将大型代码库从 Flow 迁移到 TypeScript

比较 Flow 到 TypeScript 的整体与渐进式迁移,了解代码转换工具、并行运行类型检查器、类型映射及推进迁移的方法。

OpenReplay Team
OpenReplay Team
将大型代码库从 Flow 迁移到 TypeScript

将大型代码库从 Flow 迁移到 TypeScript 时,应在动手改代码之前先在两种策略中做出选择。第一种是在整个仓库上一次性运行 codemod,适用于已经完全使用 Flow 类型标注、且能够承受短暂代码冻结的代码库。第二种是从叶子节点开始的渐进式转换,需要让两套类型检查器并行运行数月甚至数年。

每次 Flow 升级都耗时过长,所需的 libdef 要么缺失、要么过时,新入职的工程师还总是在问为什么仓库还没用上 TypeScript。可即便如此,停下一个季度的功能开发依然不现实。

本指南对比了这两种策略,介绍了渐进式迁移期间 Flow 与 TypeScript 如何共存,梳理了 codemod 无法干净转换的 Flow 特有类型,并阐述了如何避免迁移工作陷入停滞。

核心要点

  • Pinterest 通过一次 codemod 运行,在八个月内将 370 万行 Flow 代码转换为 TypeScript,并验证了两套类型系统生成的 Babel 输出逐字节完全一致。
  • Yelp 历时三年零七个月,按包逐一迁移了 140 万行源代码(SLoC),类型覆盖率从 Flow 下的 83.15% 提升到 TypeScript 下的 96.44%。
  • TypeScript 没有精确对象类型(exact object types)。只有当对象字面量被直接赋值给带类型的目标时,它才会拒绝多余属性。
  • Flow 的 opaque type 在 TypeScript 中对应品牌类型(branded type),$Shape<T> 对应 Partial<T>,后者也是 Flow 官方目前推荐的写法。
  • 渐进式迁移往往在积极的团队完成工作后陷入停滞。按照能解除多少其他模块的阻塞来对剩余模块排序,并设定明确的截止日期,可以让迁移持续推进。

为什么团队仍在从 Flow 迁移到 TypeScript

团队离开 Flow,是因为其周边生态已经萎缩。第三方包的 TypeScript 类型很容易找到,工具链默认以 TypeScript 为前提,而每多用一年 Flow,最终的迁移成本就会再增加一分。2021 年 5 月,Flow 团队宣布将优先服务 Facebook 内部代码库,而非开源社区。Yelp 将这一声明列为决定性因素之一,此外还包括 DefinitelyTyped 的类型库规模远大于 flow-typed,以及越来越多的包开始自带 TypeScript 声明。

继续留在 Flow 的代价也体现在工具链上。离开 Flow 之后,Yelp 才得以采用 swc 和 @typescript-eslint。部分 Flow 代码库还使用了 PropTypes,这属于另一个层面:PropTypes 在运行时校验 props,而 Flow 和 TypeScript 在构建时检查类型,因此是否保留 PropTypes 应与类型检查器的迁移分开决策。

一次性 codemod 还是渐进式迁移:哪种策略更合适?

一次性(big-bang)codemod 迁移适用于已经完全使用 Flow 类型标注、且能接受短暂冻结的代码库。渐进式迁移适用于无法停止功能开发的 monorepo,代价是需要让两套类型检查器并行运行数月甚至数年。

一次性 codemod渐进式
前提条件Flow 完全覆盖包之间具有清晰的依赖边界
冻结短暂,在切换时无
周期数月的准备数年
投入高峰自动化脚本与验证互操作工具链与跨团队推动
风险集中点切换当天长尾阶段
典型失败合并时构建或路径出错部分完成后陷入停滞
参考案例Pinterest:370 万行,八个月Yelp:140 万 SLoC,3 年 7 个月

Pinterest 先将 Flow 覆盖率提升到 100%,这使得单次提交完成迁移成为可能。随后其团队运行了 Stripe 的 flow-to-typescript-codemod,该工具分两步执行:先由基于 Babel 的 convert 命令将 Flow 语法改写为 TypeScript,再由基于 ts-morph 的 fix 命令对遗留的 TypeScript 错误进行修复、抑制或报告。Pinterest 每天都从 master 重新构建 TypeScript 分支,到切换时累计超过 60 次,并将所有手动修复集中到一个通过 cherry-pick 应用的提交中。Factorial 在 2022 年 2 月的一篇文章中介绍了一种混合路线:先转换一个相对解耦的模块,证明 Flow 与 TypeScript 可以在生产环境中共存,然后使用 Khan Academy 的 flow-to-ts 一次性转换其余代码。

两种 Flow 到 TypeScript 的迁移策略都可以使用同一种等价性检查。类型注解在代码运行前就会被移除,因此正确的转换应生成完全相同的 JavaScript。Pinterest 正是通过对比两份源码的 Babel 输出来证明这一点的。真正导致生产环境出问题的,通常是文件重命名、glob 模式更新和构建配置变更。Pinterest 发现了将近 100 处硬编码的 .js 引用。每次切换后回放生产环境的用户会话,可以发现静态检查遗漏的页面渲染失败和 chunk 加载错误。

让 Flow 与 TypeScript 并行运行

在渐进式迁移中,已转换的 TypeScript 模块绝不能导入尚未转换的 Flow 代码。因此,转换需要从依赖图的叶子节点开始逐层向上推进。Yelp 使用 flowgen 将每个已转换包输出的 .d.ts 转换为 Flow 兼容的类型,供仍在导入它的 Flow 代码使用。Yelp 没有找到能反向操作、从 Flow 生成 TypeScript 声明的工具,这也是它必须从最底层的内部包开始的原因。Flow 会读取 .js.flow 文件来替代同目录下的 .js 模块,因此这里正适合存放 flowgen 的输出。Meta 的 flow-api-translator 现在已经可以从 Flow 源码生成 TypeScript 定义,但其作者的定位是用于发布仍保留在 Flow 中的库,而非用于迁出 Flow。

应将未转换的 Flow 文件排除在 TypeScript 程序之外,仅由 Flow 负责检查。allowJs 和 checkJs 是为纯 JavaScript 设计的,并不适用于带 Flow 注解的文件。随着每个模块完成转换,同步迁移 ESLint 规则(Pinterest 将 flowtype/no-weak-types 映射为 @typescript-eslint/no-explicit-any),可参照 typescript-eslint 的配置方式。编辑器应配置为使用工作区内的 TypeScript 版本。

通过 CI 检查阻止 Flow 代码面继续扩大:

// scripts/no-new-flow.js
const { execSync } = require("node:child_process");
const { readFileSync } = require("node:fs");

const base = process.env.BASE_REF || "origin/main";
const added = execSync(`git diff --name-only --diff-filter=A ${base}...HEAD`, { encoding: "utf8" })
  .split("\n")
  .filter((f) => /\.(js|jsx)$/.test(f));

const offenders = added.filter((f) => /@flow\b/.test(readFileSync(f, "utf8")));

if (offenders.length) {
  console.error("New Flow files are not allowed. Write these in TypeScript:\n" + offenders.join("\n"));
  process.exit(1);
}

no-new-flow.js 脚本会列出当前分支相对于基准 ref 新增的文件,只要其中任何一个包含 @flow,就让该任务失败。重命名文件时,使用 git mv src/foo.js src/foo.ts,并将重命名与内容修改分开提交,这样 git log --follow 才能保留文件历史。

无法直接转换的 Flow 类型

有四种 Flow 结构在 TypeScript 中没有一一对应的等价物:精确对象、型变标记、不透明类型和 $Shape。

精确对象类型

TypeScript 没有精确对象类型。Flow 的精确对象会变成普通的对象类型,只有当对象字面量被直接赋值给该类型时,TypeScript 才会拒绝多余属性。Flow 的对象类型自 0.202 版本起默认即为精确类型。{| |} 是较旧的显式语法,... 则表示非精确对象。

// Flow
type User = { id: string, name: string };        // exact by default
type LooseUser = { id: string, name: string, ... };
// TypeScript
type User = { id: string; name: string };
const u: User = { id: "1", name: "Ada", role: "admin" }; // error: excess property
const extra = { id: "1", name: "Ada", role: "admin" };
const v: User = extra; // allowed: not a fresh literal

型变

Flow 的 + 属性标记对应 TypeScript 的 readonly。-(只写)标记在 TypeScript 中没有对应物。TypeScript 确实为泛型类型参数提供了 in/out 修饰符。

type Config = { readonly endpoint: string }; // Flow: { +endpoint: string }
type Sink = { value: number };               // Flow: { -value: number }; intent must be documented

不透明类型

Flow 的 opaque type 在 TypeScript 中没有对应的关键字。常见的替代方案是品牌类型:将基础类型与一个唯一的标记属性做交叉,且只有指定的构造函数才能生成该类型的值。Flow 不透明类型在其定义文件内部是透明的,而品牌类型在任何地方都会被强制检查,因此构造函数中需要使用类型断言。

declare const userIdBrand: unique symbol;
export type UserId = string & { readonly [userIdBrand]: true };
export function toUserId(s: string): UserId {
  return s as UserId;
}
const bad: UserId = "u_1"; // error

$Shape

Flow 已因安全性问题弃用 $Shape,并自 Flow 0.201 起推荐使用 Partial<T>,它可以直接映射为 TypeScript 的 Partial<T>。对于依赖 $Shape 其他行为的调用点,需要逐一检查。

function update(patch: Partial<User>) { /* ... */ } // Flow: $Shape<User>

较简单的 Flow 到 TypeScript 类型映射可以直接对应:

FlowTypeScript
?TT | null | undefined
mixedunknown
$ReadOnly<T>Readonly<T>
$Keys<T>keyof T
$ReadOnlyArray<T>ReadonlyArray<T>
React.NodeReact.ReactNode

替换 flow-typed libdef

包转换完成后,即可删除其 flow-typed libdef。如果依赖通过 package.json 的 types 字段自带类型,就直接使用;没有的话,则从 DefinitelyTyped 安装 @types/*。为内部包或无人维护的包手写的 libdef 需要重写为 .d.ts 文件。这项工作应尽早开始,因为它会阻塞所有使用方。

衡量进度,避免迁移停滞

渐进式迁移往往在积极的团队完成工作后陷入停滞。推动 Yelp 完成最后约 200 个包的关键,是按照能解除多少其他模块的阻塞来对剩余 Flow 模块排序,并发布带有截止日期的转换清单。每周跟踪以下三项数据:

grep -rl --include='*.js' --include='*.jsx' '@flow' src | wc -l                    # Flow files left
find src \( -name '*.ts' -o -name '*.tsx' \) ! -name '*.d.ts' | wc -l               # TS files
grep -ro '@ts-expect-error' src | wc -l                                            # suppressions

同时还要衡量迁移前后的类型覆盖率。根据有多少 Flow 模块在等待某个被阻塞的模块来为其打分,并将排名靠前的模块提交给其所属团队。Yelp 设定了按季度推进的截止日期,最终指向一个停止支持(end-of-life)日期。虽然这个日期推迟了一个季度,但施加的压力确实奏效了。对于最后剩下的顽固模块,应预先规划自动抑制的兜底方案:Yelp 约 6,000 条 @ts-expect-error 指令中,大约有 2,000 条来自这最后一步。

现实的规模:以月计,而非一个迭代

大型 Flow 到 TypeScript 的迁移至少需要数月时间。Pinterest 的 codemod 路线用了八个月,Yelp 的渐进式路线则用了三年半以上。转换完成后,再逐步开启各项 strict 标志。对于基于 JavaScript 的编译器(TypeScript 6.0 及更早版本),Pinterest 提高了 Node 的堆内存上限,以防止 tsc 内存溢出。其文章并未给出具体数值,因此下面的数字仅作示例:

NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit

请根据你的机器配置调整该值。即便如此,Pinterest 报告的完整类型检查耗时仍有 3 到 4 分钟。TypeScript 6.0 是最后一个基于 JavaScript 的版本,而原生的 TypeScript 7 编译器才是超大型仓库类型检查速度发生质变的关键。下一步,统计你的 @flow 文件数量,确认覆盖率是否已达到 100%,并据此选择迁移策略。

常见问题

$FlowFixMe 注释在 TypeScript 中应该改为 @ts-expect-error 还是 @ts-ignore?

使用 @ts-expect-error。两种指令都会抑制下一行的错误,但 TypeScript 3.9 引入的 @ts-expect-error 会在该行不再出错时报告“Unused @ts-expect-error directive”错误。这样,随着类型不断完善,过时的抑制注释会自动暴露出来,抑制数量只会减少不会增加。而 @ts-ignore 在底层问题修复后依然保持沉默,因此可能会掩盖同一行上新出现的错误。

在 TypeScript 中用什么替代 Flow 的 $PropertyType 和 $ElementType?

使用索引访问类型,其语法在 Flow 和 TypeScript 中完全相同:$PropertyType<T, 'k'> 改为 T['k'],$ElementType<T, K> 改为 T[K]。Flow 在 0.155 版本中弃用了这两个工具类型,并在 0.266 版本中将其移除。在转换前运行 eslint-plugin-fb-flow 中 use-indexed-access-type 规则的自动修复,可以让 codemod 原样保留这些类型。

将 @babel/preset-flow 替换为 @babel/preset-typescript 后会进行类型检查吗?

不会。Babel 只会移除 TypeScript 类型而不做检查,因此 CI 中仍需要单独执行 tsc --noEmit 步骤。在 Babel 8 中,preset-typescript 的 onlyRemoveTypeImports 选项默认值为 true,因此 Babel 只会移除标记为 import type 的导入。对仅含类型绑定的普通导入会保留在输出中,可能导致模块解析失败。请将这些导入标记为 import type,或将 onlyRemoveTypeImports 设为 false 以恢复 Babel 7 的行为。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.