🔑

本当に必要なのは仮想DOMではなくkeyだけだった - remakejs/remakeの紹介

2025/03/07に公開

拙作SPAフレームワーク remakejs を作るだけ作って解説するのが面倒だったのでAIに書かせたシリーズ第二弾です。例によって指示出しはしたけど直接は書いていません。

本当に必要なのは仮想DOMではなくkeyだった

はじめに

現代のWebフロントエンド開発において、ReactやVueなどの仮想DOM(Virtual DOM)を採用したフレームワークが主流となっています。仮想DOMは、実際のDOMを直接操作するコストを削減し、効率的なUI更新を実現するための手法として広く受け入れられてきました。

しかし、私たちは立ち止まって考える必要があります。仮想DOMは本当に必要なのでしょうか?

この記事では、remakeフレームワークの設計思想を通じて、「本当に必要なのは仮想DOMではなくkeyだった」という主張について探求します。

仮想DOMの課題

仮想DOMは確かに多くの問題を解決しましたが、同時に新たな課題も生み出しました:

  1. メモリオーバーヘッド: 実際のDOMとは別に仮想DOMツリーを保持する必要があります
  2. 計算コスト: 差分検出(diffing)アルゴリズムは複雑で計算コストが高くなりがちです
  3. 抽象化のコスト: 仮想DOMという抽象レイヤーを導入することで、コードの複雑性が増します
  4. バンドルサイズ: 仮想DOM実装はフレームワークのサイズを増大させます

これらの課題を考慮すると、仮想DOMが本当に最適な解決策なのか疑問が生じます。

keyの重要性

Reactなどのフレームワークを使っていると、リスト要素にkeyプロパティを設定することの重要性を学びます。しかし、多くの開発者はこれを「Reactのルール」として覚えるだけで、その本質的な重要性を見落としています。

実は、効率的なDOM更新において本当に重要なのはkeyなのです

keyの役割は単純ですが強力です:

  • 要素の同一性を保持する
  • 再利用可能な要素を識別する
  • 最小限の変更でDOMを更新する

remakeフレームワークは、この洞察を中心に設計されています。

remakeフレームワーク:キーを中心とした設計

remakeは、仮想DOMを使わずに、実際のDOM要素に直接キーを付与して効率的な更新を実現するミニマルなフレームワークです。

その核心的な設計思想は:

  1. 実際のDOMを直接操作する: 仮想DOMという中間層を排除
  2. keyによる要素の追跡: 各要素にkeyを付与して同一性を保持
  3. 最小限の変更: 必要な部分だけを効率的に更新
  4. シンプルなAPI: 直感的で使いやすいインターフェース

実装の詳細:驚くほどシンプルなコード

remakeの最も驚くべき点は、そのシンプルさです。たった348行のJavaScriptコードだけで、完全に機能するUIフレームワークが実現されています。これは、一般的なフロントエンドフレームワークと比較すると信じられないほど小さいサイズです。

このコンパクトさは、「本当に必要なのは仮想DOMではなくkeyだった」という洞察の証明でもあります。複雑な仮想DOM実装や差分検出アルゴリズムを排除し、keyを中心とした設計に集中することで、コードの複雑性を大幅に削減しています。

remakeの核心は、DOM要素にkeyを付与し、それを使って要素を追跡・再利用するシンプルなメカニズムです。このアプローチにより:

  1. 要素の識別: 各要素にkeyを付与して一意に識別
  2. 効率的な再利用: 既存の要素を再利用してDOM操作を最小化
  3. 自動キー生成: 明示的にキーが指定されていない場合は自動生成
  4. 最適な位置調整: 必要に応じて要素の位置を効率的に調整

このシンプルな仕組みだけで、複雑なUIの更新を効率的に処理できるのです。

パフォーマンスの優位性

remakeのアプローチには、仮想DOMを使用するフレームワークと比較して、いくつかの優位性があります:

  1. メモリ効率: 仮想DOMツリーを保持する必要がないため、メモリ使用量が少ない
  2. 計算効率: 複雑な差分検出アルゴリズムが不要で、直接必要な更新だけを行う
  3. バンドルサイズ: フレームワーク自体が軽量で、アプリケーションのバンドルサイズを削減できる
  4. 直接的な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操作を最小限に抑えます。具体的には:

  1. key=9の要素を先頭に移動
  2. key=1の要素を末尾に移動
  3. その他の要素はそのまま再利用

仮想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の状態管理の特徴:

  1. 自由な状態の保存場所:

    • 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();
    
    • 専用の状態管理ライブラリと組み合わせることも可能
  2. 状態更新の直接性:

    • ReactのsetStateのような間接的なAPIではなく、直接値を変更
    • 更新は同期的で予測可能
  3. 状態の共有:

    • コンポーネント間で状態を共有する方法も自由に選択可能
    • プロパティの受け渡し、グローバルストア、イベントなど様々な方法が使える

再レンダリング範囲:明示的な制御

Reactでの再レンダリング:

  • 状態変更時に自動的に再レンダリングが発生
  • 親コンポーネントが再レンダリングされると、子コンポーネントも再レンダリング
  • 最適化にはReact.memoshouldComponentUpdateなどが必要

remakeでの再レンダリング:

  • 再レンダリングは明示的に関数を呼び出すことで行う
  • 更新範囲を完全に制御できる

実践的なパターン:

  1. コンポーネント全体の更新:
function Component(p) {
  // ...
  const update = () => {
    // 状態更新
    state.value = newValue;
    // 自身を再レンダリング
    Component(p);
  };
  // ...
}
  1. 部分的な更新:
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>
    </>
  );
}
  1. 非同期更新:
const fetchAndUpdate = async () => {
  // データ取得中の表示
  remake(container, <Loading />);
  
  // データ取得
  const data = await fetchData();
  
  // データ表示
  remake(container, <DataView data={data} />);
};

イベント処理:ネイティブイベント

Reactでのイベント処理:

  • 合成イベントシステムを使用
  • イベントはプールされ、再利用される
  • イベントハンドラは自動的にバインドされる

remakeでのイベント処理:

  • ネイティブDOMイベントを直接使用
  • より直接的で予測可能な動作

実践的なパターン:

  1. 基本的なイベントハンドリング:
function Button(p) {
  const btn = remake(p.parent, 'button');
  btn.addEventListener('click', () => {
    console.log('Clicked!');
  });
  remake(btn, 'Click me');
}
  1. イベントの伝播:
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>
    );
  }
}
  1. イベントとコンポーネントの連携:
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.memouseMemouseCallbackなどの最適化ツール
  • 仮想DOMの差分検出に依存
  • 複雑なパフォーマンス問題のデバッグが必要

remakeでの最適化:

  • keyによる要素の追跡と再利用が基本
  • 明示的な更新範囲の制御
  • より直感的なパフォーマンス最適化

実践的なパターン:

  1. 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>);
  }
}
  1. 更新範囲の最小化:
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 />
    </>
  );
}
  1. 条件付きレンダリングの最適化:
function ConditionalContent(p) {
  const container = remake(p.parent, 'div');
  
  if (p.condition) {
    // 条件が真の場合のみ重いコンポーネントをレンダリング
    HeavyComponent({ parent: container });
  } else {
    // それ以外の場合は軽いコンポーネント
    LightComponent({ parent: container });
  }
}
  1. 大規模リストの最適化:
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