引言
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的監控圖:
通過兩個hooks的執行圖可以看出,useLayoutEffect發生在頁面渲染到屏幕(用戶可見)之前,useEffect發生在那之后,中間還經歷了DCL,FCP,FMP,LCP階段,除開DCL(DomContentLoaded)之外,這些指標是RAIL模型衡量頁面性能的標准,總的來說,渲染到屏幕的階段是一個分水嶺,那么渲染包含什么呢,還是看圖吧:
此階段完成了樣式的計算(Recalculate Style)和布局(Layout),緊接着是一個Task,完成Update Layer Tree,Paint,Composite Layers,經過這一系列的任務后,頁面最終呈現給用戶,可以用一張圖來表示瀏覽器的渲染過程:
后面會有相關學習資料,這里就不展開細說了。
模擬運行示例
在深入了解React的運行之前,首先在本地寫一個簡單的示例,大致模擬文章開始的例子:
<body>
<div id="app"></div>
<script type="text/javascript">
(function iife(){
function render() {
var appNode = document.querySelector('#app');
var textNode = document.createElement('span');
textNode.id = 'tip';
textNode.textContent = 'hello';
appNode.appendChild(textNode);
}
function useLayoutEffectDemo() {
console.log('useLayoutEffectDemo', document.querySelector('#tip'));
}
function useEffectDemo() {
console.log('useEffectDemo');
}
render();
useLayoutEffectDemo();
setTimeout(useEffectDemo, 0);
})();
</script>
</body>
然后啟用Performance監控渲染情況:
總結一下:
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的運行流程來具體看下不同階段的執行情況:
渲染流程圖(初次渲染)
簡單總結一下:
1.react-dom負責Fiber節點的創建,最終形成一個Fiber節點樹,其中每個Fiber包含需要執行的副作用和渲染到屏幕的DOM對象;
2.調用scheduler暴露的方法注冊需要調度的事件;
3.執行DOM插入;
4.執行useLyaoutEffect或者ClassComponent的生命周期函數;
5.瀏覽器接過控制權,執行渲染;
6.scheduler執行調度任務,執行useEffectDemo;
以上就是整體流程,接下來再深入一點,看看useEffect和useLayoutEffect是怎么解析和執行的:
use(Layout)Effect解析與執行
1.解析
從上圖可知,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);
}
都認識,但是不知道是干嘛的,好吧,還是用一張圖來說明吧:
這個函數的功能如下:
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都會放在這里,那么是怎么一個結構存儲的呢,可以打印看一下:
其實就是一個收尾相連的環形結構,為什么要這么設計呢,大家看下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的執行,這里再看下具體的執行過程:
執行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://developers.google.cn/web/fundamentals/performance/rendering
深入一點
了解了瀏覽器的基本渲染之后,可以更加深入窺探瀏覽器的運行,首先上一張圖:
上面這幅圖是來源於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