Fluent React メモ
なぜReactは存在するのか?
一言で言えば、その答えは「更新(updates)」にある。
Web技術が進化し、ユーザーエクスペリエンス(UX)が最優先事項となる中で、Webページをいかに素早く、効率的に更新するかが開発者にとっての大きな課題となった。
従来の静的なサイトの構築方法では、主に以下の3つの理由から、この「素早い更新」を実現するのが困難であった。
- パフォーマンス: DOM(Document Object Model)の更新は、ブラウザにページの再計算(リフロー)や再描画を強制するため、非常にコストの高い操作であった。頻繁な更新は、パフォーマンスのボトルネックに直結した。
- 信頼性: アプリケーションの状態が複数の場所に散らばっていると、それらを一貫して追跡・管理するのが非常に困難であった。特に、複数の開発者が同じコードベースで作業する場合、状態の不整合が頻繁に発生した。
- セキュリティ: クロスサイトスクリプティング(XSS)のような攻撃を防ぐためには、ページに挿入される全てのHTMLやJavaScriptを手動でサニタイズ(無害化)する必要があり、常に注意が求められた。
これらの問題をReactがどのように解決したのかを理解するために、まずはReactが登場する以前はどうだったのかについて見ていく。
React以前の世界
Reactが開発される前、Web開発者はいくつかの問題を抱えていた。
例1: 「いいね」ボタン
ごく普通の「いいね」ボタンをVanilla JavaScript(素のJavaScript)で実装する場合を考えてみる。
<button
id="likeButton"
data-pending="false"
data-liked="false"
data-failed="false"
>
Like
</button>
このボタンの状態(いいね済みか、処理中か、失敗したか)を管理し、クリックイベントに応じて表示を更新するロジックは以下のようになる。
const likeButton = document.getElementById("likeButton");
likeButton.addEventListener("click", () => {
const liked = likeButton.getAttribute("data-liked") === "true";
const isPending = likeButton.getAttribute("data-pending") === "true";
// ボタンを処理中状態にする
likeButton.setAttribute("data-pending", "true");
likeButton.setAttribute("disabled", "disabled");
// サーバーにリクエストを送信
fetch("/like", {
method: "POST",
body: JSON.stringify({ liked: !liked }),
})
.then(() => {
// 成功したら状態を更新
likeButton.setAttribute("data-liked", String(!liked));
likeButton.textContent = liked ? "Like" : "Liked";
likeButton.removeAttribute("disabled");
})
.catch(() => {
// 失敗したら状態を更新
likeButton.setAttribute("data-failed", "true");
likeButton.textContent = "Failed";
})
.finally(() => {
// 処理中状態を解除
likeButton.setAttribute("data-pending", "false");
});
});
このコードにはいくつかの欠点がある。
- テストのしづらさ: ロジックがDOMと密結合しているため、テストが非常に書きづらい。
-
煩雑な属性管理: ボタンの状態を管理するために多くの
data-*属性が必要になり、コードが複雑化する。かといって、data-stateのような単一の属性で管理しようとすると、今度は巨大なswitch文が必要になり、可読性が損なわれる。
Reactは、このような拡張性の問題を解決する。宣言的なUI記述により、インタラクティブなコンポーネントを、テスト可能で、再現性があり、パフォーマンスが高く、予測可能な方法で構築できるのである。
多くのユーザーが使うモダンなウェブアプリを作る場合、可能な限りエラーの可能性を減らし、安全かつ確実に行うために、かなりの抽象化が必要となる。
— Tejas Kumar, Fluent React (p.26)
例2: リストの追加
次に、フォームから新しい項目をリストに追加する例を見てみよう。
<ul id="list-parent"></ul>
<form id="add-item-form" action="/api/add-item" method="POST">
<input type="text" id="new-list-item-label" />
<button type="submit">Add Item</button>
</form>
(function myApp() {
var listItems = ["I love", "React", "and", "TypeScript"];
var parentList = document.getElementById("list-parent");
var addForm = document.getElementById("add-item-form");
var newListItemLabel = document.getElementById("new-list-item-label");
addForm.onsubmit = function (event) {
event.preventDefault();
listItems.push(newListItemLabel.value);
renderListItems();
};
function renderListItems() {
// 毎回リスト全体を再描画してしまう
parentList.innerHTML = '';
for (i = 0; i < listItems.length; i++) {
var el = document.createElement("li");
el.textContent = listItems[i];
parentList.appendChild(el);
}
}
renderListItems();
})();
このコードにも、いくつかの根深い課題が存在する。
-
エラーが起きやすい (Error Prone):
onsubmitのようなプロパティは簡単に上書きできてしまい、予期せぬ動作を引き起こす可能性がある。 -
予測しづらい (Unpredictable): このコードは、HTMLの構造(
idの存在や要素の型)に強く依存している。もしHTML側でidが変更されたり、要素が存在しなかったりすると、JavaScriptは簡単に壊れてしまう。これは、ロジックが「副作用」に満ちている状態と言える。 -
非効率 (Inefficient):
renderListItems関数は、項目が一つ追加されるたびにリスト全体を再描画している。これは非常にコストの高い操作であり、特にリストが長くなるとパフォーマンスが著しく低下する。
これらの課題を解決するために、jQuery、Backbone.js、AngularJSといったライブラリが登場した。
過渡期のライブラリたち
- jQuery: ブラウザ間の非互換性を吸収し、DOM操作やAjaxを簡素化したが、依然としてDOMに直接依存する「副作用」の問題は解決されなかった。
- Backbone.js: MVC(Model-View-Controller)パターンを導入し、関心の分離を試みたが、双方向データバインディングの曖昧さや、ビューのネストが難しいといった課題があった。
- AngularJS: 双方向データバインディングや依存性の注入(Dependency Injection)といった先進的な機能を導入したが、その複雑さから学習コストが高いという側面もあった。
これらのライブラリは、当時のWeb開発を大いに前進させたが、根本的な問題のいくつかは未解決のままであった。そして、その解決策として登場したのがReactである。
Reactの核心的アイデア
Reactは、これまでの課題を解決するために、いくつかの画期的なコンセプトを導入した。
JSX (JavaScript XML)
HTMLライクな構文をJavaScript内に直接記述できるJSXは、Reactの最も特徴的な機能の一つである。
-
利点:
- HTMLに馴染みのある開発者にとって直感的である。
- コンパイル時にセキュリティチェック(サニタイズ)が行われる。
- コンポーネントベースの設計を促進し、コードの再利用性を高める。
-
欠点:
- ロジックとビューが混在しているように見える(関心の混在)。
-
if文などのブロック式を直接使えないなど、JavaScriptの完全な互換性はない。
仮想DOM (The Virtual DOM)
Reactのパフォーマンスの鍵を握るのが仮想DOMである。これは、実際のDOMの構造を模した、メモリ上に存在するJavaScriptオブジェクトである。
- 状態が変更されると、Reactはまず仮想DOM上に新しいUIツリーを構築する。
- 次に、新しい仮想DOMツリーと前回の仮想DOMツリーを比較し、差分を検出する。
- 最後に、その差分だけを実際のDOMに適用する。
この仕組みにより、DOMへのアクセスを最小限に抑え、不要な再描画を防ぎ、アプリケーションのパフォーマンスを劇的に向上させることができるのである。
差分検出処理 (Reconciliation)
仮想DOMと実際のDOMを効率的に同期させる仕組みを「差分検出処理(Reconciliation)」と呼ぶ。React 16で導入されたFiber Reconcilerは、この処理をさらに進化させた。
Fiberは、更新処理を小さな単位(Fiberノード)に分割し、優先順位をつけたり、処理を中断・再開したりすることを可能にした。
-
Render Phase(レンダーフェーズ):
- コンポーネントツリーの変更差分を計算するフェーズである。
- この処理は中断可能であり、Reactは一定時間ごとにメインスレッドに制御を戻す。これにより、重い計算中でもユーザーの操作をブロックしない。
-
Commit Phase(コミットフェーズ):
- 計算された差分を実際のDOMに適用するフェーズである。
- この処理は中断不可能であり、一貫したUIを保証する。
この2段階のアプローチにより、Reactは高負荷な状況でもスムーズなユーザー体験を提供できるのである。
パフォーマンスを最適化するパターン
Reactはデフォルトで高速であるが、アプリケーションが複雑になるにつれて、不要な再レンダリングがパフォーマンスの問題を引き起こすことがある。Reactは、これを解決するための強力なツールを提供している。
React.memo
コンポーネントをReact.memoでラップすると、そのコンポーネントに渡されるpropsが変更された場合にのみ再レンダリングされるようになる。
しかし、propsにオブジェクトや配列、関数といった参照型(Non-scalar types)を渡している場合、注意が必要である。
// 親コンポーネントが再レンダリングされるたびに、
// favoriteFruitsは新しい配列インスタンスとして生成される。
const favoriteFruits = allFruits.filter((fruit) => fruit.isFavorite);
// Listコンポーネントは、配列の中身が同じでも「propsが変わった」と判断してしまう。
<List items={favoriteFruits} />
React.memoはpropsを浅い比較(shallow comparison)でチェックするため、参照が変わっていると中身が同じでも再レンダリングを引き起こしてしまう。
useMemo と useCallback
この問題を解決するのがuseMemoとuseCallbackである。
-
useMemo: 計算結果(値)をメモ化(記憶)する。依存配列の要素が変更されない限り、計算を再実行せず、前回記憶した値を返す。
// allFruitsが変更されない限り、favoriteFruitsは再計算されず、同じインスタンスが返される。
const favoriteFruits = React.useMemo(
() => allFruits.filter((fruit) => fruit.isFavorite),
[allFruits]
);
-
useCallback: 関数そのものをメモ化する。依存配列の要素が変更されない限り、同じ関数インスタンスを返す。これにより、コールバック関数を子コンポーネントに渡す際の不要な再レンダリングを防ぐ。
// currentUserが変更されない限り、onAvatarChangeは再生成されない。
const onAvatarChange = useCallback(
(newAvatarUrl) => {
updateUserModel({ avatarUrl: newAvatarUrl, id: currentUser.id });
},
[currentUser]
);
これらのフックを適切に使うことで、アプリケーションのパフォーマンスを最適化し、ユーザー体験を向上させることができる。
Discussion