【実務・中級編】 FastDOMを用いたDOM操作のバッチ処理 – Webブラウザの仕組み実践ガイド

おい、調子はどうか。今日も元気にDOMをこねくり回しているかい?

突然だが、フロントエンドの現場でこんなコードを見たことはないか?

// やってしまいがちな「最悪のアンチパターン」
const width = box.offsetWidth; // 読み取り (Read)
box.style.width = (width + 10) + ‘px’; // 書き込み (Write)

const height = box.offsetHeight; // 読み取り (Read)
box.style.height = (height + 10) + ‘px’; // 書き込み (Write)

もし君がこのコードを見て「ふつうじゃん」と思ったなら、危険信号だ。ブラウザのレンダリングエンジンにとって、この書き方は「わざわざ渋滞している高速道路に突っ込み、自分で何度も急ブレーキを踏むようなもの」だからだ。

今日は、中級からもう一段上のシニアへとステップアップしたい君に向けて、ブラウザの裏側の泥臭い仕組みと、それを華麗にハックする「FastDOM」の設計思想と実装パターンについて話をしよう。

—

1. なぜ「読み取りと書き込みの混在」がブラウザを殺すのか?

まず、ブラウザが裏側で何をやっているのかを思い出そう。JavaScriptでDOMのプロパティ(`offsetWidth`や`scrollTop`など)を読み取る(Read)とき、ブラウザはレイアウト(Reflow)を強制される。

もし、その直後にDOMのスタイルを書き込む(Write)とどうなるか?
ブラウザは「おっと、レイアウトが変わったな」と認識する。そして、その次にまた別のDOMプロパティを読み取ろう(Read)とした瞬間、ブラウザは最新のレイアウトを計算し直さざるを得なくなる。

これを繰り返すと、何が起きるか?
そう、「レイアウト・スラッシング(Layout Thrashing)」の完成だ。CPU使用率は跳ね上がり、ユーザーのスクロールはカクつき、ファンがうなりを上げる。モバイル端末なら一発でバッテリーを食いつぶす悪夢の現象さ。

これを解決するための唯一にして最大の原則、それが「読み取り(Read)と書き込み(Write)の分離とバッチ処理」だ。すべての読み取りを先に行い、その後にすべての書き込みをまとめて行う。この鉄則をエレガントにシステム化したのが、今回解説する「FastDOM」の思想なのだ。

—

2. FastDOMの設計思想:なぜ「キュー(Queue)」なのか?

FastDOMの本質は非常にシンプルだ。
「今すぐDOMを触るな。やりたいことを一度キュー(待ち行列)に積め。そして、次のフレームの描画タイミング(`requestAnimationFrame`)が来たら、読み取りを全部済ませてから、書き込みを全部実行する」これだけだ。

概念としてはこれだけなんだが、これを自前で毎回実装しようとすると、非同期処理の管理やエラーハンドリングでコードが地獄のように汚れる。だからこそ、FastDOMのような抽象化層が光る。

現場で使えるミニマムかつ頑健なFastDOMのコア実装を見てみよう。

—

3. 実装:自作FastDOMコアエンジン(TypeScriptベース)

「ライブラリをそのまま入れるほどでもない小規模なウィジェットを作りたい」「ブラウザの仕組みを完全に理解した上でコントロールしたい」という場面のために、実務でそのまま使えるミニマムなFastDOMのモジュールを書いた。

エディタを開いて、じっくり構造を見てほしい。

/

  • 現場で使えるミニマムFastDOMエンジン
  • 読み取り(Read)と書き込み(Write)を分離し、rAFでバッチ処理します。

/
class SimpleFastDOM {
private readQueue: Array<() => void> = [];
private writeQueue: Array<() => void> = [];
private isScheduled: boolean = false;

/

  • 読み取りタスクをキューに登録する

/
public measure(task: () => void): void {
this.readQueue.push(task);
this.scheduleFlush();
}

/

  • 書き込みタスクをキューに登録する

/
public mutate(task: () => void): void {
this.writeQueue.push(task);
this.scheduleFlush();
}

/

  • まだタスク実行のスケジュールがされていなければ、rAFに登録する

/
private scheduleFlush(): void {
if (this.isScheduled) return;

this.isScheduled = true;
window.requestAnimationFrame(() => {
this.flush();
});
}

/

  • キューを順番に、かつ効率的にフラッシュする(バッチ処理の核心)

/
private flush(): void {
try {
// 1. まず「すべての読み取り(Measure)」を完全に終わらせる
// これにより、不要なレイアウト再計算の連鎖を防ぐ
const reads = this.readQueue;
this.readQueue = [];
reads.forEach((task) => {
try {
task();
} catch (error) {
console.error(‘FastDOM Read Error:’, error);
}
});

// 2. 次に「すべての書き込み(Mutate)」をまとめて実行する
// このタイミングでのみDOMのスタイル変更を行う
const writes = this.writeQueue;
this.writeQueue = [];
writes.forEach((task) => {
try {
task();
} catch (error) {
console.error(‘FastDOM Write Error:’, error);
}
});
} finally {
// フラッシュが終わったらフラグを戻し、次の変更を受け付けられるようにする
this.isScheduled = false;

// もしタスク実行中に追加のキューが入っていたら再度スケジュールする
if (this.readQueue.length > 0 || this.writeQueue.length > 0) {
this.scheduleFlush();
}
}
}
}

// シングルトンとしてエクスポート
export const fastdom = new SimpleFastDOM();

—

4. 現場での実用パターン:コンポーネントでの活用例

では、このエンジンを実際のフロントエンド開発でどう使うのか。
例えば、無限スクロールや、ウィンドウのリサイズに追従して要素の高さを揃えるような、パフォーマンスがシビアに問われるシーンを想像してほしい。

以下は、複数のカード要素の高さを揃える処理のコードだ。

import { fastdom } from ‘./simple-fastdom’;

/

  • リスト内のカードの高さを動的に計測して揃える処理
  • @param {NodeListOf} cards

/
function equalizeCardHeights(cards) {
// 悪い例:ループの中で element.offsetHeight を読んで element.style.height を代入すると死ぬ

// 良い例:FastDOMを使う

// ステップ1:すべての要素の高さを「読み取る(Measure)」
// この時点ではDOMの書き込みは一切行わない
const heights = [];

cards.forEach((card) => {
fastdom.measure(() => {
// ブラウザはこの読み取りをまとめて処理する
heights.push(card.offsetHeight);
});
});

// ステップ2:すべての要素に高さを「書き込む(Mutate)」
// 読み取りがすべて終わった後にこの処理が走るため、レイアウトスラッシングが起きない
cards.forEach((card, index) => {
fastdom.mutate(() => {
// 最大の高さを適用するなどの処理
card.style.height = `${heights[index]}px`;
});
});
}

// ウィンドウリサイズ時に実行
window.addEventListener(‘resize’, () => {
const cards = document.querySelectorAll(‘.card’);
equalizeCardHeights(cards);
});

このコードの美しいところは、DOMへのアクセスがコードのあちこちに散らばっていても、内部のキューイング機構によって「読み取りのバッチ」と「書き込みのバッチ」に綺麗に交通整理される点だ。

—

5. シニアエンジニアからの実践的なアドバイス

最後に、実務でFastDOMやバッチ処理パターンを導入する際の「生きた知見」をいくつか授けておこう。

1. 「全部をFastDOMに通せばいい」というわけではない
単純な初期化処理や、DOMがまだ画面にマウントされていない(DocumentFragment内など)状態での操作にFastDOMを使う必要はない。FastDOMが真価を発揮するのは、「すでに画面に存在し、ユーザーインタラクションやアニメーション、リサイズによってブラウザに負荷がかかっている動的な操作」だ。
2. 非同期になることを意識する
FastDOMは内部で `requestAnimationFrame` を使っているため、`measure` や `mutate` に渡したコールバックの実行は必ず「次のフレーム」になる。つまり、関数の戻り値でその場ですぐに計算結果を受け取ることはできない(Promise化するラッパーを書くこともできるが、基本はコールバックやリアクティブな状態管理と組み合わせるべきだ)。
3. フレームワーク(React / Vue / Svelte)との付き合い方
現代のモダンなフレームワークは、それ自体が内部でバッチ処理(React 18のAutomatic Batchingなど)を行っている。そのため、通常のステート駆動のUI構築であればフレームワークに任せておけば安全だ。しかし、「フレームワークの管理外でDOMを直接触らざるを得ない場面(重いアニメーション、複雑なSVG操作、サードパーティ製ライブラリとの統合)」では、このFastDOMのパターンが君のコードを救う最強の武器になる。

ブラウザの仕組みを理解し、その機嫌を損ねないコードを書くこと。それこそが、動くだけのコードから、プロダクトをスケールさせる「プロのコード」への境界線だ。

さあ、今日のデプロイから、無駄なレイアウトスラッシングを根絶やしにしてやろうぜ。

コメント

タイトルとURLをコピーしました