iT邦幫忙

0

梳理useEffect和useLayoutEffect的原理與區別

點擊進入React源碼調試倉庫。

React在構建用戶界面整體遵循函數式的編程理念,即固定的輸入有固定的輸出,尤其是在推出函數式組件之後,更加強化了組件純函數的理念。但實際業務中編寫的組件不免要產生請求數據、訂閱事件、手動操作DOM這些副作用(effect),這樣難免讓函數組件變得不那麽純,於是React提供use(Layout)Effect的hook,給開發者提供專門管理副作用的方式。

下面我們會從effect的數據結構入手,梳理use(Layout)Effect在render和commit階段的整體流程。

Effect的數據結構

關於hook鏈表結構的基本概念我已經總結過壹篇文章:React hooks 的基礎概念:hooks鏈表。對函數組件來說,其fiber上的memorizedState專門用來存儲hooks鏈表,每壹個hook對應鏈表中的每壹個元素。use(Layout)Effect產生的hook會放到fiber.memorizedState上,而它們調用後最終會生成壹個effect對象,存儲到它們對應hook的memoizedState中,與其他的effect連接成環形鏈表。

單個的effect對象包括以下幾個屬性:

  • create: 傳入use(Layout)Effect函數的第壹個參數,即回調函數
  • destroy: 回調函數return的函數,在該effect銷毀的時候執行
  • deps: 依賴項
  • next: 指向下壹個effect
  • tag: effect的類型,區分是useEffect還是useLayoutEffect

單純看effect對象中的字段,很容易和平時的用法聯系起來。create函數即我們傳入use(Layout)Effect的回調函數,而通過deps,可以控制create是否執行,如需清除effect,則在create函數中return壹個新函數(即destroy)即可。

為了理解effect的數據結構,假設有如下組件:

const UseEffectExp = () => {
    const [ text, setText ] = useState('hello')
    useEffect(() => {
        console.log('effect1')
        return () => {
            console.log('destory1');
        }
    })
    useLayoutEffect(() => {
        console.log('effect2')
        return () => {
            console.log('destory2');
        }
    })
    return <div>effect</div>
}

掛載到它fiber上memoizedState的hooks鏈表結構如下

hooks鏈表結構

例如useEffect hook上的memoizedState存儲了useEffect 的 effect對象(effect1),next指向useLayoutEffect的effect對象(effect2)。effect2的next又指回effect1.在下面的useLayoutEffect hook中,也是如此的結構。

fiber.memoizedState ---> useState hook
                             |
                             |
                            next
                             |
                             ↓
                        useEffect hook
                        memoizedState: useEffect的effect對象 ---> useLayoutEffect的effect對象
                             |              ↑__________________________________|
                             |
                            next
                             |
                             ↓
                        useLayoutffect hook
                        memoizedState: useLayoutEffect的effect對象 ---> useEffect的effect對象
                                            ↑___________________________________|

effect除了保存在fiber.memoizedState對應的hook中,還會保存在fiber的updateQueue中。

fiber.updateQueue ---> useLayoutEffect ----next----> useEffect
                             ↑                          |
                             |__________________________|

現在,我們知道,調用use(Layout)Effect,最後會產生effect鏈表,這個鏈表保存在兩個地方:

  • fiber.memoizedState的hooks鏈表中,use(Layout)Effect對應hook元素的memoizedState中。
  • fiber.updateQueue中,本次更新的updateQueue,它會在本次更新的commit階段中被處理。

流程概述

基於上面的數據結構,對於use(Layout)Effect來說,React做的事情就是

  • render階段:函數組件開始渲染的時候,創建出對應的hook鏈表掛載到workInProgress的memoizedState上,並創建effect鏈表,但是基於上次和本次依賴項的比較結果,
    創建的effect是有差異的。這壹點暫且可以理解為:依賴項有變化,effect可以被處理,否則不會被處理。

  • commit階段:異步調度useEffect,layout階段同步處理useLayoutEffect的effect。等到commit階段完成,更新應用到頁面上之後,開始處理useEffect產生的effect。

第二點提到了壹個重點,就是useEffect和useLayoutEffect的執行時機不壹樣,前者被異步調度,當頁面渲染完成後再去執行,不會阻塞頁面渲染。
後者是在commit階段新的DOM準備完成,但還未渲染到屏幕之前,同步執行。

實現細節

通過整體流程可以看出,effect的整個過程涉及到render階段和commit階段。render階段只創建effect鏈表,commit階段去處理這個鏈表。所有實現的細節都是在圍繞effect鏈表。

render階段-創建effect鏈表

在實際的使用中,我們調用的use(Layout)Effect函數,在掛載和更新的過程是不同的。

掛載時,調用的是mountEffectImpl,它會為use(Layout)Effect這類hook創建壹個hook對象,將workInProgressHook指向它,然後在這個fiber節點的flag中加入副作用相關的effectTag。最後,會構建effect鏈表掛載到fiber的updateQueue,並且也會在hook上的memorizedState掛載effect。

function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  // 創建hook對象
  const hook = mountWorkInProgressHook();
  // 獲取依賴
  const nextDeps = deps === undefined ? null : deps;

  // 為fiber打上副作用的effectTag
  currentlyRenderingFiber.flags |= fiberFlags;

  // 創建effect鏈表,掛載到hook的memoizedState上和fiber的updateQueue
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps,
  );
}

currentlyRenderingFiber 即 workInProgress節點

更新時,調用updateEffectImpl,完成effect鏈表的構建。這個過程中會根據前後依賴項是否變化,從而創建不同的effect對象。具體體現在effect的tag上,如果前後依賴未變,則effect的tag就賦值為傳入的hookFlags,否則,在tag中加入HookHasEffect標誌位。正是因為這樣,在處理effect鏈表時才可以只處理依賴變化的effect,use(Layout)Effect可以根據它的依賴變化情況來決定是否執行回調。

function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;

  if (currentHook !== null) {
    // 從currentHook中獲取上壹次的effect
    const prevEffect = currentHook.memoizedState;
    // 獲取上壹次effect的destory函數,也就是useEffect回調中return的函數
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      // 比較前後依賴,push壹個不帶HookHasEffect的effect
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }

  currentlyRenderingFiber.flags |= fiberFlags;
  // 如果前後依賴有變,在effect的tag中加入HookHasEffect
  // 並將新的effect更新到hook.memoizedState上
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    destroy,
    nextDeps,
  );
}

在組件掛載和更新時,有壹個區別,就是掛載期間調用pushEffect創建effect對象的時候並沒有傳destroy函數,而更新期間傳了,這是因為每次effect執行時,都是先執行前壹次的銷毀函數,再執行新effect的創建函數。而掛載期間,上壹次的effect並不存在,執行創建函數前也就無需先銷毀。

掛載和更新,都調用了pushEffect,它的職責很單純,就是創建effect對象,構建effect鏈表,掛到WIP節點的updateQueue上。

function pushEffect(tag, create, destroy, deps) {
  // 創建effect對象
  const effect: Effect = {
    tag,
    create,
    destroy,
    deps,
    // Circular
    next: (null: any),
  };

  // 從workInProgress節點上獲取到updateQueue,為構建鏈表做準備
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    // 如果updateQueue為空,把effect放到鏈表中,和它自己形成閉環
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    // 將updateQueue賦值給WIP節點的updateQueue,實現effect鏈表的掛載
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    // updateQueue不為空,將effect接到鏈表的後邊
    const lastEffect = componentUpdateQueue.lastEffect;
    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }
  return effect;
}

函數組件和類組件的updateQueue都是環狀鏈表

以上,就是effect鏈表的構建過程。我們可以看到,effect對象創建出來最終會以兩種形式放到兩個地方:單個的effect,放到hook.memorizedState上;環狀的effect鏈表,放到fiber節點的updateQueue中。兩者各有用途,前者的effect會作為上次更新的effect,為本次創建effect對象提供參照(對比依賴項數組),後者的effect鏈表會作為最終被執行的主體,帶到commit階段處理。

commit階段-effect如何被處理

useEffect和useLayoutEffect,對它們的處理最終都落在處理fiber.updateQueue上,對前者來說,循環updateQueue時只處理包含useEffect這類tag的effect,對後者來說,只處理包含useLayoutEffect這類tag的effect,它們的處理過程都是先執行前壹次更新時effect的銷毀函數(destroy),再執行新effect的創建函數(create)。

以上是它們的處理過程在微觀上的共性,宏觀上的區別主要體現在執行時機上。useEffect是在beforeMutation或layout階段異步調度,然後在本次的更新應用到屏幕上之後再執行,而useLayoutEffect是在layout階段同步執行的。下面先分析useEffect的處理過程。

useEffect的異步調度

與 componentDidMount、componentDidUpdate 不同的是,在瀏覽器完成布局與繪制之後,傳給 useEffect 的函數會延遲調用。
這使得它適用於許多常見的副作用場景,比如設置訂閱和事件處理等情況,因此不應在函數中執行阻塞瀏覽器更新屏幕的操作。

基於useEffect回調延遲調用(實際上就是異步調用) 的需求,在實現上利用scheduler的異步調度函數:scheduleCallback,將執行useEffect的動作作為壹個任務去調度,這個任務會異步調用。

commit階段和useEffect真正扯上關系的有三個地方:commit階段的開始、beforeMutation、layout,涉及到異步調度的是後面兩個。


function commitRootImpl(root, renderPriorityLevel) {
  // 進入commit階段,先執行壹次之前未執行的useEffect
  do {
    flushPassiveEffects();
  } while (rootWithPendingPassiveEffects !== null);

  ...

  do {
    try {
      // beforeMutation階段的處理函數:commitBeforeMutationEffects內部,
      // 異步調度useEffect
      commitBeforeMutationEffects();
    } catch (error) {
      ...
    }
  } while (nextEffect !== null);

  ...

  const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;

  if (rootDoesHavePassiveEffects) {
    // 重點,記錄有副作用的effect
    rootWithPendingPassiveEffects = root;
  }
}

這三個地方去執行或者調度useEffect有什麽用意呢?我們分別來看。

  • commit開始,先執行壹下useEffect:這和useEffect異步調度的特點有關,它以壹般的優先級被調度,這就意味著壹旦有更高優先級的任務進入到commit階段,上壹次任務的useEffect還沒得到執行。所以在本次更新開始前,需要先將之前的useEffect都執行掉,以保證本次調度的useEffect都是本次更新產生的。

  • beforeMutation階段異步調度useEffect:這個是實打實地針對effectList上有副作用的節點,去異步調度useEffect。

function commitBeforeMutationEffects() {
  while (nextEffect !== null) {

    ...

    if ((flags & Passive) !== NoFlags) {
      // 如果fiber節點上的flags存在Passive調度useEffect
      if (!rootDoesHavePassiveEffects) {
        rootDoesHavePassiveEffects = true;
        scheduleCallback(NormalSchedulerPriority, () => {
          flushPassiveEffects();
          return null;
        });
      }
    }
    nextEffect = nextEffect.nextEffect;
  }
}

因為rootDoesHavePassiveEffects的限制,只會發起壹次useEffect調度,相當於用壹把鎖鎖住調度狀態,避免發起多次調度。

  • layout階段填充effect執行數組:真正useEffect執行的時候,實際上是先執行上壹次effect的銷毀,再執行本次effect的創建。React用兩個數組來分別存儲銷毀函數和
    創建函數,這兩個數組的填充就是在layout階段,到時候循環釋放執行兩個數組中的函數即可。
function commitLifeCycles(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes,
): void {
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent:
    case Block: {

      ...

      // layout階段填充effect執行數組
      schedulePassiveEffects(finishedWork);
      return;
    }
}

在調用schedulePassiveEffects填充effect執行數組時,有壹個重要的地方就是只在包含HasEffect的effectTag的時候,才將effect放到數組內,這壹點保證了依賴項有變化再去處理effect。也就是:如果前後依賴未變,則effect的tag就賦值為傳入的hookFlags,否則,在tag中加入HookHasEffect標誌位。正是因為這樣,在處理effect鏈表時才可以只處理依賴變化的effect,use(Layout)Effect才可以根據它的依賴變化情況來決定是否執行回調。

schedulePassiveEffects的實現:

function schedulePassiveEffects(finishedWork: Fiber) {
  // 獲取到函數組件的updateQueue
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  // 獲取effect鏈表
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    // 循環effect鏈表
    do {
      const {next, tag} = effect;
      if (
        (tag & HookPassive) !== NoHookEffect &&
        (tag & HookHasEffect) !== NoHookEffect
      ) {
        // 當effect的tag含有HookPassive和HookHasEffect時,向數組中push effect
        enqueuePendingPassiveHookEffectUnmount(finishedWork, effect);
        enqueuePendingPassiveHookEffectMount(finishedWork, effect);
      }
      effect = next;
    } while (effect !== firstEffect);
  }
}

在調用enqueuePendingPassiveHookEffectUnmountenqueuePendingPassiveHookEffectMount填充數組的時候,還會再異步調度壹次useEffect,但這與beforeMutation的調度是互斥的,壹旦之前調度過,就不會再調度了,同樣是rootDoesHavePassiveEffects起的作用。

執行effect

此時我們已經知道,effect得以被處理是因為之前的調度以及effect數組的填充。現在到了最後的步驟,執行effect的destroy和create。過程就是先循環待銷毀的effect數組,再循環待創建的effect數組,這壹過程發生在flushPassiveEffectsImpl函數中。循環的時候每個兩項去effect是由於奇數項存儲的是當前的fiber。

function flushPassiveEffectsImpl() {
  // 先校驗,如果root上沒有 Passive efectTag的節點,則直接return
  if (rootWithPendingPassiveEffects === null) {
    return false;
  }

  ...

  // 執行effect的銷毀
  const unmountEffects = pendingPassiveHookEffectsUnmount;
  pendingPassiveHookEffectsUnmount = [];
  for (let i = 0; i < unmountEffects.length; i += 2) {
    const effect = ((unmountEffects[i]: any): HookEffect);
    const fiber = ((unmountEffects[i + 1]: any): Fiber);
    const destroy = effect.destroy;
    effect.destroy = undefined;

    if (typeof destroy === 'function') {
      try {
        destroy();
      } catch (error) {
        captureCommitPhaseError(fiber, error);
      }
    }
  }

  // 再執行effect的創建
  const mountEffects = pendingPassiveHookEffectsMount;
  pendingPassiveHookEffectsMount = [];
  for (let i = 0; i < mountEffects.length; i += 2) {
    const effect = ((mountEffects[i]: any): HookEffect);
    const fiber = ((mountEffects[i + 1]: any): Fiber);
    try {
      const create = effect.create;
      effect.destroy = create();
    } catch (error) {

      captureCommitPhaseError(fiber, error);
    }
  }

  ...

  return true;
}

useLayoutEffect的同步執行

useLayoutEffect在執行的時候,也是先銷毀,再創建。和useEffect不同的是這兩者都是同步執行的,前者在mutation階段執行,後者在layout階段執行。
與useEffect不同的是,它不用數組去存儲銷毀和創建函數,而是直接操作fiber.updateQueue。

卸載上壹次的effect,發生在mutation階段


// 調用卸載layout effect的函數,傳入layout有關的effectTag和說明effect有變化的effectTag:HookLayout | HookHasEffect
commitHookEffectListUnmount(HookLayout | HookHasEffect, finishedWork);

function commitHookEffectListUnmount(tag: number, finishedWork: Fiber) {
  // 獲取updateQueue
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;

  // 循環updateQueue上的effect鏈表
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & tag) === tag) {
        // 執行銷毀
        const destroy = effect.destroy;
        effect.destroy = undefined;
        if (destroy !== undefined) {
          destroy();
        }
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

執行本次的effect創建,發生在layout階段

// 調用創建layout effect的函數
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);

function commitHookEffectListMount(tag: number, finishedWork: Fiber) {
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & tag) === tag) {
        // 創建
        const create = effect.create;
        effect.destroy = create();
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

總結

useEffect和useLayoutEffect作為組件的副作用,本質上是壹樣的。共用壹套結構來存儲effect鏈表。整體流程上都是先在render階段,生成effect,並將它們拼接成鏈表,存到fiber.updateQueue上,最終帶到commit階段被處理。他們彼此的區別只是最終的執行時機不同,壹個異步壹個同步,這使得useEffect不會阻塞渲染,而useLayoutEffect會阻塞渲染。


尚未有邦友留言

立即登入留言