【萬字長文】React Hooks的黑魔法

React 的 logo 是一個原子圖案, 原子組成了物質的表現。相似的, React 就像原子般構成了頁面的表現; 而 Hooks 就如夸克, 更接近 React 的本質, 可是直到 4 年後的今天才被設計出來。 —— Dan in React Conf(2018)html

正片開始

React在18年推出了hooks這一思想,以一種新的思惟模式去構建web App。咱們都知道,React認爲,UI視圖是數據的一種視覺映射,即UI = F(data),F須要負責對輸入數據進行加工、並對數據的變動作出響應。 React給UI的複用提供了極大的便利,可是對於邏輯的複用,在Class Component中並非那麼方便。在有Hooks以前,邏輯的複用一般是使用HOC來實現,使用HOC會帶來一些問題:前端

  • 嵌套地獄,每一次HOC調用都會產生一個組件實例
  • 可使用類裝飾器緩解組件嵌套帶來的可維護性問題,但裝飾器本質上仍是HOC

在有Hooks以前,Function Component只能單純地接收props、事件,而後返回jsx,自己是無狀態的組件,依賴props來響應數據(狀態)的變動,而上面提到的依賴都是從Class Component傳入的,因此在有Hooks以前Function Component是要徹底依賴Class Component存在的。可是這上面這些在Hooks出現以後所有都被打破。 本文不會介紹hooks的使用方式,詳見 官方文檔react

在使用了Hooks以後,你確定會對Hooks的原理很是感興趣,本文要講述的就是React Hooks的「黑魔法」,主要內容有:web

  1. Hooks的結構
  2. Hooks是如何區分是首次渲染(mount)仍是更新(update)的
  3. useState是如何使Function Component有狀態的?
    • useState的原理
    • useState如何觸發更新
  4. useEffect、useCallback、useMemo、useRef的實現
  5. 使用hooks的注意事項

Hooks的結構

一個Hook結構以下:數組

export type Hook = {
  memoizedState: any,

  baseState: any,
  baseUpdate: Update<any, any> | null,
  queue: UpdateQueue<any, any> | null,

  next: Hook | null,
};
複製代碼

咱們都知道,在React v16版本推出了Fiber架構,每個節點都對應一個Fiber結構。一個Function Component中能夠有不少個Hooks執行,最終造成的結構以下:緩存

上面這個圖到這裏還看不懂不要緊,下面讓咱們開始深刻Hooks的原理。

Hooks如何區分Mount和Update

要知道這個問題的答案,首先須要瞭解React的Fiber架構。React定義了Fiber結構來描述一個節點,經過Fiber節點上的child、return、sibling指針構成整個App的結構。Fiber類型定義以下:bash

export type Fiber = {|
  tag: WorkTag,
  key: null | string,
  elementType: any,
  type: any,
  stateNode: any,
  return: Fiber | null,
  child: Fiber | null,
  sibling: Fiber | null,
  index: number,
  ref: null | (((handle: mixed) => void) & {_stringRef: ?string}) | RefObject,
  pendingProps: any, // This type will be more specific once we overload the tag.
  memoizedProps: any, // The props used to create the output.
  updateQueue: UpdateQueue<any> | null,
  memoizedState: any,
  dependencies: Dependencies | null,
  mode: TypeOfMode,
  effectTag: SideEffectTag,
  nextEffect: Fiber | null,
  firstEffect: Fiber | null,
  lastEffect: Fiber | null,
  expirationTime: ExpirationTime,
  childExpirationTime: ExpirationTime,
  alternate: Fiber | null,
  // ...
|};
複製代碼

用React Conf上的例子簡單說明一下 Fiber 結構:List組件下面有四個子節點:一個button和三個Item。閉包

這個組件轉換成Fiber tree結構以下:

一個 Function Component 最終也會生成一個 Fiber 節點掛載到 Fiber tree 中。React 還使用了 Double Buffer 的思想,在 React 內部會維護兩棵 Fiber tree,一棵叫 Current,一棵叫 WorkInProgress,current 是最終展現在用戶界面上的結構,workInProgress 用於後臺進行diff更新操做。兩棵樹在更新結束的時候會互相調換,即 workInProgress 在更新以後會變爲 current 展現在用戶界面上,current 會變成 workInProgress 用於下次 update。兩棵樹之間對應的節點是經過Fiber結構上的 alternat e屬性連接的,即 workInProgress.alternate = current,current.alternate = workInProgress架構

那這和Hooks有什麼關係?其實React在初始渲染的時候,只會生成一棵workInProgress樹,當整棵樹構建完成以後,由workInProgress變爲current,在下一次更新的時候纔會生成第二棵樹。因此當Function Component對應的Fiber節點發現本身的alternate屬性爲null,說明是第一次渲染。在React的源碼中就是renderWithHooks函數中的這句(Function Component的mount和update過程會執行renderWithHooks):ide

export function renderWithHooks( // 判斷是mount仍是update:fiber.memoizedState是否有值
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  props: any,
  refOrContext: any,
  nextRenderExpirationTime: ExpirationTime,
): any {
  // ...
  nextCurrentHook = current !== null ? current.memoizedState : null;

  ReactCurrentDispatcher.current =
    nextCurrentHook === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // ...
}
複製代碼

從代碼中能看到current 等於 null的狀況下,nextCurrentHook = null,致使下面的dispatcher取得是HooksDispatcherOnMount,這個HooksDispatcherOnMount就是在初始渲染Mount階段對應的Hooks,HooksDispatcherOnUpdate顯然就是在更新階段時該調用的Hooks。

useState讓函數組件有狀態

上面知道了Hooks是如何區分Mount和Update以後,接下來分析useState的實現原理。

useState如何觸發更新

state在Function Component中就是一個普通的常量,不存在諸如數據綁定之類的邏輯,更新都是經過useStatedispatch(useState返回的數組的第二個元素),觸發了組件rerender,進而更新組件。

dispatch方法接收到最新的state後(就是第三個參數action),生成一個update,添加到queue的最後,用last指針指向這最新的一次更新,而後調用scheduleWork方法,這個scheduleWork就是觸發react更新的起始方法,在Class Component中調用this.setState時最終也是執行了這個方法開始更新。

function dispatchAction<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A, // 最新的state
) {

  const alternate = fiber.alternate;
  if (
    fiber === currentlyRenderingFiber ||
    (alternate !== null && alternate === currentlyRenderingFiber)
  ) {
    // ...
  } else {
    const currentTime = requestCurrentTime();
    const suspenseConfig = requestCurrentSuspenseConfig();
    const expirationTime = computeExpirationForFiber(
      currentTime,
      fiber,
      suspenseConfig,
    );

    const update: Update<S, A> = {
      expirationTime,
      suspenseConfig,
      action,
      eagerReducer: null,
      eagerState: null,
      next: null,
    };

    // Append the update to the end of the list.
    const last = queue.last;
    if (last === null) {
      // This is the first update. Create a circular list.
      update.next = update;
    } else {
      const first = last.next;
      if (first !== null) {
        // Still circular.
        update.next = first;
      }
      last.next = update;
    }
    queue.last = update;

    // ...

    scheduleWork(fiber, expirationTime);
  }
}
複製代碼

useState的原理

上面提到了Hooks的結構,每一次Hook的執行都會生成一個Hook結構,首次渲染的時候執行useState,會將傳過來的initialState掛在Hook的memoizedState屬性上,後續再獲取狀態就是從memoizedState屬性上獲取了。

mount階段的useState:mountState

useState最終會返回一個有兩個元素的數組,第一個元素是state,第二個元素是 修改state的方法 dispatch。 這裏dispatch方法也須要關注一下:queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber, queue ),dispatch是dispatchAction方法經過bind傳入當前的 Fiber 和 queue屬性做爲前兩個參數生成的,因此每一個useState都有本身的dispatch方法,這個dispatch方法是固定做用在指定的Fiber上的(經過閉包鎖定了前兩個參數)

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue = (hook.queue = {
    last: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  });
  const dispatch: Dispatch<BasicStateAction<S>> = (queue.dispatch = (dispatchAction.bind(
    null, currentlyRenderingFiber, queue ));
  return [hook.memoizedState, dispatch];
}
複製代碼

queue屬性用一個Update結構記錄了每次調用dispatch時候傳過來的state,造成了一個鏈表結構,其last屬性指向最新的一個state Update結構。Update結構以下,咱們須要關注的有:

  • action:最新傳過來的state值
  • next:指向後面生成的Update結構

其餘屬性涉及到的內容不在本次討論範圍

update階段的useState:updateReducer

update階段,React首先會從Hooks上獲取到 last、baseState、baseUpdate 屬性,各個值的含義以下:

  • last:queue上掛載的最新一次的Update,裏面包含了最新的state,在dispatch方法執行的時候掛載到queue上的
  • baseState:上一次更新後的state值。當dispatch傳入的是一個函數的時候,這個值就是函數執行時傳入的參數
  • baseUpdate:上一次更新生成的Update結構,做用是找到本次rerender的第一個爲處理的Update節點(baseUpdate.next),即下面代碼中的first表明第一個未處理的update
const hook = updateWorkInProgressHook();
const queue = hook.queue;

// The last update in the entire queue
const last = queue.last; // 新的值
// The last update that is part of the base state.
const baseUpdate = hook.baseUpdate; // 舊的值,next屬性指向新的Update
const baseState = hook.baseState; // 舊的值

// Find the first unprocessed update.
let first;
if (baseUpdate !== null) {
  if (last !== null) {
    last.next = null;
  }
  first = baseUpdate.next;
} else {
  first = last !== null ? last.next : null;
}
複製代碼

找到第一個未處理的update以後就須要循環對全部新增update進行處理,這裏的變量newState雖然名字叫newState,在每次執行reducer以前的值都是 就的state值,因此當useState傳入的值爲一個函數的時候,咱們能夠獲取到上一次的state,由於舊的state值是有緩存的。 當處理完全部update以後就更新hooks對應的baseState、baseUpdate、memoizedState的值。

if (first !== null) {
  let newState = baseState;
  let newBaseState = null;
  let newBaseUpdate = null;
  let prevUpdate = baseUpdate;
  let update = first;
  let didSkip = false;
  do {
      // ...
    const action = update.action;

    newState = reducer(newState, action);

    prevUpdate = update;
    update = update.next;
  } while (update !== null && update !== first);

  if (!didSkip) {
    newBaseUpdate = prevUpdate;
    newBaseState = newState;
  }

  if (!is(newState, hook.memoizedState)) {
    markWorkInProgressReceivedUpdate();
  }

  hook.memoizedState = newState;
  hook.baseUpdate = newBaseUpdate;
  hook.baseState = newBaseState;

  queue.lastRenderedState = newState;
}

// reducer函數:
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function' ? action(state) : action;
}
複製代碼

最終仍然返回的是一個數組結構,包含了最新的state和dispatch方法

const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
複製代碼

這樣Function Component中獲取到的state就是最新的值,Function Component的更新實際上就是從新執行一次函數,獲得 jsx,剩下 reconcile 和 commit 階段的就與 class Component是同樣的了

useEffect的實現

咱們知道useEffect傳入的函數是在繪製以後才執行的,因此當執行function component執行的時候確定不是 useEffect 的第一個函數參數執行的時候,那在執行useEffect的時候都作了什麼呢?

執行useEffect時是在爲後面作準備。useEffect會生成一個Effect結構,Effect結構以下:

type Effect = {
  tag: HookEffectTag,
  create: () => (() => void) | void,
  destroy: (() => void) | void,
  deps: Array<mixed> | null,
  next: Effect,
};
複製代碼

create就是咱們傳入的參數,咱們若是想取消一個反作用的話是經過create執行返回的結果(仍然是一個函數),React會在內部調用這個函數。由於create函數是在 繪製以後執行的,因此這個時候Effect的destory是null,在後面真正執行create的時候會賦值destory

生成了Effect結構以後就要將其掛載到Hooks的memorizedState上,React不光將Effect掛載到了Hook結構上,也將其直接和Fiber掛鉤:

React會將全部的effect造成一個環形鏈表,保存在FunctionComponentUpdateQueue上,其lastEffect指向最新生成的effect。 爲何要作一個環形鏈表保存全部的effect? 我認爲主要是:

  • 環形鏈表能夠很方便的找到頭結點(第一個effect),能夠處理全部的effect
  • 保存全部的effect是由於effect可能有須要unmount的時候銷燬的操做,保存以前的effect能夠調用其destory方法銷燬,避免內存泄露

最終處理上面保存的effects的函數在commit階段的commitHookEffectList函數中,代碼以下,主要工做就是:循環處理全部的effect,判斷須要銷燬仍是須要執行,循環終止條件就是從新回到環形鏈表的第一個節點。刪減後的代碼以下:

// ReactFiberCommitWork.js
function commitHookEffectList(
  unmountTag: number,
  mountTag: number,
  finishedWork: Fiber,
) {
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  let lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & unmountTag) !== NoHookEffect) {
        // Unmount
        const destroy = effect.destroy;
        effect.destroy = undefined;
        if (destroy !== undefined) {
          destroy();
        }
      }
      if ((effect.tag & mountTag) !== NoHookEffect) {
        // Mount
        const create = effect.create;
        effect.destroy = create();

      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}
複製代碼

useCallback和useMemo的實現

useCallbackuseMemo是十分類似(useCallback能夠經過useMemo實現),因此這裏咱們看一下useMemo的實現: useMemo傳入一個函數fn和一個數組dep,當dep中的值沒發生變化的時候,就會一直返回以前函數fn執行後返回的值,因此咱們首先執行函數fn,將返回的結果和依賴數組dep保存起來就行了, React 內部是這麼處理的:

const nextValue = nextCreate(); // 傳入的函數Fn
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
複製代碼

當觸發re-render的時候再次執行 useMemo,React會從 hook.memoizedState 上面取出以前保存的dep,也就是hook.memoizedState的第二個元素。比較以前的dep和新傳入的dep的每一個元素是否相同(淺比較),若是相同則返回原來保存的值(hook.memoizedState的第一個元素),不相同則從新執行 函數Fn 從新生成返回值並保存。

const prevState = hook.memoizedState;
if (prevState !== null) {
  if (nextDeps !== null) {
    const prevDeps: Array<mixed> | null = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];
    }
  }
}
const nextValue = nextCreate();
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
複製代碼

useCallback原理相同,只不過保存的值不一樣,一個是函數,一個是函數的執行結果。

useRef的實現

useRef的實現是最簡單的了,咱們先回顧一下useRef的做用:使用useRef生成一個對象以後能夠經過 current 屬性獲取到一個值,這個是不會隨着re-render而改變,只能咱們本身手動改變。固然也能夠傳遞給組件的 ref 屬性使用。常常被類比爲 Class Component的實例屬性,好比 this.xxx = xxx

在開頭部分咱們知道 Hooks 是基於 fiber 結構的,fiber 是 react 內部維護的結構,會在整個react生命週期中存在,因此useRef最簡單的實現就是 掛載到 fiber 上,做爲fiber的一個屬性存在,這樣經過 fiber 就一直能獲取到值。

可是真正設計的時候確定不能這樣來,由於 Hooks 是有本身的結構的,因此就把 useRef 的值掛載到 Hook 結構的 memorizedState上 就能夠了,因此你看到的 useRef 的結構是這樣的:

將 useRef的值掛載到 對象的 current屬性上,從 current屬性上能獲取到值

使用hooks的注意事項

使用React.memo減小沒必要要的組件渲染

React.memo is equivalent to PureComponent, but it only compares props. (You can also add a second argument to specify a custom comparison function that takes the old and new props. If it returns true, the update is skipped.)

使用 React.memo包裹組件以後,當父組件傳過來的props不變時,子組件不會re-render。舉個例子:

const ChildComponent = React.memo((props) => {
    return (
        <div>{props.name}</div>
    )
}, compare)
複製代碼

React.memo是對新舊Props進行淺比較,也能夠自定義compare函數比較nextPropsprevProps,淺比較就會帶來問題:每次Function Component執行內部的對象都會從新生成,這個時候若是傳給子組件的是一個對象的話,其實仍是會形成刷新。例:

function ParentComponent() {
    const [state, setState] = useState(0)
    function handleClickButton() {
       console.log(state)
        // balabalabala
    }
    const someProps = {
        name: 'xxx'
    }
    return (
        <div>
            <input />
            <ChildExpensiveComponent onPress={handleClickButton} someProps={someProps}/>
        </div>
    )
}
複製代碼

因此這裏咱們想到用 useMemouseCallback 來保存值,這樣只要依賴不變值就不變。因此下面代碼這種改變以後確實不會觸發沒必要要的刷新了

function ParentComponent() {
    const [state, setState] = useState(0)
    const handleClickButton  = useCallback(() => {
        // balabalabala
        console.log(state) // 0
    }, [])
    const memoProps = useMemo(() => {
        return {
            name: 'xxx'
        }
    }, [])
    return (
        <div>
            <p>{state}</p>
            <ChildExpensiveComponent onPress={handleClickButton} someProps={memoProps}/>
        </div>
    )
}
複製代碼

可是Hooks是經過closure實現的,除useRef以外,其餘的Hooks都會存在capture values的特色。上面例子中handleClickButton每次執行,不管state如何變化,打印的state值將一直是0。 由於useCallback經過閉包保存了一開始的state的值,這個值不會像Class Component同樣每次都會取到最新的值。

那咱們是否是給handleClick加個dependence就好了,像這樣:

const handleClickButton  = useCallback(() => {
    // balabalabala
    console.log(state) // 0
}, [state])
複製代碼

可是當你的state頻繁發生變化的時候,handleClickButton其實會頻繁改變,這樣的話你的子組件經過React.memo實現的優化就失效了。

因此當依賴常常變更時,盲目使用useCallbackuseMemo可能會致使性能不升反降。 上面咱們已經瞭解了,在react內部,useCallback執行會生成一個Hook結構,將函數和deps保存在這個Hook結構的memoizedState上,在每次rerender的時候,react會去比較prevDepsnextDeps,相等會返回保存的值/函數,不相等會從新執行,因此當deps頻繁改變的時候,會多了一個比較deps是否改變的操做,也會浪費性能。這裏有一個來自React官網的例子:

function Form() {
  const [text, updateText] = useState('');

  const handleSubmit = useCallback(() => {
    console.log(text);
  }, [text]); // 每次 text 變化時 handleSubmit 都會變

  return (
    <>
      <input value={text} onChange={(e) => updateText(e.target.value)} />
      <ExpensiveTree onSubmit={handleSubmit} /> // 很重的組件
    </>
  );
}
複製代碼

解決這種問題React官方也給了一種方式,就是使用useRef

function Form() {
  const [text, updateText] = useState('');
  const textRef = useRef();

  useEffect(() => {
    textRef.current = text; // Write it to the ref  });

  const handleSubmit = useCallback(() => {
    const currentText = textRef.current; // Read it from the ref   
    alert(currentText);
  }, [textRef]); // Don't recreate handleSubmit like [text] would do return ( <> <input value={text} onChange={e => updateText(e.target.value)} /> <ExpensiveTree onSubmit={handleSubmit} /> </> ); } 複製代碼

當使用Hooks時不要把思惟仍然侷限在class Component的定式中,useRef一般給人的感受是和class Component的createRef相似的做用,但 useRef 除了在 ref 使用以外,還能夠用來保存值,上面這個例子是一個很是好的例子,幫咱們更好的去使用Hooks避免一些性能的浪費。

推薦閱讀

reactjs.org/docs/hooks-…

reactjs.org/docs/hooks-…

zhuanlan.zhihu.com/p/142735113

juejin.im/post/5c9827…

medium.com/@dan_abramo…

dev.to/tylermcginn…

歡迎關注個人我的技術公衆號,不按期分享各類前端技術~

相關文章
相關標籤/搜索