DOMキャプチャの改善

DOMキャプチャの改善

はじめに

今回、echoサービスでSnap機能を担当して開発することになりました。Snapは、ユーザーがヘッダーのカメラアイコンを押すと、現在見ている画面をそのままキャプチャする機能です。文章だけで説明するのではなく、画面のスクリーンショットも一緒に送るため、キャプチャ結果は何が間違っているのかを示す説明であり、証拠でもあります。

そのため、この機能は速度と正確性を一定の基準以上に引き上げる必要がありました。まず速度については、目標を1秒前後としました。報告しようとしている流れをキャプチャが妨げてはいけないからです。もう一つは正確性で、報告者が見た画面とスクリーンショットが同じでなければなりません。異なっていれば、証拠としての意味がありません。

最初に改善作業を始めたときは、速度だけを改善すれば問題は解決すると思っていました。速度を改善しながらさまざまなスクリーンショットを撮る過程で、正確性にも問題があることを把握しました。この記事では、その二つの段階をそれぞれどのように解決したのか、そしてその結果、現在どのようなパイプラインが動いているのかをまとめます。結果から書くと、次のとおりです。

画面

ノード数

現在

クローン座標の照合(正確性)

単純な一覧画面

1,476

436〜457ms

996個中0個

ダイアグラム画面

4,745

1,186ms

4,281個中0個

改善前は3.3秒かかっていたキャプチャが、現在は軽い画面で0.45秒、ノード数が3倍ある重い画面でも1.2秒になりました。さらに正確性の面でも改善され、重い画面では4,281個の要素の座標が実際の画面と完全に一致するようになりました。

DOMキャプチャの限界

問題の根本原因は、snapが実際のスクリーンショットを撮っていなかったことでした。html-to-imageのようなライブラリは、Webページが実際に描画されたピクセルにアクセスできないため、実際には次のように動作します

①画面のDOMツリー全体を複製

②各要素に現在適用されているCSSの値をインラインスタイルとして一つずつ適用

③その結果をSVGの<foreignObject>内に注入

④そのSVGを<img>として読み込み、canvasに描画

つまり、ブラウザに同じHTMLをもう一度描画させるのに近い仕組みです。問題は、④の段階にある<img>内のSVGが元のページから完全に隔離されていることです。セキュリティ上の理由からそのように設計されていますが、この隔離には三つの性質があり、それぞれが後に実際のバグとして現れました。

  • 親ドキュメントのWebフォントにアクセスできません。埋め込まなければシステムフォントに置き換えられ、文字幅が変わってレイアウトがずれます。

  • ネットワークが遮断されます。画像URLをそのままにしておくと取得できないため、キャプチャ前にすべてdata URI(ファイルの内容を文字列に変換して埋め込んだ形式)にしておく必要があります。

  • スクリプトが無効になっています。JavaScriptの実行有無によって動作が変わる要素は、キャプチャ内でのみ異なる形にレンダリングされます。

開発過程1 — 遅い原因を探し、四つのライブラリを組み合わせてみました

最初に受けた依頼は「スクリーンショットの生成に少し時間がかかっているようだ」でした。実際に測定してみると、ダイアログはすぐに開くものの、スクリーンショットの準備が完了するまで3秒以上スピナーが回り続け、その間メインスレッドが完全に停止してクリックすら処理されませんでした。ブラウザのJavaScriptは一度に一つの処理だけを行うシングルスレッド構造のため、キャプチャがそのスレッドを占有すると、画面がフリーズしたように見えます。

区間ごとにボトルネックが発生している箇所を調査

最初は画面の要素が多いために時間がかかっているのだと考え、まずどの段階にどれくらい時間がかかるのかを分けて測定することにしました。測定の結果、原因の一つはシリアライズされたSVGのサイズでした。分析の結果、ノード一つあたり約40KBのインラインスタイルが付いているという結論に達しました。もう一つはcssTextで、この値が空の場合、ライブラリはスタイルを一度にコピーする高速な経路ではなく、プロパティを一つずつ移す経路を通ることをソースから確認しました。

実際に一覧を抽出してみると、このアプリのcomputed valueプロパティは約1,000個あり、その半分がCSSカスタムプロパティでした。テーマカラーやアイコンのパスを持つ、--で始まる変数です。しかしcomputed valueは変数がすでに置換された最終値として返されるため、複製側に変数そのものを改めて埋め込む理由はまったくありませんでした。さらに、見た目と関係のないものも取り除き、約100個を残しました。

// utils/inlineStyleProperties.ts — 인라인할 CSS 프로퍼티 127개
export const INLINE_STYLE_PROPERTIES = [
  'display', 'position', 'top', 'right', 'bottom', 'left', /* ... */

  // 'box-sizing' 은 반드시 있어야 합니다. Chrome의 computed width 는 그 요소의
  // box-sizing 기준값이라, 빼면 클론이 padding+border 만큼 넓어집니다.
  'width', 'height', 'box-sizing', /* ... */

  // ::before / ::after 규칙도 이 목록으로 만들어집니다.
  // 빠지면 의사요소(구분선·체크박스·화살표)가 통째로 사라집니다.
  'content',

  // 아이콘 대부분이 mask 로 렌더됩니다. 빠지면 아이콘이 전부 사라집니다.
  'mask-image', 'mask-size', '-webkit-mask-image', /* ... */
];

コード1。一覧から外れてもエラーは発生せず、スクリーンショットだけがひっそりとずれます。そのため、理由をコメントとして残しておきました。

この一覧で最も危険だったのはcontentでした。もし抜けていたら、::before / ::afterで描画される要素がすべて空のままレンダリングされていたはずです。しかし、これをテストで発見したのではなく、ライブラリのソースを読んでいる途中に偶然見つけました。一覧を作る際に、何が必要なのかを検証する方法も同時に用意していなかったことが、この時点での最大の弱点であり、後に別のライブラリを検討する直接の理由にもなりました。

四つの案をそれぞれ実装して比較しました

AIで抽出した一覧は、それ自体が危険です。Snapが組み込まれる場所は、複数のエピソードで活用されることを前提とした共有ライブラリですが、一覧は私が一部の画面だけを見て決めたものです。そのため、別チームのアプリが一覧にないプロパティを使うと、エラーは発生せずスクリーンショットだけがずれます。バグ報告ツールでは、ひっそりと間違った結果のほうが遅い結果より悪いと判断し、キュレーションが不要な代替案をテストしてみました。

方式

軽い画面

重い画面

増加率

A案

html-to-image + AIで抽出したcssプロパティ

378–388ms

828–865ms

2.2×

B案

modern-screenshot + ランタイムで生成したcssプロパティ

575–863ms

1,870–2,045ms

2.8×

C案

snapdom + クラスの重複排除

1,235–1,985ms

5,186ms(初回は26秒)

4.2×

D案

html2canvas — CSSをJSで解釈して直接描画

516–610ms

(測定条件が異なる)

この表で決め手になったのは最後の列でした。ノードが4.3倍に増えたとき、増加率そのものが異なりました。ノードあたりに読み書きするプロパティがA案では約100個、B案では約450個だったため、画面が重くなるほど差が広がるのは当然の結果でした。もし軽い画面だけを比較していたら、B案を選んでいたでしょう。

  • B案(modern-screenshot) — ライブラリだけを置き換えた場合、改善は0でした。タグごとのデフォルト値と比較して異なるものだけをインライン化する方式なので、生成物は小さくなりますが、何が異なるのかを知るには結局すべてを読み取る必要があるためです。リストをランタイムで生成して渡すと806msになりましたが、重い画面では2秒を超えました。

  • C案(snapdom) — 同じスタイルをクラスにまとめる方式なのでマークアップが最も小さく、レンダリングの忠実度は3つの中で唯一、完璧でした。しかし、フォントのサブセットだけを含める方法は公開オプションにはなく、アプリのフォント全体を一緒に埋め込むことになります。その結果、重い画面では初回キャプチャに26秒かかりました。

  • D案(html2canvas) — 一般的によく使われているライブラリなのでテストしてみました。系統自体が異なり(CSSをJSで解釈してcanvasに直接描画する)、フォントの埋め込みがまったく不要なのは魅力でしたが、このアプリのアイコンの大半が使用するmask-imageのサポートがバンドルにまったくなく、ヘッダーとツールバーのアイコン7個がすべて黒い四角形として描画されました。2022年が最後のリリースなので、最新のCSS関数に遭遇すると例外を投げて停止することもありました。

なぜ元々使っていたライブラリをそのまま残したのか

結論はA案の維持でした。重い画面で1秒という要件を満たせるのはA案だけであり、snap-viewがどの画面に組み込まれるか分からない以上、測定した画面でかろうじて通過するだけでは不十分だと考えました。

B案を支持していた根拠は「キュレーションがないので、未知のCSSに対してより安全だ」というものでした。しかし、ホワイトリストを作成する際に見ていなかった画面で2つのキャプチャをピクセル単位で比較したところ、約10%の差が生じることを確認しました。最初は、やはりA案が何かを取りこぼしているのだと思いましたが、実際の画面座標と1つずつ照合してみると、両方とも同じように間違っていました。違いは、どちらかがより正確だからではなく、2つのライブラリが同じCSSを少しずつ異なる方法で描画するためでした。確認されていない利点のために、2.2倍遅い方を選ぶことはできないと判断しました。ただし、これは「A案の方が安全だ」という証明ではありません。2つの画面で確認されなかったというだけなので、この弱点は今もそのまま残っています。

D案の実験では、代償の大きい教訓も1つ得ました。html2canvasは失敗するとページにiframeを残しますが、querySelectorAll('*')ではその内部を見ることができません。5つ積み重なった状態でA案を再測定すると、マークアップは66MB、キャプチャは4,960msに膨れ上がりました。基準線から13倍ずれていたことだけが手がかりで、その数値をそのまま信じていたら、「A案はこの画面で5秒かかる」という完全に誤った文書ができていたでしょう。

開発過程 ② — 高速化した後で、キャプチャが画面と異なることに気づいた

速度の問題を解決した後、キャプチャと実際の画面を並べて見てみると、4つの点でずれていました。そして4つとも、原因は最初の推測とは異なっていました。特に2・3・4番はすべてフォントの問題だと推測していましたが、実際にはフォントとは無関係でした。

#

症状

本当の原因

性質

1

ag-gridのチェックボックスとソート矢印が丸ごと表示されない

疑似要素の背景は、ライブラリのリソースインライン化処理の対象にならない

リソース

2

文字が大きくなり、ずれて見える

pixelRatio: 1はCSSピクセル基準(ユーザーズーム90%)

倍率

3

ヘッダーのパンくずリストが2行に折り返される

小数点の丸めにより0.007pxずれてflex-wrapが反転する

レイアウト

4

ヘッダーの下に空白の帯ができる

<noscript>がキャプチャ内でのみ復活する

レイアウト

消えたアイコン

行選択用のチェックボックスと列ソート用の矢印が、キャプチャでだけ丸ごと消えていました。一方、ヘッダーのカメラと通知のアイコンは正常でした。同じアイコンなのになぜ分かれるのか分からず、ライブラリのソースを調べたところ、リソースをdata URIに変換する箇所は2つしかありませんでした。要素自身の背景を読み取る経路と、<img>タグを読み取る経路です。しかし、疑似要素のルールは<style>テキストとして作成されて追加されるため、そのどちらにも該当しませんでした。問題のCSSはホストアプリのSCSSにあったため、私たちが修正することもできませんでした。

手がかりは意外なところにありました。フォントを埋め込む用途にだけ使っていたfontEmbedCSSオプションのソースを見ると、受け取った文字列をそのまま<style>にして、複製先の先頭に挿入する動作になっていました。名前はフォントですが、実際には任意のCSSを注入する場所だったのです。

const cssText = options.fontEmbedCSS != null ? options.fontEmbedCSS : ...
if (cssText) {
  const styleNode = document.createElement('style')
  styleNode.appendChild(document.createTextNode(cssText))
  clonedNode.insertBefore(styleNode, clonedNode.firstChild)
}

コード2。この1行を確認できたおかげで、「修正する経路がない」と書き留めていた問題が解決しました。

そこでキャプチャ直前にすべての要素の疑似要素を調べ、url()を使っているものだけを抽出して該当する画像をdata URIに変換した後、対象要素にマーカー属性を付け、そのマーカーを対象とするCSSルールを作成して渡しました。必須だった点は2つあります。!importantは、ライブラリが作成したルールの方が後から挿入され、文書の順序によって優先されるためです。また、宣言が同じ要素同士でマーカー番号を共有するようにしたのは、そうしないとag-gridの各行が同じチェックボックス画像データを行数分コピーし、SVGが膨れ上がるためです。

ここで前提が1つ覆りました。すべての要素を調べるよりスタイルシートを解析する方が安いだろうと考え、先にそちらを実装しましたが、実測ではスタイルシートをスキャンする方式の方が、全要素を走査する方式よりはるかに遅くなりました。getComputedStyleは思ったより安く、スタイルシートを調べる方が高コストだったのです。直接走査に変更するとコードも短くなり、キャプチャ時間は348msから332msへと、むしろ短くなりました。

画面より大きくなったキャプチャ

複数の画面でキャプチャしていると、ヘッダーの文字が大きくなり、横にずれて見える現象がありました。レイアウトが崩れたように見えましたが、実際には倍率の問題でした。画面はCSSピクセル1つあたりdevicePixelRatio分の実ピクセルで描画されますが、オプションがpixelRatio: 1に固定されていました。私はブラウザのズームを90%にしていたため、DPRが0.9となり、キャプチャが1.112倍(1÷0.9)に拡大されていたのです。

const capturePixelRatio = (): number =>
  Math.min(window.devicePixelRatio || 1, 2);

コード3。DPR 2の環境でもキャプチャ時間はほぼ変わらず、増えたのは容量だけでした(120KB → 316KB)。

ヘッダーの下の空白帯

キャプチャでのみヘッダーのすぐ下に24pxの空白帯が生じ、その下のコンテンツがすべて押し下げられていました。しかし、複製版のレイアウト座標を測り直すと完全に一致していました。DOM段階で余白がないのであれば、残るのはラスタライズ段階だけです。そこで、キャプチャ画像のピクセルを直接スキャンして「インクのあるy区間」を抽出しました。ヘッダーだけは所定の位置にあり、それ以外は丸ごと24px下がっていました。累積ではなく一度だけずれていたので、フローの先頭で何かが1行分のスペースを占有しているという意味でした。

原因は<noscript>でした。HTML仕様上、「これを非表示にする」というブラウザーのデフォルトルールは、スクリプティングが有効な場合にのみ適用されます。しかし前述のとおり、<img>内のSVGはスクリプティングが無効なコンテキストであるため、キャプチャ内でのみこのタグが復活し、1行分のスペースを占有していました。ヘッダーが正常だったのは、position: fixedによってフローの外にあるためです。そのため「ヘッダーの下の空白帯」のように見えていました。Chromeのバグではなく、仕様に従った動作でした。

const EXCLUDED_TAG_NAMES = new Set(['NOSCRIPT', 'SCRIPT']);

filter: (node) => {
  if (!(node instanceof Element)) return true;
  if (EXCLUDED_TAG_NAMES.has(node.tagName)) return false;
  return !EXCLUDED_CLASS_NAMES.some((name) => node.classList?.contains(name));
},

コード6。修正後は、タブの下線やグリッドヘッダーの境界線のような1pxの線まで所定の位置に戻りました。

結局のところ、核心は診断ツールでした

振り返ると、今回実際に行ったことの大半はツールを作ることでした。目視でスクリーンショットを比較している間は、4つの問題のうち1つも見つけられませんでした。

  • ステップ1・手作業で作った複製版との座標照合 — 996個のうちずれていたノードは1個(アニメーション中)だけでした。ここでは何も見つけられませんでした。

  • ステップ2・ライブラリが実際に生成したSVGを取り出して照合 — 疑似要素のスタイルとフィルターがすべて反映された状態です。ここでflexの折り返しを見つけました。

  • ステップ3・キャプチャ画像のピクセルを直接スキャン — 行ごとにインクの有無を数え、コンテンツ領域の境界を抽出しました。ここで<noscript>を見つけました。

ステップ2では不十分だった理由こそが、この作業全体の教訓だと思います。照合に使ったiframeはスクリプティングが有効なため、<noscript>が非表示になります。そのため座標は完全に一致しているのに、実際の画像だけがずれていました。DOM座標の照合ではラスタライズ段階の問題を見つけられないことを、ツールをもう一段階進めて初めて理解しました。

現在のキャプチャパイプライン

上記の判断と修正をすべて反映した現在のコードです。一時的に画面へ変更を加えてから元に戻す処理が3つ組み込まれており、この順序にはそれぞれ理由があるため、コメントとして残しています。

export const captureViewport = async (): Promise<string | null> => {
  try {
    // 퀵메뉴 닫힘 등 직전 DOM 변경이 화면에 반영된 뒤 캡처합니다.
    await new Promise((r) =>
      requestAnimationFrame(() => requestAnimationFrame(r)));

    // 의사요소 배경을 data URI 로. pinScrollOffsets 보다 먼저
    const { css: pseudoCss, restore: restorePseudo } =
      await inlinePseudoBackgrounds();
    // 현재 라인을 파악해야 하므로 스크롤 처리보다 먼저 진행
    const unpinFlexLines = pinFlexLines();
    // scrollTop 은 CSS 가 아니라 런타임 상태여서 복제본이 찾지 못함
    //  transform 으로 번역해 두면 복제본이 그대로 복사함
    const unpinScrollOffsets = pinScrollOffsets();

    let result: string | null;
    try {
      const shot = toJpeg(document.body, {
        ...CAPTURE_OPTIONS,               // 화이트리스트
        pixelRatio: capturePixelRatio(),  // 실 기기 픽셀 기준
        // foreignObject 는 웹폰트에도 네트워크에도 접근할 수 없으므로 필요한 것은 전부 여기에 넣음
        fontEmbedCSS: CAPTURE_FONT_EMBED_CSS + GLOBAL_CAPTURE_CSS + pseudoCss,
      });
      const timeout = new Promise<null>((r) => setTimeout(r, TIMEOUT_MS, null));
      result = await Promise.race([shot, timeout]);
    } finally {
      // 복구는 반드시 역순. 순서를 바꾸면 스크롤 값이 엉뚱하게 잡힘
      unpinScrollOffsets();
      unpinFlexLines();
      restorePseudo();
    }
    return result;   // 실패·타임아웃이면 null — 제보 흐름을 막지 않음
  } catch (e) {
    console.warn('[captureViewport] failed', e);
    return null;
  }
};

コード7。キャプチャのエントリーポイント。

ここではpinScrollOffsetsについてもう少し説明します。このアプリはブラウザーウィンドウ全体ではなくmain内部コンテナーがスクロールする構造ですが、スクロール位置はCSSではなくランタイム状態であるため、複製版では0になります。そのため、ユーザーが画面の中央を見て報告しても、キャプチャには一番上が含まれます。3つのライブラリすべてで同じ問題があり、対応オプションがある2つも、この構造では効果がありませんでした。

解決策は、スクロールをCSSに変換することでした。スクロールされたコンテナーの子要素をスクロール量だけtransform: translate()で移動し、同時にコンテナーのスクロールを0に戻します。2つの変更が互いに相殺されるため、実際の画面は1ピクセルも動きませんが、transformはcomputed styleなので、複製版にもそのままコピーされます。副作用としてsticky/fixed要素が本来の位置から外れるため、ずれた量を測定して逆方向に補正しました。補正前は96個がずれていましたが、適用後は1個に減りました。

まだ残っていること

最大の問題は、ホワイトリストを依然として手作業で作成する必要があることです。リストにないプロパティはブラウザーのデフォルト値でレンダリングされ、エラーにはなりません。さらに、本ライブラリが組み込まれる各ホストは別々のリポジトリであり、リストはデプロイ済みパッケージ内にあるため、別のチームが問題を発見しても、私たちに知らせてリリースを待たなければなりません。現在のリストは最小限に選んだものなので、視覚に関係しないものだけを除外する方式で300個前後まで広げる余地があり、1秒の予算内で最適点を探すつもりです。

2つ目は、検証を担保する仕組みがないことです。前述の座標照合とピクセルスキャンは、その都度作って使ったツールであって、自動化された検査ではありません。contentを取りこぼしかけた状況は、今でも同じように再現される可能性があります。キャプチャ結果を自動的に照合する検査を追加することが次の課題だと考えています。

3つ目は、検証範囲です。これまで測定したのは、グリッド一覧画面とダイアグラム画面の2種類だけです。それ以外の画面でどのCSSが使われるかは分からず、キャプチャが静かにずれても誰も気づかないという構造自体はそのままです。

そのため、ユーザーが自動キャプチャではなく、自分で撮った写真をアップロードできる経路をそのまま開けておきました。自動キャプチャは「何もしなくてもデフォルトで付いてくる便利機能」として扱い、それで望む場面を収められなかった場合は、ユーザーが自分で添付できるようにしたのです。ただし、これは根本的な解決ではなく、緩衝策に近いものです。キャプチャ対象を画面全体ではなくユーザーが指定した領域に絞る方式や、ブラウザーの画面キャプチャAPIを使う方式など、ほかに改善の方向性がないか、今後も検討していく予定です。

おわりに

今回の作業を通じて感じたのは、これは実際には性質の異なる2つの仕事だったということです。速度は何が高コストなのかを見つける問題であり、数字が答えを教えてくれました。正確さは何が異なるのかを見られるようにする問題であり、答えより先にツールを作る必要がありました。目視から座標へ、座標からピクセルへと段階を上げるたびに、1つずつ見つけることができました。

そして、これらのライブラリはドキュメントだけを見て取り組むと行き詰まることが多くありました。includeStylePropertiesは特定の分岐でのみ使われるため、最初は「Chromeでは無視される」と誤って結論づけました。fontEmbedCSSは名前とは異なり任意のCSS注入ポイントであり、restoreScrollPositionやclip: 'viewport'のように、名前だけを見るとぴったり合っているように見えるオプションも、この構造では効果がありませんでした。行き詰まったときは、そのオプションがソースのどこで使われているのかを先に探すことが、最も速いデバッグ方法でした。

心残りもあります。ホワイトリストを作る際に、それを守るための検証も同時に作らなかったこと、そして正確さの問題を速度の作業と同じタイミングで確認できず、後になって初めて発見したことです。ただ、今回の経験によって、単にライブラリを導入して使う段階を超え、ブラウザーが画面を描画する過程と、その結果を再び画像へ変換するときに何が失われるのかを、少し深く理解できるようになりました。

参考資料

Owler

Site footer