TLNR#
私はこの記事が読者が以下の点を理解するのに役立つことを願っています:
- use (layout) Effect が呼び出されると、effct オブジェクトが作成され、そのオブジェクトはリスト形式で fiber の updateQueue にマウントされます。
- useEffect は Scheduler によって NormalSchedulerPriority でスケジュールされるため、非同期操作です。
- useLayoutEffect の create は layout フェーズで同期的に処理され、その前の destroy は mutation フェーズで同期的に処理されるため、この hook 内で時間のかかる操作を呼び出すとページの更新がブロックされます。
閲覧前の注意#
- useEffect は Scheduler との相互作用に関わるため、その実行順序を理解するには React のスケジューリングの動作について一定の理解が必要です。簡単に言えば、次のように理解できます(厳密な表現ではなく、意図を伝えるためのものです):
- Scheduler に scheduleCallback を通じて登録されたすべてのタスクは非同期タスクであり、そのタスクのタイプは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とすることに合意
}
}, []); // 2番目の引数を依存関係とすることに合意
effect リスト#
effect リストの構築は beginWork 内で行われます。
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 オブジェクトを指します。
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 リストは 2 か所にマウントされ、useEffect もこの時点で終了します。
- use (Layout) Effect に対応する hook 要素の memoizedState
- fiber.updateQueue
次に実行される useLayoutEffect も上記の手順と一致するため、effect リストは次のようになります。
実際にそうです。
これでマウントフェーズでの 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,
);
}
マウント時とは異なり、更新時の pushEffect は前回の destroy 関数を持ち込みます。
useEffect 内の create と destroy の実行順序について#
ここで useEffect の create 関数にブレークポイントを設定して、その呼び出しスタックを確認します。
この時、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;
}
結論#
- useEffect は非同期操作であり、副作用をクリアする操作(flushPassiveEffectsImpl)はタスクとして登録されます。
- layout フェーズ内で effect は実行配列に収集されますが、スケジューリングは before Mutation で発生します。
- 副作用をクリアする操作(flushPassiveEffectsImpl)のスケジューリング優先度は高くないため、スケジューリング時により高い優先度のタスクに割り込まれる可能性があります。
- マウント時には create 関数のみが実行され、更新時には currentHook の destroy が実行され、その後 newHook の create が実行されます。
useLayoutEffect の実行タイミングについて#
useLayoutEffect はトリガータイミングを除いて、他の動作は useEffect と一致します。
ここで useLayoutEffect の create 関数にブレークポイントを設定して、その呼び出しスタックを確認します。
この時、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) {
// マウント
const create = effect.create;
effect.destroy = create();
}
effect = effect.next;
} while (effect !== firstEffect); // 一周します
}
}
useLayoutEffect の destroy 関数にブレークポイントを設定すると
この時、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) {
// アンマウント
const destroy = effect.destroy;
effect.destroy = undefined;
if (destroy !== undefined) {
destroy();
}
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
結論#
- useLayoutEffect は同期呼び出しであり、その destroy は mutation フェーズで呼び出され、その create は layout フェーズで呼び出されます。