skip to content
usubeni fantasy logo Usubeni Fantasy

Reactの設計哲学

/ 31 min read

This Post is Available In: CN EN ES JA

Reactのレンダリング機構

Webページで文字を入力するたびに半秒ほどカクつく経験や、実行ボタンを押すとページ全体が画像のように固まって、どこをクリックしても反応しなくなる経験はありませんか?

以前のReactレンダリングには致命的な欠陥がありました。setState時に、現在のコンポーネントのツリー全体がレンダリング関数を実行してしまうのです。仮想DOMを使用していても、ここで相当な時間が無駄になっていました。なぜなら、依存するpropsが変わっていない、あるいは全くpropsに依存していないコンポーネントでも、(デフォルトでは)再レンダリングされてdiffが実行されるからです。大規模なWebアプリケーションでは、この欠陥が明らかなカクつきを引き起こしていました。

一方、Vueはコンポーネントがどの値に依存しているかを最初から把握しているため、コンポーネント単位で精密に再レンダリングできます。

カクつきが発生する原因

ご存知の通り、ブラウザのWebページレンダリングとJavaScriptの実行は同じスレッドを奪い合っています。

リフレッシュ時に必要な画面が準備できていない場合(最も一般的な原因はJavaScriptの長時間タスクによるブロック)、そのフレームは破棄され、カクつきの原因となります。基本的な滑らかさを保つには、レンダリングをブロックするタスクの時間は30msを超えてはならず、16msに抑えられればより良いでしょう。requestAnimationFrameを使用することで、リフレッシュタイミングに効果的に合わせ、JavaScriptの実行時間をより余裕を持たせることができます。

React開発のシナリオでは、開発者が意図的に最適化していない場合、上位のコンポーネントを変更するほど、より長時間のレンダリングが発生します。古いdiffアルゴリズム(stack reconciler)は中断できないため、レンダリングが開始されると、新旧の仮想DOMツリーが同期的に再帰比較され、DOM操作が実行されます。このような再帰操作には脱出手段がなく、大型コンポーネントは簡単に16msを超えてカクつきを引き起こします。

では、Reactはどのようにして大型コンポーネントのレンダリング時間を16msに圧縮するのでしょうか?

Fiberが救世主として登場

React 16から、ReactはReact Fiberを導入し、同時にFiber Reconcilerを更新しました。その核心アルゴリズムは、コンポーネントツリーの各コンポーネントを一つの単位として扱い、これらの単位をバッチで実行することです。Reactが実行時間が不足していることを検出すると、スレッドをレンダリングに戻し、レンダリング完了後、中断された位置から実行を継続します。つまり、FiberはReactレンダリングに一時停止、中止、優先度調整の能力を提供するのです。

同時に、新バージョンの全体的なレンダリングフローはRenderとCommitの2段階に分かれています。

まず、Fiberとは何かを見てみましょう。実際には、それは単なるJavaScriptオブジェクトで、このシナリオでは3つの属性に注目するだけで十分です:

export type Fiber = {
// ...
return: Fiber | null;
child: Fiber | null;
sibling: Fiber | null;
// ...
};

Renderは単一スレッドでFiberを走査するプロセスで、このプロセスは中断可能です。これは古いアルゴリズムの再帰メカニズムでは実現できませんでした。基本的なアルゴリズムは以下の通りで、子ノードを優先的に探し、なければ兄弟ノードを探し、兄弟ノードも処理完了後は上位層にreturnして、上位層の兄弟ノードを処理し続け、ルートノードに戻るまで続けます。

let root = fiber;
let node = fiber;
while (true) {
// Do something with node
if (node.child) {
node = node.child;
continue;
}
if (node === root) {
return;
}
while (!node.sibling) {
if (!node.return || node.return === root) {
return;
}
node = node.return;
}
node = node.sibling;
}
節点遍歴

ReactはWork LoopでFiber Reconcilerをスケジューリングし、時間が不足している時、ReactはJavaScriptにスレッドを譲ってユーザー操作とページレンダリングに応答させる必要があります。このスケジューリングアルゴリズムは当初requestIdleCallbackインターフェースに依存する予定でしたが、ブラウザの互換性問題が解決されなかったため、React独自の実装を行いました。

React Workloop

ここから得られるインスピレーションとして、Work Loopのようなタイムスライシングの考え方は、Vueの長いリストレンダリングでも活用できます。

Commitは完全なRender後に、実際のDOMに対する一連の操作を収集して一括実行するステップで、この段階は同期実行されます。2つの段階に分けることで、Renderの実行が中断された時にUIが半分だけ変更されて不整合が生じることを防げます。また、この2つのステップが分離され、レンダリングステップが中断可能になったため、以前はcomponentWillUpdateなどのメソッドが2回呼ばれる問題が発生し、後にこれらのメソッドは非推奨となりました。

このDemoでは、新旧2つのレンダリングメカニズムのパフォーマンス比較を見ることができ、その差は非常に大きいです。

Stackのパフォーマンス分析:

stackのパフォーマンス分析

Fiberのパフォーマンス分析:

fiberのパフォーマンス分析

Fiber + Reconciliationアルゴリズムはシステムの応答速度を大幅に向上させ、全体的なレンダリング時間が若干延長される代償はありますが、非常にコストパフォーマンスが良いように見えます。

関数型コンポーネントの哲学

Fiberの核心はrenderから得られる新しい仮想DOMを迅速に取得することであり、React自体も関数型プログラミングを積極的に採用しているため、結果として両者は完璧にマッチしました。Fiberの更新の機会に乗じて、Hookを導入し、関数型コンポーネントを徹底的に貫きました。

Classコンポーネント:

class Example extends React.Component {
state = { count: 0 };
componentDidMount() {
// ライフサイクルメソッド
}
render() {
return <div>{this.state.count}</div>;
}
}

関数型コンポーネント:

function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
// ライフサイクルの代替
}, []);
return <div>{count}</div>;
}

比較すると、関数型コンポーネントの主な利点は、より簡潔な記述でthisを省略でき、共通ロジックを抽出するためのより便利な基盤を提供することです。また、ライフサイクル関数もuseEffectなどの副作用フックに統一され、前述のcomponentWillUpdateの非対称問題も併せて解決されました。

レンダリング関数の特徴は、純粋関数でなければならないことです。入力が確定していれば、出力も確定して不変であり、1万回実行しても同じ結果になります。これがいわゆる副作用がないということです。この動作はUI = f(state)という公式で表現できます。

この特性により、少し直感に反する問題が生じます。alertの結果は何でしょうか:

export default function Counter({ init }) {
const [number, setNumber] = useState(init);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 5);
alert(number);
}}
>
+5
</button>
</>
);
}

Reactの底層メカニズムとJavaScriptの特性を真に理解すれば、結果は明らかに古い値です。現在のレンダリングにおいて、JavaScriptの静的スコープの特性により、numberは固定された値であり、setNumber後に即座に再レンダリングされることはありません(Work Loopのスケジューリングを待つキューに入るだけです)。そのため、alertsetNumberの後にあっても、即座に新しい結果を得ることはできません。

Hookの導入

hook

この画像は素晴らしい比喩です。Hookは関数型コンポーネント内のフックです。古いバージョンと比較して、関数型コンポーネントを完全に採用した後、以前のクラスコンポーネントの様々な属性を再び保存する方法を考える必要がありました。Hookの導入により、これらのレンダリング関数がReactの追加機能を引き続き持てるようになりました。

純粋関数は状態を記録できないため、Hookの本質は状態をどこかに転移させ、フックを通じて関数外の値にアクセスすることです。

なぜ外層でなければならないのか

Reactが状態を転移する際、呼び出し順序が重要です。if関数内に書くと、呼び出し順序が全く保証できません。

DidactuseState実装を見てみましょう:

let wipFiber = null;
let hookIndex = null; // hookの順序を保存
function updateFunctionComponent(fiber) {
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function useState(initial) {
const oldHook =
wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]; // indexに基づいてhookを取得
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
};
const actions = oldHook ? oldHook.queue : [];
actions.forEach((action) => {
hook.state = action(hook.state);
});
const setState = (action) => {
hook.queue.push(action);
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextUnitOfWork = wipRoot;
deletions = [];
};
wipFiber.hooks.push(hook);
hookIndex++; // index + 1 次のhookで使用
return [hook.state, setState];
}

新パラダイムに基づく最適化

React新バージョンの関数型 + Fiberの新パラダイムでは、主にキャッシュスケジューリングの2つの観点から新しい最適化方法がもたらされました。

キャッシュ

Classコンポーネントが更新時にrender関数を実行するのと比較して、関数型コンポーネントに変更後は、毎回のレンダリングで関数全体が実行されます。このコンポーネントに大型関数やCPU集約的な操作のキャッシュが含まれている場合、パフォーマンス問題を引き起こしやすくなります。

前述したように、Hookは状態を他の場所に転移することですが、状態以外にも、大型関数、依存される関数、CPU集約的な操作のキャッシュを保存する必要がある場合があります。

ここでuseMemouseCallbackについて言及する必要があります。

Demoを見てみましょう:

import React, { useState, useMemo } from "react";
// 第n番目のフィボナッチ数を計算する関数(CPU集約的な操作)
const fibonacci = (n) => {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
};
const App = () => {
const [num, setNum] = useState(10); // ユーザー入力の数値
const [count, setCount] = useState(0); // その他の無関係な状態
// useMemoを使用して計算結果をキャッシュ
const fibValueMemo = useMemo(() => {
console.log("Calculating Fibonacci...");
return fibonacci(num);
}, [num]);
// キャッシュなし
// const fibValue = fibonacci(num)
return (
<div style={{ padding: "20px" }}>
<h1>useMemo CPU集約的操作キャッシュの例</h1>
<div>
<label>
第{" "}
<input
type="number"
value={num}
onChange={(e) => setNum(parseInt(e.target.value, 10) || 0)}
style={{ width: "50px" }}
/>{" "}
番目のフィボナッチ数を計算:
</label>
</div>
<p>結果: {fibValueMemo}</p>
<button onClick={() => setCount(count + 1)}>クリック回数: {count}</button>
</div>
);
};
export default App;

fibonacciのパラメータが38以上になると、UIがカクつき始めます。useMemoを使用しない場合、ボタンをクリックするたびにWebページが反応しなくなるのを感じるでしょう。

useCallbackは関数のキャッシュに使用され、唯一の機能はその参照を不変に保つことです。

// ...
const createOptions = useCallback(() => {
return {
serverUrl: "https://localhost:1234",
roomId: roomId,
};
}, [roomId]); // ✅ Only changes when roomId changes
useEffect(() => {
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // ✅ Only changes when createOptions changes
// ...

関数が依存関係になる場合、まずキャッシュされる必要があります。なぜなら、キャッシュしない場合、関数型コンポーネントの特性により、毎回実行時に全く新しい関数となり、依存関係が変更されたかどうかをマッチングする際に常にtrueになってしまうからです。

関数宣言も時間がかかるため、キャッシュ後にこの部分の時間を節約できると思わないでください。 実際には、上記の例は以下と同等です:

const fn = () => {
return {
serverUrl: "https://localhost:1234",
roomId: roomId,
};
};
const createOptions = useCallback(fn, [roomId]);

fnの宣言は絶対に省けません。useCallbackは依存関係が変わらない場合に前回の関数宣言を返すだけで、今回は宣言しなくて済むと騙されないでください😂。

もちろん、関数がstatepropを全く使用していない場合は、コンポーネントの外で直接定義するのが最適で、その場合は本当に節約できます。

関数と実行結果をキャッシュできるなら、より高次元でキャッシュの可能性を考えて、コンポーネント全体をキャッシュしてはどうでしょうか?

レンダリング関数が純粋関数UI = f(state)であるという特性に基づき、stateが変わらない限り、このコンポーネントの実行結果は安全に再利用できます。ReactはReact.memo関数を提供してコンポーネントを保存し、同じパラメータでのコンポーネントの再レンダリングを省略できます。

const ExpensiveItem = React.memo(({ count }) => {
blockMainThread(500);
const arr = Array(count).fill(0);
return (
<>
{arr.map((i) => (
<div>i</div>
))}
</>
);
});

スケジューリング

以下の2つの最適化は、どちらも新しいレンダリングメカニズムと密接に関連しています:

useTransitionを一言で表すと、トランジション用のレンダリングです。startTransition内でトリガーされたレンダリングは即座に中断され、より高い優先度の要求を実行できます。

このHookの本質は、Fiberの中断可能なメカニズムを利用して、現在の重要でないレンダリングを直接中断し、ユーザーが後で必要とするコンテンツを直接提供することです。

Demo

export default function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState("about");
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
return (
<>
<Suspense fallback={<div>加载中,请稍候...</div>}>
<DelayedComponent />
</Suspense>
<TabButton isActive={tab === "about"} onClick={() => selectTab("about")}>
About
</TabButton>
<TabButton isActive={tab === "posts"} onClick={() => selectTab("posts")}>
Posts (slow) {isPending ? "loading" : ""}
</TabButton>
<TabButton isActive={tab === "contact"} onClick={() => selectTab("contact")}>
Contact
</TabButton>
<hr />
{tab === "about" && <AboutTab />}
{tab === "posts" && <PostsTab />}
{tab === "contact" && <ContactTab />}
</>
);
}

startTransitionをコメントアウトすることで、応答速度の飛躍的な向上を感じることができます。

もう一つのHookuseDeferredValueを一言で表すと、React特化の高度なデバウンスです。useDeferredValueはReactによってスケジューリングされ、スレッドが忙しくない時にスケジュールに上がり、忙しい時は常に延期されます。

Reactがシステムが忙しいことを検出すると、useDeferredValueをトリガーしないため、この値は忙しい期間が過ぎた後にのみ更新され、React.memoと組み合わせて使用すると絶大な効果があります。

Demo

import React, { useState, useDeferredValue, startTransition } from "react";
function blockMainThread(duration) {
const start = performance.now();
while (performance.now() - start < duration) {}
}
const ExpensiveItem = React.memo(({ count }) => {
blockMainThread(500);
const arr = Array(count).fill(0);
return (
<>
{arr.map((i) => (
<div>i</div>
))}
</>
);
});
const App = () => {
const [count, setCount] = useState(0);
const deferredCouont = useDeferredValue(count);
console.log(count, deferredCouont);
return (
<>
<button onClick={() => setCount(count + 1)}>
<span role="img" aria-label="react-emoji">
⚛️
</span>{" "}
{count}
</button>
<ExpensiveItem count={deferredCouont} />
</>
);
};
export default App;
react fiber 优先级

純粋関数を破る特例

純粋関数としてのレンダリングコンポーネントでも、時には避けられない副作用を生成する必要があります。例えば、レンダリング後にページにデータを提供するためにajaxインターフェースを自動的にリクエストする場合があります。ReactはuseEffect Hookを導入し、副作用を安心してここに配置できます。

一般的な副作用操作には以下があります:

  • データ取得(API呼び出しなど)
  • イベント購読(WebSocketやDOMイベントリスナーなど)
  • 手動でのDOM変更(documentの直接操作など)
  • タイマー設定(setTimeoutやsetIntervalなど)
  • グローバル変数や外部状態の変更

副作用に対する責任

副作用がコンポーネントのマウント時に発生した場合、コンポーネントの破棄時にこれらの副作用をクリーンアップする責任があります。

useEffect(() => {
const connection = createConnection(serverUrl, roomId);
return () => connection.disconnect();
}, [roomId]);

useEffectが2回実行される問題を聞いたことがあるかもしれません。これは有名な問題で、原因はReactが開発環境で推奨するStrict Modeです。Strict Modeが意図的に2回実行するのは、useEffectの副作用が適切にクリーンアップされているかどうかの問題を露呈させるためです。通常、純粋関数の実行結果は必ず同じになるため、重複実行しても何の問題もありません。問題が発生した場合、開発者がReactレンダリングは純粋関数でなければならないという設定を守っていないか、useEffectによる副作用がクリーンアップされていないためにプログラムが期待通りに動作していないことを意味します。

開いてすぐに異常を感じる場合、Strict Modeの重複実行が原因である可能性が高いです。この時にすべきことはStrict Modeを無効にすることではなく、コンポーネントが意図しない副作用を導入していないかを考えることです。

イベントと副作用

useEffect以外にも、イベントは非純粋関数になることができます。なぜなら、イベントはレンダリング時に実行されないからです。イベントとuseEffectの違いは、イベントはユーザー操作によって引き起こされるのに対し、useEffectによる副作用はReactレンダリングと強く関連していることです。

useEffectは、コンポーネントがレンダリングされるたびにトリガーされる機会がある関数で、トリガーされるかどうかは依存値が変更されたかどうかによって決まります。

公式ドキュメントでは、初心者がuseEffectを使用する可能性があるが、実際には必要ない状況について非常に大きな紙面を割いて説明しています。その中で重要な思想の一つがイベント優先です。ここで簡単な例を挙げてみましょう:

useEffect(() => {
setSearch("");
}, [currentTeam]);

searchcurrentTeamに依存していない状況で、このように書くとThis hook specifies more dependencies than necessary: currentTeam(Biomeを使用)という警告が表示されます。実際にこのようにすることはでっち上げの依存関係を作ることであり、より良い処理方法はcurrentTeamをトリガーするイベント自体でsearchをリセットすることです。

このようにすることで、後のコード保守時にこれらのでっち上げの関係を理解するのが困難になるだけでなく、アプリケーションのパフォーマンスにも影響します。currentTeamが変更された時に既に再レンダリングされ、再レンダリング後にsetSearchを呼び出すと、再度renderを実行することになります。より良い解決策は以下の通りです:

<button
onClick={() => {
setCurrentTeam(item.teamId);
setSearch("");
refetch();
}}
>
{item.name}
</button>

この状況ではuseEffectはVueのwatchと少し似ており、同様に、Vueでも実際にはwatchを乱用すべきではありません。

一言でまとめると、イベント駆動を活用することです。イベントで解決できる問題には、useEffectを使用しないでください。(Vueも同様)

useEffectを使わざるを得ない場合でも、Reactには追加の要求があります。依存関係を適当に書かないことです。

副作用内のイベント

もう一度この例を見てみましょう。コンポーネントがroomIdの変化時にwebsocketを再接続する必要があるが、websocketに接続する関数内でthemeも使用する必要がある場合、themeを依存関係に書くと、テーマを変更する時に無駄に再接続してしまいます。

ReactはuseEffectの依存関係問題に対して新しいHookuseEffectEventを導入しました。開発者はこのHookを通じて関数を「副作用イベント」として抽象化し、useEffect専用のイベントにします。

以下の点に注意が必要です:

  • 「リアクティブな値」ではなく、依存関係にもなれない
  • 依存関係はないが、参照する値は常に最新を保つ
  • 名前から推測すると、useEffectでのみ使用すべき

これは間違いなく開発者にとって非常に理解しにくいHookです。結局のところ、useEffectの依存関係問題を解決するための専用のものであり、これは穴を埋めるために別の穴を掘るようなものです。

無理に説明するとすれば、「イベント」(さらには関数)は本来変化をトリガーする要素ではなく、状態こそがそうであるのに、現在Reactはすべての関数をリアクティブな値として扱っており、この設計自体に問題があると大まかに理解できます。

// before
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on("connected", () => {
showNotification("Connected!", theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);
return <h1>Welcome to the {roomId} room!</h1>;
}
// after
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification("Connected!", theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on("connected", () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h1>Welcome to the {roomId} room!</h1>;
}

しかし、これだけ説明しても、実際にはuseEffectEventはまだ実験段階にあるため、この問題は現在のところlinterの警告を無視することで対処する必要があります。

useEffect is your last resort

覚えておいてください。useEffect最後の切り札であり、イベントが使える場合はイベントを優先してください!本当にイベントトリガーがない場合にのみ使用を検討してください。

未来のReact

関数型コンポーネントを基盤とするJsxは確実により自由になっていますが、代償として学習曲線が急峻になっています。関数型コンポーネントの数多くの概念を理解すること、パフォーマンス問題の処理、useEffectの依存関係問題など、これらの設計はすべて初心者に認知負荷をもたらします

幸い、Reactチームは既にこの問題を認識しており、将来のバージョンではforwardRefが削除され、React compilerの自動キャッシュメカニズム(コンポーネント内容の細粒度キャッシュと関数実行結果キャッシュを含む)、つまり上記で述べた多くの最適化内容も開発者が追加で制御する必要がなくなります。

Reactは最初のレンダリングパフォーマンス最適化のFiberから、コンポーネントパラダイムをFiberにより適した関数型コンポーネントに変更し、その後Fiberのパフォーマンスを最大限に活用するHookを導入するまで、この一連の進化を通じて確実に日々良くなっています。

今後はより多くのHookがReactの設計理念を完善し、関数型というこの道は最後まで歩み続けるでしょう。早く使えば早く享受でき、遅く使えば楽に享受できるということです。

参考

评论组件加载中……