- 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 も高階コンポーネントのネスト地獄の問題を解決しました。レンダープロップも 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 には保存機能がないため、毎回再レンダリングすると前回の状態がリセットされてしまうからです。ここで、外部に変数を使って保存することを考えます。
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: マウント時に全くフックが使用されていない場合、更新時にいくつかが使用されると警告します。
// 現在、nextCurrentHook === null のため、更新レンダリングをマウントとして識別します。
// これは、特定のタイプのコンポーネント(例:React.lazy)にとっては有効です。
// nextCurrentHook を使用して、マウント/更新を区別するのは、少なくとも一つの状態フックが使用されている場合にのみ機能します。
// 非状態フック(例:コンテキスト)は memoizedState に追加されないため、
// 更新時とマウント時に nextCurrentHook は 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 とプロダクションバンドルで同じように機能します。
// 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 の詳細を解説しません。fiber === currentlyRenderingFiber の場合は re-render であり、現在の更新周期内で新しい周期が発生したことを意味します。もし re-render であれば、didScheduleRenderPhaseUpdate を true に設定し、renderWithHooks 内で didScheduleRenderPhaseUpdate が true の場合、numberOfReRenders を使って re-render の回数をカウントします。また、nextWorkInProgressHook も値を持つことになります。したがって、後続のコードでは numberOfReRenders > 0 を使用して re-render かどうかを判断し、nextWorkInProgressHook が null でないかどうかを使用して 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#
ここで補足しますが、最初の行のコードは Hook を取得する方法が mountState と異なります。updateWorkInProgressHook は現在作業中の 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 のソースコードについてまだ疑問がある場合は、小さなデモを自分で書いて、重要な関数でブレークポイントを設定してデバッグしてみてください。