他社サイトに <script src="https://widget.example.com/embed.js"></script> 1 行で設置してもらう Web ウィジェットを開発・運用する立場の人向けの、Core Web Vitals を壊さないための実装チェックリストです。同じ外部埋め込みでも、設計を詰めていないと埋め込み先の LCP(Largest Contentful Paint)・INP(Interaction to Next Paint)・CLS(Cumulative Layout Shift)を一気に悪化させるので、SEO 目線のお客様から苦情が来ます(埋め込み先の Google 検索順位に影響する可能性があるため)。
前回書いた関連記事 qiita-widget-embed-patterns(実装パターン3種比較)・qiita-widget-script-cdn-cors(配信レイヤー設計)の続編として、Core Web Vitals + CSP + バージョン固定の観点で詰めていきます。サンプルは TypeScript + Next.js App Router ベース、ゲスト側は WordPress / Shopify / ペライチ / 素の HTML を横断する想定です。
想定読者
- 他社サイトに貼ってもらう「レビューウィジェット」「チャットウィジェット」「SNS 埋め込み」「アクセス解析タグ」等を開発している方
-
<script async>vs<script defer>の選び方で毎回迷う方 - 埋め込み先の CLS(レイアウトずれ)のクレームで困ったことがある方
- CSP
script-srcでブロックされて埋め込みが動かないサイトに遭遇したことがある方
Core Web Vitals の 3 指標と埋め込みスクリプトの関係(おさらい)
Core Web Vitals は Google Search Central の Page Experience 文書でランキング要因の一部として位置付けられている指標群です(2026-04 時点、最新の詳細は web.dev/vitals を確認)。本稿では埋め込みスクリプトが影響する可能性が高い 3 つに絞ります。
| 指標 | 意味 | ウィジェットで悪化しやすいシナリオ |
|---|---|---|
| LCP | メインコンテンツの描画時刻 | 同期 <script> が HTML パースを止め、LCP 候補(画像/テキスト)の描画が後ろ倒しに |
| INP | 最悪のユーザー操作応答時間 | 重い JS がメインスレッドを長時間占有し、クリック・タップ応答が 200ms を超える |
| CLS | レイアウトのズレ累積 | ウィジェット領域が遅延挿入されて、下の要素を押し下げる |
埋め込み側で「何も考えずに script タグを貼る」運用だと、LCP と CLS が同時に悪化しがちです。以下、具体的に詰めます。
1. async / defer / type=module のどれで配信するか
埋め込みスクリプトの読み込み方は、大きく次の 3 つに分類できます(MDN の <script> 要素の仕様を前提)。
<!-- A. 同期(古いが見かけることがある) -->
<script src="https://widget.example.com/embed.js"></script>
<!-- B. defer: HTML パース後、DOMContentLoaded 前 -->
<script src="https://widget.example.com/embed.js" defer></script>
<!-- C. async: ダウンロード完了したらいつでも実行 -->
<script src="https://widget.example.com/embed.js" async></script>
<!-- D. type=module: defer 相当の挙動 + ES Modules -->
<script src="https://widget.example.com/embed.mjs" type="module"></script>
ウィジェット配信の実務では、以下の判断軸で選びます。
LCP を守りたい → async / defer 必須
A(同期)は HTML パースを止めるため、LCP 候補要素の描画が widget JS のダウンロード完了まで遅延します。ゲスト側の CDN や回線によっては数百ミリ秒単位で LCP が悪化するので、原則禁止の扱いで運用します。
実行タイミングをゲスト側 DOM に依存させたい → defer
ウィジェットが <div data-koe-widget></div> のようにページ上の特定 DOM を探して自分を差し込む設計なら、defer が合います。HTML パース完了後に実行されるので、DOM 参照が安全です。
完全に副作用ゼロ / タグ検出だけ → async
ページビュー計測やフラグ読み込みだけの目的なら async でも安全です。ただしユーザー操作の前後どちらで実行されるか不確定なので、DOM 操作するウィジェットには不向きです。
ES Modules の import/export が欲しい → type=module
内部で複数ファイルに分けた配信をしたい場合は type=module。ただし IE は対象外(2026 時点では IE サポートは事実上不要ですが、一部の古い CMS テーマで <script type="module"> が二重ロードを起こすケースがあるため、単一バンドル + defer のほうが互換性は高め)。
結論:defer を第一候補、async は副作用ゼロのときのみ。同期は原則使わない、が実務の既定値です。
2. CLS をゼロにするための「プレースホルダ予約」
ウィジェットが「あとから差し込まれる UI」である場合、埋め込み先のページは「JS 実行前はウィジェットの領域が 0px、JS 実行後に高さ◯px の要素が挿入される」動きをします。これは CLS を直接悪化させる最も典型的な原因です(web.dev/cls の定義で、レイアウトシフトはビューポート内の移動量で計上されます)。
解決策は 2 つ。
解 1. ホスト側に「高さ指定済み」のプレースホルダを置いてもらう
<!-- ホストに貼ってもらう HTML -->
<div class="koe-widget-root" data-koe-uuid="..." style="min-height:420px;"></div>
<script src="https://widget.example.com/embed.js" defer></script>
min-height が予約されていれば、JS が DOM を埋めたときにレイアウトが動かない(CLS 増加ゼロ)。
埋め込みコード例のドキュメント・コピペ UI に、デフォルトで min-height を含めるのが実務の鉄則です(ドキュメントから style を剥がすユーザーが一定数いるため、埋め込みコードを配布する側がデフォルトで含めてしまう)。
解 2. サーバ側で高さのメタ情報を返し、JS がそれを使う
ウィジェット JS のフェッチ先 API で、コンテンツ量に対して高さの推定値を返し、JS が DOM 差し込み前に element.style.minHeight = estimated を設定してから差し込む、という手順を踏むことでもレイアウトシフトを抑えられます。ただし推定誤差がそのまま CLS に効くため、解 1 のほうが安全です。
3. LCP 候補の上に乗らない(ヒーロー領域を避ける)
埋め込みウィジェットをページの最上部に設置する運用は、LCP 候補(通常はヒーロー画像 or 見出しテキスト)の描画と直接競合します。ウィジェット配布側ができる対策は次の 2 つ。
- ドキュメントで「ページ最上部のファーストビュー内には配置しないことを推奨」と記載する
- デフォルトの埋め込みコード例で
loading属性を画像に活用するのと似て、埋め込み領域にフォールバック画像(本当の表示前の仮プレビュー)を含めるテンプレを提供する(ただし CLS が悪化しないよう解 1 と組み合わせる)
4. CSP script-src に弾かれないための設計
埋め込み先サイトが Content Security Policy の script-src を厳格化していると、外部スクリプトが拒否されます。埋め込み先の代表的な CSP 方針:
# 厳格な例
Content-Security-Policy: default-src 'self'; script-src 'self';
# nonce 制 (nonce 必須)
Content-Security-Policy: script-src 'nonce-XXXXX';
# allow list 制
Content-Security-Policy: script-src 'self' https://widget.example.com;
配信側(ウィジェット開発者)ができる対策:
-
配信ドメインを固定し、サブドメインを増やさない(allow list 制への対応)。
widget.example.comで固定、cdn-01.example.comのような可変ホストを使わない -
Subresource Integrity (SRI) のハッシュを埋め込みコード例に提供し、
integrity="sha384-…"付きでドキュメントに載せる。これにより厳しめの CSP でも「ハッシュ指定で OK」とする運用が取れる -
nonce 制 CSP の顧客には、埋め込みコード生成 UI で
<script src="…" nonce="…">形式を選べるようにする(「この nonce を CSP に追加してください」と案内) - FAQ に CSP 違反時のエラーメッセージ例を載せる(管理画面・サポートチャットからの「なぜか動かない」問い合わせの半分くらいは CSP 起因)
5. バージョン固定とキャッシュ戦略
埋め込みスクリプトのバージョニングは、以下の 2 択の組み合わせで設計するのが実務的です(詳細は前回記事 qiita-widget-script-cdn-cors を参照)。
-
latest(短命キャッシュ):
https://widget.example.com/embed.js(Cache-Control: max-age=300, must-revalidate) -
fixed(immutable):
https://widget.example.com/v1.2.3/embed.js(Cache-Control: public, max-age=31536000, immutable)
埋め込みコード配布側のドキュメントでは、デフォルトは latest、動作検証が必要なクライアントには fixed を案内する運用が一番事故りません。latest 側の Cache-Control を短命にしておくことで、bug fix を 5 分で反映できる運用が取れます。immutable にすると反映に時間がかかるため、latest 側での採用は避けます。
バージョン固定は CWV 改善の文脈では副次的ですが、「昨日まで速かったのに突然遅くなった」などのトラブルシュートに役立ちます。Resource Timing API で埋め込み先から計測すれば、特定バージョンのリリース後に悪化したかどうかを切り分けられます。
// 埋め込み先で Resource Timing を取るデバッグ例
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
const widgetEntry = entries.find(e => e.name.includes('widget.example.com'));
if (widgetEntry) {
console.log({
duration: widgetEntry.duration,
transferSize: widgetEntry.transferSize,
decodedBodySize: widgetEntry.decodedBodySize,
});
}
6. INP を壊さないための DOM 操作の分割
ウィジェットがレビュー一覧の数十件を一度に DOM に流し込む実装をすると、メインスレッドが長時間ブロックされ、INP(最悪応答時間)が悪化します。対策は以下の 2 つ。
A. requestIdleCallback で分割
function renderReviews(reviews: Review[], container: HTMLElement) {
let i = 0;
const step = (deadline: IdleDeadline) => {
while (i < reviews.length && deadline.timeRemaining() > 4) {
container.appendChild(renderOne(reviews[i++]));
}
if (i < reviews.length) requestIdleCallback(step);
};
requestIdleCallback(step);
}
B. DocumentFragment にまとめて 1 回の挿入に
const frag = document.createDocumentFragment();
for (const r of reviews) frag.appendChild(renderOne(r));
container.appendChild(frag);
件数が 100 を超える場合は A、それ未満は B が目安です。両者を併用して「最初の 20 件を即座に、残りを requestIdleCallback」に分けると、ユーザーの体感が良くなります。
7. 計測(埋め込み先の CWV を自分で見られるようにする)
埋め込み配布側が「埋め込み先の CWV を壊していないか」を確認するには、以下のいずれかの運用が必要です。
- 自社サイトに代表的な配置例を設置し、CrUX / PageSpeed Insights で観測
- 大口顧客から CWV レポート(Search Console の Page Experience、PageSpeed Insights のスクリーンショット)を共有してもらう
- 埋め込みスクリプト側で、LCP/INP/CLS の値を自社バックエンドに送信(Performance Observer +
navigator.sendBeacon)。ただし顧客のプライバシー・CSP 方針に配慮し、オプトインにする
埋め込みスクリプト側の計測を入れる場合の例(送信は省略):
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.name === 'largest-contentful-paint' 等
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
計測を入れる是非は、顧客の規約・プライバシーポリシー・CSP と個別に相談します。勝手に計測を送ると CSP connect-src に弾かれてエラーが増えるため、送信先ドメインの allow list 化のお願いとセットで進めます。
8. チェックリスト(実装前にこのリストを埋める)
-
<script>の属性を決めた(原則 defer、副作用ゼロなら async、同期は不可) -
埋め込みコード例のデフォルトに
min-heightプレースホルダが入っている - ドキュメントに「ファーストビュー内に置かない」注意書きがある
- 配信ドメインを固定化した(サブドメインを増やさない)
-
integrity属性の SRI ハッシュを生成・ドキュメントに提供 - FAQ / エラー時の案内に CSP のケース説明がある
-
latest 側の
Cache-Controlを短命にした(max-age=300目安) -
fixed バージョン URL を提供、
immutableで配信 -
DOM 挿入を
DocumentFragment/requestIdleCallbackで分割した - 100 件以上のレンダリングで INP の影響を実測した
以上を埋めれば、埋め込み先サイトの CWV を悪化させずにウィジェットを配信できる構成になります。
まとめ
- 同期
<script>は使わない。原則 defer、副作用ゼロの計測系のみ async - ウィジェット領域はホスト側プレースホルダで高さ予約し、CLS をゼロに
- CSP
script-srcには配信ドメイン固定 + SRI ハッシュで対応、nonce 制顧客用に別パスも用意 - 大量レンダリングは DocumentFragment + requestIdleCallback でメインスレッド占有を回避
- バージョン固定(
v1.2.3/embed.js)と短命 latest の併用で、リリースとロールバックを分離
Core Web Vitals は埋め込み先の Google 検索の Page Experience 評価に影響する可能性があるため、ウィジェット配布側が壊さない設計で出すことが、結果的にお客様に喜ばれる運用に直結します。