React團隊測試並發特性詳解

引言

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不外乎setTimeoutsetIntervalPromise等。

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測的用例,一般結合ReactDOMReactTestUtils(瀏覽器環境的輔助方法)完成
  • 需要把控中間過程的用例,使用Scheduler的測試包,用Scheduler.unstable_yieldValue記錄過程信息
  • 脫離宿主環境,單獨測試React內部運行流程的,使用React-Noop-Renderer
  • 測試並發下的場景,需要結合上述工具與jest-react一起使用

如果想深入學習下React中與測試相關的技巧,可以看下司徒正美老師的作品anu。

這是個類React框架,但能跑通800+的React用例。裡面實現瞭ReactTestUtilsReact-Noop-Renderer的簡化版。

以上就是React團隊測試並發特性詳解的詳細內容,更多關於React團隊測試並發特性的資料請關註WalkonNet其它相關文章!

推薦閱讀: