引言
React18 进入大家视野已经有一段时间了,不知道各位有没有尝试并发特性呢?
当启用并发特性后, React 会从同步更新变为异步、带优先级、可中断的更新。
这也为编写单元测试带来了一些难度。
本文来聊聊 React 团队如何测试并发特性。
遇到的困境
主要有两个问题需要面对。
1. 如何表达渲染结果?
React 可以对接不同宿主环境的渲染器,大家最熟悉的渲染器想必是 ReactDOM ,用于对接浏览器与Node环境(SSR)。
对于一些场景,可以用 ReactDOM 的输出结果做测试。
比如,下面是使用 ReactDOM 的输出结果测试无状态组件的渲染结果是否符合预期(测试框架是 jest ):
it('should render stateless component', () => { const el = document.createElement('div'); ReactDOM.render(<FunctionComponent name="A" />, el); expect(el.textContent).toBe('A'); });
这里有个不方便的地方 —— 这个用例依赖浏览器环境与 DOM API (比如用到 document.createElement )。
对于测试React内部运行机制这样的场景,掺杂了宿主环境相关信息显然会让测试用例编写起来更繁琐。
2. 如何测试并发环境?
如果将上文的用例中 ReactDOM.render 改为 ReactDOM.createRoot ,那么用例就会失败:
// 之前 ReactDOM.render(<FunctionComponent name="A" />, el); expect(el.textContent).toBe('A'); // 之后 ReactDOM.createRoot(el).render(<FunctionComponent name="A" />); expect(el.textContent).toBe('A');
这是因为在新的架构下,很多同步更新变成了并发更新,当 render 执行后,页面还没完成渲染。
要让上述用例成功,最简单的修改方式是:
ReactDOM.createRoot(el).render(<FunctionComponent name="A" />); setTimeout(() => { // 异步获取结果 expect(el.textContent).toBe('A'); })
如何优雅的应对这种变化?
React的应对策略
接下来我们来看 React 团队的应对方式。
首先来看第一个问题 —— 如何表达渲染结果?
既然 ReactDOM 渲染器对应浏览器、 Node 环境, ReactNative 渲染器对应 Native 环境。
那能不能为测试内部运行流程专门开发一个渲染器呢?
答案是肯定的。
这个渲染器叫 React-Noop-Renderer 。
简单的说,这个渲染器会渲染出纯 JS 对象。
实现一个渲染器
React 内部有个叫 Reconciler 的包,他会引用一些操作宿主环境的 API 。
比如如下方法用于向容器中插入节点:
function appendChildToContainer(child, container) { // 具体实现 }
对于浏览器环境( ReactDOM ),使用 appendChild 方法实现即可:
function appendChildToContainer(child, container) { // 使用appendChild方法 container.appendChild(child); }
打包工具( rollup )将 Reconciler 包与上述这类针对浏览器环境的API打包起来,就是 ReactDOM 包。
在 React-Noop-Renderer 中,与 ReactDOM 中的 DOM 节点对标的是如下数据结构:
const instance = { id: instanceCounter++, type: type, children: [], parent: -1, props };
注意其中的 children 字段,用于保存子节点。
所以 appendChildToContainer 方法在 React-Noop-Renderer 中可以实现的很简单:
function appendChildToContainer(child, container) { const index = container.children.indexOf(child); if (index !== -1) { container.children.splice(index, 1); } container.children.push(child); };
打包工具将 Reconciler 包与上述这类针对React-Noop的API打包起来,就是 React-Noop-Renderer 包。
基于 React-Noop-Renderer ,可以完全脱离正常的宿主环境,测试 Reconciler 内部的逻辑。
接下来来看第二个问题。
如何测试并发环境?
并发特性再复杂,说到底也只是各种异步执行代码的策略,最终执行策略的 API 不外乎 setTimeout 、 setInterval 、 Promise 等。
在 jest 中,可以模拟这些异步 API ,控制他们的执行时机。
比如上面的异步代码,在 React 中的测试用例会这么写:
// 测试用例修改后: await act(() => { ReactDOM.createRoot(el).render(<FunctionComponent name="A" />); }) expect(el.textContent).toBe('A');
act 方法来自 jest-react 包,他的内部会执行 jest.runOnlyPendingTimers 方法,让所有等待中的计时器触发回调。
比如如下代码:
setTimeout(() => { console.log('执行') }, 9999999)
执行 jest.runOnlyPendingTimers 后会立刻打印执行。
通过这种方式,人为控制 React 并发更新的速度,同时对框架代码0侵入。
除此之外,用于驱动并发更新的 Scheduler (调度器)模块,本身也有一个针对测试的版本。
在这个版本中,开发者可以手动控制 Scheduler 的输入、输出。
比如,我想测试组件卸载时 useEffect 回调的执行顺序。
如下面代码所示,其中 Parent 为挂载的被测试组件:
function Parent() { useEffect(() => { return () => Scheduler.unstable_yieldValue('Unmount parent'); }); return <Child />; } function Child() { useEffect(() => { return () => Scheduler.unstable_yieldValue('Unmount child'); }); return 'Child'; } await act(async () => { root.render(<Parent />); });
根据 yieldValue 的插入顺序是否符合预期,就能确定 useEffect 的逻辑是否符合预期:
expect(Scheduler).toHaveYielded(['Unmount parent', 'Unmount child']);
总结
React 中测试用例的编写策略为:
可以用 ReactDOM 测的用例,一般结合 ReactDOM 与 ReactTestUtils (浏览器环境的辅助方法)完成 需要把控中间过程的用例,使用 Scheduler 的测试包,用 Scheduler.unstable_yieldValue 记录过程信息 脱离宿主环境,单独测试 React 内部运行流程的,使用 React-Noop-Renderer 测试并发下的场景,需要结合上述工具与 jest-react 一起使用如果想深入学习下 React 中与测试相关的技巧,可以看下司徒正美老师的作品 anu 。
这是个类 React 框架,但能跑通800+的 React 用例。里面实现了 ReactTestUtils 、 React-Noop-Renderer 的简化版。
以上就是React团队测试并发特性详解的详细内容,更多关于React团队测试并发特性的资料请关注其它相关文章!