初期化時のスタイル再計算を4つのアプローチで削減
重いDOMを持つページでの初期化時に発生するブラウザのスタイル再計算(UpdateLayoutTree)を、互いに独立した4つの変更で大幅に削減します。Basecampのチャットページ(DOM要素数 約17,000)での計測では、初期化中に49回発生していたUpdateLayoutTreeイベント(合計約1,168ms)が主なターゲットです。
背景
前回のPRではlexxy-editorホスト要素にcontainを適用し、エディタ内部のミューテーションがホスト境界を越えないようにしていました。しかしそれでも、document.bodyへの一時ノードのアタッチと、冗長なリコンサイラサイクルが非エディタツリーを撹乱し続けていました。
ブラウザのスタイル再計算コストは、DOMツリーのどこかで要素が追加・削除されるたびに、:has()や子孫コンビネータ、ユニバーサル兄弟ルールといった祖先依存セレクタの一致評価がドキュメント全体に波及することで生じます。Basecampのような大規模なCSSを持つページでは、たとえ小さなDOM操作であっても1回あたり13,000以上の要素を走査するスタイル再計算が発生していました。
このPRは残存する4つの発生源をそれぞれ独立したコミットで封じることで、問題を体系的に解消します。
技術的な変更
1. 厳密に隔離されたスタイルリゾルバルート(415b8518)
computeStyleValues(StyleCanonicalizer用)とeditor.js#resolveColorsが計算スタイル読み取りのために一時的なspan要素をdocument.bodyやエディタホストに直接アタッチしていた問題を、新しいヘルパー styleResolverRoot() で解決しました。
新たに追加された src/helpers/style_resolver_root.js は、contain: strict を持つ <div> を一度だけ作成してdocument.bodyに追加する共有ルートを提供します。
let resolverRoot = null
export function styleResolverRoot() {
if (resolverRoot && resolverRoot.isConnected) return resolverRoot
resolverRoot = document.createElement("div")
resolverRoot.setAttribute("aria-hidden", "true")
resolverRoot.setAttribute("data-lexxy-style-resolver", "")
resolverRoot.style.cssText = "contain: strict; position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; width: 0; height: 0;"
document.body.appendChild(resolverRoot)
return resolverRoot
}
contain: strict は size・layout・paint・style の全隔離を意味します。この要素自体は visibility: hidden でペイントされず、position: fixed と明示的なゼロサイズにより幾何的影響もゼロです。computeStyleValues の呼び出し先が document.body.appendChild(fragment) から styleResolverRoot().appendChild(fragment) に変わることで、以降の子要素ミューテーションは隔離されたサブツリー内に閉じ込められます。
2. set value のrAFワークアラウンドを初回ロード時のみに限定(5794c731)
set value 内の空の editor.update(() => {}) は、初期化直後のエディタでコンテンツ設定後に状態が不整合になるLexicalのバグを回避するためのものでした。しかし2回目以降のset value呼び出しではこの状態は発生しないため、余分なリコンサイラサイクルが無駄に発生していました。
変更前:
set value(html) {
this.editor.update(() => {
// ...
})
// rAFワークアラウンドが毎回実行される
}
変更後:
set value(html) {
const wasEmpty = !this.#initialValueLoaded
this.editor.update(() => {
// ...
})
// wasEmpty が true のときだけワークアラウンドを実行
}
新設した #initialValueLoaded フラグにより、ワークアラウンドは初回ロード時のみ発動します。ページロードあたりエディタ1つにつき1回のリコンサイラサイクルを削減します。
3. contenteditable ルートへのCSS containment 追加(bd3e8413)
.lexxy-editor__content(LexicalのリコンサイラがDOMミューテーションをコミットするcontenteditable要素)に contain: layout style を追加しました。
/* Isolate the contenteditable root's layout and style. Lexical's reconciler
commits mutations inside this element (nodes appended, text inserted,
class flipped) on every update; containment keeps those mutations from
invalidating ancestor-dependent selectors and sibling layout elsewhere
in the editor. */
contain: layout style;
外側の lexxy-editor はすでにcontainmentを持ちますが、タイピング・選択変更・初期状態ロード時に細粒度のDOMミューテーションが集中するcontenteditable要素自体をさらに隔離することで、ツールバーや添付ファイルプレビュー、プロンプトメニューといった兄弟要素のレイアウト再評価を防ぎます。
4. フォーカス済み時の editor.focus() をスキップ(a2773bfc)
editor.focus() はカーソル位置決定のためにリコンサイラの更新をコミットします。contenteditable がすでにフォーカスを持っている場合、この呼び出しは論理的には何もしませんが、フルのリコンサイラサイクルとスタイル再計算を引き起こしていました。
focus() {
// `editor.focus()` commits a reconciler update to position the cursor.
// Skip if the contenteditable already owns focus — the update would be a
// no-op but still triggers a full style/layout pass on pages with large DOMs.
if (this.#isContentFocused) return
this.editor.focus(() => this.#onFocus())
}
get #isContentFocused() {
return !!this.editorContentElement && this.editorContentElement.contains(document.activeElement)
}
#isContentFocused ゲッターが editorContentElement.contains(document.activeElement) で判定することで、繰り返しフォーカス呼び出しによる無駄なリコンサイラサイクルを排除します。
設計判断
4つの変更を独立したコミットとして管理する方針が採られています。それぞれがレビュー・リバートを単独で行えるよう、変更の粒度が意図的に小さく保たれています。
各変更の性質は一様ではありません。変更1と3はCSSのcontainプロパティによるブラウザへの宣言的なヒントであり、変更2と4はJavaScriptレベルでの条件ガードです。この二層のアプローチにより、DOM隔離とロジック最適化の両面からスタイル再計算の発生源を封じています。
styleResolverRoot() の設計では、resolverRoot.isConnected チェックにより要素がDOMから切り離された場合でも再生成できる防御的な実装になっています。aria-hidden="true" と pointer-events: none を付与することで、スクリーンリーダーやマウスインタラクションへの影響も排除されています。
まとめ
本PRは、スタイル再計算の発生源を「DOMへの直接アタッチ」「冗長なリコンサイラサイクル」「不十分なcontainment境界」の3種類に分類し、それぞれに適した手法で個別に封じる構成になっています。contain: strict による隔離ルートの共有化という手法は、計算スタイル読み取りが必要な箇所を今後拡張する際のパターンとしても活用できる設計です。