1. 程式人生 > >React的useEffect與useLayoutEffect執行機制剖析

React的useEffect與useLayoutEffect執行機制剖析

---- ## 引言 useEffect和useLayoutEffect是React官方推出的兩個hooks,都是用來執行副作用的鉤子函式,名字類似,功能相近,唯一不同的就是執行的時機有差異,今天這篇文章主要是從這兩個鉤子函式的執行時機入手,來剖析一下React的執行原理和瀏覽器的渲染流程。 ## 官方解釋 `useLayoutEffect`其函式簽名與 `useEffect` 相同,但它會在所有的 DOM 變更之後同步呼叫 effect。可以使用它來讀取 DOM 佈局並同步觸發重渲染。在瀏覽器執行繪製之前, `useLayoutEffect` 內部的更新計劃將被同步重新整理,儘可能使用標準的 `useEffect` 以避免阻塞視覺更新。 簡單來講,就是:useEffect是非同步的,useLayoutEffect是同步的,異(同)步是相對於瀏覽器執行重新整理螢幕Task來說的。 ## 眼見為實 下面將通過一個簡單的demo示例來說明具體的執行過程,其中React是16.13.1版本,首先是示例程式碼: ``` import React, { useState, useEffect, useLayoutEffect } from 'react'; const EffectDemo = () => { const [count, setCount] = useState(0); useEffect(function useEffectDemo() { console.log('useEffect:', count); }, [count]); useLayoutEffect(function useLayoutEffectDemo() { console.log('useLayoutEffect:', count); }, [count]); return ( <div> <button onClick={() => { setCount(count + 1); }} >click me</button> </div> ); }; export default EffectDemo; ``` 功能很簡單,就不做介面展示,這裡主要是看一下瀏覽器控制檯Performance的監控圖: ![圖片描述](https://fulu-common-util.oss-cn-hangzhou.aliyuncs.com/wiki_assets/useEffect/react-render.png) 通過兩個hooks的執行圖可以看出,useLayoutEffect發生在頁面渲染到螢幕(使用者可見)之前,useEffect發生在那之後,中間還經歷了DCL,FCP,FMP,LCP階段,除開DCL(DomContentLoaded)之外,這些指標是RAIL模型衡量頁面效能的標準,總的來說,渲染到螢幕的階段是一個分水嶺,那麼渲染包含什麼呢,還是看圖吧: ![圖片描述](https://fulu-common-util.oss-cn-hangzhou.aliyuncs.com/wiki_assets/useEffect/px-pipe.png) 此階段完成了樣式的計算(Recalculate Style)和佈局(Layout),緊接著是一個Task,完成Update Layer Tree,Paint,Composite Layers,經過這一系列的任務後,頁面最終呈現給使用者,可以用一張圖來表示瀏覽器的渲染過程: ![圖片描述](https://fulu-common-util.oss-cn-hangzhou.aliyuncs.com/wiki_assets/useEffect/browser-render.png) 後面會有相關學習資料,這裡就不展開細說了。 ## 模擬執行示例 在深入瞭解React的執行之前,首先在本地寫一個簡單的示例,大致模擬文章開始的例子: ``` ``` 然後啟用Performance監控渲染情況: ![圖片描述](https://fulu-common-util.oss-cn-hangzhou.aliyuncs.com/wiki_assets/useEffect/simulation-render.png) 總結一下: 1.首先執行render,完成後立即執行useLayoutEffectDemo函式(雖然已經插入DOM,但是介面還沒有渲染出來); 2.註冊非同步回撥函式useEffectDemo,該函式將在0ms過後加入EventLoop中的巨集任務佇列; 3.頁面開始渲染:Recalculate Style->Layout->Update Layer Tree->
Paint->Composite Layers->GPU繪製; 4.取出巨集任務useEffectDemo,執行回撥; React的執行比這個模擬示例複雜很多,但是抽象出的流程節點大同小異,瞭解之後,我們可以繼續深入挖掘React的執行機制了。 ## React執行原理 React渲染頁面分為兩個階段: 1.排程階段(reconciliation):找出需要更新的節點元素 2.渲染階段(commit):將需要更新的元素插入DOM 接下來就跟著React的執行流程來具體看下不同階段的執行情況: ### 渲染流程圖(初次渲染) ![圖片描述](https://fulu-common-util.oss-cn-hangzhou.aliyuncs.com/wiki_assets/useEffect/render-process.jpg) 簡單總結一下: 1.react-dom負責Fiber節點的建立,最終形成一個Fiber節點樹,其中每個Fiber包含需要執行的副作用和渲染到螢幕的DOM物件; 2.呼叫scheduler暴露的方法註冊需要排程的事件; 3.執行DOM插入; 4.執行useLyaoutEffect或者ClassComponent的生命週期函式; 5.瀏覽器接過控制權,執行渲染; 6.scheduler執行排程任務,執行useEffectDemo; 以上就是整體流程,接下來再深入一點,看看useEffect和useLayoutEffect是怎麼解析和執行的: ![圖片描述](https://fulu-common-util.oss-cn-hangzhou.aliyuncs.com/wiki_assets/useEffect/effect-fiber.jpg) ### use(Layout)Effect解析與執行 #### 1.解析 ![圖片描述](https://fulu-common-util.oss-cn-hangzhou.aliyuncs.com/wiki_assets/useEffect/useExec.jpg) 從上圖可知,uesEffect和useLayoutEffect最終都會呼叫mountEffectImpl函式,然後初始化/更新Fiber的updateQueue,可以看一下mountEffectImpl函式是怎樣的: ``` function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps) { var hook = mountWorkInProgressHook(); var nextDeps = deps === undefined ? null : deps; currentlyRenderingFiber$1.effectTag |= fiberEffectTag; hook.memoizedState = pushEffect(HasEffect | hookEffectTag, create, undefined, nextDeps); } ``` 都認識,但是不知道是幹嘛的,好吧,還是用一張圖來說明吧: ![圖片描述](https://fulu-common-util.oss-cn-hangzhou.aliyuncs.com/wiki_assets/useEffect/workInProgressHook.jpg) 這個函式的功能如下: 1.建立hook物件,放入到workInProgressHook連結串列中; 2.Fiber的updateQueue和上一步建立的hook關聯,這樣每一個Fiber物件上就知道要執行Effect了; 那麼workInProgressHook是幹嘛的呢,看下原始碼的解釋吧: ``` var workInProgressHook = null; // Whether an update was scheduled at any point during the render phase. This // does not get reset if we do another render pass; only when we're completely // finished evaluating this component. This is an optimization so we know // whether we need to clear render phase updates after a throw. ``` #### 2.updateQueue資料結構 上面說到updateQueue,最終我們寫的useEffectDemo和useLayoutEffectDemo都會放在這裡,那麼是怎麼一個結構儲存的呢,可以列印看一下: ![圖片描述](https://fulu-common-util.oss-cn-hangzhou.aliyuncs.com/wiki_assets/useEffect/updateQueue.jpg) 其實就是一個收尾相連的環形結構,為什麼要這麼設計呢,大家看下commitHookEffectListMount執行函式的遍歷方式就知道了: ``` function commitHookEffectListMount(tag, finishedWork) { var updateQueue = finishedWork.updateQueue; var lastEffect = updateQueue !== null ? updateQueue.lastEffect : null; if (lastEffect !== null) { var firstEffect = lastEffect.next; var effect = firstEffect; do { if ((effect.tag & tag) === tag) { // Mount var create = effect.create; effect.destroy = create(); { var destroy = effect.destroy; if (destroy !== undefined && typeof destroy !== 'function') { var addendum = void 0; if (destroy === null) { addendum = ' You returned null. If your effect does not require clean ' + 'up, return undefined (or nothing).'; } else if (typeof destroy.then === 'function') { addendum = '\n\nIt looks like you wrote useEffect(async () =>
...) or returned a Promise. ' + 'Instead, write the async function inside your effect ' + 'and call it immediately:\n\n' + 'useEffect(() => {\n' + ' async function fetchData() {\n' + ' // You can await here\n' + ' const response = await MyAPI.getData(someId);\n' + ' // ...\n' + ' }\n' + ' fetchData();\n' + "}, [someId]); // Or [] if effect doesn't need props or state\n\n" + 'Learn more about data fetching with Hooks: https://fb.me/react-hooks-data-fetching'; } else { addendum = ' You returned: ' + destroy; } error('An effect function must not return anything besides a function, ' + 'which is used for clean-up.%s%s', addendum, getStackByFiberInDevAndProd(finishedWork)); } } } effect = effect.next; } while (effect !== firstEffect); } } ``` 這裡根據effect的tag不同決定執行哪一種effect,這裡我們的useEffectDemo和useLayoutEfectDemo的tag分別是5和3,因此需要執行useEffect中的副作用函式時,commitHookEffectListMount的tag肯定就是5了,執行useLayoutEffect中的副作用函式時,commitHookEffectListMount的tag肯定就是3。 總的來說所有的useEffect和useLayoutEffect的副作用函式都是在這裡執行的,通過tag來控制他們的執行時機。 #### 3.執行 其實上面已經講了commitHookEffectListMount的執行,這裡再看下具體的執行過程: ![圖片描述](https://fulu-common-util.oss-cn-hangzhou.aliyuncs.com/wiki_assets/useEffect/effectExec.jpg) 執行useEffect的入口: ``` function commitLifeCycles(finishedRoot, current, finishedWork, committedExpirationTime) { switch (finishedWork.tag) { case FunctionComponent: case ForwardRef: case SimpleMemoComponent: case Block: { commitHookEffectListMount(Layout | HasEffect, finishedWork); return; } ...... } ``` 執行useLayoutEffect的入口: ``` function commitPassiveHookEffects(finishedWork) { if ((finishedWork.effectTag & Passive) !== NoEffect) { switch (finishedWork.tag) { case FunctionComponent: case ForwardRef: case SimpleMemoComponent: case Block: { ...... commitHookEffectListMount(Passive$1 | HasEffect, finishedWork); break; } } } } ``` 可以看出兩個執行入口傳入的第一個入參tag是不一樣的,最終執行的副作用函式就區分開來了。 ### MessageChannel非同步排程 現在大家應該對useEffect和useLayoutEffect的執行有了一個大致的瞭解,那麼還有一個關於scheduler非同步排程的小問題,本文最開始模擬的一個例子裡是通過setTimeout來完成的,React中則是通過MessageChannel來實現的,如果不熟悉可以查查使用方式,這裡來看下非同步執行的過程: ![圖片描述](https://fulu-common-util.oss-cn-hangzhou.aliyuncs.com/wiki_assets/useEffect/onmessage.png) ## 瀏覽器渲染流程 * 關於瀏覽器的渲染這裡我就以推薦學習資料為主,因為我自己也沒有這些講解得好,就沒必要重複了; ### 基礎知識 瀏覽器的渲染是一個十分複雜的過程,如果不是很瞭解,可以瀏覽谷歌提供的介紹文章,連結如下:https://developers.google.cn/web/fundamentals/performance/rendering ### 深入一點 瞭解了瀏覽器的基本渲染之後,可以更加深入窺探瀏覽器的執行,首先上一張圖: ![圖片描述](https://fulu-common-util.oss-cn-hangzhou.aliyuncs.com/wiki_assets/useEffect/anatomy-of-a-frame.jpg) 上面這幅圖是來源於https://aerotwist.com/blog/the-anatomy-of-a-frame 這裡還給大家推薦一篇講解瀏覽器渲染的文章:https://juejin.im/entry/6844903476506394638 ## 其他生命週期函式 在學習Hooks的時候,難免會和class元件中的生命週期做比較,這裡我們只關注useEffect,useEffect在某些程度上相當於`componentDidMount` 、 `componentDidUpdate` 、 `componentWillUnmount`三個鉤子函式的集合,因為這些函式都會阻塞瀏覽器的渲染,其中`componentDidMount` 、 `componentDidUpdate`的執行是在哪裡呢,看一下上面提到的commitLifeCycles函式就清楚了(componentWillUnmount大家有興趣自己找找吧); ``` function commitLifeCycles(finishedRoot, current, finishedWork, committedExpirationTime) { switch (finishedWork.tag) { case FunctionComponent: case ForwardRef: case SimpleMemoComponent: case Block: { commitHookEffectListMount(Layout | HasEffect, finishedWork); return; } case ClassComponent: { var instance = finishedWork.stateNode; if (finishedWork.effectTag & Update) { if (current === null) { // 初次渲染 ...... instance.componentDidMount(); stopPhaseTimer(); } else { // 更新渲染 ...... instance.componentDidUpdate(prevProps, prevState, instance.__reactInternalSnapshotBeforeUpdate); stopPhaseTimer(); } } ``` ## 參考資料 * https://mp.weixin.qq.com/s/of1ulUPtz7c8Evc9A8cYdw * https://developers.google.cn/web/fundamentals/performance/rendering * https://juejin.im/entry/6844903476506394638 * https://indepth.dev/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react/ * https://blog.csdn.net/frontend_frank/article/details/107273939 福祿ICH·架構組 福