エディタ初期化時のレイアウトスラッシングを一括読み書きで解消

basecamp/lexxy

エディタ初期化時にDOMの読み書きがループ内で交互に発生する「レイアウトスラッシング」を修正し、複数エディタ存在時のページロードでの約1.5秒のブロッキングを解消しました。読み書きをフェーズ分離してバッチ処理することで、N回の強制リフローを最小限に抑えています。

背景

レイアウトスラッシング(Layout Thrashing)は、DOMへの書き込みと読み取りをループ内で交互に繰り返すことで、ブラウザが毎イテレーション強制的にレイアウト再計算(Reflow)を実行してしまうパターンです。通常1〜5ms程度の個々のリフローが、ページ初期表示時のようにDOMが大量に「ダーティ」な状態では50〜100msに膨らみます。

初期化時に呼ばれる3つの処理が独立してこのパターンを持っており、複数エディタが存在するページで最大約1.5秒のメインスレッドブロッキングを引き起こしていました。高速な開発マシンでは影響が小さくても、低スペックなデバイスや複雑なページでは影響がリニアにスケールするため、パターン自体の修正が必要とされました。

技術的な変更

3つの箇所それぞれで、「全要素を生成(書き込み)→ まとめてDOMに追加(書き込み)→ 全値を一括読み取り(読み込み)→ 一括削除(書き込み)」というフェーズ分離パターンを適用しました。

editor.js#resolveColors()

変更前は単一の resolver 要素を使い回し、setProperty(書き込み)→ getComputedStyle(強制再計算)→ removeProperty(書き込み)をカラー値ごとにループしていました。ハイライトカラー約9色 × 2プロパティ分の計約18回のリフローが発生していました。

変更前:

#resolveColors(property, cssValues) {
  const resolver = document.createElement("span")
  resolver.style.display = "none"
  this.appendChild(resolver)

  const resolved = cssValues.map(cssValue => {
    resolver.style.setProperty(property, cssValue)
    const value = window.getComputedStyle(resolver).getPropertyValue(property)
    resolver.style.removeProperty(property)
    return { name: cssValue, value }
  })

  resolver.remove()
  return resolved
}

変更後は、カラー値ごとに個別の element を生成してまず container (非表示の span)に収め、container を一度だけDOMに追加してから全要素の getComputedStyle を一括読み取りします。

変更後:

#resolveColors(property, cssValues) {
  const container = document.createElement("span")
  container.style.display = "none"

  const resolvers = cssValues.map(cssValue => {
    const element = document.createElement("span")
    element.style.setProperty(property, cssValue)
    container.appendChild(element)
    return { element, name: cssValue }
  })

  this.appendChild(container)

  const resolved = resolvers.map(({ element, name }) => ({
    name,
    value: window.getComputedStyle(element).getPropertyValue(property)
  }))

  container.remove()
  return resolved
}

toolbar.js#compactMenu()

変更前scrollWidth/clientWidth(レイアウト読み取り)と prepend()(DOM書き込み)をボタンごとのループ内で交互に実行し、オーバーフローするボタン1つにつき1回のフルリフローが発生していました。この処理は connectedCallbackResizeObserver コールバック、setEditor の3箇所から呼ばれます。

変更前:

#compactMenu() {
  const buttons = this.#buttons.reverse()
  let movedToOverflow = false

  for (const button of buttons) {
    if (this.#toolbarIsOverflowing()) {
      this.#overflowMenu.prepend(button)
      movedToOverflow = true
    } else {
      if (movedToOverflow) this.#overflowMenu.prepend(button)
      break
    }
  }
}

変更後は読み書きを明確に分離します。まず全ボタンの offsetLeft + offsetWidth(右端位置)を一括で読み取り、availableWidth との比較は純粋な算術演算で行い、オーバーフローするボタン群を最後に一括移動します。Safariのズームレベル問題への対処として +1 のオフセットも維持されています。

変更後:

#compactMenu() {
  const buttons = this.#buttons
  if (buttons.length === 0) return

  const availableWidth = this.clientWidth + 1 // +1 for Safari zoom rounding
  const buttonRightEdges = buttons.map(button => button.offsetLeft + button.offsetWidth)

  let firstOverflowing = -1
  for (let i = 0; i < buttons.length; i++) {
    if (buttonRightEdges[i] > availableWidth) {
      firstOverflowing = i
      break
    }
  }

  if (firstOverflowing === -1) return
  // Move overflowing buttons in a single write pass...
}

format_helper.jscomputeStyleValues()

旧関数 getComputedStyleForProperty() は呼び出しごとに要素生成・DOM追加・getComputedStyle・削除のサイクルを1回実行していました。メモ化により初回以降は呼ばれないものの、初回呼び出し時のN回分のスラッシングは残っていました。

変更前:

function getComputedStyleForProperty(property, value) {
  const style = `${property}: ${value};`
  const element = document.body.appendChild(createElement("span", { style: "display: none;" + style }))
  const computedStyle = window.getComputedStyle(element).getPropertyValue(property)
  element.remove()
  return computedStyle
}

新関数 computeStyleValues() は値の配列を受け取り、DocumentFragment に全要素を組み立ててから document.body へ一度だけ追加し、全値を一括読み取りして一括削除します。これにより、N値の処理が「最大1回のリフロー」に収まります。

変更後:

function computeStyleValues(property, values) {
  const fragment = document.createDocumentFragment()

  const elements = values.map(value => {
    const element = createElement("span", { style: `display: none; ${property}: ${value};` })
    fragment.appendChild(element)
    return element
  })

  document.body.appendChild(fragment)

  const computed = elements.map(element =>
    window.getComputedStyle(element).getPropertyValue(property)
  )

  elements.forEach(element => element.remove())
  return computed
}

設計判断

3つの修正すべてで 「読み書きのフェーズ分離」 という統一されたパターンが採用されました。個々の箇所ごとに異なる最適化手法(キャッシュ、requestAnimationFrame、Web Workers等)を選ぶのではなく、問題の根本原因であるインターリーブを取り除くという一貫した方針を採っています。

#compactMenu() では、#toolbarIsOverflowing() メソッドを削除し、インライン計算に置き換えています。このメソッドはループ内での読み取りを前提とした設計だったため、フェーズ分離後は不要になりました。ループロジックも「後ろから走査してオーバーフローが止まるまで移動する」から「前から走査して最初のオーバーフロー位置を特定してから一括移動する」へと変わり、アルゴリズムの意図がより明確になっています。

Chrome DevToolsの ForcedReflow インサイトを基準として検証が行われており、修正後はLexxyバンドルに起因するリフロー時間がゼロになっています。ハードウェアの速度に関わらずリフロー回数がO(N)からO(1)に削減されているため、低スペックデバイスや要素数が多いページで恩恵が大きくなります。

まとめ

本PRは、3箇所の初期化処理に散在していたレイアウトスラッシングを「読み書きのフェーズ分離」という単一のパターンで統一的に解消しています。個々のリフローコストではなくリフロー回数そのものをO(N)からO(1)に削減したことで、デバイス性能やページ複雑度にスケールしない堅牢な実装に改善されました。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
4be539f9

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

「リード文(総論)→背景・技術詳細(各論)→まとめ(結論)」の3部構成が明確に適用されており、必須要素も全て含まれています。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(`言語:ファイルパス`)とPR番号のリンク記法(`[#123](URL)`)が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

「レイアウトスラッシング」「強制リフロー」などの専門用語を前提としており、対象読者であるエンジニアに適した技術レベルと表現です。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論→各論の構成で、各段落はトピックセンテンスで始まり、1段落1トピックの原則が守られています。可読性が非常に高いです。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内のすべてのコードブロックは、提供されたDiff情報と正確に一致しています。コードの省略も説明を妨げない範囲で適切です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「レイアウトスラッシング」「強制リフロー」「DocumentFragment」など、PR内容に沿った技術用語が正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

DOMの読み書きを分離してリフローを削減するという変更の技術的背景と効果に関する説明は、論理的かつ正確です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内の主張はすべてPRのDescriptionやDiffの内容に基づいており、根拠のない推測や憶測(ハルシネーション)は見られません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#982)、ブロッキング時間(~1.5s)、リフロー時間(1–5ms, 50–100ms)など、すべての数値と固有名詞がPR情報と一致しています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトル「エディタ初期化時のレイアウトスラッシングを一括読み書きで解消」は、PRの主題を的確に要約しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

PRで言及されていない外部知識(バージョンのサポート状況、リリース日程など)は含まれておらず、記事内容はPR情報に限定されています。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現に歪曲は見られず、PRで記述された事実関係が正確に反映されています。