怎么安全地换掉一层:一次真实的渲染层替换

我把一个前端项目的渲染层从手写 DOM 换成了 React,业务逻辑一行没改。这篇讲的不是 React 怎么用(那是上一篇),而是「换掉一层」这件事本身该怎么做才算做对——以及验收标准为什么不是「新代码能跑」。中途还撞到一个有意思的坑:验证工具自己把被测对象弄脏了。

换掉一层代码的验收:渲染层替换示意

导读:我把一个前端项目的渲染层从手写 DOM 换成了 React,业务逻辑一行没改。这篇讲的不是 React 怎么用(那是上一篇),而是「换掉一层」这件事本身该怎么做才算做对——以及验收标准为什么不是「新代码能跑」。中途还撞到一个有意思的坑:验证工具自己把被测对象弄脏了。

一、为什么「能跑」不等于「换对了」

换掉一层代码,最容易犯的错是把「新代码能跑」当成完工的标志。

打个装修的比方。你请人把墙里的水管全换成新材质。第二天他说好了,你打开水龙头,有水。这算验收通过吗?

不算。你还得确认:出水口位置没变、水压没变、冷热龙头没接反、热水器还能正常打火。真正的验收标准是「住户察觉不到装修队来过」——除了那些说好要改的地方,其他一切照旧。

换代码是同一回事。渲染层换掉之后,「页面能显示」只说明新代码没崩。它没告诉你:

  • 加载失败时的提示文案还一样吗?
  • 超时和断网还是两种不同的提示吗,还是被合并成了「出错了」?
  • 加载中那一瞬间,取消按钮是可点的吗?
  • 用户正在输入的内容,会不会因为别处刷新而消失?

这些都属于「住户会察觉到」的东西。而它们坏掉的时候通常不报错——页面照样渲染,只是行为悄悄变了。这类问题最麻烦:上线时测不出来,几周后用户报「怎么感觉不太对」,你已经想不起来动过哪里了。

所以换层之前,先问一句:我拿什么证明外部行为没变?

如果答案是「我手点一遍看看」,那这次替换的风险比你以为的大。手点能覆盖三四种情况,而一个稍微成熟的功能有十几种状态组合。

二、先找到那条不该动的线

我这个项目在换 React 之前,代码是这样分层的:

schemas/   —— 校验后端返回的数据(Zod)
api/       —— 发请求、把错误分类成 network/timeout/http 等
lib/       —— 纯计算(把状态翻译成提示文案、按状态筛选文档)
state/     —— 状态机(idle / loading / success / error 四种状态)
ui/        —— 手写 DOM,把状态画成页面   ← 这次要换的就是这层

分层的说法大家都听过,但它的价值平时看不太出来——分层要多写文件、多绕一层调用,日常开发时甚至更麻烦。

它的价值只在「要换掉其中一层」那天才结算。

这次替换的目标就变得很具体:只动 ui/,上面四层一行不改。如果做不到——比如为了接 React 不得不去改 api/ 的返回格式——那说明当初的分层是假的,只是文件分开了,逻辑还是缠在一起。

回到装修的比方:换水管不该需要动水龙头。如果换个管子还得把龙头拆了重装,那说明它们本来就是焊死的。

最后的结果是 api/schemas/lib/state/ 一行没改,ui/ 整个目录删掉,换成一组 React 组件。这个「一行没改」不是我克制,是真的不需要——它们从头到尾不知道谁在渲染它们。

顺带说一个判断标准:一层代码如果知道调用它的是谁,那它就没有真正分开。 状态机里如果出现「通知 React 更新」这样的代码,它就绑死在 React 上了,下次换框架还得再改一遍。

三、让新框架去适应旧代码

那问题来了:状态机不知道 React 存在,React 怎么知道状态变了?

我原来的状态机是个很普通的观察者模式,比 React 出现得早得多,接口就两个方法:

状态机.getState()          // 拿当前状态
状态机.subscribe(回调)      // 状态变了叫我

手写 DOM 的时候是这么用的:订阅一次,状态一变就整体重画。

换 React 时有两条路:

路 A:把状态机改成 React 的形状。 用 Context 或者某个状态库重写一遍。这是不少人的第一反应,因为看起来「更 React」。

路 B:写一个适配层,让 React 去接现有的接口。 状态机一个字不改。

我选 B,理由是 A 会让这次改动的范围失控。状态机里有一堆经过实战检验的细节——发新请求前取消旧请求(防止慢响应后到覆盖新结果)、用户主动取消不算错误(不弹红条)、四种状态用「可辨识联合」表达(保证「加载中」和「有错误」不可能同时成立)。重写它,等于把这些细节全部重新经历一遍风险,而它们跟 React 半点关系没有。

React 恰好提供了专门接这种外部状态源的钩子:

function 使用文档状态() {
  return useSyncExternalStore(状态机.subscribe, 状态机.getState);
}

两个参数就是状态机原有的两个方法,原样传进去。整个适配层不到十行。

为什么不用 useState + useEffect 手动同步——那是很多人的写法:在 effect 里订阅,收到变化就 setState。能跑,但有个隐患:effect 是渲染之后才执行的,从渲染到订阅建立之间有个空隙,状态如果在这个空隙里变了,这次变化就丢了。useSyncExternalStore 存在的全部意义就是消掉这个空隙。

接入新框架时,优先让新框架适应旧代码。反过来做,改动范围会从「一层」扩散到「一片」。

四、引用稳定:一个不写就死循环的隐形契约

useSyncExternalStore 有个要求,文档里写着但很容易看漏:

getState 在状态没变时,必须返回同一个对象。

不是「内容相同的对象」,是同一个。如果每次调用都返回一个新对象,React 会认为状态一直在变,于是不停重新渲染——页面卡死,而报错信息跟根因毫无关系。

// ❌ 每次都是新对象,引用不同,React 认为一直在变
getState() {
  return { 阶段: 当前阶段, 数据: 当前数据 };
}

// ✅ 返回持有的那个对象本身
getState() {
  return 状态;
}

有意思的是我的状态机恰好满足这条,而当初这么写完全是为了另一件事。

状态机内部改状态时,我用的是整体替换而不是合并:

// 我当初的写法
function 设置状态(新状态) {
  状态 = 新状态;              // 整体替换
}

// 常见的另一种写法
function 设置状态(部分) {
  状态 = { ...状态, ...部分 };  // 合并
}

选整体替换的原因跟 React 无关,是为了类型安全。状态用可辨识联合表达了四种情况,其中「文档数据」只存在于成功状态里。如果用合并,从「成功」进入「加载中」时会留下上一次的数据字段,得到一个 { 阶段: "加载中", 文档: [...] }——这个形状在类型定义里不存在,但运行时真的在内存里。类型说它不可能,实际它就在那儿,这比没有类型更危险。

于是一个纯粹为「类型别撒谎」做的决定,在完全无关的场景里满足了 React 的运行时要求。

这种巧合不能当成运气来庆祝,它有原因:「让不该同时存在的状态在结构上就无法构造」和「状态变化要能被可靠地检测」,这两个要求背后是同一件事——状态的变化必须是明确的、整体的,而不是零散地修修补补。 一个原则做对了,会在你没预料的地方也是对的。

五、抽纯函数,顺手问出一个老问题

有个函数负责把状态翻译成用户看到的提示文案,原来长这样:

function 渲染状态条(元素, 状态) {
  // 算出提示语和语气
  // 然后写进 DOM
}

它把两件事焊在一起了:算文案改 DOM。要测它,得先造一个 DOM 元素。所以实际上它从来没被单元测试覆盖过,只在浏览器端到端测试里跑过。

换 React 时我把算文案的部分抽成纯函数:

function 描述状态(状态) {
  return { 语气: "错误", 文案: "服务器出错(500),请稍后重试" };
}

抽离的理由不是「这样更干净」,是很实际的:纯函数能被直接测试。 输入是普通对象,输出是普通对象,不需要浏览器、不需要 DOM、不需要起服务器。

抽完补了 14 个测试。其中一个问出了以前没人问过的情况。

背景是这样:数据校验层遇到不合规的数据会丢掉,同时报一个「丢了几条」的计数。文案逻辑里有两条规则:

  • 数据为空 → 显示「还没有文档,上传第一个吧」
  • 丢过数据 → 在文案后面加「(N 条无法显示)」

那么,如果所有数据都被丢掉了呢?

数据是空的,于是命中第一条规则,显示「还没有文档,上传第一个吧」。丢弃计数完全没被提及。

而用户明明上传过文档。他看到的提示在告诉他「你还没上传」,而真相是后端有数据,只是前端一条都没看懂。这条文案在这种情况下是彻底误导的,甚至会引导他去重复上传。

这个问题在浏览器测试里问不出来。不是测不了,是那套测试跑一次要起 mock 服务器加浏览器,为一个边缘情况加一遍的成本高到没人愿意——所以它一直没被问。抽成纯函数之后,问它只需要三行代码。

我没有当场修。原因下一节会讲:这次替换要证明的是「行为一个字没变」,改文案会让那个证明失效。所以我把当前行为写进测试并注明原因,留到接正式后端接口那天一起处理。

测试成本决定了哪些问题会被问。把逻辑抽成纯函数,是在降低提问的门槛。

六、住户验收:旧的检查一行都不改

现在到了最关键的部分:怎么证明外部行为没变。

我手上有一个浏览器验证脚本,它把八种情况跑一遍——空闲、加载成功、成功但没数据、HTTP 500、加载中、用户取消、请求超时、网络断开——每种情况都检查提示语气、列表行数、两个按钮的禁用状态、完整文案,还截图。

换 React 的时候,我给自己定了一条规矩:这个脚本一行都不改。

这条规矩是这次替换里最重要的一个决定,值得说清为什么。

那个脚本是靠 #status-bar#document-list li#load-btn 这些选择器工作的。React 组件完全可以换一套更「现代」的选择器(比如 data-testid),然后同步把脚本改掉。改完跑一遍,全绿。

但那个「全绿」什么都不能证明。

因为渲染代码和检查代码是在同一次改动里一起变的。全绿只说明「新代码和新断言互相自洽」,不说明「行为跟以前一样」。如果我在换渲染层时不小心把超时和断网的文案弄成一样了,同时又调整了断言,两边一起错,测试照样绿。

要让验收有意义,检查的那一方必须没有参与这次改动。所以组件照原样输出那批 id 和属性——不是因为那套选择器更好,而是因为不改它,那个脚本就仍然是一个独立的裁判。

结果是八种情况逐个对比,语气、行数、按钮状态、文案全部和替换前一致。渲染层被整个换掉,外部行为一个字符没变。

这个判决之所以有力,就在于那个脚本不知道我做了什么。

然后加了第九种情况。因为换成 React 引入了一种手写 DOM 时代不存在的新失败模式

React 在状态变化时会重新执行组件函数。如果组件树接错了——比如把输入框的状态提到了公共父组件、或者列表 key 不稳定导致组件被卸载重建——那么远端数据刷新时,用户正在输入的内容会凭空消失

原来的手写 DOM 不会有这个问题,因为那时候更新是精确的:代码只改它明确点名的那几个元素,不会碰输入框。

所以第九种情况这样测:先在提问框里写一段文字、在上传区选一个文件,然后触发一整轮文档加载,最后检查——

  • 草稿还在,一个字符都没变
  • 已选文件还在
  • 同时,文档列表正常更新到了 4 行

前两条证明本地状态没被远端刷新冲掉;第三条是反向确认——本地状态存在也不该妨碍远端数据正常更新。两个方向都成立,才叫互不干扰。

验收的检查必须没参与被验收的改动。自己改断言让自己通过,等于没验收。

七、装修队弄脏了现场

新加的检查里有一条是「页面上不应该有内联样式」(内联样式会绕过样式表,是维护上的隐患)。

第一次跑,它报错了:发现 2 处内联样式。

我去源码里搜,一处都没有。React 组件里没写,HTML 里也没写。

于是单独写了个小脚本,起服务器、打开页面、查有多少内联样式。结果是 0 处

两次结果矛盾,但都是真的。差别在于:验证脚本在检查之前,先给页面截了一张图。

Playwright 截「整页」图的时候,需要临时调整页面样式来把完整长度拍下来。截完之后,那些临时样式留下了残留

也就是说——测量工具自己把被测对象弄脏了,然后我的检查抓到了工具留下的痕迹,并报告说被测对象有问题。

修法很简单,把这个检查挪到第一次截图之前,那时候页面还是刚渲染完、没被任何工具动过的状态。挪完就干净了。

但这个小坑值得单独讲一节,因为它有个很容易走上的岔路:

我当时完全可以「修」掉这个报错——把断言从「必须是 0 处」改成「不超过 2 处」。 报错消失,一切正常,我继续干活。

而那样做的代价是:这条检查从此永久瞎了。以后真有人往组件里写了内联样式,它也报不出来了,因为已经有 2 处的容忍额度。这类「为了让测试通过而放宽测试」的修改,代价要过很久才显现,显现时已经没人记得当初为什么放宽。

判断标准其实很清楚:断言报错时,先搞清楚是断言写错了,还是被测对象真有问题,还是测量方式有问题。这三种情况的处理完全不同,而放宽断言只在第一种情况下才是对的。

这次是第三种。所以修的是测量时机,不是断言。

断言报错时别急着改断言。先分清是断言错了、代码错了,还是你的测量方式错了。

八、小结

把这次替换的做法收成几条:

环节做法为什么
确定范围只动一层,其他层一行不改改不动才说明分层是真的
接入新框架写适配层让新框架适应旧代码反过来做会让改动范围扩散
验收用没参与本次改动的检查来裁判自己改断言让自己通过等于没验收
新风险为新引入的失败模式补新检查旧检查覆盖不到框架带来的新问题
遇到报错先分清是断言、代码、还是测量方式错了放宽断言会让检查永久失效

最后回到分层这件事。

写分层代码平时是有成本的:文件更多、调用更绕,功能简单时甚至显得多余。这个成本是天天付的,而回报要等到「某一层需要整体替换」那天才结算。

这次就是结算日。渲染层整个换掉,上面四层一行没改——因为它们从来不知道谁在渲染它们。

如果那天到来时发现改不动,那付出的成本就白付了:文件是分开的,逻辑还是缠着的。分层是不是真的,只有在换层那天才知道。

至于 React 本身怎么用——声明式渲染、state、props、列表 key 那些——在上一篇里。这一篇讲的方法跟框架无关,换的是数据库、消息队列、还是别的什么层,道理一样。

换掉一层的验收标准不是「新代码能跑」,是「除了说好要改的地方,其他一切照旧」——而且要由一个没参与改动的裁判来判。

没有回应

    发表回复

    您的邮箱地址不会被公开。 必填项已用 * 标注