本当に必要なのは仮想DOMではなくkeyだけだった - remakejs/remakeの紹介
拙作SPAフレームワーク remakejs を作るだけ作って解説するのが面倒だったのでAIに書かせたシリーズ第二弾です。例によって指示出しはしたけど直接は書いていません。
本当に必要なのは仮想DOMではなくkeyだった
はじめに
現代のWebフロントエンド開発において、ReactやVueなどの仮想DOM(Virtual DOM)を採用したフレームワークが主流となっています。仮想DOMは、実際のDOMを直接操作するコストを削減し、効率的なUI更新を実現するための手法として広く受け入れられてきました。
しかし、私たちは立ち止まって考える必要があります。仮想DOMは本当に必要なのでしょうか?
この記事では、remakeフレームワークの設計思想を通じて、「本当に必要なのは仮想DOMではなくkeyだった」という主張について探求します。
仮想DOMの課題
仮想DOMは確かに多くの問題を解決しましたが、同時に新たな課題も生み出しました:
- メモリオーバーヘッド: 実際のDOMとは別に仮想DOMツリーを保持する必要があります
- 計算コスト: 差分検出(diffing)アルゴリズムは複雑で計算コストが高くなりがちです
- 抽象化のコスト: 仮想DOMという抽象レイヤーを導入することで、コードの複雑性が増します
- バンドルサイズ: 仮想DOM実装はフレームワークのサイズを増大させます
これらの課題を考慮すると、仮想DOMが本当に最適な解決策なのか疑問が生じます。
keyの重要性
Reactなどのフレームワークを使っていると、リスト要素にkey
プロパティを設定することの重要性を学びます。しかし、多くの開発者はこれを「Reactのルール」として覚えるだけで、その本質的な重要性を見落としています。
実は、効率的なDOM更新において本当に重要なのはkeyなのです。
keyの役割は単純ですが強力です:
- 要素の同一性を保持する
- 再利用可能な要素を識別する
- 最小限の変更でDOMを更新する
remakeフレームワークは、この洞察を中心に設計されています。
remakeフレームワーク:キーを中心とした設計
remakeは、仮想DOMを使わずに、実際のDOM要素に直接キーを付与して効率的な更新を実現するミニマルなフレームワークです。
その核心的な設計思想は:
- 実際のDOMを直接操作する: 仮想DOMという中間層を排除
- keyによる要素の追跡: 各要素にkeyを付与して同一性を保持
- 最小限の変更: 必要な部分だけを効率的に更新
- シンプルなAPI: 直感的で使いやすいインターフェース
実装の詳細:驚くほどシンプルなコード
remakeの最も驚くべき点は、そのシンプルさです。たった348行のJavaScriptコードだけで、完全に機能するUIフレームワークが実現されています。これは、一般的なフロントエンドフレームワークと比較すると信じられないほど小さいサイズです。
このコンパクトさは、「本当に必要なのは仮想DOMではなくkeyだった」という洞察の証明でもあります。複雑な仮想DOM実装や差分検出アルゴリズムを排除し、keyを中心とした設計に集中することで、コードの複雑性を大幅に削減しています。
remakeの核心は、DOM要素にkeyを付与し、それを使って要素を追跡・再利用するシンプルなメカニズムです。このアプローチにより:
- 要素の識別: 各要素にkeyを付与して一意に識別
- 効率的な再利用: 既存の要素を再利用してDOM操作を最小化
- 自動キー生成: 明示的にキーが指定されていない場合は自動生成
- 最適な位置調整: 必要に応じて要素の位置を効率的に調整
このシンプルな仕組みだけで、複雑なUIの更新を効率的に処理できるのです。
パフォーマンスの優位性
remakeのアプローチには、仮想DOMを使用するフレームワークと比較して、いくつかの優位性があります:
- メモリ効率: 仮想DOMツリーを保持する必要がないため、メモリ使用量が少ない
- 計算効率: 複雑な差分検出アルゴリズムが不要で、直接必要な更新だけを行う
- バンドルサイズ: フレームワーク自体が軽量で、アプリケーションのバンドルサイズを削減できる
- 直接的なDOM操作: 中間層がないため、DOMの操作がより直接的で予測可能
使用例:シンプルなAPIとJSXサポート
remakeは、シンプルながらも強力なAPIを提供し、Reactライクな構文(JSX)もサポートしています。以下は、シンプルなカウンターアプリケーションの例です:
import { remake, React } from '@remakejs/remake';
function Counter(p) {
const root = remake(p.parent, 'div');
// 状態の初期化
root.count ||= 0;
// カウントを増やして再レンダリングする関数
const increment = () => {
root.count++;
Counter(p); // 明示的に再レンダリング
};
// UIの構築
remake(root,
<>
<h2>カウンター</h2>
<div>現在のカウント: {root.count}</div>
<button onClick={increment}>増やす</button>
</>
);
}
// 使用例
Counter({ parent: document.body });
この例からわかるように、remakeは:
- JSX構文をサポート
- 状態管理が柔軟(この例ではDOM要素自体にプロパティとして状態を保存していますが、他の方法も自由に選択可能)
- イベントハンドリングが直感的
- 再レンダリングのメカニズムが明示的で理解しやすい
リスト操作の効率性
remakeの真価は、リスト操作において特に発揮されます。以下のようなリスト操作を考えてみましょう:
- 要素の入れ替え
- 要素の削除(先頭、末尾、中間)
- 要素の挿入(先頭、末尾、中間、複数)
- リストの完全な更新
remakeでは、これらの操作がすべてkeyを使って効率的に処理されます。例えば、要素の入れ替えを行う場合:
// 初期リスト
for (let i = 1; i < 10; i++) {
remake(ul, <li key={i}>value is {i}</li>);
}
// 要素を入れ替えたリスト
const newValues = [9, 2, 3, 4, 5, 6, 7, 8, 1];
for (let i of newValues) {
remake(ul, <li key={i}>value is {i}</li>);
}
この場合、remakeは各要素のkeyを使って同一性を追跡し、実際のDOM操作を最小限に抑えます。具体的には:
- key=9の要素を先頭に移動
- key=1の要素を末尾に移動
- その他の要素はそのまま再利用
仮想DOMを使用するアプローチでは、まず仮想DOMツリー全体を再構築し、それを実際のDOMと比較して差分を検出する必要があります。一方、remakeのアプローチでは、keyを使って直接必要な操作だけを行います。
Reactの呪縛からの解放
Reactは素晴らしいフレームワークですが、長年使っていると特定の「思考の枠組み」に囚われがちです。remakeは、そうした呪縛から開発者を解放し、より自由で直接的なアプローチを提供します。
思考の転換:フレームワークの制約から自由へ
Reactでは「Reactの流儀」に従うことが求められます。フックのルール、コンポーネントのライフサイクル、状態管理の方法など、多くの制約があります。remakeでは、これらの制約から解放され、開発者が主導権を持つことができます。
// Reactの場合:特定のルールに従う必要がある
function Component() {
// フックは最上位でのみ呼び出せる
const [state, setState] = useState(initialState);
// 条件付きでフックを呼び出すことはできない
// if (condition) {
// useEffect(() => {}, []); // これはエラー
// }
// ...
}
// remakeの場合:より自由なアプローチが可能
function Component(p) {
const root = remake(p.parent, 'div');
// 条件に応じて異なる処理が可能
if (condition) {
setupSpecialBehavior(root);
}
// 自由な状態管理
const state = getStateFromAnywhere();
// ...
}
「魔法の箱」からの脱却:透明性の高い実装
Reactの内部実装(仮想DOM、ファイバー、調整アルゴリズムなど)は複雑で、多くの開発者にとって「魔法の箱」のように感じられます。remakeは、その実装が透明で理解しやすいため、予測可能な動作が期待できます。
// Reactの場合:なぜ再レンダリングが発生するのか理解しづらいことがある
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<Child /> {/* countが変わるたびに再レンダリングされる(なぜ?) */}
</div>
);
}
// remakeの場合:再レンダリングは明示的
function Parent(p) {
const root = remake(p.parent, 'div');
root.count ||= 0;
const increment = () => {
root.count++;
Parent(p); // 明示的に自分自身を再レンダリング
};
remake(root,
<>
<button onClick={increment}>Increment</button>
<Child /> {/* 明示的に呼び出さない限り再レンダリングされない */}
</>
);
}
状態管理の自由:一つの「正しい方法」からの解放
Reactでは、状態管理に関して「正しい方法」があるように感じられます(useState、useReducer、Context APIなど)。remakeでは、状態をどこに、どのように保存するかを自由に選択できます。
// DOM要素に直接保存
function Counter1(p) {
const root = remake(p.parent, 'div');
root.count ||= 0;
// ...
}
// グローバルストアに保存
const store = {};
function Counter2(p) {
const root = remake(p.parent, 'div');
const id = root.id || (root.id = generateId());
store[id] = store[id] || { count: 0 };
// ...
}
// クロージャに保存
function createCounter(parent) {
let count = 0;
return function render() {
const root = remake(parent, 'div');
// countはクロージャ内に保持
// ...
};
}
// 外部ライブラリと統合
import { createStore } from 'redux';
const reduxStore = createStore(reducer);
function Counter3(p) {
const root = remake(p.parent, 'div');
// Reduxと統合
const state = reduxStore.getState();
reduxStore.subscribe(() => Counter3(p));
// ...
}
更新範囲の制御:自動から明示的へ
Reactでは、状態が変更されると自動的に再レンダリングが発生し、その範囲はReactが決定します。remakeでは、更新する範囲を開発者が明示的に制御できます。
function App(p) {
const root = remake(p.parent, 'div');
// 時計だけを更新する関数
const updateClock = () => {
Clock({ parent: root.querySelector('.clock') });
};
// ヘッダーだけを更新する関数
const updateHeader = () => {
Header({ parent: root.querySelector('header') });
};
// 1秒ごとに時計だけを更新
setInterval(updateClock, 1000);
// ユーザーアクションでヘッダーだけを更新
const handleUserAction = () => {
// 何らかの処理
updateHeader();
};
remake(root,
<>
<header><Header /></header>
<div className="clock"><Clock /></div>
<main><Content onAction={handleUserAction} /></main>
</>
);
}
この例では、時計は1秒ごとに更新されますが、他の部分(ヘッダーやコンテンツ)は更新されません。これにより、パフォーマンスを細かく制御できます。
メンタルモデルの転換:宣言的から命令的+宣言的へ
Reactは完全に宣言的なパラダイムを採用していますが、remakeでは宣言的なJSX構文と命令的なDOM操作を組み合わせることができます。これにより、状況に応じて最適なアプローチを選択できます。
function Component(p) {
const root = remake(p.parent, 'div');
// 宣言的なJSX
remake(root,
<>
<header>Title</header>
<main>{/* ... */}</main>
</>
);
// 命令的なDOM操作
const header = root.querySelector('header');
header.style.color = 'red';
header.addEventListener('click', () => {
// 直接DOMを操作
header.textContent = 'Clicked!';
});
}
学習曲線の緩和:複雑な概念からの解放
Reactを完全に理解するには、多くの概念(仮想DOM、フック、ファイバー、調整など)を学ぶ必要があります。remakeは、基本的なDOM操作とJSXの知識があれば使い始めることができます。
実践的な解放:具体例
実際のアプリケーション開発において、remakeの自由さがどのように役立つかを見てみましょう。
例1: 部分的な更新
Reactでは、コンポーネントツリーの一部だけを更新することは難しいですが、remakeでは簡単です。
function Dashboard(p) {
const root = remake(p.parent, 'div');
// 特定のウィジェットだけを更新
const updateWidget = (id) => {
const widget = root.querySelector(`[data-widget-id="${id}"]`);
Widget({ parent: widget, id });
};
// データ取得と特定ウィジェットの更新
const fetchDataAndUpdateWidget = async (id) => {
const data = await fetchData(id);
updateWidget(id);
};
// 全ウィジェットの配置
remake(root,
<div className="dashboard">
{widgetIds.map(id => (
<div data-widget-id={id} key={id}>
<Widget id={id} />
</div>
))}
</div>
);
// 各ウィジェットの更新間隔を設定
widgetIds.forEach(id => {
setInterval(() => fetchDataAndUpdateWidget(id), getUpdateInterval(id));
});
}
この例では、各ウィジェットが独立した更新間隔を持ち、他のウィジェットに影響を与えることなく更新されます。
例2: パフォーマンスの最適化
Reactでは、パフォーマンスの最適化に多くのテクニック(memo、useMemo、useCallbackなど)が必要ですが、remakeではより直感的です。
function App(p) {
const root = remake(p.parent, 'div');
// 静的な部分(めったに変更されない)
if (!root.headerRendered) {
Header({ parent: root });
root.headerRendered = true;
}
// 動的な部分(頻繁に更新される)
Content({ parent: root });
// 条件付きの部分(必要な場合のみ更新)
if (shouldShowFooter()) {
Footer({ parent: root });
}
}
この例では、ヘッダーは一度だけレンダリングされ、コンテンツは毎回更新され、フッターは条件に応じて表示/非表示が切り替わります。
remakeは、Reactの「正しいやり方」という呪縛から開発者を解放し、より自由で直接的なアプローチを提供します。これにより、開発者は状況に応じて最適な方法を選択し、より効率的で理解しやすいコードを書くことができます。
Reactからremakeへの移行ガイド:補足
Reactに慣れた開発者がremakeに移行する際、いくつかの重要な概念の違いを理解する必要があります。この移行ガイドでは、主要なトピックごとに、Reactとremakeのアプローチの違いと、remakeでの実践的なパターンを紹介します。
状態管理:自由と柔軟性
Reactでの状態管理:
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<div>Count is {count}</div>
</div>
);
}
remakeでの状態管理:
function Counter(p) {
const root = remake(p.parent, 'div');
root.count ||= 0;
const incr = () => { root.count++; Counter(p); };
remake(root,
<>
<button onClick={incr}>Increment</button>
<div>Count is {root.count}</div>
</>
);
}
remakeの状態管理の特徴:
-
自由な状態の保存場所:
- DOM要素自体にプロパティとして保存(例:
root.count
) - グローバルなMapやObjectに保存
const state = new Map(); function Counter(p) { const root = remake(p.parent, 'div'); const id = root.id || (root.id = Math.random().toString(36).slice(2)); state.has(id) || state.set(id, { count: 0 }); const myState = state.get(id); const incr = () => { myState.count++; Counter(p); }; // ... }
- クロージャ内の変数として保持
function createCounter(parent) { let count = 0; function render() { const root = remake(parent, 'div'); const incr = () => { count++; render(); }; remake(root, /* ... */); } return render; } const counter = createCounter(document.body); counter();
- 専用の状態管理ライブラリと組み合わせることも可能
- DOM要素自体にプロパティとして保存(例:
-
状態更新の直接性:
- Reactの
setState
のような間接的なAPIではなく、直接値を変更 - 更新は同期的で予測可能
- Reactの
-
状態の共有:
- コンポーネント間で状態を共有する方法も自由に選択可能
- プロパティの受け渡し、グローバルストア、イベントなど様々な方法が使える
再レンダリング範囲:明示的な制御
Reactでの再レンダリング:
- 状態変更時に自動的に再レンダリングが発生
- 親コンポーネントが再レンダリングされると、子コンポーネントも再レンダリング
- 最適化には
React.memo
やshouldComponentUpdate
などが必要
remakeでの再レンダリング:
- 再レンダリングは明示的に関数を呼び出すことで行う
- 更新範囲を完全に制御できる
実践的なパターン:
- コンポーネント全体の更新:
function Component(p) {
// ...
const update = () => {
// 状態更新
state.value = newValue;
// 自身を再レンダリング
Component(p);
};
// ...
}
- 部分的な更新:
function App(p) {
const root = remake(p.parent, 'div');
// ヘッダーのみを更新
const updateHeader = () => {
Header({ parent: root.querySelector('header') });
};
// 特定のリスト項目のみを更新
const updateListItem = (id) => {
ListItem({
parent: root.querySelector(`li[data-id="${id}"]`),
id
});
};
remake(root,
<>
<Header />
<main>
<List onItemUpdate={updateListItem} />
</main>
<footer>
<button onClick={updateHeader}>Update Header</button>
</footer>
</>
);
}
- 非同期更新:
const fetchAndUpdate = async () => {
// データ取得中の表示
remake(container, <Loading />);
// データ取得
const data = await fetchData();
// データ表示
remake(container, <DataView data={data} />);
};
イベント処理:ネイティブイベント
Reactでのイベント処理:
- 合成イベントシステムを使用
- イベントはプールされ、再利用される
- イベントハンドラは自動的にバインドされる
remakeでのイベント処理:
- ネイティブDOMイベントを直接使用
- より直接的で予測可能な動作
実践的なパターン:
- 基本的なイベントハンドリング:
function Button(p) {
const btn = remake(p.parent, 'button');
btn.addEventListener('click', () => {
console.log('Clicked!');
});
remake(btn, 'Click me');
}
- イベントの伝播:
function List(p) {
const ul = remake(p.parent, 'ul');
// イベント委譲パターン
ul.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') {
const li = e.target.closest('li');
const id = li.dataset.id;
p.onItemAction(id);
}
});
// リスト項目の作成
for (const item of p.items) {
remake(ul,
<li data-id={item.id}>
{item.text}
<button>Action</button>
</li>
);
}
}
- イベントとコンポーネントの連携:
function Form(p) {
const form = remake(p.parent, 'form');
const state = { name: '', email: '' };
const handleSubmit = (e) => {
e.preventDefault();
p.onSubmit(state);
};
const handleChange = (e) => {
state[e.target.name] = e.target.value;
};
remake(form,
<>
<input name="name" onChange={handleChange} />
<input name="email" onChange={handleChange} />
<button onClick={handleSubmit}>Submit</button>
</>
);
}
最適化:シンプルで効果的
Reactでの最適化:
-
React.memo
、useMemo
、useCallback
などの最適化ツール - 仮想DOMの差分検出に依存
- 複雑なパフォーマンス問題のデバッグが必要
remakeでの最適化:
- keyによる要素の追跡と再利用が基本
- 明示的な更新範囲の制御
- より直感的なパフォーマンス最適化
実践的なパターン:
- keyの効果的な使用:
function List(p) {
const ul = remake(p.parent, 'ul');
for (const item of p.items) {
// keyを使って要素を効率的に再利用
remake(ul, <li key={item.id}>{item.text}</li>);
}
}
- 更新範囲の最小化:
function App(p) {
const root = remake(p.parent, 'div');
// 頻繁に更新される部分を分離
const updateClock = () => {
Clock({ parent: root.querySelector('.clock') });
};
// 1秒ごとに時計だけを更新
setInterval(updateClock, 1000);
remake(root,
<>
<Header />
<div className="clock">
<Clock />
</div>
<Content />
</>
);
}
- 条件付きレンダリングの最適化:
function ConditionalContent(p) {
const container = remake(p.parent, 'div');
if (p.condition) {
// 条件が真の場合のみ重いコンポーネントをレンダリング
HeavyComponent({ parent: container });
} else {
// それ以外の場合は軽いコンポーネント
LightComponent({ parent: container });
}
}
- 大規模リストの最適化:
function VirtualList(p) {
const container = remake(p.parent, 'div');
const { items, itemHeight, visibleItems } = p;
// 表示領域の計算
const scrollTop = container.scrollTop;
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(startIndex + visibleItems, items.length);
// コンテナのサイズ設定
container.style.height = `${items.length * itemHeight}px`;
// 可視範囲のアイテムのみをレンダリング
const visibleList = remake(container, 'div');
visibleList.style.transform = `translateY(${startIndex * itemHeight}px)`;
for (let i = startIndex; i < endIndex; i++) {
remake(visibleList,
<div key={items[i].id} style={{ height: `${itemHeight}px` }}>
{items[i].content}
</div>
);
}
// スクロールイベントで更新
container.onscroll = () => VirtualList(p);
}
Reactの呪縛からの解放
remakeへの移行は、単なるフレームワークの変更ではなく、Webフロントエンド開発に対する考え方の転換でもあります。Reactの「正しいやり方」という呪縛から解放され、より自由で直接的なアプローチを取ることができます。
- フレームワークの制約からの解放: 特定のライフサイクルやフックに縛られない自由な設計
- 透明性: 「魔法の箱」ではなく、透明性の高い実装
- 制御: フレームワークに制御を委ねるのではなく、開発者が主導権を持つ
- シンプルさ: 複雑な抽象化レイヤーを排除し、本質的な部分に集中
remakeは、「本当に必要なのは仮想DOMではなくkeyだった」という洞察に基づき、より直接的で効率的なアプローチを提供します。このアプローチは、特に大規模なアプリケーションや、パフォーマンスが重要な場面で真価を発揮するでしょう。
まとめ:本当に必要なのはkeyだった
remakeフレームワークの設計思想と実装を通じて、私たちは重要な洞察を得ることができました:効率的なUI更新において本当に必要なのは仮想DOMではなく、keyによる要素の追跡と再利用のメカニズムだったのです。
仮想DOMは確かに多くの問題を解決しましたが、それは単なる実装の詳細であり、本質的な解決策ではありませんでした。keyを中心とした設計により、より直接的で効率的なアプローチが可能になります。
remakeは、この洞察に基づいた最小限のフレームワークとして、フロントエンド開発の新たな可能性を示しています。仮想DOMという抽象化レイヤーを排除し、実際のDOMを直接操作することで、より軽量で効率的なアプリケーション開発が可能になるのです。
フロントエンド開発の未来において、私たちは複雑な抽象化に頼るのではなく、本質的な問題に立ち返り、シンプルで効率的な解決策を追求すべきではないでしょうか。remakeは、その一つの答えを提示しています。
Discussion