React
は v16 から新しいアーキテクチャである Fiber を導入しました。以前の実装に比べてパフォーマンスが大幅に向上しており、この記事では例を交えて Fiber
の内部構造を詳しく分析します。
概覧#
まずは例を見てみましょう :
class ClickCounter extends React.Component {
constructor (props) {
super(props)
this.state = { count: 0 }
this.handleClick = this.handleClick.bind(this)
}
handleClick () {
this.setState(state => {
return {
count: state.count++
}
})
}
render () {
return [
<button key="1" onClick={this.handleClick}>カウンターを更新</button>,
<span key="2">{this.state.count}</span>
]
}
}
私たちが「カウンターを更新」ボタンをクリックすると、React
は以下のことを行います :
ClickCounter
コンポーネントのstate
のcount
属性を更新します。ClickCounter
コンポーネントの子コンポーネントとそのprops
を取得して比較します。span
要素のprops
を更新します。
次に、このプロセスを詳しく解析していきましょう~
React 要素から Fiber ノードへ#
React
の各コンポーネントがレンダリングする UI は render
メソッドを通じて行われます。私たちが使用する JSX
構文は、React.createElemnet
メソッドを呼び出す形にコンパイルされます。例えば、上記の UI 構造は次のように変換されます :
<button key="1" onClick={this.onClick}>カウンターを更新</button>
<span key="2">{this.state.count}</span>
以下のコードに変換されます :
React.createElement(
'button',
{
key: '1',
onClick: this.onClick
},
'カウンターを更新'
),
React.createElement(
'span',
{
key: '2'
},
this.state.count
)
その後、次のような二つのデータ構造が生成されます :
[
{
$$typeof: Symbol(react.element),
type: 'button',
key: "1",
props: {
children: 'カウンターを更新',
onClick: () => { ... }
}
},
{
$$typeof: Symbol(react.element),
type: 'span',
key: "2",
props: {
children: 0
}
}
]
上記の属性について簡単に説明します :
$$typeof
:React
要素として一意に識別されます。type
、key
、props
は要素に対応する属性を説明します。- その他の
ref
属性についてはここでは触れません。
ClickCounter
コンポーネントには props
や key
はありません :
{
$$typeof: Symbol(react.element),
key: null,
props: {},
ref: null,
type: ClickCounter
}
Fiber ノード#
調整アルゴリズムの呼び出し中に、各 render
で変換された React
要素は Fiber
ノードツリーに統合されます。各 React
要素には対応する Fiber
ノードがあり、React
要素とは異なり、fibers
は各 render
に伴って再作成されることはありません。これらは可変のデータ構造です。
異なるタイプの
React
要素には、対応する type があり、実行する必要のある作業を定義します。この観点から、
Fiber
は何をする必要があるかを示すデータ構造として理解できます。Fiber
アーキテクチャは、作業を追跡、スケジュール、停止、そして中断する便利な方法を提供します。
Fiber
ノードが初めて作成された後、以降の更新で React
は Fiber
ノードを再利用し、必要な属性のみを更新します。key
が定義されている場合、React
は性能を最適化するためにノードを単純に移動するかどうかを選択します。
変換が完了すると、次のようなツリー構造が得られます :
すべての Fiber
ノードは、child
、sibling
、および return
の三つの属性を持つ連結リストで接続されています。(Fiber
ノードの設計については後で詳しく説明します)。
Current と workInProgress ツリー#
上記の変換が完了したツリーは current ツリーです。React
が更新を開始すると、current ツリーを走査し、対応するノードを作成して workInProgress ツリーを構成します。すべての更新と関連操作が完了すると、React
は workInProgress ツリーの内容を画面にレンダリングし、その後 workInProgress ツリーは current ツリーに変わります。
workInProgress
ツリーはfinishedWork
ツリーとも呼ばれます。
ソースコード内の重要な関数は次の通りです :
function updateHostComponent (current, workInProgress, renderExpirationTime) { ... }
副作用線形表#
通常の更新操作に加えて、React
は「副作用」の操作を定義しています : データの取得、購読操作、または DOM 構造の手動変更。これらの操作は Fiber
ノード内で effectTag
フィールドにエンコードされます。
ReactSideEffectTags.js は、インスタンスの更新処理後に実行される操作を定義しています :
- DOM コンポーネントの場合:要素の追加、更新、または削除操作を含みます。
- クラスコンポーネントの場合 :
ref
の更新やcomponentDidMount
、componentDidUpdate
ライフサイクルメソッドの呼び出しを含みます。 - ......
更新を迅速に処理するために、React
は多くの効率的な手段を採用しており、その一つは Fiber
ノードの線形表を構築することです。線形表を走査する速度はツリーを走査する速度よりも速いです。線形表を構築する目的は、DOM 更新やその他の副作用を持つノードにマークを付けることです。これは finishedWork
ツリーのサブセットであり、nextEffect
属性で接続されています。
例えば、私たちの更新が c2
を DOM に挿入し、同時に d2
と c1
の属性値が変更され、b2
がライフサイクルメソッドを呼び出した場合、副作用線形表はそれらを連結し、React
はその後他のノードをスキップできます :
次に、Fiber
の実行アルゴリズムを詳しく見ていきましょう~
二つの段階#
React
は二つの主要な段階で作業を実行します : render と commit 。
render
段階では、React
は setState
と React.render
によって計画されたコンポーネントに更新を適用し、UI に更新が必要な部分を特定します。初回レンダリングの場合、React
は新しい Fiber
ノードを作成し、以降の更新では既存の Fiber
ノードを再利用して更新を行います。この段階では副作用がマークされた Fiber
ノードツリーが最終的に生成されます。副作用は次の commit
段階で何をする必要があるかを示します。
render
段階の作業は非同期に処理される可能性があります。この段階では、React
は(ブラウザの)空き時間に基づいて一つまたは複数の Fiber
ノードを処理し、その後完了した作業をキャッシュし、いくつかのイベントに応答します。処理を中断した場所から再開することも、必要に応じて完了した部分を破棄して最初からやり直すこともできます。これらはこの段階の処理がユーザーに見える変化を引き起こさないために可能です。言い換えれば、この段階は 中断可能 です。
render
段階ではいくつかのライフサイクルメソッドが呼び出されます :
- [不安全] componentWillMount (非推奨)
- [不安全] componentWillReceiveProps (非推奨)
- getDerivedStateFromProps
- shouldComponentUpdate
- [不安全] componentWillUpdate (非推奨)
- render
v16.3 以降、一部の古いライフサイクルメソッドは 不安全 としてマークされており、公式には使用が推奨されていません。これらは将来の v16.x バージョンで警告が表示され、v17.0 バージョンで削除される予定です。詳細は Update on Async Rendering をご覧ください。
なぜ公式が不安全とマークしたのでしょうか?それは render
段階が DOM 更新のような副作用を引き起こさないため、React
はコンポーネントの更新を非同期に処理できますが、不安全とマークされたこれらのメソッドは開発者によって誤用されることが多く、しばしば副作用のあるコードがこれらのメソッドに入れられ、非同期レンダリングのエラーを引き起こすからです。
対照的に、commit
段階は常に同期的です。この段階の処理はユーザーに見える変化を引き起こすため、例えば DOM 更新など、React
はそれらを一度に完了させる必要があります。
commit
段階では次のライフサイクルメソッドが呼び出されます :
- getSnapshotBeforeUpdate
- componentDidMount
- componentDidUpdate
- componentWillUnmount
これらのメソッドは同期的な commit
段階で実行されるため、副作用のあるコードを含むことができます。
render 段階#
調整アルゴリズムは常に renderRoot 関数を使用して HostRoot
fiber ノードのトップから開始しますが、すでに処理された fiber ノードをスキップし、未完了の作業を持つノードに到達するまで進みます。例えば、コンポーネントツリーの深いところで setState
を呼び出した場合、React
はトップから開始し、親コンポーネントを迅速にスキップし、setState
メソッドが呼び出されたコンポーネントに到達します。
作業ループの主要なステップ#
すべての fiber ノードは 作業ループ 関数内で処理されます。ループ内の同期部分の実装を見てみましょう :
function workLoop (isYieldy) {
if (!isYieldy) {
while (nextUnitOfWork !== null) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
}
} else {
...
}
}
nextUnitOfWork
は workInProgress
ツリーから処理する必要のあるノードの参照を含みます。React
が Fiber
ツリーを走査する際、この変数を使用して他に未完了の fiber ノードがあるかどうかを知ります。現在の fiber ノードを処理した後、この変数はツリー内の次の fiber ノードまたは null
を指します。この時点で React
は作業ループを終了し、変更を提出する準備をします。
Fiber
ツリーを走査する際、四つの主要な関数が呼び出され、作業を初期化または完了させます :
ここにそれらがどのように使用されるかを示すアニメーションがあります。子ノードは優先的に完了します。
最初に二つの関数 performUnitOfWork
と beginWork
を見てみましょう :
function performUnitOfWork (workInProgress) {
let next = beginWork(workInProgress)
if (next === null) {
next = completeUnitOfWork(workInProgress)
}
return next
}
function beginWork (workInProgress) {
console.log('作業が行われました: ' + workInProgress.name)
return workInProgress.child
}
performUnitOfWork
関数は workInProgress
ツリーからの fiber ノードを受け取り、beginWork
関数を呼び出して作業を開始します。この関数は fiber ノードが実行する必要のあるすべての操作を実行します(簡略化のため、ここでは fiber ノードの名前を表示するだけです)。beginWork
関数は常に次の子ノードへのポインタまたは null
を返します。
次の子ノードがある場合、それは workLoop
関数内の変数 nextUnitOfWork
に割り当てられます。子ノードがなくなった場合、React
はこのノードのブランチの末尾に到達したことを知り、現在のノードを完了できます。ノードが完了すると、兄弟ノードの作業を処理し、親ノードに戻ります。completeUnitOfWork
関数を見てみましょう :
function completeUnitOfWork (workInProgress) {
while (true) {
let returnFiber = workInProgress.return
let siblingFiber = workInProgress.sibling
nextUnitOfWork = completeWork(workInProgress)
if (siblingFiber !== null) {
return siblingFiber
} else if (returnFiber !== null) {
workInProgress = returnFiber
continue
} else {
return null
}
}
}
function completeWork (workInProgress) {
console.log('作業が完了しました: ' + workInProgress.name)
return null
}
Commit 段階#
この段階は completeRoot 関数の呼び出しから始まります。ここで React
は DOM を更新し、可変のライフサイクルメソッドを呼び出します。
この段階では、React
は current
と finishedWork
(workInProgress
)ツリー、および副作用線形表を持っています。
副作用線形表は、どのノードが挿入、更新、または削除される必要があるか、またはどのコンポーネントがライフサイクルメソッドを呼び出す必要があるかを教えてくれます。これが commit 段階で処理される内容です。
commit 段階で主に実行される関数は commitRoot であり、この関数は以下のことを行います:
Snapshot
副作用がマークされたノードのgetSnapshotBeforeUpdate
メソッドを呼び出します。Deletion
副作用がマークされたノードのcomponentWillUnmount
メソッドを呼び出します。- すべての DOM の挿入、更新、削除操作を実行します。
finishedWork
ツリーをcurrent
に設定します。Placement
副作用がマークされたノードのcomponentDidMount
メソッドを呼び出します。Update
副作用がマークされたノードのcomponentDidUpdate
メソッドを呼び出します。
以下は、上記のプロセスを説明する簡略化された関数です :
function commitRoot (root, finishedWork) {
commitBeforeMutationLifecycles()
commitAllHostEffects()
root.current = finishedWork
commitAllLifeCycles()
}
各サブ関数は副作用線形表を走査し、副作用のタイプを確認して更新を実行します :
-
commitBeforeMutationLifecycles
: ノードにSnapshot
副作用マークがあるかどうかを確認して走査します。function commitBeforeMutationLifecycles () { while (nextEffect !== null) { const effectTag = nextEffect.effectTag if (effectTag & Snapshot) { const current = nextEffect.alternate commitBeforeMutationLifeCycles(current, nextEffect) } nextEffect = nextEffect.nextEffect } }
-
commitAllHostEffects
: DOM 更新を実行する場所で、ノードが実行する必要のある操作を定義し、それを実行します。function commitAllHostEffects () { switch (primaryEffectTag) { case Placement: { commitPlacement(nextEffect) ... } case PlacementAndUpdate: { commitPlacement(nextEffect) commitWork(current, nextEffect) ... } case Update: { commitWork(current, nextEffect) ... } case Deletion: { commitDeletion(nextEffect) ... } } }
面白いことに、
commitDeletion
関数内でReact
はcomponentWillUnmount
メソッドを呼び出します。 -
commitAllLifecycles
:React
は残りのライフサイクルメソッドcomponentDidMount
とcomponentDidUpdate
を呼び出します。
これで、調整アルゴリズムが主に実行するプロセスを理解しましたので、Fiber
ノードの設計に目を向けましょう:全体が連結リストで接続され、child
、sibling
、および return
の三つの属性を持っています。なぜこのように設計されたのでしょうか?
設計原則#
私たちは Fiber
アーキテクチャには二つの主要な段階があることを知っています : render と commit 。
render 段階では、React
はコンポーネントツリー全体を走査し、一連の操作を実行します。これらの操作はすべて Fiber
内部で実行され、異なる要素タイプには異なる作業が処理されます。Andrew が言ったように :
UI を処理する際の大きな問題は、大量の作業が同時に実行されると、アニメーションがフレームを落とすことです。
もし React
が同期的な方法でコンポーネントツリー全体を走査し、各コンポーネントの作業を処理した場合、簡単に 16ms を超えてしまい、視覚的なカクつきを引き起こすことになります。しかし、私たちは同期的な方法を取る必要は全くありません。React
の 設計原則 にはいくつかの重要なポイントがあります :
- すべての UI 更新が即座に反映される必要はありません(そうしないとフレームが落ちる可能性があります)。
- 更新の種類によって優先度が異なります(アニメーションの応答はデータ取得よりもはるかに重要です)。
- プッシュベースのスキームは開発者がスケジュールを決定しますが、プルベースのスキームは
React
がスケジュールを決定します。
これらの原則に基づいて、私たちが実現する必要があるアーキテクチャは次のことを実現する必要があります :
- タスクを一時停止し、後で再開できること。
- 異なるタスクに異なる優先度を設定できること。
- すでに完了したタスクを再利用できること。
- もはや必要のないタスクを終了できること。
では、何がこれを実現するのに役立つのでしょうか?
新しいブラウザ(および React Native)は、この問題を解決するのに役立つ API を実装しています : requestIdleCallback 。
このグローバル関数は、ブラウザが空いている間に呼び出される関数をキューに入れるために使用されます。簡単にその使用法を見てみましょう :
requestIdleCallback((deadline) => {
console.log(deadline.timeRemaining(), deadline.didTimeout)
})
deadline.timeRemaining()
は、作業を行うためにどれだけの時間が残っているかを示し、deadline.didTimeout
は割り当てられたすべての時間が使い果たされたかどうかを示します。timeRemaining
はブラウザが特定の作業を完了するとすぐに変更されるため、常にチェックする必要があります。
requestIdleCallback
は実際にはあまりにも厳格で、しばしば スムーズな UI レンダリングを実現するには不十分です 。そのため、React チームは 独自のバージョンを再実装する必要がありました。
React
はこのように requestIdleCallback
を呼び出して作業をスケジュールし、実行するすべてのものを performWork
関数に入れます :
requestIdleCallback((deadline) => {
while ((deadlne.timeRemaining() > 0 || deadline.didTimeout) && nextComponent) {
nextComponent = performWork(nextComponent)
}
})
作業を処理する API をうまく活用するためには、レンダリング作業を複数のインクリメンタルユニットに分解する必要があります。この問題を解決するために、React
はアルゴリズムを再実装しました。元の同期再帰モデルから、リンクリストとポインタを持つ非同期モデルに変更されました。Andrew が言ったように :
内蔵のスタックに依存するだけでは、スタックが空になるまで作業を続けますが、スタックを自由に中断し、スタックフレームを手動で操作できるとしたら、素晴らしいことではないでしょうか?これが React Fiber の目的です。Fiber は React コンポーネントのために再実装されたスタックです。単一の fiber を仮想スタックフレームとして見ることができます。
再帰的走査#
React 公式ドキュメント は以前の再帰プロセスを次のように説明しています :
デフォルトでは、
React
は DOM ノードの子ノードを再帰的に走査する際、二つの子リストを同時に反復し、差異が発生した場合に変更を生成します。
各再帰呼び出しはスタックにフレームを追加し、これは同期的に実行されます。私たちはこのようなコンポーネントツリーを持つことになります :
再帰的な方法は非常に直感的ですが、私たちが述べたように、制限があります。最大の問題は、タスクを分割できず、タスクを柔軟に制御できないことです。そこで React
は新しい単方向リストツリー走査アルゴリズムを提案し、走査を一時停止し、成長するスタックを終了することを可能にしました。
リンクリスト走査#
Sebastian Markbage ここで このアルゴリズムを簡単に説明しました。このアルゴリズムを実現するために、次の三つの属性を持つデータ構造が必要です :
- child : 最初の子ノードを指します
- sibling : 最初の兄弟ノードを指します
- return : 親ノードを指します
このようなデータ構造に基づいて、私たちはこのようなコンポーネント構造を持つことになります :
このような構造に基づいて、私たちはブラウザのスタック実装を自分の実装に置き換えることができます。
もし二つの走査の詳細なコード実装を知りたい場合は、React の Fiber におけるリンクリストの使用法とその理由 を参照してください。
最後に、詳細な Fiber
ノード構造を見てみましょう。ClickCounter
と span
の例を挙げます :
{
stateNode: new ClickCounter,
type: ClickCounter,
alternate: null,
key: null,
updateQueue: null,
memoizedState: { count: 0 },
pendingProps: {},
memoizedProps: {},
tag: 1,
effectTag: 0,
nextEffect: null
}
{
stateNode: new HTMLSpanElement,
type: "span",
alternate: null,
key: "2",
updateQueue: null,
memoizedState: null,
pendingProps: {children: 0},
memoizedProps: {children: 0},
tag: 5,
effectTag: 0,
nextEffect: null
}
alternate
、effectTag
、および nextEffect
については前述の通りです。他の属性を見てみましょう :
- stateNode : クラスコンポーネント、DOM ノード、またはその他の
React
要素タイプに関連するものを指します。 - type : この fiber に対応するコンポーネントを説明します。
- tag : fiber のタイプ を定義し、何を処理する必要があるかを決定します。(処理関数は createFiberFromTypeAndProps)
- updateQueue : 状態更新、コールバック関数、および DOM 更新のキューです。
- memoizedState : 出力 fiber を生成するために使用されます。更新時には、画面に現在レンダリングされている状態を反映します。
- pendingProps : 対照的に
memoizedProps
があり、前者は実行を開始する際に設定され、後者は終了時に設定されます。渡されたpendingProps
がmemoizedProps
と等しい場合、この fiber の以前の出力を再利用でき、不要な作業を回避できます。 - key : 一意の識別子として、
React
が変更、追加、または削除された項目を特定するのに役立ちます。(リストとキー を参照)
Fiber
の動作プロセスについては、非常に見る価値のある動画があります : Lin Clark - A Cartoon Intro to Fiber - React Conf 2017 。
参考 :