エディタ初期化時のレイアウトスラッシングを一括読み書きで解消
エディタ初期化時に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回のフルリフローが発生していました。この処理は connectedCallback、ResizeObserver コールバック、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.js の computeStyleValues()
旧関数 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)に削減したことで、デバイス性能やページ複雑度にスケールしない堅牢な実装に改善されました。