jeremygo

jeremygo

我是把下一颗珍珠串在绳子上的人

リアクトファイバー

Reactv16 から新しいアーキテクチャである 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 コンポーネントの statecount 属性を更新します。
  • 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 要素として一意に識別されます。
  • typekeyprops は要素に対応する属性を説明します。
  • その他の ref 属性についてはここでは触れません。

ClickCounter コンポーネントには propskey はありません :

{
    $$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 ノードが初めて作成された後、以降の更新で ReactFiber ノードを再利用し、必要な属性のみを更新します。key が定義されている場合、React は性能を最適化するためにノードを単純に移動するかどうかを選択します。

変換が完了すると、次のようなツリー構造が得られます :

fiber-tree

すべての Fiber ノードは、childsibling、および return の三つの属性を持つ連結リストで接続されています。(Fiber ノードの設計については後で詳しく説明します)。

Current と workInProgress ツリー#

上記の変換が完了したツリーは current ツリーです。React が更新を開始すると、current ツリーを走査し、対応するノードを作成して workInProgress ツリーを構成します。すべての更新と関連操作が完了すると、ReactworkInProgress ツリーの内容を画面にレンダリングし、その後 workInProgress ツリーは current ツリーに変わります。

workInProgress ツリーは finishedWork ツリーとも呼ばれます。

ソースコード内の重要な関数は次の通りです :

function updateHostComponent (current, workInProgress, renderExpirationTime) { ... }

副作用線形表#

通常の更新操作に加えて、React は「副作用」の操作を定義しています : データの取得購読操作、または DOM 構造の手動変更。これらの操作は Fiber ノード内で effectTag フィールドにエンコードされます。

ReactSideEffectTags.js は、インスタンスの更新処理後に実行される操作を定義しています :

  • DOM コンポーネントの場合:要素の追加、更新、または削除操作を含みます。
  • クラスコンポーネントの場合 : ref の更新や componentDidMountcomponentDidUpdate ライフサイクルメソッドの呼び出しを含みます。
  • ......

更新を迅速に処理するために、React は多くの効率的な手段を採用しており、その一つは Fiber ノードの線形表を構築することです。線形表を走査する速度はツリーを走査する速度よりも速いです。線形表を構築する目的は、DOM 更新やその他の副作用を持つノードにマークを付けることです。これは finishedWork ツリーのサブセットであり、nextEffect 属性で接続されています。

例えば、私たちの更新が c2 を DOM に挿入し、同時に d2c1 の属性値が変更され、b2 がライフサイクルメソッドを呼び出した場合、副作用線形表はそれらを連結し、React はその後他のノードをスキップできます :

effect-list

次に、Fiber の実行アルゴリズムを詳しく見ていきましょう~

二つの段階#

React は二つの主要な段階で作業を実行します : rendercommit

render 段階では、ReactsetStateReact.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 {
        ...
    }
}

nextUnitOfWorkworkInProgress ツリーから処理する必要のあるノードの参照を含みます。ReactFiber ツリーを走査する際、この変数を使用して他に未完了の fiber ノードがあるかどうかを知ります。現在の fiber ノードを処理した後、この変数はツリー内の次の fiber ノードまたは null を指します。この時点で React は作業ループを終了し、変更を提出する準備をします。

Fiber ツリーを走査する際、四つの主要な関数が呼び出され、作業を初期化または完了させます :

ここにそれらがどのように使用されるかを示すアニメーションがあります。子ノードは優先的に完了します。

workloop

最初に二つの関数 performUnitOfWorkbeginWork を見てみましょう :

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 を更新し、可変のライフサイクルメソッドを呼び出します。

この段階では、ReactcurrentfinishedWorkworkInProgress)ツリー、および副作用線形表を持っています。

副作用線形表は、どのノードが挿入、更新、または削除される必要があるか、またはどのコンポーネントがライフサイクルメソッドを呼び出す必要があるかを教えてくれます。これが 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 関数内で ReactcomponentWillUnmount メソッドを呼び出します。

  • commitAllLifecycles : React は残りのライフサイクルメソッド componentDidMountcomponentDidUpdate を呼び出します。

これで、調整アルゴリズムが主に実行するプロセスを理解しましたので、Fiber ノードの設計に目を向けましょう:全体が連結リストで接続され、childsibling、および 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 ノードの子ノードを再帰的に走査する際、二つの子リストを同時に反復し、差異が発生した場合に変更を生成します。

各再帰呼び出しはスタックにフレームを追加し、これは同期的に実行されます。私たちはこのようなコンポーネントツリーを持つことになります :

recursivetree

再帰的な方法は非常に直感的ですが、私たちが述べたように、制限があります。最大の問題は、タスクを分割できず、タスクを柔軟に制御できないことです。そこで React は新しい単方向リストツリー走査アルゴリズムを提案し、走査を一時停止し、成長するスタックを終了することを可能にしました。

リンクリスト走査#

Sebastian Markbage ここで このアルゴリズムを簡単に説明しました。このアルゴリズムを実現するために、次の三つの属性を持つデータ構造が必要です :

  • child : 最初の子ノードを指します
  • sibling : 最初の兄弟ノードを指します
  • return : 親ノードを指します

このようなデータ構造に基づいて、私たちはこのようなコンポーネント構造を持つことになります :

linkedlistfiber

このような構造に基づいて、私たちはブラウザのスタック実装を自分の実装に置き換えることができます。

もし二つの走査の詳細なコード実装を知りたい場合は、React の Fiber におけるリンクリストの使用法とその理由 を参照してください。

最後に、詳細な Fiber ノード構造を見てみましょう。ClickCounterspan の例を挙げます :

{
    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
}

alternateeffectTag、および nextEffect については前述の通りです。他の属性を見てみましょう :

  • stateNode : クラスコンポーネント、DOM ノード、またはその他の React 要素タイプに関連するものを指します。
  • type : この fiber に対応するコンポーネントを説明します。
  • tag : fiber のタイプ を定義し、何を処理する必要があるかを決定します。(処理関数は createFiberFromTypeAndProps
  • updateQueue : 状態更新、コールバック関数、および DOM 更新のキューです。
  • memoizedState : 出力 fiber を生成するために使用されます。更新時には、画面に現在レンダリングされている状態を反映します。
  • pendingProps : 対照的に memoizedProps があり、前者は実行を開始する際に設定され、後者は終了時に設定されます。渡された pendingPropsmemoizedProps と等しい場合、この fiber の以前の出力を再利用でき、不要な作業を回避できます。
  • key : 一意の識別子として、React が変更、追加、または削除された項目を特定するのに役立ちます。(リストとキー を参照)

Fiber の動作プロセスについては、非常に見る価値のある動画があります : Lin Clark - A Cartoon Intro to Fiber - React Conf 2017

参考 :

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。