banner
IWSR

IWSR

我永远喜欢志喜屋梦子!

React Hooks: useEffect與useLayoutEffect

TLNR#

我希望這篇文章可以幫助讀者理解以下幾點:

  1. use (layout) Effect 被調用時會創建 effect 對象,且該對象會以鏈表的形式被掛載到 fiber 的 updateQueue 上
  2. useEffect 由於會被 Scheduler 以 NormalSchedulerPriority 進行調度,因此它是一個異步操作
  3. useLayoutEffect 的 create 是在 layout 階段被同步處理的,其上一輪的 destroy 是在 mutation 階段被同步處理的,因此在該 hook 內調用耗時操作會阻塞頁面更新

閱前須知#

  • useEffect 涉及到與 Scheduler 的互動,因此在理解其執行順序時需要對 react 的調度行為有一定的了解。簡單來說可以這樣理解(不嚴謹的說法會個意即可)
    • 所有通過 scheduleCallback 註冊進 Scheduler 的任務都是一個異步任務,且該任務類型為 Task
    • react 內存在高優先級插隊機制,對 useEffect 的調度可能被打斷,出現與預期不符合的情況

說明#

依然使用 React hooks: hooks 鏈表 內的示例代碼。

  function UseEffectAnduseLayoutEffect() {
    const [text, setText] = useState(0);

    useEffect(() => {
      console.log('useEffect create');
      
      return () => {
        console.log('useEffect destroy');
      }
    }, [text]);

    useLayoutEffect(() => {
      console.log('useLayoutEffect create');

      return () => {
        console.log('useLayoutEffect destroy');
      }
    }, [text]);

    return (
      <div onClick={ () => { setText(1) } }>{ text }</div>
    )
  }

根據之前的調試經驗我們知道,每一個 hook 被調用時都會調用 mountWorkInProgressHook 去創建一個 hook 對象,並根據調用順序構成一個鏈表結構,且該 hook 鏈表會被掛載到對應 fiber 對象的 memoizedState 屬性上。

而除此之外每個不同類型的 hook 的邏輯都不相同,這裡只對 useEffect 進行分析。但在此之前我們先對其結構的稱呼做出約定。

  use(Layout)Effect(() => { // 約定其第一個參數為 create
    return () => { // 約定 create 函數的返回值為 destroy

    }
  }, []); // 約定其第二個參數為 依賴

effect 鏈表#

構建 effect 鏈表發生在 beginWork 內。

image

hook 會根據組件是掛載還是更新執行不同的邏輯,對於 use (Layout) Effect 來說在掛載時會調用 mount (Layout) Effect,更新時會調用 update (Layout) Effect。

掛載時生成對應的 effect 鏈表#

我們先對例子中的 useEffect 打入斷點後會進入 mountEffect 內的 mountEffectImpl。

  mountEffectImpl(
    UpdateEffect | PassiveEffect, // react 中關於權限存在大量的位操作,此處的**或**操作常有賦予權限的意味
    HookPassive,
    create,
    deps,
  );

  function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
    /**
     * hook 鏈表內有分析過因此跳過
    */
    const hook = mountWorkInProgressHook();
    const nextDeps = deps === undefined ? null : deps;
    /**
     * A |= B => A = A | B
    */
    currentlyRenderingFiber.flags |= fiberFlags;
    /**
     * 此處的邏輯是一處重點,涉及到 effect 對象的創建與鏈表的連接
     * 還需要注意到的 useEffect 對應的 hook 對象上的 memoizedState 會掛載上 effect 鏈表
     * useLayoutEffect 的邏輯和 useEffect 大致相同,只是會對 effect 對象打上不同的標籤而已
    */
    hook.memoizedState = pushEffect(
      HookHasEffect | hookFlags,
      create,
      undefined,
      nextDeps,
    );
  }

pushEffect 是構成 effect 鏈表的關鍵,其內生成的 effect 環状鏈表會被掛載到當前組件的 fiber 上的 updateQueue 上,並且 updateQueue.lastEffect 會指向最新生成的 effect 的對象。

image

effect 對象結構如下

  const effect: Effect = {
    tag,
    create, // use(Layout)Effect 的 create
    destroy, // use(Layout)Effect 的 destroy
    deps, // use(Layout)Effect 的 依賴
    // Circular
    next: (null: any), // 指向下一個effect
  };

執行完 mountEffectImpl 後,生成的 effect 鏈表會被掛載到兩處,而 useEffect 此時也運行結束

  1. use (Layout) Effect 對應 hook 元素的 memoizedState
  2. fiber.updateQueue

而接下來運行的 useLayoutEffect 也與上述步驟一致,因此 effect 鏈表會變成下面這樣

image

事實上也確實如此

image

至此掛載階段生成 effect 鏈表的相關邏輯已經結束了。

更新時生成對應的 effect 鏈表#

保持原有的斷點,然後觸發點擊回調,此時會進入 updateEffect 的 updateEffectImpl

  updateEffectImpl(
    UpdateEffect | PassiveEffect,
    HookPassive,
    create,
    deps,
  )

  function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
    const hook = updateWorkInProgressHook(); // 更新情況下,會對 currentHook 進行賦值
    const nextDeps = deps === undefined ? null : deps;
    let destroy = undefined;

    if (currentHook !== null) {
      const prevEffect = currentHook.memoizedState; // 獲取 currentHook 對應的 effect 對象
      destroy = prevEffect.destroy; // 將上一次的 destroy 函數賦值給 wip 並在這一次調用
      if (nextDeps !== null) {
        const prevDeps = prevEffect.deps;
        if (areHookInputsEqual(nextDeps, prevDeps)) { // 一一比較新舊的dep,不過是淺對比
          pushEffect(hookFlags, create, destroy, nextDeps);
          return;
        }
      }
    }

    currentlyRenderingFiber.flags |= fiberFlags;

    hook.memoizedState = pushEffect(
      HookHasEffect | hookFlags, // HookHasEffect 標記更新
      create,
      destroy,
      nextDeps,
    );
  }

和 mount 不同,update 的 pushEffect 會帶上上一次的 destroy 函數。

關於 useEffect 內 create 與 destroy 運行順序#

此時我們對 useEffect 的 create 函數打上斷點來查看它的調用棧

image

此時我們注意到 flushPassiveEffectsImpl 這一函數,該函數在 flushPassiveEffects 內以 NormalSchedulerPriority 級別的優先級被調度,因此 flushPassiveEffectsImpl 會是一個異步任務,且優先級不高。但當前例子不需要考慮被插隊的情況。

通過對 flushPassiveEffects 進行全局的查找(線索是 pendingPassiveHookEffectsMount 的賦值),最後定位調用位置為 commitBeforeMutationEffects

查詢異步操作的調用很麻煩,但最後還是定位在了 commitRoot ——> commitRootImpl ——> commitBeforeMutationEffects。該函數是 BeforeMutation 階段的入口。

收集 effect#

在執行 flushPassiveEffectsImpl 清空 effect 前,首先需要對 fiber 上的 effect 鏈表進行收集,這一操作發生在 schedulePassiveEffects 內。由於 schedulePassiveEffects 是在 commitLayoutEffects ——> commitLifeCycles 內被調用,因此可以視為在 layout 階段內被調用

  /**
   * 此處的 finishedWork 為 UseEffectAnduseLayoutEffect 的 fiber 對象
  */
  function schedulePassiveEffects(finishedWork: Fiber) {
    /**
     * fiber 的 updateQueue上掛載了對應的 effect 鏈表
    */
    const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
    const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
    if (lastEffect !== null) {
      // lastEffect這一指針是指向最後一個 effect 對象上的,因此通過 next 可以直接獲取到第一個元素
      const firstEffect = lastEffect.next;
      let effect = firstEffect;
      do {
        const {next, tag} = effect;
        if (
          (tag & HookPassive) !== NoHookEffect && // 過濾了非 use(Layout)Effect 創建的 effect 對象
          (tag & HookHasEffect) !== NoHookEffect // 過濾了依賴未發生變動的 effect 對象
        ) {
          enqueuePendingPassiveHookEffectUnmount(finishedWork, effect); // 給pendingPassiveHookEffectsUnmount賦值
          enqueuePendingPassiveHookEffectMount(finishedWork, effect);// 給pendingPassiveHookEffectsMount賦值
          // 目前執行完 pendingPassiveHookEffectsUnmount 與 pendingPassiveHookEffectsMount數據一致
        }
        effect = next;
      } while (effect !== firstEffect); // 環状鏈表循環一圈停止
    }
  }

執行 effect#

直接對 flushPassiveEffectsImpl 進行調試,下面的代碼已經刪除了多餘的部分。該函數內部涉及到了對 effect 對象上的 create 與 destroy 執行的邏輯。

function flushPassiveEffectsImpl() {
  ...
  /**
   * 執行 effect 的 destroy 函數
   * 在掛載時,由於在執行 pushEffect 時第三個參數為 undefined,effect 對象上的 destroy 屬性為空。因此 destroy 不會被執行
   * 但是在更新時,會傳入由 create 返回的 destroy 函數,因此會執行。下面會提到。
  */
  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') {
      if (
        enableProfilerTimer &&
        enableProfilerCommitHooks &&
        fiber.mode & ProfileMode
      ) {
        try {
          startPassiveEffectTimer();
          destroy();
        } finally {
          recordPassiveEffectDuration(fiber);
        }
      } else {
        destroy();
      }
    }
  }
  /**
   * 執行 effect 的 create 函數
  */
  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);

    const create = effect.create;
    if (
      enableProfilerTimer &&
      enableProfilerCommitHooks &&
      fiber.mode & ProfileMode
    ) {
      try {
        startPassiveEffectTimer();
        /**
         * create 的返回值是 destroy,如果這個 effect 對象因為 update 再次被收集後,
         * 會在下一次flushPassiveEffectsImpl時被指向
        */
        effect.destroy = create(); 
      } finally {
        recordPassiveEffectDuration(fiber);
      }
    } else {
      effect.destroy = create();
    }

  }
  ...

  return true;
}

結論#

  1. useEffect 是異步操作,清空副作用的操作(flushPassiveEffectsImpl)會被註冊為一個 Task
  2. layout 階段中 effect 會被收集進執行數組,但是調度發生在 before Mutation 調度
  3. 清空副作用的操作(flushPassiveEffectsImpl)的調度優先級不高,所以有可能在調度時有可能被更高優先級的任務插隊
  4. 掛載時,只會執行 create 的函數;更新時,會執行 currentHook 的 destroy,然後執行 newHook 的 create

關於 useLayoutEffect 的運行時機#

useLayoutEffect 除了觸發時機,其他表現與 useEffect 是一致的。

此時我們對 useLayoutEffect 的 create 函數打上斷點來查看它的調用棧

image

此時對 commitHookEffectListMount 打斷點,可發現都是同步操作。(commitLayoutEffects 是 layout 的入口)

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 {
      // 傳入的 tag 是 HookLayout | HookHasEffect,因此可過濾出 useLayoutEffect 的 effect
      if ((effect.tag & tag) === tag) { 
        // Mount
        const create = effect.create;
        effect.destroy = create(); 
      }
      effect = effect.next;
    } while (effect !== firstEffect); // 循環一圈
  }
}

如果對 useLayoutEffect 的 destroy 函數打上斷點

image

此時對 commitHookEffectListUnmount 打斷點,可發現都是同步操作。(commitMutationEffects 是 mutation 的入口)

function commitHookEffectListUnmount(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) {
        // Unmount
        const destroy = effect.destroy;
        effect.destroy = undefined;
        if (destroy !== undefined) {
          destroy();
        }
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

結論#

  1. useLayoutEffect 是同步調用的,其 destroy 在 mutation 階段調用,其 create 在 layout 階段調用
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。