仮想DOM | 差分検索アルゴリズム | パフォーマンス
Reactは仮想DOMと差分検索アルゴリズムを使って、UIのアップデートを行っています。Reactは最適化された方法でUIを更新し、実際に変更があった部分だけを変えますが、これはどうやって行われているのでしょうか?見ていきましょう。
仮想DOM
仮想DOM(VDOM)は、プログラミングのコンセプトの一種で、理想的な、あるいは「仮想的な」ユーザーインターフェースをメモリ上に保持し、ReactDOMのようなライブラリを通じて「実際の」DOMと同期させるものです。
仮想DOMは画面上にレンダリングされたDOMの全ノードを持ち、差分検索アルゴリズムを使ってUIの部分的な更新を行います。これにより、Reactの宣言的なAPIが_state_の操作を可能にし、「実際の」DOMの操作でパフォーマンスが向上します。
差分検索アルゴリズム
Reactは宣言的なAPIを提供します。つまり、何が変わるかを伝えるだけで、どのように変わるかはReactの内部の責任です。Reactは差分検索アルゴリズムを使って画面上での更新を行います。
状態やpropsに変更があるたびに、Reactは仮想DOMを更新・再作成し、実際のDOMと比較して更新を行います。これは属性レベルで行われ、二つの要素が同じでも、その属性の一つだけが変更された場合、Reactはその属性だけをブラウザのDOMで変更する必要があることを認識します。
<div className="before" title="stuff" />
<div className="after" title="stuff" />
Reactは差分検索アルゴリズムを通じて、正確に何が変更されたかを特定し、クラスだけでなく、その部分の更新を行います。
リストの子要素などを比較するときには、key属性を一意の識別子として使用することが重要です。これにより、Reactが変更を識別し、パフォーマンスを向上させるのを助け、要素が並べ替えられた場合のレンダリングの問題を避けることができます。
レンダリングフロー
Reactでの再レンダリングフローは基本的に次のようになります:
- Reactはメモリ上に仮想DOM(画面上のDOMのコピー)を保持しています。
- コンポーネントが更新されると、新しい仮想DOMが作成されます。
- 差分検索アルゴリズムを通じて比較が行われます。この比較はメモリ上で行われるので、まだコンポーネントはDOM上で更新されていません。
- 比較の後、Reactは必要な変更を含む新しい仮想DOMを作成します。
- 次に、最小限の変更だけを加えてブラウザのDOMを更新し、全体のDOMを再レンダリングすることなく。これにより、アプリケーションのパフォーマンスが大幅に向上します。
レンダリングフローと差分検索アルゴリズムの仕組みを理解することは、Reactアプリケーションでのデバッグとパフォーマンスの改善に役立ちます。
パフォーマンスを向上させるためにmemo、useMemo、useCallbackを使う
今まで見てきたように、Reactのレンダリングフローを理解すると、特定のケースでアプリケーションのパフォーマンスを向上させることができます。つまり、特定の条件が満たされない場合には、コンポーネントが新しい仮想DOMを作成して差分検索を行うフローに入らないようにすることが可能です。
関数memo、useMemo、useCallbackは、この目的で存在します。それぞれに特徴と使用シナリオがあるので、例を通じてどのように機能するかを理解しましょう。
例 - ListItemsコンポーネント
import React, { memo, useCallback, useMemo, useState } from "react";
let count = 0;
export const ListItems = () => {
const [items, setItems] = useState([]);
const [itemValue, setItemValue] = useState("");
console.log("RE-RENDER - LIST ITEMS");
function handleAddItem(e) {
e.preventDefault();
setItems([...items, { id: count++, text: itemValue }]);
}
const handleRemoveItem = useCallback(
(id) => setItems((state) => state.filter((item) => item.id !== id)),
[setItems]);
const slowCalc = useMemo(() => {
console.log("useMemo");
return items.filter((item) => item.text.includes("a")).length;
}, [items]);
return (
<>
<form onSubmit={handleAddItem}>
<input
onChange={(e) => setItemValue(e.target.value)}
value={itemValue}
/>
<button type="submit">Add Item</button>
</form>
<ul>
{items.length > 0 &&
items.map((item) => (
<Item
key={item.id}
item={item}
handleRemoveItem={handleRemoveItem}
/>
))}
</ul>
<p style={{ textAlign: "center" }}>
Letra (a)が含まれるアイテムの数量: {slowCalc}
</p>
</>
);
};
この例では、ListItems
コンポーネントはアイテムをリストに追加するためのフォームをレンダリングし、リストからアイテムを追加・削除する関数もあります。JSXには入力フィールドとボタンがあるフォームがあり、アイテムがstateのitemsに追加されたときにレンダリングされるItems
コンポーネントの呼び出しがあります。また、アイテム削除関数にはuseCallbackが使用されており、処理に多くのリソースが必要とされるuseMemoの例として、文字aが含まれるアイテムの数を数える変数を作成しています。
例 - Itemコンポーネント
const Item = memo(({ item, handleRemoveItem }) => {
console.log("RE-RENDER - ITEMS");
return (
<li key={item.id}>
{item.text} <button onClick={() => handleRemoveItem(item.id)}>x</button>
</li>
);
});
Item
コンポーネントは2つのpropsを受け取ります。itemはidとtextを持つオブジェクトで、アイテムを削除するために使用される関数はidを引数にしています。その後inputで入力されたテキストと、対応するidを渡したhandleRemoveItem関数を持つ<button>
で構成される<li>
を返します。
この例を理解したところで、memo、useMemo、useCallbackが一体何のために、いつ使用するのかを見ていきましょう。
memo
memoは、メモリ化されたコンポーネントを返す関数です。つまり、そのpropsやstateが変更されない限り、そのコンポーネントはReactのレンダリングフローには参加しません。例では、Item
コンポーネントにはitemと関数handleRemoveItemのpropsがあります。したがって、入力フィールドのvalueが変更されたとき、Itemsは変更されないものの、ListItems
コンポーネントは再度レンダリングされ、その結果handleRemoveItem関数の参照が変更されます。これを解決するためにuseCallbackを使用します。
useCallback
useCallbackは、Reactのフックの一つです。メモリ化された関数を返します。そのために、関数と依存配列をパラメータとして受け取ります。依存配列は、その関数が再宣言される(参照が変更される)べき依存関係を定義します。例では、handleRemoveItem関数はitems状態が更新されたときだけ再宣言されます。これはつまり、入力関連の状態が変更されても、この関数の参照は変わらないということです。そのため、itemもhandleRemoveItemも変更されずに残り、「Item」コンポーネントは再描画されません。これにより、特定のケースでアプリケーションのパフォーマンスに影響を与えることがあります。
useMemo
useMemoは、useCallbackと同じパラメータ(関数とその依存配列)を受け取りますが、違いはuseMemoが関数そのものではなく、その関数の実行結果をメモリ化して返すことです。この操作は、その依存が変更されたときにのみ再実行されます。計算に多大なコンピューティングリソースを必要とする場合、useMemoは効果を発揮します。
React Hooksに関する投稿はこちらで読むことができます。
この動画では、どのコンポーネントが再描画されるかが分かります。useMemoとItem
コンポーネントは、依存配列に変更がある場合にのみ呼び出されます。
これらのリソースをいつ使用してパフォーマンスを向上させるのですか?
これらのフックをすべてのコンポーネントや任意の関数に使用するべきではないことを理解することが重要です。なぜなら、その動作のための比較が行われるためで、それは必ずしもReactの通常のレンダリングフローよりもパフォーマンスが良いわけではありません。
memoをいつ使用するのですか?
- 純粋なコンポーネント - 同じ入力で常に同じ結果を返すコンポーネントです。
- 過度の再描画 - 変更(propsの変化なしで)なしに多くの再描画が行われるコンポーネント。
- 中~大サイズのコンポーネント - とても小さいコンポーネントはアプリケーションのパフォーマンスに影響しませんが、画面上に多くのものを描画する中~大サイズのコンポーネントでは、memoの使用を検討することができます。
useMemoをいつ使用するのですか?
- 複雑な計算 - 再描画のたびに複雑な再計算を避けるために使用しますが、単純な計算の場合はその使用は意味がありません。
useCallbackをいつ使用するのですか?
- 参照の同一性を解決するため - 再描画のたびに関数が再作成されることを避け、その結果memoを使用した子コンポーネントが再描画されてしまうことを防ぐために使用します。
これでReactのレンダリングフローと、アプリケーションのパフォーマンスを向上させるためのフックの使用方法について十分な知識を得ることができました。
読んでくださってありがとうございます!
参照先:Reconciliação | Fluxo de renderização e o algoritmo de reconciliação — React e React Native | Virtual DOM | 動画 - React - 定義済みのパフォーマンスガイド (useMemo, useCallback, memo) - Code/drops #82
サンプルコード:[リポジトリ - Reactエコシステム](https://github.com/nascimento-dev-io/react-ecosystem
こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。
https://dev.to/nascimento_/virtual-dom-algoritmo-de-reconciliacao-performance-1ilg