react-hooks 原理解析

一、引言

hook 是 React 16.8 的新增特性。它可以讓你在不編寫 class 的情況下使用 state 以及其他的 React 特性。hook 的誕生是爲了解決以下幾個痛點。

1. 在組件之間複用狀態邏輯很難

先來舉個栗子,我們要監聽滾動事件,滾動到 600,展示 回到頂部 按鈕,實現如下:

const getPosition = () ={

  left: document.body.scrollLeft,

  top: document.body.scrollTop

}

const BackToTop = (props) ={

const [position, setPosition] = useState(getPosition())

useEffect(() ={

    const handler = () => setPosition(getPosition())

    document.addEventListener("scroll", handler)

    return () ={

        document.removeEventListener("scroll", handler)

    }

    }[])

  return {position.top > 600 ? '返回頂部' : '' }

}

假設現在我們要監聽滾動事件,頂部有固定的 tab 標籤,滾動到某個標籤的內容處,tab 指向那個標籤。

對於以上兩個例子來說,監聽滾動事件邏輯是完全一致的,毫無疑問,我們想要複用這一邏輯。如果你使用過 React 一段時間,你也許會熟悉一些解決此類問題的方案,比如 render props 和 高階組件。但是這類方案需要重新組織你的組件結構,這可能會很麻煩,使你的代碼難以理解,由 providers,consumers,高階組件,render props 等其他抽象層組成的組件會形成 “嵌套地獄”。

我們可以用自定義 hook 的方式來 複用狀態邏輯,**自定義 Hook 是一個函數,其名稱以 “use” 開頭,函數內部可以調用其他的 Hook。**如下:

const usePosition = () ={

const [position, setPosition] = useState(getPosition())

useEffect(() ={

    const handler = () => setPosition(getPosition())

    document.addEventListener("scroll", handler)

    return () ={

    document.removeEventListener("scroll", handler)

    }

    }[])

  return position

}

較 render props 和高階組件,自定義 hook 簡單、容易理解、學習成本低、易於維護、沒有嵌套地獄等。

2、複雜組件變得難以理解

我們經常維護一些組件,組件起初很簡單,但是逐漸會被狀態邏輯和副作用充斥。每個生命週期常常包含一些不相關的邏輯。例如,組件常常在 componentDidMount 和 componentDidUpdate 中獲取數據。但是,同一個 componentDidMount 中可能也包含很多其它的邏輯,如設置事件監聽,而之後需在 componentWillUnmount 中清除。相互關聯且需要對照修改的代碼被進行了拆分,而完全不相關的代碼卻在同一個方法中組合在一起。如此很容易產生 bug,並且導致邏輯不一致。

用 hook 你可以將一個功能放在同一個 useEffect(或其他 hook)內,可以使用多個 useEffect,對於代碼可讀性、複雜性和可維護性都有很大提升。

3、難以理解的 class

除了代碼複用和代碼管理會遇到困難外,我們還發現 class 是學習 React 的一大屏障。你必須去理解 javascript 中 this 的工作方式,要了解 React.Component 的 api 等,class 不能很好的壓縮,會使熱重載出現不穩定的情況。

因此,react 要提供一個使代碼更易於優化的 API,這就是 hook。接下來分析一下常用 hook 的實現原理:useState、useReducer、useEffect、useLayoutEffect、useCallback、useMemo。

二、原理解析

1、useState

1.1、示例解析

import React, { useState } from 'react'

function App() {

    const [count, setCount] = useState(0)

    return (

        <div>

            <div>{count}</div>

            <div

                onClick={() ={

                    setCount(1)

                    setCount((state) => state + 2)

                    setCount((state) => state + 3)
                }}

            >加</div>

        </div>
    )
}

export default App

對於上面的示例,我們需要關注的點是第一次調用函數組件時做了什麼事情?(首次渲染setCount 時做了什麼事情?再次執行函數組件時做了什麼事情?(再次渲染

在瞭解這個之前,先聊三個基礎知識:

(1)react16 + 將 dom 節點以 fiber 節點的形式進行存儲,具體可參見源碼。

(2)react16 + 架構可以分爲三層:

(3)函數組件是在 commit 階段執行的。

現在我們再來看 首次渲染 – setCount - 再次渲染 做了什麼事情:

(1)首次渲染主要是初始化 hook,將初始值存入 hook 內,將 hook 插入到 fiber.memoizedState 的末尾。

(2)setCount 主要是將更新信息插入到 hook.queue.penging 的末尾。這裏注意一下爲什麼沒有直接更新 hook.memoizedState 呢?答案是 react 是批量更新的,需要將更新信息先存儲下來,等到合適的時機統一計算更新。

(3)再次渲染主要是根據 setCount 存儲的更新信息來計算最新的 state。

那具體數據都是怎麼流轉的呢?react-fiber 主要是圍繞着 fiber 數據結構做一些更新存儲計算等操作,那在這幾個過程中 fiber 都經歷了什麼呢?帶着這兩個問題,我們來做一下講解。

1.1.1、首次渲染

我們知道 hook 可以讓你在不編寫 class 的情況下使用 state 以及其他的 React 特性。所以 hook 很大一個功能是存儲 state。在 class 組件中 state 是存儲在 fiber.memoizedState 字段的,是一個對象。同理在函數組件內 hook(所有的 hook,不止是 useState)的信息也是存儲在 fiber.memoizedState 字段. 的。以上示例中,當第一次執行 const [count,  setCount] = useState(0) 時,得到的 fiber.memoizedState 的數據結構如圖所示:

圖片

字段釋義:

hook = {

// 保存本hook的信息,不同的hook存儲的結構不一致,在useState上代表的是state值,在上例中就是0
memoizedState: null,

// 每次更新時的基準state,大部分情況下和memoizedState一致,有異步更新時會有差別
baseState: null,

// 記錄更新的信息
queue: {

    dispatchSetState, // 在setCount時所執行的方法

    lastRenderedReducer, // 每次計算新state時的方法

    lastRenderedState, // 上一次state的值

    pending, // 存儲更新,講到setCount時再講一下其結構

},

// 表示上一次計算新的state之後,剩下的優先級低的更新,會流入下一次任務中計算,結構同queue.pending
baseQueue: null

// 指向下一個hook對象。
next: null,

};

hook 都是這個數據結構,useReducer 和 useState 完全一樣,其他的 hook 只用到了 memoizedState 字段。

1.1.2、setCount

當我們點擊按鈕,在執行

setCount(1)

setCount((state) => state + 2)

setCount((state) => state + 3)

時,得到的 fiber 的數據結構如圖所示,其中 hook.queue.pengding 爲環狀鏈表:

圖片

解讀一下 queue.pending 的數據結構,baseQueue 和此結構保持一致

pending = {

  action: action, // setCount傳遞的值,可能function、常量或對象

  eagerReducer: null, // 如果是第一個更新,在dispatchSetState的時候就計算出來存儲在這裏

  eagerState: null, // 如果是第一個更新,在dispatchSetState的時候就存儲reducer

  lane: lane, // 更新的優先級

  next: null, // 指向下一個更新

}

Q:關於更新隊列爲什麼是環狀?

A:這是因爲方便定位到鏈表的第一個元素。pending 指向它的最後一個 update,pending.next 指向它的第一個 update。試想一下,若不使用環狀鏈表,pending 指向最後一個元素,需要遍歷才能獲取鏈表首部。即使將 pending 指向第一個元素,那麼新增 update 時仍然要遍歷到尾部才能將新增的接入鏈表。而環狀鏈表,只需記住尾部,無需遍歷操作就可以找到首部。

1.1.3、再次渲染

執行了 setCount 之後,react 會再次進入 render 階段,執行函數組件所對應的方法,再次渲染,react 需要計算最新的值。計算的方法就是 看傳遞給 setCount 的參數是不是一個方法,是的話就執行(參數爲上一次計算出來的最新的 state)計算新值,否則傳進來的參數賦值給新值。將新值賦值在 hook.memoizedState 上。

我們的例子中,setCount(1),新值爲 1;setCount((state) => state + 2),新值爲 1+2=3;setCount((state) => state + 3),新值爲 3+3=6。新值 6 賦值給 hook.memoizedState,得到 fiber 結構如下圖所示:

圖片

1.2、源碼實現

當函數組件進入 render 階段 時,會調用 renderWithHooks 方法,該方法內部會執行函數組件對應函數(即 App())。

我們來看一個流程圖,此圖對 首次渲染 – setCount - 再次渲染 進行了總結。

圖片

2、useReducer vs useState

上面講解了 useState,有一個和他作用比較相似的 hook,它就是 useReducer,也是用來存儲狀態的,不同的是,計算新的狀態的時候,是用戶自己計算的,可以支持更復雜的場景,我們先來看一下它的用法。

2.1、示例

const initialState = {count: 0};

function reducer(state, action) {
    switch (action.type) {
        case 'increment':
            return {count: state.count + 1};
        case 'decrement':
            return {count: state.count - 1};
        default:
            throw new Error();
    }
}

function Counter() {

    const [state, dispatch] = useReducer(reducer, initialState);
    return (
        <> 
            Count: {state.count}
            <button onClick={() => dispatch({type: 'decrement'})}>-</button>
            <button onClick={() => dispatch({type: 'increment'})}>+</button>
       </>
    );
}

我們看到在用法上,useReducer 和 useState 的返回值是一致的,區別是 useReducer.

第一個參數是一個 function,是用於計算新的 state 所執行的方法,對標 useState 的 basicStateReducer。我們看到 reducer 的實現和 redux 很相似,原因是 Redux 的作者 Dan 加入 React 核心團隊,其一大貢獻就是 “將 Redux 的理念帶入 React”。

第二個參數是初始值。

第三個參數是計算初始值的方法,其執行時候的參數是上述第二個參數。

2.2、使用場景

所有用 useState 實現的場景都可以用 useReducer 來實現,像如下複雜的場景更適合用 useReducer,比如 state 邏輯較複雜且包含多個子值,或者下一個 state 依賴於之前的 state 等。

3、useEffect vs useLayoutEffect

上面講了存儲狀態相關的兩個 hook,接下來講解下類比 class 組件生命週期的兩個 hook,useEffect 和 useLayoutEffect 相當於 class 組件的以下生命週期: componentDidMount、componentDidUpdate、componentWillUnMount,二者使用方式完全一樣,不同的點是調用的時機不同,以 useEffect 爲示例來說明一下。

3.1、示例解析

import React, { useState, useEffect } from 'react'

function App() {

    const [count, setCount] = useState(0);

    useEffect(() ={

        console.log('useEffect:', count);

        return () ={
            console.log('useEffect destory:', count);
        }

    }[count])

    return (

        <div>
            <div>{count}</div>
            <div onClick={() => setCount(count + 1) }>加1<?div>
        </div>
    )
}

export default App;

上面的示例,我們需要關注的是 useEffect 的回調函數和其返回的函數,是什麼時機執行的?又是通過什麼機制來判定要不要執行呢?fiber 的結構又是怎麼變化的呢?

這裏先概括一下:useEffect 的實現,是在 render 階段給 fiber 和 hook 設置標誌位,在 commit 階段根據標誌位來執行回調函數和銷燬函數,後面將按照 render 階段 - commit 階段 來進行講解,commit 階段又分爲 3 個階段,分別是 before mutation 階段 (執行 dom 操作前)、mutation 階段(執行 dom 操作)、layout 階段(執行 dom 操作後)

上述示例中首次渲染執行到 useEffect 之後,掛載到 fiber.memoizedState 的數據結構如下:

圖片

再次渲染結果,此時 destory 是有值的,其他不變。結果如下:

圖片

數據結構解讀:

effectMemoized = {

  create: create, // useEffect的回調函數

  destroy: destroy,// useEffect的回調函數的返回值(即後面所說的銷燬函數)

  deps: deps,// 依賴的數組

  tag: tag,// hook的標誌位,commit階段會根據這個標誌來決定是不是要執行回調函數和銷燬函數

  next: null, // 下一個hook

}

在 commit 階段就是根據 fiber.flags 和 hook.tag 來判段是否執行 create 或者 destory。

每個階段分別作了什麼事情,我們來看一下,render 階段流程圖:

圖片

commit 階段流程圖:

圖片

對上述兩圖做一下講解

3.1.1、render 階段

A、首次渲染

B、再次渲染

useEffectLayout vs useEffect 標誌位情況如下:

圖片

其中這幾個標誌位是二進制,如下:

// flags相關

export const UpdateEffect = 0b000000000000000100;

export const PassiveEffect = 0b000000001000000000;

export const PassiveStaticEffect = 0b001000000000000000;

export const MountLayoutDevEffect = 0b010000000000000000;

export const MountPassiveDevEffect = 0b100000000000000000;


// hook標誌位相關

export const HookHasEffect = 0b001;

export const HookPassive = 0b100;

export const HookLayout = 0b010;

export const NoHookEffect = 0b000;

我們看到,在設置標誌位的時候,都是用的邏輯或,即是在某一位上添加上 1,在判斷的時候,我們只需要判斷 fiber 或者 hook 上在某一位上是不是 1 即可,這時候應該用邏輯與來判斷。

3.1.2、commit 階段

A、before mutation 階段(執行 DOM 操作前)

B、mutation 階段(執行 DOM 操作)

C、layout 階段

異步調度的原理:如下圖,需要注意的是 GUI 線程和 js 引擎線程是互斥的,當 js 引擎執行時,GUI 線程會被掛起,相當於被凍結了,GUI 更新會被保存在一個隊列中,等 js 引擎空閒時(可以理解 js 運行完後)立即被執行。

圖片

圖片

如上圖,我們的調度可以簡單的理解爲是類似 setTimeout 的宏任務,當然其內部實現要比這個複雜多了。當 commit 階段整個執行完畢之後,瀏覽器會啓動 GUI 渲染引擎 進行一次繪製,繪製完畢之後,react 會取出一個宏任務來執行(react 會保證我們異步調度的 useEffect 的函數會在下一次更新之前執行完畢)。因此,在 mutaiton 階段,我們已經把發生的變化映射到真實 DOM 上了,但由於 JS 線程和瀏覽器渲染線程是互斥的,因爲 JS 虛擬機還在運行,即使內存中的真實 DOM 已經變化,瀏覽器也沒有立刻繪製到屏幕上。

commit 階段是不可打斷的,會一次性把所有需要 commit 的節點全部 commit 完,至此 react 更新完畢,JS 停止執行。GUI 渲染線程把發生變化的 DOM 繪製到屏幕上,到此爲止 react 把所有需要更新的 DOM 節點全部更新完成。

繪製完成後,瀏覽器通知 react 自己處於空閒階段,react 開始執行自己調度隊列中的任務,此時纔開始執行異步調度的函數( 也就是去執行 useEffect(create, deps) 的產生的函數)。

3.2、使用場景

useEffect: 適用於許多常見的副作用場景,比如設置訂閱和事件處理等情況,不會在函數中執行阻塞瀏覽器更新屏幕的操作。

useLayoutEffect: 適用於在瀏覽器執行下一次繪製前,用戶可見的 DOM 變更就必須同步執行,這樣用戶纔不會感覺到視覺上的不一致。

4、useCallback

上面講述了類似 class 組件生命週期相關的 hook,這裏講一下性能優化相關的 hook,useCallback 和 useMemo。

4.1、示例解析

import React, { useState, useCallback } from 'react'

function App() {

    const {count, setCount} = useState(0);

    const memoizedCallback = useCallback(() => count, [count]);

    return (
        <div>
            <div>{count}</div>
            <div>{memoizedCallback}</div>
        </div>
    )
}

export default App;

得到的 fiber 數據結構如圖:

圖片

其中 mountCallback 就是將傳入的方法返回,並且將 [function, 依賴項數組] 做爲數組存儲在 hook.memoizedState 上面。updateCallback 查看依賴項是否和上次一致,如果一致,就返回 function,如果不一致,就返回新傳入的 function,並且重新存儲一下[function, 依賴項數組]。

5、useMemo vs useCallback

5.1、示例解析

import React, { useState, useMemo } from 'react'

function App() {

    const {count, setCount} = useState(0);

    const memoizedMemo = useMemo(() => count, [count]);

    return (
        <div>
            <div> {count}</div>
            <div>{memoizedMemo}</div>
        </div>
    )
}

export default App;

我們看下 useMemo 和 useCallback 的區別,用法一樣,返回值 useMemo 是返回的執行方法之後得到的結果,memoizedState 存儲的第一項也是執行方法之後得到的結果。

5.2、使用場景

class 組件一個性能優化的點:shouldComponentUpdate,function 組件沒有 shouldComponentUpdate,有較大的性能損耗,useMemo 和 useCallback 就是解決性能問題的殺手鐧。

5.2.1、useCallback

如下面的例子,父組件傳遞給子組件一個 callback 函數,那麼當 input 框內有變化時,都會觸發更新渲染操作,Parent 方法組件都會執行,每次 callback 都是新定義的一個方法變量,那每次指針也都是不一致的,所以每次也會觸發 Child 方法組件的更新,而我們看到 Child 組件只是用到了 count,並沒有用到 name,所以我們希望的是 input 有變化(也就是 name 變化時)不重新渲染 Child,這個時候就可以用 useCallback 了。

import React, { useState, useCallback, useEffect } from 'react';

function Parent() {

    const [count, setCount] = useState(1);

    const [val, setVal] = useState('');

    const callback = () ={

        return count;

    };

    return <div>

        <h4>{count}</h4>

        <Child callback={callback}/>

        <div>

            <button onClick={() => setCount(count + 1)}>+</button>

            <input value={val} onChange={event => setVal(event.target.value)}/>

        </div>

    </div>;

}

function Child({ callback }) {

    const [count, setCount] = useState(() => callback());

    useEffect(() ={

        setCount(callback());

    }[callback]);

    return <div> {count} </div>

}

我們對 callback 做如下改造

const callback = useCallback(() => count, [count])

如此一來,只有當 count 改變的時候,callback 纔會重新賦值,當 count 不改變的時候,就會從內存中取值了。

5.2.2、useMemo

export default function WithoutMemo() {

    const [count, setCount] = useState(1);

    const [val, setValue] = useState('');

    const expensive = () ={

        let sum = 0;

        for (let i = 0; i < count * 100; i++) {
            sum += i;
        }
        return sum;
    };

    return <div>

        <h4>{count}-{expensive}</h4>

        {val}

        <div>
            <button onClick={ () => setCount(count + 1)}>+c1</button>
            <input value={val} onChange={ event => setValue(event.target.value)} />
        </div>
    </div>;
}

這裏創建了兩個 state,然後通過 expensive 函數,執行一次昂貴的計算,拿到 count 對應的某個值。我們可以看到:無論是修改 count 還是 val,由於組件的重新渲染,都會觸發 expensive 的執行 (能夠在控制檯看到,即使修改 val,也會打印);但是這裏的昂貴計算只依賴於 count 的值,在 val 修改的時候,是沒有必要再次計算的。在這種情況下,我們就可以使用 useMemo,只在 count 的值修改時,執行 expensive 計算:

const expensive = useMemo(() ={

  let sum = 0;

  for (let i = 0; i < count * 100; i++) {
    sum += i;
  }

    return sum;

  }[count]);

上面我們可以看到,使用 useMemo 來執行昂貴的計算,然後將計算值返回,並且將 count 作爲依賴值傳遞進去。這樣,就只會在 count 改變的時候觸發 expensive 執行,在修改 val 的時候,返回上一次緩存的值。

小結:

1、如果有函數傳遞給子組件,使用 useCallback

2、緩存一個組件內的複雜計算邏輯需要返回值時,使用 useMemo

3、如果有值傳遞給子組件,使用 useMemo

三、小結

以上兩章講解了 hook 出現解決了現有的痛點,以及常用的 hook 如何使用、原理和使用場景,總結一下這幾個 hook 的整體流程,其中相似的 hook 我們只講一個,如下表:

圖片

注:源碼部分,是以 17.0.0-dev 分支展開。

1、renderWithHooks 和各個 hook 的實現在:https://github.com/facebook/react/blob/17.0.0-dev/packages/react-reconciler/src/ReactFiberHooks.new.js

2、commit 階段的入口在:https://github.com/facebook/react/blob/17.0.0-dev/packages/react-reconciler/src/ReactFiberWorkLoop.new.js#L1775

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/2FPspnyJkOc3Ly7Sxxi4VA