← シリーズに戻る

YouTube iframe API: 993バイトを1行ずつ読む

Part 1 / 6 JavaScript SDK 設計: 実在5製品のバンドル分解

script タグを1行貼るだけで YouTube の iframe embed API が丸ごと有効になります:

<script src="https://www.youtube.com/iframe_api"></script>

レスポンスは非圧縮で 993 バイト、gzip で線上 572 バイトです。埋め込み型JS SDK は全て(Vimeo・Safie・Mux・Video.js・これから自分が作るもの)、この最初のリクエストで同じ問題群を解いています。YouTube の loader はたまたま、端から端まで読めるほど簡潔に解いています。993 バイトを、ブラウザから観測できる HTTP コンテキストとセットで読みます。あらゆる script-tag SDK に一般化できる7パターンが出てきます。

測定は 2026-08-08 に fetch したもの(ビルドID 854a788e)です。ビルドIDは回りますが、形は変わりません。

実際に線を流れているもの

リクエストは 1本ではなく 2本で、分割は意図的にそうしています。

リクエストURLサイズCache-Control役割
loaderyoutube.com/iframe_api993 B raw / 572 B gzipprivate, max-age=0本体スクリプトを注入し、グローバルを立ち上げます
本体youtube.com/s/player/854a788e/www-widgetapi.vflset/www-widgetapi.js26.8 KBpublic, max-age=31536000 (1年)実際の widget コード

loader の URL は固定で、顧客は HTML に一度貼ったら二度と変えません。本体URLにはビルドID(854a788e)が埋まっているので、YouTube がアップデートを出すたびに loader は新しい URL を指し、キャッシュがまだ更新されていない側でも古いURLは動き続けます。コンテンツを介したリダイレクト、と言ってもいいでしょう。loader そのものがリダイレクトの役割を果たすので、実行中のブラウザは 302 の往復を必要としません。

顧客が固定する URL と YouTube が差し替える URL の図。顧客の HTML は src が …/iframe_api の script タグを一度貼ったら二度と変えない。loader は youtube.com/iframe_api、993 B (gzip 572 B)、Cache-Control は private, max-age=0 で、毎回確認して最新のビルド ID を知る。loader が指す本体は …/s/player/854a788e/www-widgetapi.vflset/…、26.8 KB、Cache-Control は public, max-age=31536000 で、URL ごとに中身が変わらないので1年キャッシュできる。初回の訪問は loader と本体 26.8 KB を取り、2回目以降の1年間は loader 993 B だけで本体はキャッシュから。更新時は loader の中身だけが新しいビルド ID の URL を指し、古い URL も動き続ける。測定 2026-08-08 (ビルド ID 854a788e)

loader 全文

整形版です(実際に配信されているのは1行につながっています):

var scriptUrl = 'https://www.youtube.com/s/player/854a788e/www-widgetapi.vflset/www-widgetapi.js';
try {
  var ttPolicy = window.trustedTypes.createPolicy('youtube-widget-api', {
    createScriptURL: function (x) { return x }
  });
  scriptUrl = ttPolicy.createScriptURL(scriptUrl);
} catch (e) {}

var YT;
if (!window['YT']) YT = { loading: 0, loaded: 0 };
var YTConfig;
if (!window['YTConfig']) YTConfig = { host: 'https://www.youtube.com' };

if (!YT.loading) {
  YT.loading = 1;
  (function () {
    var l = [];
    YT.ready = function (f) { if (YT.loaded) f(); else l.push(f) };
    window.onYTReady = function () {
      YT.loaded = 1;
      var i = 0;
      for (; i < l.length; i++) try { l[i]() } catch (e) {}
    };
    YT.setConfig = function (c) {
      var k;
      for (k in c) if (c.hasOwnProperty(k)) YTConfig[k] = c[k];
    };
    var a = document.createElement('script');
    a.type = 'text/javascript';
    a.id = 'www-widgetapi-script';
    a.src = scriptUrl;
    a.async = true;
    var c = document.currentScript;
    if (c) {
      var n = c.nonce || c.getAttribute('nonce');
      if (n) a.setAttribute('nonce', n);
    }
    var b = document.getElementsByTagName('script')[0];
    b.parentNode.insertBefore(a, b);
  })();
}

これで全部です。パターンは7つ、順番に出てきます。

整形した loader の抜粋と7つのパターンの対応を示した図。題は「993 バイトの中に、パターンが上から順に並ぶ」。パターン2 だけはコードではなくレスポンスヘッダにあり、loader は private, max-age=0、本体は public, max-age=31536000。コードの上から順に、パターン1 は var scriptUrl = '…/player/854a788e/www-widgetapi.vflset/…'; で、2URL 分割 (本体の URL を最初に持つ)。パターン3 は try { … window.trustedTypes.createPolicy(…); scriptUrl = ttPolicy.createScriptURL(scriptUrl); } catch (e) {} で、Trusted Types (包めなくても先へ進む)。パターン4 は window に YT が無ければ YT = { loading: 0, loaded: 0 }; とする行と、if (!YT.loading) { YT.loading = 1; … の行で、二重ロードのガード (2段で見る)。パターン5 は YT.ready = function (f) { if (YT.loaded) f(); else l.push(f) }; と window.onYTReady = function () { YT.loaded = 1; … }; で、ready キュー (本体より先の呼び出しを積む)。番号の無い var a = document.createElement('script'); a.src = scriptUrl; a.async = true; は本体の script を作る部分。パターン6 は var n = c.nonce || c.getAttribute('nonce'); if (n) a.setAttribute('nonce', n); で、nonce の伝播 (自分の nonce を付け直す)。パターン7 は b.parentNode.insertBefore(a, b); で、挿入位置 (最初の script の手前)。youtube.com/iframe_api (2026-08-08 取得、10-01 にビルド ID 以外同じことを確認)

パターン1: 2URL 分割

scriptUrl が真っ先に宣言されています。loader はこれを指すために存在しています。widget コードを loader 自体に inline しようという試みはありません。loader を小さく(993 B)保つことで単一 TCP 往復に収まり、キャッシュも安く済みます。

単一 URL で widget 全体を流すという代替案は考えられます。ですが YouTube はそうしませんし、iframe を前面に立てている Safie も同様です (Vimeo の player.js は単一ファイルです)。理由は退屈で、顧客が固定する URL がひとつ、こちらが差し替える URL がひとつ、それだけです。loader は顧客の HTML と交わす固定契約、本体はこちら側のスケジュールで差し替える版付きの成果物、という役割分担になっています。

パターン2: cache-control の非対称

loader の Cache-Control: private, max-age=0 は一見無駄に見えます。毎回再検証するので。ですが無駄ではありません。ETag なしの max-age=0 は、ブラウザに loader を必ず fetch させるので、常に最新のビルドIDを学べます。本体URLにはそのビルドIDが埋め込まれているため、一度取ってしまえばブラウザキャッシュに 1年住み続けられます(ハッシュ付きURLは max-age=31536000 で配るのが慣行で、YouTube の本体は public, max-age=31536000 で配信されています。immutable は付いていません)。

計算はリピーターに有利に働きます。訪問ごとに 993 バイトの loader(gzipで ~572 B)、本体は 26.8 KB を一度だけ、それから 1年間はゼロ。初回訪問者だけ両方払い、リピーターは 1リクエスト分だけ払います。単一の 27 KB スクリプトを短めのTTLで配る場合と比べると、そちらはキャッシュミスのたびに全体を払わせることになります。

落とし穴は、loader が大きくなると常時再検証コストに食われる点です。YouTube の 993 B なら平気です。gzip で 30 KB 級になるなら別の戦略が必要になると私は考えます。

パターン3: Trusted Types と try/catch fallback

try {
  var ttPolicy = window.trustedTypes.createPolicy('youtube-widget-api', {
    createScriptURL: function (x) { return x }
  });
  scriptUrl = ttPolicy.createScriptURL(scriptUrl);
} catch (e) {}

Trusted Types (require-trusted-types-for 'script') は CSP のディレクティブで、script.src のような sink に生の文字列を代入するのを禁じます。このポリシー下では a.src = 'https://...' は throw します。名前付きポリシーを通して TrustedScriptURL を返させる必要があります。

youtube.com 自身のレスポンスヘッダには content-security-policy: require-trusted-types-for 'script' が入っています。だから loader がその文脈で走るとき(YouTube 所有ページから読まれた場合、あるいは顧客ページが Trusted Types をオプトインしている場合)、生文字列は名前付きポリシーを通してロンダリングする必要があります。中身の恒等関数(return x)は何もサニタイズしていません。ここでのセキュリティモデルは「このURLは名前付きポリシーから出てきたか?はい/いいえ」であって「このURLは安全か?」ではありません。YouTube は自分の文字列を信じていて、儀式が CSP の要求に応えている、という構図です。

try/catch が効いてきます。window.trustedTypes が無いブラウザもありますし (Firefox は 148、Safari は 26 からの対応です)、createPolicy は複数の条件で throw します(名前衝突、禁止された名前)。fallback は「もし何か失敗したら生文字列のまま、ホストページが Trusted Types を強制していないことを祈る」というものです。そのポリシー下にない顧客ページなら生文字列で動きます。

loader の try/catch がブラウザとページの条件ごとにどこへ落ちるかを4段で示した表の図。題は「包めなくても先へ進む。止まるのは4段目だけ」。loader は scriptUrl を名前付きポリシーで包もうとし、失敗したら生の文字列のまま a.src に入れる。1 Trusted Types の無いブラウザ (window.trustedTypes が undefined): createPolicy は TypeError で catch に入り、scriptUrl は生の文字列、a.src への代入は通る (強制するしくみが無い)。2 ページは強制していない (ブラウザは Trusted Types 対応): createPolicy は成功し、scriptUrl は TrustedScriptURL、代入は通る。3 ページが require-trusted-types-for 'script' で強制している: createPolicy は成功し、TrustedScriptURL なので代入は通る (生の文字列なら TypeError)。4 強制していて名前も縛っている (trusted-types の許可リストに無い名前、または同じ名前が2回目): createPolicy は TypeError で catch に入り、生の文字列のまま代入して TypeError、本体の script は読まれない。結論は「包んでおけば、3段目のページに埋め込まれても SDK は無変更で動く」。根拠は W3C Trusted Types (createPolicy と script.src の強制)、4段目は default ポリシーが無い場合。Trusted Types の対応は Chrome 83・Firefox 148・Safari 26 から (MDN browser-compat-data 8.1.3)

盗むべきパターン: Trusted Types ページで配信しないとしても、URL を包むコスト自体は安いです。顧客の CSP が締まったとき、SDK は無変更で動き続けます。

パターン4: グローバル名前空間ガード

var YT;
if (!window['YT']) YT = { loading: 0, loaded: 0 };

二重ロードは起きます。タグマネージャは script タグを複製します。うっかりした統合作業では同じスニペットを2回貼ります。ページ上で誰か別の人がすでに widget をロード済み、ということもあります。ガードは言っています。「YT がすでに存在するなら触るな」。少し下の if (!YT.loading) は一段深い防御です。「loader の前のコピーがすでに始まっているなら、本体スクリプトを2回目に注入するな」。

5製品のうち4製品で、なんらかの版のこれを確認できました。YouTube のガードは定番イディオムです。Vimeo は要素に data-vimeo-initialized を付けます。Mux は customElements.get() で Web Components がすでに存在するかを検出します。Video.js はプレイヤー化した要素に player プロパティを付け、それが付いた要素を飛ばします。Safie の loader には window.Safie の存在確認が見当たりませんでした (2026-09-30 に取り直した版)。初日から必要です。持たなかった日は、顧客のタグマネがスニペットを2回発火し、コンソールが重複登録エラーで埋まる日になります。

パターン5: YT.ready() で早着した呼び出しをキューする

var l = [];
YT.ready = function (f) { if (YT.loaded) f(); else l.push(f) };
window.onYTReady = function () {
  YT.loaded = 1;
  var i = 0;
  for (; i < l.length; i++) try { l[i]() } catch (e) {}
};

loader が終わった時点で本体スクリプトはまだダウンロードされていません。ですが顧客はこう書きます:

<script src="https://www.youtube.com/iframe_api"></script>
<script>
  YT.ready(function () { /* YT.Player を使う */ });
</script>

…YT.ready が普通に動くことを期待して。実際動きます。loader が YT.ready を即座に定義しているからで、本体がロード済みなら callback をすぐ発火し、そうでなければ l に push します。本体スクリプトが到着すると window.onYTReady() が呼ばれ、loaded = 1 をセットしてキューを flush します。

本体が届く前の YT.ready(f) が loader のキューに積まれる流れを、顧客のコード・loader (993 B)・本体 (26.8 KB) の3段と4つの時点で示した図。1 loader が走る: loader が YT.ready を定義して本体を async で注入し、本体はダウンロード中。2 顧客が YT.ready(f) を呼ぶ: YT.loaded が 0 なので loader がキューに積む (l.push(f))。本体はまだ届いていない。3 本体が届く: 本体が window.onYTReady() を呼び、loader は loaded = 1 にしてキューを順に実行する。1本ずつ try/catch で、壊れた1本で残りを止めない。4 その後に YT.ready(g) を呼ぶと、loaded なのですぐ g() を実行する。下に「YT.ready が本体の中で定義されていたら、2 の時点で顧客は未定義の関数を呼んでしまう」

盗むべきは2点です。callback ループ内の try/catch(壊れた callback 1本で残りを止めない)。それから、YT.ready() を loader 側で同期的に 定義していること。もしこれが本体スクリプトの中で定義されていたら、本体到着前に呼んだ顧客は未定義関数を踏むことになります。

パターン6: currentScript.nonce の伝播

var c = document.currentScript;
if (c) {
  var n = c.nonce || c.getAttribute('nonce');
  if (n) a.setAttribute('nonce', n);
}

script-src 'nonce-XYZ' のように nonce で許可する CSP 下では(YouTube 自身のページも、セキュリティ意識の高い顧客ページも張っています)、リクエストの現在の nonce が付いたスクリプトだけが実行を許されます。nonce だけで許可するポリシーなら、nonce なしで注入されたスクリプトはブロックされます。'strict-dynamic' を足したポリシー (YouTube 自身のページはこちら) では、「信頼されたスクリプトが createElement で注入したスクリプトは信頼を継承する」ので nonce なしでも通ります。nonce をコピーしておけば、どちらのポリシーでも動きます。

nonce をコピーしておけばどちらの CSP でも本体が動くことを示した図。上段は流れで、顧客のページが nonce="XYZ" 付きの script タグで …/iframe_api の loader を読み、loader が currentScript.nonce を読み、createElement で作った本体の script タグに nonce="XYZ" を付けて挿す。下の表は顧客ページの CSP ごとの結果。nonce だけで許可する script-src 'nonce-XYZ' では、注入した script に nonce が無いと nonce が一致せずブロック、nonce をコピーすれば一致して実行される。strict-dynamic 付きの script-src 'nonce-XYZ' 'strict-dynamic' では、nonce が無くても信頼された script が createElement で作った script は信頼を引き継ぐので実行され、コピーしても実行される。結論は「コピーしないと、nonce だけで許可するページで本体が止まる」。根拠は W3C CSP Level 3 §8.2、YouTube 自身のページは strict-dynamic 付き

loader は自分の nonce を document.currentScript から読み、注入する本体スクリプトにコピーします。細部が 2つあります。

  • c.nonce は DOM プロパティ、c.getAttribute('nonce') は fallback です。プロパティを先に読むのは、ブラウザがセキュリティ上の理由で属性の値を getAttribute から隠すからです (Chrome 147 では空文字が返り、プロパティには値が残っていました)。getAttribute は、nonce プロパティを持たない古いブラウザ (Firefox は 75 から対応) のためにあります。
  • document.currentScript が返すのは、いま同期的に実行中の classic script の要素です。バンドラが他のコードと1本の classic script に連結しても、返るのはそのバンドルの <script> 要素で、nonce はページで共通の値なのでコピーはそのまま効きます。null になるのは、module script (type="module" や import で読まれたもの) の中と、setTimeout やイベントハンドラのように後から走るコールバックの中です。ESM で出力するバンドラに通されたり、読み込み後に遅れて実行するラッパーに包まれたりすると、c が null になり、本体は nonce なしで注入されます。

自分の SDK が strict-CSP ページに埋め込まれる可能性があるなら、これがパターンです。nonce をコピーする、防御的に書く、顧客には loader を classic の script タグとして読み込むよう (type="module" や遅延実行のラッパーに入れないよう) 文書化する、以上です。

パターン7: 古典的な挿入位置

var b = document.getElementsByTagName('script')[0];
b.parentNode.insertBefore(a, b);

Google Analytics の旧スニペットが広めた書き方で、YouTube の loader もこれを使っています。どこでも動きます。loader が実行される時点で script が DOM に少なくとも 1本ある(自分自身)ことは保証されています。その手前に挿入すれば、<body> があるかや document.body が null かを知らずに、可能なら <head> 内の正しい document に入ります。

モダンな代替のうち、document.body.appendChild は端で崩れます。loader が <head> で走る時点では <body> がまだパースされておらず、document.body は null です。document.head.appendChild は HTML のページならまず動きます。<head> を書き忘れたページでも、パーサは最初の script より先に head 要素を暗黙に作るからです (null になるのは <head> を書いていない XHTML くらいです)。b を文書で最初の <script> (loader 自身が必ず1本あります) にして insertBefore(a, b) で差せば、こうした場合分けを考えずに済みます。

loader が head で走る瞬間の DOM と、本体の script の差し込み方2つを比べた図。題は「loader が head で走る瞬間、body はまだ無い」。左は loader を実行している瞬間の DOM で、html の下に head があり、head の中に実行中の loader の script (この例では、これが最初の script) と、その手前に本体が入る位置がある。body はまだパースされていない。loader より前に別の script があれば、本体はその手前に入る。右上は document.body.appendChild(a) で、document.body が null なので TypeError。loader が head にあると、ここで止まる。右下は getElementsByTagName('script') の先頭を b にして b.parentNode.insertBefore(a, b) で差す方法で、最初の script と同じ親に入り、どこでも動く。loader 自身が DOM にあるので、b は必ず見つかる。結論は「loader が head にあっても body の末尾にあっても、差し込み先に困らない」。根拠は HTML Standard (document.body は body 要素が無ければ null)

自分の SDK で盗むべきもの

993バイトのファイル1本に運用面の利点が7つあります:

  1. 本体URLはハッシュで版を切り、loader URL は固定に保つ。顧客が固定する URL がひとつ、こちらが差し替える URL がひとつです。
  2. loader は0秒、本体は1年キャッシュ。リピーターは loader の小さな再検証だけ払います。
  3. script URL は Trusted Types ポリシーで包み try/catch で fallback。今のコストは低く、顧客の CSP が締まったときに本当に効きます。
  4. 二重ロード対策は最初のコミットから。タグマネはスニペットを2回発火します、絶対に。
  5. .ready() キューは loader 側で定義、本体側ではありません。本体到着前に呼ぶ顧客が未定義関数を踏まないようにします。
  6. document.currentScript.nonce を注入 <script> にコピー。厳しい CSP は増える一方なので、これは互換性の当て板になります。
  7. 注入する <script> は最初の <script> の手前に insertBefore で差す。document.head や document.body がまだ null の瞬間でも差し込み先に困りません。

二段分離は他の6パターンが乗る土台です。SDK がモノリシック 1ファイルだと半分できません。分離すれば loader が十分小さくなり、1バイトずつ注釈をつけて読むという営みが成立します。それをやらせてくれるのが YouTube loader で、10分で端から端まで読めます。


これは script-tag JS SDK 5製品(YouTube・Safie・Vimeo・Mux Player・Video.js)分解シリーズの part 1 です。全体目次は シリーズpillar を参照してください。測定日 2026-08-08、ビルドID 854a788e に対する測定です。