- React ソースコードバージョン: v16.11.0
- ソースコード注釈ノート:airingursb/react
この記事を書く前に、ネット上のいくつかの記事を事前に読みましたが、Hooks のソースコード解析はあまりにも浅いか、または詳細ではないものが多かったため、この記事ではソースコードを重点的に解説し、浅いところから深いところまで、一行のコードも見逃さないようにします。本シリーズの最初の Hooks の解説は useState であり、useState の使い方から始め、ルールを説明し、原理を解説し、簡単に実装し、最後にソースコードを解析します。また、この記事の冒頭で、Hooks の概要を補足します。前の二つの記事では、ページ数の関係で書けなかった部分です。
注:前回の記事からすでに 2 ヶ月が経過しました。この 2 ヶ月は業務が忙しく、シリーズの記事を更新する時間があまりありませんでしたが、React はこの 2 ヶ月で 16.9 から 16.11 に更新されました。これらの更新をレビューしたところ、Hooks に関する内容は含まれていなかったため、ソースコードノートの部分を直接 16.11 に更新しました。
1. React Hooks 概要#
Hook は React 16.8 の新機能で、クラスを作成せずに state や他の React 機能を使用できるようにします。本質的には、特別な関数の一種で、use
で始まることが約束されており、Function Component にいくつかの機能を注入し、Function Component に Class Component が持つ能力を与えます。
例えば、元々 Function Component は状態を保存できないと言われていたため、Stateless Function Component とよく言われていましたが、今では useState という hook を利用することで、Function Component も Class Component のように状態を持つことができます。最近、@types/react も SFC を FC に変更しました。
1.1 動機#
React 公式サイトの Hook の紹介 では、Hook を導入する理由が挙げられています:
- コンポーネント間で状態ロジックを再利用するのが難しい
- 複雑なコンポーネントが理解しにくくなる
- 理解しにくいクラス
一、コンポーネント間で状態ロジックを再利用するのが難しい。これは私たちのシリーズの第二篇でずっと議論してきた問題で、ここでは繰り返しません。
二、複雑なコンポーネントが理解しにくくなる、つまりコンポーネントのロジックが複雑になることです。主に Class Component に関して言えば、私たちはしばしばコンポーネントのさまざまなライフサイクルの中でコードを書く必要があります。例えば、componentDidMount や componentDidUpdate でデータを取得することが多いですが、componentDidMount には他の多くのロジックも含まれている可能性があり、コンポーネントが開発されるにつれてどんどん肥大化し、ロジックが明らかにさまざまなライフサイクル関数に集中してしまい、React 開発が「ライフサイクル指向プログラミング」になってしまいます。しかし、Hooks の登場により、この「ライフサイクル指向プログラミング」が「ビジネスロジック指向プログラミング」に変わり、開発者は本来関心を持つべきでないライフサイクルを気にする必要がなくなります。
三、理解しにくいクラスは、関数型プログラミングが OOP よりも簡単であることを示しています。では、パフォーマンスを深く考えてみましょう。Hook はレンダリング時に関数を作成するために遅くなるのでしょうか?答えは「ならない」です。現在のブラウザでは、クロージャとクラスの元のパフォーマンスは極端なシナリオでのみ明らかな違いがあります。むしろ、Hook の設計はある面でより効率的であると考えることができます:
- Hook はクラスに必要な追加コストを回避します。例えば、クラスインスタンスの作成やコンストラクタ内でのイベントハンドラのバインディングのコストです。
- 言語習慣に合ったコードは、Hook を使用する際に深いコンポーネントツリーのネストを必要としません。この現象は、高階コンポーネント、レンダープロップ、コンテキストを使用するコードベースで非常に一般的です。コンポーネントツリーが小さくなると、React の作業量も減少します。
実際、React Hooks がもたらす利点は、単により関数型で、更新の粒度が細かく、コードがより明確であるだけでなく、以下の 3 つの利点もあります:
- 複数の状態がネストを生じず、書き方は平坦です:async/await がコールバック地獄に対処するのと同様に、hooks も高階コンポーネントのネスト地獄の問題を解決しました。renderProps もこの問題を compose で解決できますが、使用がやや煩雑であり、強制的に新しいオブジェクトをラップするために実体の数が増加します。
- Hooks は他の Hooks を参照でき、自作の Hooks がより柔軟です。
- コンポーネントの UI と状態を分離するのが容易です。
1.2 Hooks API#
- useState
- useEffect
- useContext
- useReducer
- useCallback
- useMemo
- useRef
- useImperativeHandle
- useLayoutEffect
- useDebugValue
- useResponder
上記の Hooks API は今後一つずつ解説しますので、ここでは繰り返しません。この記事ではまず useState を解説します。
1.3 自作 Hooks#
自作 Hook を通じて、コンポーネントロジックを再利用可能な関数に抽出できます。ここでお勧めのサイトがあります:https://usehooks.com/、実用的な自作 Hooks が集められており、プロジェクトにシームレスに組み込んで使用でき、Hooks の再利用性の強さと使用の簡単さを十分に示しています。
2. useState の使い方とルール#
import React, { useState } from 'react'
const App: React.FC = () => {
const [count, setCount] = useState<number>(0)
const [name, setName] = useState<string>('airing')
const [age, setAge] = useState<number>(18)
return (
<>
<p>あなたは {count} 回クリックしました</p>
<button onClick={() => {
setCount(count + 1)
setAge(age + 1)
}}>
クリックしてください
</button>
</>
)
}
export default App
redux を使ったことがあるなら、このシーンは非常に馴染み深いでしょう。初期状態を設定し、アクションを dispatch し、reducer を介して状態を変更し、新しい状態を返し、コンポーネントを再レンダリングします。
これは以下の Class Component に相当します:
import React from 'react'
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0,
age: 18,
name: 'airing'
};
}
render() {
return (
<>
<p>あなたは {this.state.count} 回クリックしました</p>
<button onClick={() => this.setState({
count: this.state.count + 1,
age: this.state.age + 1
})}>
クリックしてください
</button>
</>
);
}
}
export default App
Function Component は Class Component よりも簡潔で、useState の使用も非常に簡単です。ただし、Hooks の使用はこのルールに従う必要があります:Hook は毎回のレンダリングで同じ順序で呼び出されることを確認してください。したがって、Hook は毎回最上位でのみ使用することが最善であり、ループ、条件、ネストされた関数内で Hooks を呼び出さないでください。そうしないと、エラーが発生しやすくなります。
では、なぜこのルールを満たす必要があるのでしょうか?次に、useState の実装原理を見て、自分で useState を実装してみると、一目瞭然です。
3. useState の原理と簡単な実装#
3.1 デモ 1: dispatch#
第二節では useState の使い方が Redux に似ていることがわかりましたので、Redux の思想に基づいて、自分で useState を実装してみましょう:
function useState(initialValue) {
let state = initialValue
function dispatch(newState) {
state = newState
render(<App />, document.getElementById('root'))
}
return [state, dispatch]
}
React からインポートした useState を自分で実装したものに置き換えます:
import React from 'react'
import { render } from 'react-dom'
function useState(initialValue: any) {
let state = initialValue
function dispatch(newState: any) {
state = newState
render(<App />, document.getElementById('root'))
}
return [state, dispatch]
}
const App: React.FC = () => {
const [count, setCount] = useState(0)
const [name, setName] = useState('airing')
const [age, setAge] = useState(18)
return (
<>
<p>あなたは {count} 回クリックしました</p>
<p>あなたの年齢は {age} です</p>
<p>あなたの名前は {name} です</p>
<button onClick={() => {
setCount(count + 1)
setAge(age + 1)
}}>
クリックしてください
</button>
</>
)
}
export default App
この時、ボタンをクリックしても何も反応がないことに気づきます。count と age は変化しません。なぜなら、私たちが実装した useState にはストレージ機能がなく、毎回再レンダリングする際に前回の state がリセットされてしまうからです。ここで、外部に変数を使って保存することを考えます。
3.2 デモ 2: state の記憶#
これを基に、先ほど実装した useState を最適化してみましょう:
let _state: any
function useState(initialValue: any) {
_state = _state || initialValue
function setState(newState: any) {
_state = newState
render(<App />, document.getElementById('root'))
}
return [_state, setState]
}
ボタンをクリックすると変化が見られますが、効果はあまり正しくありません。age と name の二つの useState を削除すると、効果が正常であることがわかります。これは、単一の変数を使って保存しているため、当然一つの useState の値しか保存できないからです。そこで、メモリを使って、すべての state を保存するために配列を使用することを考えますが、同時に配列のインデックスを適切に管理する必要があります。
3.3 デモ 3: メモリ#
これを基に、再度先ほど実装した useState を最適化してみましょう:
let memoizedState: any[] = [] // hooks の値はこの配列に保存されます
let cursor = 0 // 現在の memoizedState のインデックス
function useState(initialValue: any) {
memoizedState[cursor] = memoizedState[cursor] || initialValue
const currentCursor = cursor
function setState(newState: any) {
memoizedState[currentCursor] = newState
cursor = 0
render(<App />, document.getElementById('root'))
}
return [memoizedState[cursor++], setState] // 現在の state を返し、cursor を 1 増やします
}
ボタンを三回クリックした後、memoizedState のデータは以下のようになります:
ページを開くと初回レンダリングされ、useState が実行されるたびに対応する setState が対応するインデックスにバインドされ、初期 state が memoizedState に保存されます。
ボタンをクリックすると、setCount と setAge がトリガーされ、それぞれの setState には対応するインデックスの参照があるため、対応する setState をトリガーすると、対応する位置の state の値が変更されます。
ここでは useState を模擬実装しているため、setState を呼び出すたびに再レンダリングのプロセスが発生します。
再レンダリングは依然として useState を順に実行しますが、memoizedState にはすでに前回の state 値があるため、初期化の値は渡された初期値ではなく、前回の値になります。
したがって、第二節で残された問題の答えは明らかです。なぜ Hooks は毎回のレンダリングで同じ順序で呼び出される必要があるのでしょうか?それは memoizedState が Hooks の定義された順序に従ってデータを配置するからです。もし Hooks の順序が変わると、memoizedState はそれに気づきません。したがって、毎回最上位でのみ Hook を使用し、ループ、条件、ネストされた関数内で Hooks を呼び出さないことが最善です。
最後に、React で useState がどのように実装されているかを見てみましょう。
4. useState ソースコード解析#
4.1 エントリ#
まず、エントリファイル packages/react/src/React.js で useState を見つけます。これは packages/react/src/ReactHooks.js に由来します。
export function useState<S>(initialState: (() => S) | S) {
const dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
resolveDispatcher () は ReactCurrentDispatcher.current を返すため、useState は実際には ReactCurrentDispatcher.current.useState です。
では、ReactCurrentDispatcher とは何でしょうか?
import type {Dispatcher} from 'react-reconciler/src/ReactFiberHooks';
const ReactCurrentDispatcher = {
current: (null: null | Dispatcher),
}
最終的に packages/react-reconciler/src/ReactFiberHooks.js にたどり着き、ここに useState の具体的な実装があります。このファイルにはすべての React Hooks のコア処理ロジックも含まれています。
4.2 型定義#
4.2.1 Hook#
始める前に、ReactFiberHooks.js のいくつかの型定義を見てみましょう。まずは Hooks:
export type Hook = {
memoizedState: any, // 現在のレンダーノード Fiber を指し、前回の完全な更新後の最終状態値
baseState: any, // 初期 initialState、すでに毎回 dispatch した後の newState
baseUpdate: Update<any, any> | null, // 現在必要な更新の Update、毎回更新後に前の update に代入され、React がレンダリングエラーのエッジでデータを回溯できるようにする
queue: UpdateQueue<any, any> | null, // キャッシュされた更新キュー、複数の更新アクションを保存する
next: Hook | null, // 次の hooks へのリンク、next を通じて各 hooks を連結します
};
Hooks のデータ構造は、私たちが以前に実装したものと基本的に一致しており、memoizedState も配列です。正確には、React の Hooks は単方向リストであり、Hook.next は次の Hook を指します。
4.2.2 Update & UpdateQueue#
では、baseUpdate と queue は何でしょうか?まずは Update と UpdateQueue の型定義を見てみましょう:
type Update<S, A> = {
expirationTime: ExpirationTime, // 現在の更新の期限時間
suspenseConfig: null | SuspenseConfig,
action: A,
eagerReducer: ((S, A) => S) | null,
eagerState: S | null,
next: Update<S, A> | null, // 次の Update へのリンク
priority?: ReactPriorityLevel, // 優先度
};
type UpdateQueue<S, A> = {
last: Update<S, A> | null,
dispatch: (A => mixed) | null,
lastRenderedReducer: ((S, A) => S) | null,
lastRenderedState: S | null,
};
Update は更新を指し、React 更新をスケジュールする際に使用されます。UpdateQueue は Update のキューで、更新時の dispatch を持っています。具体的な React Fiber と React 更新スケジューリングのプロセスについては、この記事では触れませんが、後で別の記事で補足して解説します。
4.2.3 HooksDispatcherOnMount & HooksDispatcherOnUpdate#
もう二つの Dispatch の型定義に注目する必要があります。一つは最初の読み込み時の HooksDispatcherOnMount、もう一つは更新時の HooksDispatcherOnUpdate です。
const HooksDispatcherOnMount: Dispatcher = {
readContext,
useCallback: mountCallback,
useContext: readContext,
useEffect: mountEffect,
useImperativeHandle: mountImperativeHandle,
useLayoutEffect: mountLayoutEffect,
useMemo: mountMemo,
useReducer: mountReducer,
useRef: mountRef,
useState: mountState,
useDebugValue: mountDebugValue,
useResponder: createResponderListener,
};
const HooksDispatcherOnUpdate: Dispatcher = {
readContext,
useCallback: updateCallback,
useContext: readContext,
useEffect: updateEffect,
useImperativeHandle: updateImperativeHandle,
useLayoutEffect: updateLayoutEffect,
useMemo: updateMemo,
useReducer: updateReducer,
useRef: updateRef,
useState: updateState,
useDebugValue: updateDebugValue,
useResponder: createResponderListener,
};
4.3 初回レンダリング#
4.3.1 renderWithHooks#
React Fiber は packages/react-reconciler/src/ReactFiberBeginWork.js の beginWork () から実行を開始します(React Fiber の具体的なプロセスについては、後で別の記事で補足して解説します)。Function Component に対しては、以下のロジックでコンポーネントを読み込みまたは更新します:
case FunctionComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderExpirationTime,
);
}
updateFunctionComponent 内で、Hooks の処理は次のようになります:
nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderExpirationTime,
);
したがって、React Hooks のレンダリングのコアエントリは renderWithHooks であることがわかります。他のレンダリングプロセスは気にしませんが、この記事では renderWithHooks とその後のロジックを重点的に見ていきます。
ReactFiberHooks.js に戻り、renderWithHooks が具体的に何をしているのか、エラーハンドリングコードと __DEV__
の部分を除いて、renderWithHooks のコードは以下のようになります:
export function renderWithHooks(
current: Fiber | null,
workInProgress: Fiber,
Component: any,
props: any,
refOrContext: any,
nextRenderExpirationTime: ExpirationTime,
): any {
renderExpirationTime = nextRenderExpirationTime;
currentlyRenderingFiber = workInProgress;
nextCurrentHook = current !== null ? current.memoizedState : null;
// 以下はすでにリセットされているはずです
// currentHook = null;
// workInProgressHook = null;
// remainingExpirationTime = NoWork;
// componentUpdateQueue = null;
// didScheduleRenderPhaseUpdate = false;
// renderPhaseUpdates = null;
// numberOfReRenders = 0;
// sideEffectTag = 0;
// TODO: マウント時に全く hooks が使用されていない場合に警告を出す。次に更新時にいくつかが使用される場合。
// 現在、次の currentHook === null で更新レンダリングをマウントとして識別します。
// これは、特定のタイプのコンポーネント(例:React.lazy)にとっては有効です。
// nextCurrentHook を使用してマウント/更新を区別することは、少なくとも一つの状態フックが使用されている場合にのみ機能します。
// 非状態フック(例:コンテキスト)は memoizedState に追加されないため、
// 次の currentHook は更新時とマウント時に null になります。
ReactCurrentDispatcher.current =
nextCurrentHook === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
let children = Component(props, refOrContext);
if (didScheduleRenderPhaseUpdate) {
do {
didScheduleRenderPhaseUpdate = false;
numberOfReRenders += 1;
// リストの最初からやり直します
nextCurrentHook = current !== null ? current.memoizedState : null;
nextWorkInProgressHook = firstWorkInProgressHook;
currentHook = null;
workInProgressHook = null;
componentUpdateQueue = null;
ReactCurrentDispatcher.current = __DEV__
? HooksDispatcherOnUpdateInDEV
: HooksDispatcherOnUpdate;
children = Component(props, refOrContext);
} while (didScheduleRenderPhaseUpdate);
renderPhaseUpdates = null;
numberOfReRenders = 0;
}
// 前のディスパッチャは常にこのものであると仮定できます。レンダリングフェーズの最初に設定し、再入はありません。
ReactCurrentDispatcher.current = ContextOnlyDispatcher;
const renderedWork: Fiber = (currentlyRenderingFiber: any);
renderedWork.memoizedState = firstWorkInProgressHook;
renderedWork.expirationTime = remainingExpirationTime;
renderedWork.updateQueue = (componentUpdateQueue: any);
renderedWork.effectTag |= sideEffectTag;
// このチェックは currentHook を使用しているため、DEV と prod バンドルで同じように機能します。
// hookTypesDev はもっと多くのケースをキャッチできます(例:コンテキスト)が、DEV バンドルのみに限られます。
const didRenderTooFewHooks =
currentHook !== null && currentHook.next !== null;
renderExpirationTime = NoWork;
currentlyRenderingFiber = null;
currentHook = null;
nextCurrentHook = null;
firstWorkInProgressHook = null;
workInProgressHook = null;
nextWorkInProgressHook = null;
remainingExpirationTime = NoWork;
componentUpdateQueue = null;
sideEffectTag = 0;
// これらは上でリセットされました
// didScheduleRenderPhaseUpdate = false;
// renderPhaseUpdates = null;
// numberOfReRenders = 0;
return children;
}
renderWithHooks には三つの部分が含まれています。まずは 4.1 で言及した ReactCurrentDispatcher.current を設定し、その後 didScheduleRenderPhaseUpdate といくつかの初期化作業を行います。コアは第一部分です。見てみましょう:
nextCurrentHook = current !== null ? current.memoizedState : null;
ReactCurrentDispatcher.current =
nextCurrentHook === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
現在の Fiber が null であれば、初回読み込みと見なし、ReactCurrentDispatcher.current.useState は HooksDispatcherOnMount.useState に設定されます。そうでなければ、HooksDispatcherOnUpdate.useState に設定されます。4.2 の型定義に従い、初回読み込み時は useState = ReactCurrentDispatcher.current.useState = HooksDispatcherOnMount.useState = mountState;更新時は useState = ReactCurrentDispatcher.current.useState = HooksDispatcherOnUpdate.useState = updateState となります。
4.3.2 mountState#
まず、mountState の実装を見てみましょう:
// コンポーネントの useState を初めて呼び出すときに実際に呼び出されるメソッド
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
// 新しい Hook を作成し、現在の workInProgressHook を返します
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
initialState = initialState();
}
hook.memoizedState = hook.baseState = initialState;
// 新しいキューを作成します
const queue = (hook.queue = {
last: null, // 最後の更新ロジック、{action,next} つまり状態値と次の Update
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any), // 最後のレンダリング時の状態
});
const dispatch: Dispatch<
BasicStateAction<S>,
> = (queue.dispatch = (dispatchAction.bind(
null,
// 現在の fiber と queue をバインドします。
((currentlyRenderingFiber: any): Fiber),
queue,
): any));
return [hook.memoizedState, dispatch];
}
4.3.3 mountWorkInProgressHook#
mountWorkInProgressHook は新しい Hook を作成し、現在の workInProgressHook を返します。実装は以下の通りです:
// 新しい hook を作成し、現在の workInProgressHook を返します
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
baseState: null,
queue: null,
baseUpdate: null,
next: null,
};
// 初回ページを開いたときだけ、workInProgressHook は null です
if (workInProgressHook === null) {
firstWorkInProgressHook = workInProgressHook = hook;
} else {
// すでに workInProgressHook が存在する場合は、新しく作成した Hook を workInProgressHook の末尾に接続します。
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
4.3.4 dispatchAction#
注意すべきは、mountState で重要なことをもう一つ行っていることです。dispatchAction に現在の fiber と queue をバインドしています:
const dispatch: Dispatch<
BasicStateAction<S>,
> = (queue.dispatch = (dispatchAction.bind(
null,
// 現在の fiber と queue をバインドします
((currentlyRenderingFiber: any): Fiber),
queue,
): any));
では、dispatchAction がどのように実装されているか見てみましょう:
function dispatchAction<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
) {
const alternate = fiber.alternate;
if (
fiber === currentlyRenderingFiber ||
(alternate !== null && alternate === currentlyRenderingFiber)
) {
// この分岐は re-render 時の Fiber スケジューリング処理です
didScheduleRenderPhaseUpdate = true;
const update: Update<S, A> = {
expirationTime: renderExpirationTime,
suspenseConfig: null,
action,
eagerReducer: null,
eagerState: null,
next: null,
};
// この更新周期内の更新記録を renderPhaseUpdates にキャッシュします
if (renderPhaseUpdates === null) {
renderPhaseUpdates = new Map();
}
const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
if (firstRenderPhaseUpdate === undefined) {
renderPhaseUpdates.set(queue, update);
} else {
let lastRenderPhaseUpdate = firstRenderPhaseUpdate;
while (lastRenderPhaseUpdate.next !== null) {
lastRenderPhaseUpdate = lastRenderPhaseUpdate.next;
}
lastRenderPhaseUpdate.next = update;
}
} else {
const currentTime = requestCurrentTime();
const suspenseConfig = requestCurrentSuspenseConfig();
const expirationTime = computeExpirationForFiber(
currentTime,
fiber,
suspenseConfig,
);
// すべての更新アクションを保存し、re-render プロセスで最新の状態値を計算できるようにします
const update: Update<S, A> = {
expirationTime,
suspenseConfig,
action,
eagerReducer: null,
eagerState: null,
next: null,
};
// 更新をリストの末尾に追加します。
const last = queue.last;
if (last === null) {
// これは最初の更新です。循環リストを作成します。
update.next = update;
} else {
// ... 更新循環リスト
const first = last.next;
if (first !== null) {
// 依然として循環しています。
update.next = first;
}
last.next = update;
}
queue.last = update;
// 特殊な場合の Fiber NoWork 時のコードは省略します
// 更新タスクを作成し、fiber のレンダリングを実行します
scheduleWork(fiber, expirationTime);
}
}
if の最初の分岐は Fiber のスケジューリングに関するもので、ここでは触れませんが、本文では触れませんが、fiber === currentlyRenderingFiber の場合は re-render であり、現在の更新周期の中で新しい周期が発生したことを意味します。もし re-render の場合、didScheduleRenderPhaseUpdate を true に設定し、renderWithHooks 内で didScheduleRenderPhaseUpdate が true の場合、numberOfReRenders をカウントして re-render の回数を記録します。また、nextWorkInProgressHook も値を持つことになります。したがって、後続のコードでは、numberOfReRenders > 0 を使用して re-render かどうかを判断し、nextWorkInProgressHook が空であるかどうかを判断して re-render かどうかを判断します。
また、re-render の場合、すべての更新プロセスで発生した更新を renderPhaseUpdates という Map に記録します。各 Hook の queue をキーとして使用します。
最後に、scheduleWork の具体的な作業については、後で別の記事で分析します。
4.4 更新#
4.4.1 updateState#
更新プロセス中の useState が実際に呼び出されるメソッド updateState を見てみましょう:
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
return typeof action === 'function' ? action(state) : action;
}
// 一度目以降の useState を実行する際に実際に呼び出されるメソッド
function updateState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
return updateReducer(basicStateReducer, (initialState: any));
}
実際には、updateState は最終的に updateReducer を呼び出します。useState がトリガーする update アクションに関しては、basicStateReducer は action の値を直接返します(action が関数の場合はそれを呼び出します)。したがって、useState は useReducer の特殊なケースに過ぎず、渡される reducer は basicStateReducer であり、state を変更する役割を果たしますが、useReducer のようにカスタム reducer を渡すことはできません。
4.4.2 updateReducer#
では、updateReducer が何をしているのか見てみましょう:
function updateReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
// 現在の作業中の hook を取得します
const hook = updateWorkInProgressHook();
const queue = hook.queue;
queue.lastRenderedReducer = reducer;
if (numberOfReRenders > 0) {
// re-render:現在の更新周期の中で新しい更新が発生しました
const dispatch: Dispatch<A> = (queue.dispatch: any);
if (renderPhaseUpdates !== null) {
// すべての更新プロセスで発生した更新を renderPhaseUpdates という Map に記録します。各 Hook の queue をキーとして使用します。
const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
if (firstRenderPhaseUpdate !== undefined) {
renderPhaseUpdates.delete(queue);
let newState = hook.memoizedState;
let update = firstRenderPhaseUpdate;
do {
// re-render の場合、現在のレンダリング周期において新しい更新が発生した場合、これらの更新を続けて実行します
const action = update.action;
newState = reducer(newState, action);
update = update.next;
} while (update !== null);
if (!is(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate();
}
hook.memoizedState = newState;
if (hook.baseUpdate === queue.last) {
hook.baseState = newState;
}
queue.lastRenderedState = newState;
return [newState, dispatch];
}
}
return [hook.memoizedState, dispatch];
}
const last = queue.last;
const baseUpdate = hook.baseUpdate;
const baseState = hook.baseState;
let first;
if (baseUpdate !== null) {
if (last !== null) {
last.next = null;
}
first = baseUpdate.next;
} else {
first = last !== null ? last.next : null;
}
if (first !== null) {
let newState = baseState;
let newBaseState = null;
let newBaseUpdate = null;
let prevUpdate = baseUpdate;
let update = first;
let didSkip = false;
do {
const updateExpirationTime = update.expirationTime;
if (updateExpirationTime < renderExpirationTime) {
if (!didSkip) {
didSkip = true;
newBaseUpdate = prevUpdate;
newBaseState = newState;
}
if (updateExpirationTime > remainingExpirationTime) {
remainingExpirationTime = updateExpirationTime;
}
} else {
markRenderEventTimeAndConfig(
updateExpirationTime,
update.suspenseConfig,
);
// 循環リストを走査し、各更新を実行します
if (update.eagerReducer === reducer) {
newState = ((update.eagerState: any): S);
} else {
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;
}
const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
}
updateReducer は二つの状況に分かれます:
- 非 re-render、つまり現在の更新周期に一つの Update しかない場合。
- re-render、つまり現在の更新周期に新しい更新が発生した場合。
4.3.4 で述べたように、numberOfReRenders は re-render の回数を記録しており、0 より大きい場合は現在の更新周期に新しい更新が発生したことを示します。したがって、これらの更新を続けて実行し、reducer と update.action に基づいて新しい state を作成し、現在のレンダリング周期において新しい更新が発生しなくなるまで続けます。最後に、Hook.memoizedState と Hook.baseState に値を代入します。
注:実際には、useState を単独で使用する場合、re-render のシナリオに遭遇することはほとんどありません。setState を関数のトップに書く場合を除いては、無限の re-render を引き起こすことになりますが、これにより numberOfReRenders が制限を超えてしまい、4.3.4 の dispatchAction でプログラムがエラーを報告します(4.3.4 では
__DEV__
とこの部分のエラーハンドリングコードが省略されています):
invariant(
numberOfReRenders < RE_RENDER_LIMIT,
'Too many re-renders. React limits the number of renders to prevent ' +
'an infinite loop.',
);
次に、非 re-render の状況を見てみましょう。Fiber に関連するコードや特殊なロジックを除外すると、重要なのは do-while ループです。このコードは、循環リストを走査し、各更新を実行する役割を果たします:
do {
// 循環リストを走査し、各更新を実行します
if (update.eagerReducer === reducer) {
newState = ((update.eagerState: any): S);
} else {
const action = update.action;
newState = reducer(newState, action);
}
prevUpdate = update;
update = update.next;
} while (update !== null && update !== first);
もう一つ注意が必要なのは、この状況では、各 update の優先度を判断する必要があり、現在の全体の更新優先度内にない更新はスキップされます。最初にスキップされた update は新しい Hook.baseUpdate になります。後続の更新は baseUpdate 更新後の基盤の上で再度実行される必要があり、結果が異なる可能性があります。この具体的なロジックについては、後で別の記事で解析します。最後に、Hook.memoizedState と Hook.baseState にも同様に代入されます。
4.4.3 updateWorkInProgressHook#
ここで補足しますが、updateWorkInProgressHook の最初の行のコードは、mountState とは異なり、現在作業中の Hook を取得する方法です。実装は以下の通りです:
// 現在作業中の Hook を取得します。つまり workInProgressHook
function updateWorkInProgressHook(): Hook {
if (nextWorkInProgressHook !== null) {
// すでに作業中のものがあります。再利用します。
workInProgressHook = nextWorkInProgressHook;
nextWorkInProgressHook = workInProgressHook.next;
currentHook = nextCurrentHook;
nextCurrentHook = currentHook !== null ? currentHook.next : null;
} else {
// 現在の hook からクローンします。
currentHook = nextCurrentHook;
const newHook: Hook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
queue: currentHook.queue,
baseUpdate: currentHook.baseUpdate,
next: null,
};
if (workInProgressHook === null) {
workInProgressHook = firstWorkInProgressHook = newHook;
} else {
workInProgressHook = workInProgressHook.next = newHook;
}
nextCurrentHook = currentHook.next;
}
return workInProgressHook;
}
ここでは二つの状況に分かれます。4.3.4 で述べたように、nextWorkInProgressHook が存在する場合は re-render であり、現在の更新周期においてさらに処理する必要があることを示します。
re-render でない場合は、次の Hook を現在の Hook とし、4.3.3 の mountWorkInProgressHook と同様に、新しい Hook を作成して workInProgressHook を返します。
要するに、updateWorkInProgressHook は現在作業中の workInProgressHook を取得しました。
5 結論#
直感的に、Hook が実行中のデータ構造をキャプチャしたものを以下に示します:
上記の解析のプロセスをまとめると、以下の図のようになります:
useState のソースコードについてまだ疑問がある場合は、小さなデモを作成し、重要な関数でブレークポイントを設定してデバッグしてみてください。