那种会碰到每一个页面的迁移
一次大版本升级把一个同步 API 变成了异步。codemod 处理了大部分。剩下的那一小部分,才是真正的教训所在。

那种会碰到每一个页面的迁移 🔁
我们在一次改动里把框架升了两个大版本、UI 库升了一个大版本。头条破坏性变更描述起来很小、应用起来很宽:路由参数变成异步的了。以前页面读 params.lang,现在得先 await 那个 params 对象。
一个属性、一个 await。乘以一个多语言应用里的每一条路由 —— 也就是全部路由。
「宽而浅」和「深而窄」是两种动物
你害怕的大多数迁移是深的:某个子系统语义变了,你得对少数几个地方认真思考。这次正相反 —— 推理是平凡的,表面积是巨大的。它的失败方式不同,计划方式也该不同。
宽而浅的特定危险在于:它看起来早就完成了,而其实远没有。 codemod 跑完、构建变绿,而它看不见的那二十个调用点,全都在自动化工具最弱的地方:动态元数据函数、嵌套 layout、路由处理器、以及某个在参数抵达处往下两层才解构的组件。
工具稳定漏掉的三种模式:
- 经由辅助函数传递的参数。 工具改写的是参数被声明的地方,而不是你自己写的那个把它往下递的包装函数。
- 元数据生成。 它住在页面旁边,常常拥有同一签名的另一份副本,而 codemod 可能把它当路由、也可能不。
- 任何带条件的写法。
const lang = params?.lang ?? "en"不匹配改写所寻找的形状,于是它悄悄保持同步 —— 并且从此悄悄地对每个请求都解析成那个兜底值。
最后这种是危险的形状:它不抛异常,它照常渲染,用默认语言,一直如此。
两个升级一起做是对的,理由只有一个
把框架和 UI 库放在同一次改动里听起来鲁莽,通常也确实是。这次它是对的,因为这两者是耦合的:新框架大版本要求新库大版本。拆开做意味着要经过一个两边都不满足的中间态,而花在让这个中间态跑起来的时间,是花在一个永远不会有人运行的配置上。
由此可以给出一条规则:耦合的东西一起升,独立的东西分开升。 合并升级的代价是 bisect 更难。拆开一对耦合体的代价是给一座孤岛修桥。
让合并版本可存活的关键,是其他一切都保持静止。分支里不做功能。不做顺手的重构。评审的人应该能读着 diff,看见一个机械改动重复了很多遍,外加一份简短的真实例外清单 —— 而那份例外清单才是真正要评审的东西。
最值得注意的那部分
另一处坏掉的是"服务端组件里渲染必须在客户端跑的东西",值得和参数改动分开讲,因为它是推理错误而不是机械错误。
当一个组件只能在浏览器里跑时 —— 一个 canvas 动画、任何碰 window 的东西 —— 旧的逃生舱是动态导入它并关掉服务端渲染。在服务端组件里这个选项不再存在,因为那条边界不再是一个运行时开关,而是"组件位于何处"这一结构属性。
修法不是开关,而是把客户端边界显式画出来:一个小的客户端组件持有那个只属于浏览器的东西,由负责页面布局的服务端组件导入。这比一个开关要多写些代码。它同时也是正确的模型,而这次迁移不过是不再允许我们回避它。
我们会再做一次的事
- 让机械改动单独落地,分支里不放别的。
- 在 codemod 之后 grep 那个模式,而不是之前。剩余清单很短,而全部风险都在那里。
- 专门搜索"静默兜底"的形状 —— 围绕被改动 API 的可选链和默认值。它们不会大声失败,所以不会出现在构建输出里。
- 每种语言点开一个页面看看,因为这次升级的失败模式是"内容用错误的语言渲染出来"而不是报错。
宽而浅的迁移,在你把例外枚举完时才算结束,而不是在构建变绿时。构建变绿是评审的开始,不是工作的结束。