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
不外乎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團隊測試並發特性的資料請關註WalkonNet其它相關文章!