Pretextのアーキテクチャ:なぜ2フェーズ設計がDOM計測を凌駕するのか
前提知識
- ›Webフロントエンド開発とDOMの基礎知識
- ›CSSのテキストレイアウトモデル(white-space、overflow-wrap)
- ›TypeScriptの型システムへの理解
Pretextのアーキテクチャ:なぜ2フェーズ設計がDOM計測を凌駕するのか
チャット画面のレンダリング中にChrome DevToolsが「Forced reflow」の紫色の警告で埋め尽くされる光景を見たことがあるなら、Pretextが解決しようとしている問題を直感的に理解できるはずです。Chenglou氏が開発した @chenglou/pretext は、リサイズ時にDOMへ一切触れることなく複数行テキストの計測とレイアウトを行う、純粋なJavaScriptライブラリです。1回のレイアウト呼び出しあたり約0.0002msという驚異的なパフォーマンスを達成しています。その秘訣は同じ問題に対して巧みなアルゴリズムを当てることではありません。コストのかかる処理をホットパスから完全に切り離す構造的な分解にあります。
これは全6回のシリーズの第1回です。まずアーキテクチャの骨格から始めましょう。2フェーズモデル、モジュールの依存グラフ、不透過なハンドルパターン、そして改行エンジンを高速にする並列配列データモデルについて解説します。
問題の本質:DOM計測のインターリーブとレイアウトスラッシング
layout.ts の冒頭コメントに、この問題が端的に記されています。
UIコンポーネントが getBoundingClientRect() や offsetHeight を使ってそれぞれ独自にテキストの高さを計測すると、読み取り操作のたびに同期的なレイアウトリフローが強制されます。さらに、DOMへの書き込み(コンテナの style.width を設定するなど)と読み取り(結果として生じるテキストの高さを取得するなど)がインターリーブすると、ブラウザは読み書きのサイクルごとにドキュメント全体を再レイアウトします。
sequenceDiagram
participant App as Application
participant DOM as Browser DOM
Note over App,DOM: Layout Thrashing Pattern
App->>DOM: Set container width (WRITE)
App->>DOM: Read text height (READ) — forces reflow
App->>DOM: Set another container width (WRITE)
App->>DOM: Read text height (READ) — forces reflow again
Note over DOM: Each read invalidates the layout,<br/>costing 30ms+ for 500 text blocks
表示中のメッセージが500件あるチャットアプリケーションでは、このパターンが1フレームあたり30ms以上のコストを生み出し、フレームバジェットを軽く超えてしまいます。「読み取りと書き込みをバッチ処理せよ」というのが定石のアドバイスですが、それには独立したコンポーネント間での調整が必要で、カプセル化の原則を壊すことになります。
解決策:prepare()とlayout()の2フェーズ設計
Pretextは、テキスト計測をパフォーマンス特性がまったく異なる2つのフェーズに分けることでこの問題に答えます。
フェーズ1:prepare(text, font) — テキストが初めて表示されたときに一度だけ呼び出します。Intl.Segmenter でテキストを分割し、CanvasのmeasureText()で各セグメントの幅を計測してキャッシュします。フォントエンジンの呼び出し、セグメンテーション、Unicode解析を伴うため処理コストは高めですが、テキストブロックごとに一度しか実行されません。
フェーズ2:layout(prepared, maxWidth, lineHeight) — リサイズのたびに呼び出します。キャッシュ済みの幅データを純粋な算術演算で走査し、行数と高さを計算します。Canvas呼び出しもDOM読み取りも文字列操作もメモリアロケーションも、一切発生しません。
flowchart LR
subgraph "Phase 1 — prepare() [once]"
A[Raw text] --> B[Whitespace normalize]
B --> C[Intl.Segmenter]
C --> D[Merge cascade]
D --> E[Canvas measureText]
E --> F[PreparedText handle]
end
subgraph "Phase 2 — layout() [every resize]"
F --> G[Walk cached widths]
G --> H[Pure arithmetic]
H --> I["{lineCount, height}"]
end
prepare() の結果は幅に依存しません。同じ PreparedText ハンドルが、どんな maxWidth や lineHeight に対しても機能します。ここが設計の核心です。コストのかかる処理(セグメンテーション、計測、Unicode解析)はコンテナの幅に依存しない一方、安価な処理(改行計算)だけが幅に依存します。
layout() のコメントはそのパフォーマンス目標を明確に示しています。
// ~0.0002ms per text block. Call on every resize.
200ナノ秒 — 1ミリ秒で5,000個のテキストブロックをレイアウトできる速さです。
ヒント: コンテンツの更新頻度は低いがリサイズが頻繁に起きるテキストブロック(チャットメッセージ、コメント、ソーシャルフィードなど)を扱うアプリケーションなら、2フェーズモデルによってリサイズ時の再レイアウトをほぼゼロコストにできます。メッセージが届いたタイミングで
prepare()を一度呼び、アニメーション中は毎フレームlayout()を呼ぶだけです。
モジュール依存のアーキテクチャ
Pretextは5つのソースモジュールで構成されており、依存関係はクリーンなDAG(有向非巡回グラフ)になっています。
| モジュール | 役割 | 行数 |
|---|---|---|
layout.ts |
パブリックAPIのオーケストレーター、計測ブリッジ、行のマテリアライゼーション | ~718 |
analysis.ts |
空白の正規化、セグメンテーション、マージカスケード | ~1020 |
measurement.ts |
Canvas計測、絵文字補正、エンジンプロファイル、キャッシュ | ~232 |
line-break.ts |
改行エンジン(シンプルパスとフルパス) | ~1059 |
bidi.ts |
リッチレンダリング向けのUAX #9 bidiレベル(簡略版) | ~174 |
graph TD
layout["layout.ts<br/>(Public API)"]
analysis["analysis.ts<br/>(Text Analysis)"]
measurement["measurement.ts<br/>(Canvas Measurement)"]
linebreak["line-break.ts<br/>(Line Walker)"]
bidi["bidi.ts<br/>(Bidi Metadata)"]
layout --> analysis
layout --> measurement
layout --> linebreak
layout --> bidi
linebreak --> analysis
linebreak --> measurement
measurement --> analysis
依存関係は厳密に一方向です。layout.ts が他の4つのモジュールをインポートし、line-break.ts は analysis.ts から型を、measurement.ts からエンジンプロファイルをインポートし、measurement.ts は analysis.ts から isCJK ヘルパーをインポートします。循環依存はありません。
この設計が重要な理由は2つあります。第一に、ツリーシェイキングの観点から、不透過パスだけが必要な場合、バンドラーが必要なものを正確に特定できます。第二に、認知負荷の観点から、モジュール間の変更伝播を気にすることなく、対象モジュールとそのインポートを読むだけで理解が完結します。
PreparedTextの不透過ハンドルパターン
Pretextは、TypeScriptのブランド型を活用することで、内部表現を自由に変更しながらもパブリックAPIを安定させています。
パブリック型は空のブランドインターフェースです。
declare const preparedTextBrand: unique symbol
export type PreparedText = {
readonly [preparedTextBrand]: true
}
内部では実際のデータが InternalPreparedText に格納されており、ブランドと並列配列を含む PreparedCore 型を両方継承しています。外部コードは内部フィールドに一切アクセスできません。ブランドは declare で宣言された unique symbol(実際には値が割り当てられない)であるため、外部から構築したり分解したりすることは不可能です。
classDiagram
class PreparedText {
<<branded>>
+[preparedTextBrand]: true
}
class PreparedCore {
+widths: number[]
+kinds: SegmentBreakKind[]
+lineEndFitAdvances: number[]
+lineEndPaintAdvances: number[]
+breakableWidths: (number[] | null)[]
+breakablePrefixWidths: (number[] | null)[]
+simpleLineWalkFastPath: boolean
+chunks: PreparedLineChunk[]
+discretionaryHyphenWidth: number
+tabStopAdvance: number
+segLevels: Int8Array | null
}
class InternalPreparedText {
<<internal>>
}
class PreparedTextWithSegments {
+segments: string[]
}
PreparedText <|-- InternalPreparedText
PreparedCore <|-- InternalPreparedText
InternalPreparedText <|-- PreparedTextWithSegments
パブリック型と内部型のキャストは1つの関数で完結します。
function getInternalPrepared(prepared: PreparedText): InternalPreparedText {
return prepared as InternalPreparedText
}
このパターンは、内部データ構造が進化する可能性のあるライブラリ全般で応用できます。ブランド型によって、利用者は変更される可能性のあるフィールド名に依存できなくなり、内部キャストはランタイムにゼロコストです。
2層のパブリックAPI:ファストパスとリッチパス
Pretextは機能を2つの層として公開しています。
第1層:不透過ファストパス — prepare() + layout()。行数と高さのみを返します。セグメントデータ、bidiメタデータ、文字列のマテリアライゼーションは一切行いません。リサイズのホットパスに特化した構成です。
第2層:リッチなセグメント対応パス — prepareWithSegments() + layoutWithLines() / walkLineRanges() / layoutNextLine()。セグメントのテキスト、行境界、行ごとの幅、カスタムレンダリング向けのbidiレベルを公開します。
flowchart TD
subgraph "Tier 1: Fast Path"
P1[prepare] --> L1[layout]
L1 --> R1["{lineCount, height}"]
end
subgraph "Tier 2: Rich Path"
P2[prepareWithSegments] --> L2[layoutWithLines]
P2 --> L3[walkLineRanges]
P2 --> L4[layoutNextLine]
L2 --> R2["{lines: LayoutLine[]}"]
L3 --> R3["onLine callback"]
L4 --> R4["LayoutLine | null"]
end
prepare時の主な違いは、prepareWithSegments() がセグメントの文字列テキストとbidiレベルを保持する点です。不透過な prepare() ではこれらをスキップします。layout時は、layoutWithLines() が行テキストをマテリアライズ(任意ハイフン挿入を含む)、walkLineRanges() が文字列のマテリアライゼーションなしで行ジオメトリを提供、layoutNextLine() がイテレーター形式の可変幅レイアウトを可能にします。
ヒント: コンテナのサイズ計算には不透過パス(
prepare+layout)を使いましょう。Canvasへの描画やカスタム選択矩形の構築など、自前でテキストをレンダリングする必要があるときだけ、リッチパスに切り替えてください。
並列配列データモデル
PreparedCore 型は、array-of-structs(構造体の配列)ではなく、struct-of-arrays(配列の構造体)レイアウトを採用しています。
type PreparedCore = {
widths: number[] // Segment widths
lineEndFitAdvances: number[] // Width contribution for line-fit decisions
lineEndPaintAdvances: number[] // Width contribution for visible painting
kinds: SegmentBreakKind[] // Break behavior per segment
breakableWidths: (number[] | null)[] // Per-grapheme widths for overflow-wrap
breakablePrefixWidths: (number[] | null)[] // Cumulative prefix widths
simpleLineWalkFastPath: boolean
// ...
}
各配列はセグメントのインデックスで対応付けられており、widths[i]・kinds[i]・lineEndFitAdvances[i] はすべて同じセグメントを表します。改行ウォーカーのホットループはシーケンシャルな配列位置へアクセスするため、キャッシュフレンドリーであり、オブジェクトを経由したポインタ追跡も発生しません。
erDiagram
SEGMENT_INDEX ||--|| WIDTHS : "widths[i]"
SEGMENT_INDEX ||--|| KINDS : "kinds[i]"
SEGMENT_INDEX ||--|| FIT_ADVANCES : "lineEndFitAdvances[i]"
SEGMENT_INDEX ||--|| PAINT_ADVANCES : "lineEndPaintAdvances[i]"
SEGMENT_INDEX ||--o| BREAKABLE_WIDTHS : "breakableWidths[i]"
SEGMENT_INDEX ||--o| BREAKABLE_PREFIX : "breakablePrefixWidths[i]"
breakableWidths 配列は特に注目に値します。複数のグラフェムを持つ単語状のセグメントに対しては、グラフェムごとの幅の配列を格納することで、文字レベルの改行(overflow-wrap: break-word)を実現します。一方、シングルグラフェムや改行不可のセグメントには null を格納し、アロケーションを発生させません。姉妹配列の breakablePrefixWidths は累積プレフィックス幅を格納しており、Safariに対するブラウザ固有のシムとして機能します。詳細はパート4で取り上げます。
simpleLineWalkFastPath ブール値は、prepare時に算出されるファストパスの分岐フラグです。テキストが text・space・zero-width-break セグメントのみで構成されていれば(強制改行、ソフトハイフン、タブ、グルーがなければ)、改行ウォーカーはよりシンプルで高速なループを使用できます。現実のテキストの大多数はこの条件を満たします。
次回に向けて
この記事では構造的な基盤を確認しました。リサイズを安価にする2フェーズ分割、内部構造を隠蔽する不透過ハンドル、改行ウォーカーを高速化する並列配列です。しかし、システムの最も複雑な部分、つまり生の文字列をこれらの並列配列へと変換するテキスト解析パイプラインについては、まだほとんど触れていません。
パート2では analysis.ts の内部に踏み込みます。空白の正規化・Intl.Segmenter・多段階のマージカスケードが、CJKのグラフェム分割やアラビア語のノースペースクラスターをどう処理するかを見ていきます。禁則処理やURLのマージなど半ダースもの国際化の課題を1ピクセルも計測する前に解決する仕組みも明らかになります。