- React ソースコードバージョン: v16.11.0
- ソースコード注釈ノート:airingursb/react
1. useEffect の紹介#
1.1 なぜ useEffect が必要なのか#
前述の通り、React Hooks により Functional Component は Class Component の特性を持つようになりました。その主な動機には以下のようなものがあります:
- コンポーネント間で状態ロジックを再利用するのが難しい
- 複雑なコンポーネントは理解しづらい
- 理解しづらいクラス
2 つ目について、まず、Class Component に関して、React アプリを作成する際には、コンポーネントのさまざまなライフサイクルの中でコードを書くことがよくあります。例えば、componentDidMount
や componentDidUpdate
で HTTP リクエストを送信したり、イベントをバインドしたり、さらには追加のロジックを実行したりすることで、ビジネスロジックがコンポーネントのライフサイクル関数に集中してしまいます。この時、私たちのプログラミングの考え方は「コンポーネントが読み込まれた時に何をする必要があるか」、「コンポーネントが更新された時に何をする必要があるか」というものであり、これにより React 開発はライフサイクル指向プログラミングとなり、ライフサイクル中に書いたロジックはコンポーネントライフサイクル関数の副作用となります。
次に、ライフサイクル指向プログラミングはビジネスロジックが各ライフサイクル関数に散在する原因となります。例えば、componentDidMount
で行ったイベントバインディングは componentDidUnmount
で解除する必要があり、そのためイベント管理のロジックが統一されず、コードが散発的になり、レビューが面倒になります:
import React from 'react'
class A extends React.Component {
componentDidMount() {
document.getElementById('js_button')
.addEventListener('click', this.log)
}
componentDidUnmount() {
document.getElementById('js_button')
.removeEventListener('click', this.log)
}
log = () => {
console.log('log')
}
render() {
return (
<div id="js_button">button</div>
)
}
}
useEffect
の登場により、開発者の関心はライフサイクルからビジネスロジックに再び焦点を当てることができるようになりました。実際、effect の正式名称は副作用(side effect)であり、useEffect は本来ライフサイクル関数内の副作用ロジックを処理するためのものです。
次に、useEffect の使い方を見てみましょう。
1.2 useEffect の使い方#
上記のコードを useEffect を使って書き換えると以下のようになります:
import React, { useEffect } from 'react'
function A() {
const log = () => {
console.log('log')
}
useEffect(() => {
document
.getElementById('js_button')
.addEventListener('click', log)
return () => {
document
.getElementById('js_button')
.removeEventListener('click', log)
}
})
return (<div id="js_button">button</div>)
}
useEffect は 2 つの引数を受け取ります。最初の引数は関数で、その実装はバインド操作を行い、unbind をサンク関数として返します。2 つ目の引数はオプションの依存配列で、dependencies が存在しない場合、関数は毎回レンダー時に実行されます。dependencies が存在する場合、変更があった時のみ関数が実行されます。したがって、dependencies が空の配列の場合、初回のレンダー時にのみ関数が実行されることが推測できます。
useEffect(
() => {
const subscription = props.source.subscribe();
return () => {
subscription.unsubscribe();
};
},
[props.source],
);
さらに詳しい使い方については、React 公式サイトの useEffect API の紹介をお読みください: https://reactjs.org/docs/hooks-reference.html#useeffect
2. useEffect の原理と簡単な実装#
useEffect の使い方に基づいて、簡単な useEffect を自分で実装することができます:
let _deps;
function useEffect(callback, dependencies) {
const hasChanged = _deps
&& !dependencies.every((el, i) => el === _deps[i])
|| true;
// dependencies が存在しない、または dependencies に変化があった場合、callback を実行
if (!dependencies || hasChanged) {
callback();
_deps = dependencies;
}
}
3. useEffect ソースコード解析#
3.1 mountEffect & updateEffect#
useEffect のエントリーポイントは、前のセクションの useState と同様に、ReactFiberHooks.js というファイルにあります。また、useState と同様に、初回の読み込み時に useEffect が実際に実行するのは mountEffect であり、その後の各レンダー時には updateEffect が実行されます。ここでは詳しく説明しませんが、mountEffect と updateEffect が実際に何をしているのかを重点的に見ていきましょう。
mountEffect に関して:
function mountEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return mountEffectImpl(
UpdateEffect | PassiveEffect,
UnmountPassive | MountPassive,
create,
deps,
);
}
updateEffect に関して:
function updateEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return updateEffectImpl(
UpdateEffect | PassiveEffect,
UnmountPassive | MountPassive,
create,
deps,
);
}
mountEffect と updateEffect の引数は関数と配列であり、これは前述の useEffect で渡した callback と deps に対応しています。また、mountEffect と updateEffect が実際に呼び出しているのは mountEffectImpl と updateEffectImpl であり、受け取る 4 つの引数は全く同じです。後の 2 つの引数はそのまま透過されるので説明は不要ですが、前の UpdateEffect | PassiveEffect
、UnmountPassive | MountPassive
は一体何でしょうか?
コードを読むと、これらは ReactSideEffectTags
と ReactHookEffectTags
からインポートされています。
import {
Update as UpdateEffect,
Passive as PassiveEffect,
} from 'shared/ReactSideEffectTags';
import {
NoEffect as NoHookEffect,
UnmountPassive,
MountPassive,
} from './ReactHookEffectTags';
ReactSideEffectTags.js と ReactHookEffectTags.js の定義を見てみましょう:
// これらの2つの値は変更しないでください。React Dev Tools で使用されます。
export const NoEffect = /* */ 0b0000000000000;
export const PerformedWork = /* */ 0b0000000000001;
// 残りは変更できます(さらに追加できます)。
export const Placement = /* */ 0b0000000000010;
export const Update = /* */ 0b0000000000100;
export const PlacementAndUpdate = /* */ 0b0000000000110;
export const Deletion = /* */ 0b0000000001000;
export const ContentReset = /* */ 0b0000000010000;
export const Callback = /* */ 0b0000000100000;
export const DidCapture = /* */ 0b0000001000000;
export const Ref = /* */ 0b0000010000000;
export const Snapshot = /* */ 0b0000100000000;
export const Passive = /* */ 0b0001000000000;
export const Hydrating = /* */ 0b0010000000000;
export const HydratingAndUpdate = /* */ 0b0010000000100;
export const NoEffect = /* */ 0b00000000;
export const UnmountSnapshot = /* */ 0b00000010;
export const UnmountMutation = /* */ 0b00000100;
export const MountMutation = /* */ 0b00001000;
export const UnmountLayout = /* */ 0b00010000;
export const MountLayout = /* */ 0b00100000;
export const MountPassive = /* */ 0b01000000;
export const UnmountPassive = /* */ 0b10000000;
このように設計されているのは、タイプの比較と複合を簡素化するためです。プロジェクト開発の過程で複合権限システムの設計経験があれば、最初の瞬間に反応できるかもしれません。したがって、UnmountPassive | MountPassive
は 0b11000000 です。対応するビットがゼロでない場合、タグが指定された動作を実行していることを示します。これは将来的に使用されるので、ここでは触れないことにします。
3.2 mountEffectImpl & updateEffectImpl#
次に、mountEffectImpl
と updateEffectImpl
の具体的な実装を見ていきましょう。
3.2.1 mountEffectImpl#
まずは mountEffectImpl
:
function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
const hook = mountWorkInProgressHook(); // 新しい Hook を作成し、現在の workInProgressHook を返す
const nextDeps = deps === undefined ? null : deps;
sideEffectTag |= fiberEffectTag;
hook.memoizedState = pushEffect(hookEffectTag, create, undefined, nextDeps);
}
mountWorkInProgressHook
は第 3 篇 4.3.3: mountWorkInProgressHookで解析したように、新しい Hook を作成し、現在の workInProgressHook を返します。具体的な原理についてはここでは詳しく説明しません。
sideEffectTag
はビット単位で fiberEffectTag
を OR して代入され、renderWithHooks
の中で renderedWork.effectTag
にマウントされ、各レンダー後に 0 にリセットされます。
renderedWork.effectTag |= sideEffectTag;
sideEffectTag = 0;
具体的に renderedWork.effectTag
が何に使われるのかは、後で説明します。
renderWithHooks は 第 3 篇 4.3.1: renderWithHooks で解析したので、ここでは詳しく説明しません。
hook.memoizedState
は pushEffect
の戻り値を記録します。これは useState の newState を記録する原理と一致しています。現在の焦点は pushEffect
が一体何をしているのかに移ります。
3.3.2 updateEffectImpl#
次に、updateEffectImpl
が何をしているのかを見てみましょう。
function updateEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
const hook = updateWorkInProgressHook(); // 現在作業中の Hook を取得
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;
if (currentHook !== null) {
const prevEffect = currentHook.memoizedState;
destroy = prevEffect.destroy;
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) {
pushEffect(NoHookEffect, create, destroy, nextDeps);
return;
}
}
}
sideEffectTag |= fiberEffectTag;
hook.memoizedState = pushEffect(hookEffectTag, create, destroy, nextDeps);
}
updateWorkInProgressHook
は第 3 篇 4.4.3: updateWorkInProgressHookで解析したように、現在作業中の Hook を取得します。具体的な原理についてはここでは詳しく説明しません。
currentHook
が空でない場合、updateEffectImpl
のロジックは mountEffectImpl
のロジックと全く同じです。currentHook
が空でない場合、pushEffect
の第 3 引数は undefined ではなく destroy になります。また、この分岐には areHookInputsEqual(nextDeps, prevDeps)
が存在し、現在の useEffect の deps と前の useEffect の deps が等しい場合(areHookInputsEqual
が行うのは 2 つの deps を比較することです)、pushEffect(NoHookEffect, create, destroy, nextDeps);
を実行します。大胆に推測すると、NoHookEffect
の意味はこの useEffect を実行しないことです。このように、このコードのロジックは以前に自分たちが実装した useEffect と一致します。
第 3 篇 4.4.3: updateWorkInProgressHookによれば、currentHook
は現在の段階で処理中の Hook であり、通常のロジックでは空であってはなりません。次に重点的に注目すべきは pushEffect
が何をしているのか、そしてその第 3 引数が何を意味するのかです。
3.3 pushEffect#
function pushEffect(tag, create, destroy, deps) {
// 新しい effect を宣言
const effect: Effect = {
tag,
create,
destroy,
deps,
// 循環
next: (null: any), // 関数コンポーネント内で定義された次の effect の参照
};
if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue(); // componentUpdateQueue を初期化
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
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;
}
type Effect = {
tag: HookEffectTag, // effect の動作を決定する二進数
create: () => (() => void) | void, // 描画後に実行されるコールバック
destroy: (() => void) | void, // effect を破棄して再作成するかどうかを決定するためのもの
deps: Array<mixed> | null, // 再描画後に実行されるかどうかを決定する deps
next: Effect, // 関数コンポーネント内で定義された次の effect の参照
};
この関数は、引数に基づいて新しい effect を宣言します。データ構造も示されていますが、これも循環リストです。tag は
次に、componentUpdateQueue が空かどうかに応じて 2 つのロジックを実行しますが、componentUpdateQueue の構造は非常にシンプルです:
export type FunctionComponentUpdateQueue = {
lastEffect: Effect | null,
};
見ての通り、componentUpdateQueue は実際には Effect を保存するグローバル変数です。
-
componentUpdateQueue が空の場合:この場合は mountEffect の時のロジックで、空の componentUpdateQueue を作成します。これは実際には
{lastEffect: null}
です。その後、componentUpdateQueue.lastEffect
をeffect.next
に指し示します。実際には effect を少し保存しただけです。 -
componentUpdateQueue が空でない場合:この場合は updateEffect の時に通るロジックです。
-
lastEffect が空の場合:この場合は新しいレンダリング段階の最初の useEffect で、ロジック処理は componentUpdateQueue が空の場合と一致します。
-
lastEffect が空でない場合:この場合はこのコンポーネントに複数の useEffect が存在し、2 回目以降の useEffect がこの分岐を通ります。lastEffect を次の effect に指し示します。
最後に effect を返します。
3.4 React Fiber プロセス分析#
ソースコードはここで終わりのように見えますが、まだ解決していないいくつかの問題が残っています:
effect.tag
の二進数は何を意味するのか?pushEffect
の後に何かロジックがあるのか?componentUpdateQueue
が Effect を保存した後、どこで使用されるのか?
renderWithHooks
の中で、componentUpdateQueue
は renderedWork.updateQueue
に代入され、私たちが 3.2 で見た sideEffectTag
も renderedWork.effectTag
に代入されます。
renderedWork.updateQueue = (componentUpdateQueue: any);
renderedWork.effectTag |= sideEffectTag;
第 3 篇 4.3.1: renderWithHooksで分析したように、renderWithHooks は関数コンポーネントの更新段階(updateFunctionComponent
)で実行される関数です。ここで、上記の 3 つの質問の答えを知りたい場合、Reconciler のプロセス全体を一通り見ていく必要があります。個人的には Fiber は React 16 の中で最も複雑なロジックの一部だと思っていますので、前の数篇では軽く触れただけで、詳細な解析は行っていません。Fiber の内容は非常に多く、展開すれば数篇の文章を書くのに十分ですので、ここでもできるだけ簡潔にプロセスを追い、本文に関連しない詳細は省略し、部分的なロジックの実装に重点を置いて、useEffect を呼び出した後のロジックに焦点を当てます。
注:この部分に興味がない方は、直接 3.5 に進んでお読みください。
React Fiber に関する優れた記事はたくさんありますので、興味のある方は以下のいくつかの記事や動画を読んで理解を深めてください。
それでは始めましょう!
3.4.1 ReactDOM.js#
ページレンダリングの唯一のエントリーポイントは ReactDOM.render です。
ReactRoot.prototype.render = ReactSyncRoot.prototype.render = function(
children: ReactNodeList,
callback: ?() => mixed,
): Work {
// ... 無関係なコードを省略
updateContainer(children, root, null, work._onCommit);
return work;
};
render の核心は updateContainer
を呼び出すことです。この関数は react-reconciler の中の ReactFiberReconciler.js から来ています。
3.4.2 ReactFiberReconciler.js#
このファイルは実際には react-reconciler のエントリーポイントでもあります。まず、updateContainer
が何であるかを見てみましょう:
export function updateContainer(
element: ReactNodeList,
container: OpaqueRoot,
parentComponent: ?React$Component<any, any>,
callback: ?Function,
): ExpirationTime {
// ... 無関係なコードを省略
return updateContainerAtExpirationTime(
element,
container,
parentComponent,
expirationTime,
suspenseConfig,
callback,
);
}
無関係なコードを省略すると、実際には updateContainerAtExpirationTime
の一層のラッピングであることがわかります。それでは、この関数が何であるかを見てみましょう:
export function updateContainerAtExpirationTime(
element: ReactNodeList,
container: OpaqueRoot,
parentComponent: ?React$Component<any, any>,
expirationTime: ExpirationTime,
suspenseConfig: null | SuspenseConfig,
callback: ?Function,
) {
// ... 無関係なコードを省略
return scheduleRootUpdate(
current,
element,
expirationTime,
suspenseConfig,
callback,
);
}
無関係なコードを少し省略すると、実際には scheduleRootUpdate
の一層のラッピングであることがわかります。それでは scheduleRootUpdate
が何であるかを見てみましょう:
function scheduleRootUpdate(
current: Fiber,
element: ReactNodeList,
expirationTime: ExpirationTime,
suspenseConfig: null | SuspenseConfig,
callback: ?Function,
) {
// ... 無関係なコードを省略
enqueueUpdate(current, update);
scheduleWork(current, expirationTime);
return expirationTime;
}
少し無関係なコードを省略すると、実際には 2 つのことを行っています。enqueueUpdate
はここでは一旦無視し、タスクスケジューリング scheduleWork
に注目します。これは Fiber ロジックのエントリーポイントに相当します。ReactFiberWorkLoop.js に定義されています。
3.4.3 ReactFiberWorkLoop.js - render#
ReactFiberWorkLoop.js の内容は非常に長く、2900 行のコードが含まれており、タスクループの主なロジックを含んでいますが、先ほど scheduleWork
から始めることがわかったので、ゆっくりと整理していきます:
export function scheduleUpdateOnFiber(
fiber: Fiber,
expirationTime: ExpirationTime,
) {
// ... 無関係なコードを省略
const priorityLevel = getCurrentPriorityLevel();
if (expirationTime === Sync) {
if (
(executionContext & LegacyUnbatchedContext) !== NoContext &&
(executionContext & (RenderContext | CommitContext)) === NoContext
) {
schedulePendingInteractions(root, expirationTime);
let callback = renderRoot(root, Sync, true);
while (callback !== null) {
callback = callback(true);
}
} else {
scheduleCallbackForRoot(root, ImmediatePriority, Sync);
if (executionContext === NoContext) {
flushSyncCallbackQueue();
}
}
} else {
scheduleCallbackForRoot(root, priorityLevel, expirationTime);
}
// ... 特殊な状況の処理を省略
}
export const scheduleWork = scheduleUpdateOnFiber;
実際、このコードの大部分の分岐は renderRoot
に戻ります。したがって、scheduleWork
が Fiber ロジックのエントリーポイントであると言うよりも、renderRoot
がエントリーポイントであると言えます。renderRoot
は Fiber の 2 つの段階の中で非常に有名な render 段階です。
図源 A Cartoon Intro to Fiber - React Conf 2017
実際、デバッグすると簡単にわかりますが、これら 2 つの段階は次のようになります:
renderRoot
のコードも非常に複雑で、私たちは本文に関連するロジックに重点を置いています:
function renderRoot(
root: FiberRoot,
expirationTime: ExpirationTime,
isSync: boolean,
): SchedulerCallback | null {
if (isSync && root.finishedExpirationTime === expirationTime) {
// この期限で既に保留中のコミットがあります。
return commitRoot.bind(null, root); // コミット段階に入る
}
// ...
do {
try {
if (isSync) {
workLoopSync();
} else {
workLoop(); // 核心ロジック
}
break;
} catch (thrownValue) {
// ...
} while (true);
// ...
}
無駄なコードを省略すると、2 つの重要なポイントに注目できます:
workLoop
はコードの核心部分で、ループを使ってタスクループを実現しています。- タイムアウトが発生した場合、コミット段階に入ります。
まずは workLoop
のロジックを見てみましょう:
function workLoop() {
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
重要なのは performUnitOfWork
を見てみることです:
function performUnitOfWork(unitOfWork: Fiber): Fiber | null {
const current = unitOfWork.alternate;
// ... 計時ロジックを省略
let next;
if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
next = beginWork(current, unitOfWork, renderExpirationTime);
} else {
next = beginWork(current, unitOfWork, renderExpirationTime);
}
// ... 特殊ロジックを省略
ReactCurrentOwner.current = null;
return next;
}
無駄な計時ロジックを省略すると、このコードの内容は実際には 2 つの beginWork
です(ここで、私たちが第 3 篇の 4.3.1 で残した質問の答えが得られます)。この beginWork
は ReactFiberBeginWork.js からインポートされています。
3.4.4 ReactFiberBeginWork.js#
このセクションのコード分析は第 3 篇 4.3.1: renderWithHooksと同様に、ここでは詳しく説明しません。
つまり、現在 renderedWork
上の updateQueue
(覚えていますか?その内容は Effect の連結リストです)と effectTag
が Fiber にマウントされ、これらを処理する方法を見ていきます。
3.4.5 ReactFiberWorkLoop.js - commit#
先ほど renderRoot
の分析の中で、タスクがタイムアウトした後、直接コミット段階に入ることを確認しました。まずは commitRoot
のコードを見てみましょう:
function commitRoot(root) {
const renderPriorityLevel = getCurrentPriorityLevel();
runWithPriority(
ImmediatePriority,
commitRootImpl.bind(null, root, renderPriorityLevel),
);
return null;
}
ここで注目すべきは commitRootImpl
です。それを見てみましょう:
function commitRootImpl(root, renderPriorityLevel) {
// ...
startCommitTimer();
// Effect のリストを取得します。
let firstEffect;
if (finishedWork.effectTag > PerformedWork) {
if (finishedWork.lastEffect !== null) {
finishedWork.lastEffect.nextEffect = finishedWork;
firstEffect = finishedWork.firstEffect;
} else {
firstEffect = finishedWork;
}
} else {
firstEffect = finishedWork.firstEffect;
}
if (firstEffect !== null) {
do {
try {
commitBeforeMutationEffects();
} catch (error) {
invariant(nextEffect !== null, 'Should be working on an effect.');
captureCommitPhaseError(nextEffect, error);
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
stopCommitSnapshotEffectsTimer();
if (enableProfilerTimer) {
// 現在のコミット時間をマークし、このバッチ内のすべてのプロファイラーで共有されます。
recordCommitTime();
}
// 次の段階はミューテーション段階で、ホストツリーが変更されます。
startCommitHostEffectsTimer();
nextEffect = firstEffect;
do {
try {
commitMutationEffects(root, renderPriorityLevel);
} catch (error) {
invariant(nextEffect !== null, 'Should be working on an effect.');
captureCommitPhaseError(nextEffect, error);
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
stopCommitHostEffectsTimer();
resetAfterCommit(root.containerInfo);
// 作業中のツリーが現在のツリーになります。これはミューテーション段階の後に来る必要があります。
root.current = finishedWork;
// 次の段階はレイアウト段階で、ホストツリーが変更された後にそれを読み取る effect を呼び出します。
startCommitLifeCyclesTimer();
nextEffect = firstEffect;
do {
try {
commitLayoutEffects(root, expirationTime);
} catch (error) {
invariant(nextEffect !== null, 'Should be working on an effect.');
captureCommitPhaseError(nextEffect, error);
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
stopCommitLifeCyclesTimer();
nextEffect = null;
// スケジューラーにフレームの終わりで yield するように指示し、ブラウザが描画する機会を与えます。
requestPaint();
if (enableSchedulerTracing) {
__interactionsRef.current = ((prevInteractions: any): Set<Interaction>);
}
executionContext = prevExecutionContext;
} else {
// Effect がありません。
// ...
}
stopCommitTimer();
nextEffect = firstEffect;
while (nextEffect !== null) {
const nextNextEffect = nextEffect.nextEffect;
nextEffect.nextEffect = null;
nextEffect = nextNextEffect;
}
// ...
return null;
}
commitRootImpl
のコードは非常に長いですが、無関係なコードを省略すると、effect が存在する場合、3 つのロジックを処理する必要があることがわかります。それらのロジックは基本的に同じで、effect 連結リストをループして 3 つの異なる関数に渡します。それらは:
- commitBeforeMutationEffects
- commitMutationEffects
- commitLayoutEffects
最後に effect をループし、nextEffect を nextNextEffect に設定します。
限られたスペースのため、これら 3 つの関数については詳細に説明しませんが、次回の記事で useLayoutEffect を分析する際に詳しく説明します。したがって、3.4 で残した質問 —— effect.tag
の二進数は何を意味するのか?この質問は次回の記事で説明します。
ここでは useEffect のソースコードを明確に説明しましたが、1 つの問題が残っています:effect.tag
というパラメータは一体何のためにあるのか?現時点では、NoHookEffect
の場合に何もしないことはわかっていますが、他の値についてはまだ分析していません。それらの分析ロジックは、3.4.5 で省略した 3 つの関数に主にあります。次回の記事では、useLayoutEffect の中で詳細に分析します。
それでは、皆さんさようなら。
最後に、3.4 篇の分析のフローチャートを添付します: