この記事はkarasunouta.comからの転載です。
WordPress向けのプラグインを開発している際、多くの方が一度は直面するのがプラグイン同士の競合です。先日、投稿内のYouTube動画をスクロールに応じて画面の隅に追従させるプラグイン(KU Sticky Video for YouTube)を開発していたときのこと。ローカルのクリーンなテスト環境では完璧に動作し、「よし、これで完成だ!」と胸を張っていました。
しかし検証の舞台を自作の別のYouTube系プラグイン(未公開)や、他の動画系プラグインが有効化されているマルチプラグイン環境に移したとたん、事態は一変しました。直前までスムーズに動いていた追従機能はピクリとも動かずに沈黙し、ブラウザのコンソールを開くとそこにはいまいましい赤いエラー文字が。なんてことだ。
でもこれは、プラグイン開発では「よくあること」に過ぎません。そして競合の原因によって、解決方法もさまざまなのが現実です。今回は、YouTube系プラグインの多くが依存するYouTube Iframe APIがマルチ環境下で引き起こす「主導権争い」のメカニズムと、その問題を回避して他のスクリプトと相乗りし共存化を実現するためのJavaScriptの設計について紹介します。
YouTube動画をJavaScriptから高度に制御するには、公式のYouTube Iframe Player APIをロードする必要があります。一般的な解説記事では、以下のような実装が紹介されているかと思います。
// APIスクリプトをロード
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
const firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
// ロード完了時に呼ばれるグローバル関数を定義
window.onYouTubeIframeAPIReady = function() {
const player = new YT.Player('player-id', {
events: {
'onStateChange': onPlayerStateChange
}
});
};
一見何の問題もないコードですが、WordPressのように複数のプラグインやテーマが非同期に動く環境では、このソースが問題を引き起こします。単純な話、もし他のプラグインがまったく同じように window.onYouTubeIframeAPIReady を定義していたらどうなるでしょう?JavaScriptの性質上、後から実行されたプラグインの処理によって、先に定義されていた初期化関数は丸ごと上書きされて消えてしまいますよね。
結果として、先にロードされた側のプラグインは「APIのロード完了」を検知できず、永遠に起動しなくなってしまうのです。初期バージョンのKU Sticky Video for YouTubeが動かなくなってしまったのもそのせいです。
解決へのアプローチ: いかにしてプラグイン同士の「喧嘩」を避けるか
この競合を防ぎ、あらゆる環境で安全に動作させるために、KU Sticky Video for YouTubeプラグインでは以下のような対策を取ることにしました。
1. 非同期ロードの隙間を埋める「スクリプト存在チェック」
YouTube APIのスクリプト読み込みが完了すると、ブラウザのグローバル空間(window)に window.YT というオブジェクトが自動的に生成されます。この window.YT は、YouTubeプレイヤーの生成や操作を行うためのAPIのすべての機能(実態)が格納されているオブジェクトです。
「なるほど。じゃあ window.YT の存在チェックさえしとけばいいや」…とはならないのが厄介なところです。それだけだと 「他のプラグインがタグを挿入した直後の、ネットワークロード中(window.YT は未定義)」 のタイミングで処理が走ったらどうなるか。二重にスクリプトタグが挿入されてしまいますよね。これを防ぐため、window.YT だけではなく「DOMツリー上にすでにYouTube APIのスクリプトタグが存在するか」も併せて確認します。
// URLの表記揺れ(iframe_api / player_api)を考慮して部分一致で検索
const alreadyLoading = document.querySelector(
'script[src*="youtube.com/iframe_api"], script[src*="youtube.com/player_api"]'
);
if ( ! window.YT && ! alreadyLoading ) {
// ここで初めてスクリプトタグを挿入します
}
なぜ window.YT も引き続きチェックするの?
上のソース、一見すると「DOM上にスクリプトタグがあるか(alreadyLoading)」のチェックだけで十分に見えます。「windows.YT が生成されるのはスクリプトが読み込まれた後」と決まっているわけですから。しかし、他プラグインがたとえばVite等のビルドツール経由でJS内にAPIを直接バンドルしてロードしている場合など、DOM上にYouTube APIのスクリプトタグが存在しないケースも考えられます。
そのため、すでにロードが完了していることを100%保証する window.YT に加え、「まさに今、非同期ロードしている途中」であることをスクリプトタグによって検出しようとする alreadyLoading で二重チェックを行っておくことは必要です。両者の併用により、競合リスクを最小限に抑えることができます。
2. window.YT.get() による「イベントの相乗り(バインド)」
ページ内のYouTube iframeに対して他プラグインがすでに YT.Player インスタンスを生成していた場合、後から二重に new YT.Player を実行するとエラーになります。そこでYouTube APIのグローバルメソッドである window.YT.get() を使い、すでに作成済みのプレイヤーインスタンスがあれば取得して、そこにイベントリスナーだけを「相乗り」させてもらいます。
let existingPlayer = null;
if ( window.YT && typeof window.YT.get === 'function' ) {
const id = iframeElement.id;
// IDがあれば公式仕様で取得。なければDOM要素渡しでフォールバック
existingPlayer = ( id ? window.YT.get( id ) : null ) || window.YT.get( iframeElement );
}
if ( existingPlayer ) {
// 既存インスタンスがあればイベントを相乗り
// ※YouTube APIのaddEventListenerはDOM標準と異なり「on」プリフィクス必須(onStateChange)
existingPlayer.addEventListener('onStateChange', function(event) {
handlePlayerStateChange(event);
});
} else {
// なければ新規生成
const player = new window.YT.Player(iframeElement, { ... });
}
YT.get() の公式な引数は「iframeのID文字列」ですが、ID属性がない場合に備えて非公式ながら互換性の高いDOM要素渡しをフォールバックとし、2段構えのバインド処理を組んでいます。
3. タイミングの競合を防ぐ「ポーリング制御」
一連の対策の核となるのが、APIのロード完了と他プラグインの初期化完了を見極めるタイミングの制御です。そのために今回はポーリングという手法を採用することにしました。
しかしYouTube APIには、スクリプトのロード完了時にAPI側から自動実行される window.onYouTubeIframeAPIReady というグローバルコールバック関数が用意されています。なぜその定番のイベントハンドラー監視ではなく、ポーリングという少々回りくどい方法が必要になったのでしょうか?
3-1. 定番「onYouTubeIframeAPIReady」のラッパーがはらむ非同期リスク
他プラグインとの競合を考慮する場合、多くの開発ブログ等で常套手段として紹介されているのが「先客のコールバック関数を一度変数に退避してラップし、他者の初期化と自身の初期化を連続実行する」という以下のようなアプローチです。
// 先客のコールバックがあれば退避しておく
const previousOnReady = window.onYouTubeIframeAPIReady;
// グローバルコールバックを上書きし、他者の処理も連続して実行させる
window.onYouTubeIframeAPIReady = function () {
if ( typeof previousOnReady === 'function' ) {
previousOnReady();
}
myPluginInit(); // 自プラグインの初期化
};
一見すると完璧な共存対策に思えますが、実はこの実装には 「他プラグインが非同期処理を挟んでプレイヤーを初期化する場合」に対応できない という致命的なリスクが潜んでいます。
たとえば、共存相手となる他プラグインが「API Readyの検知後、Ajaxで設定データを取得してからプレイヤーを生成する」という非同期処理を挟んで初期化を行う設計だったとします。このとき、自プラグイン(Sticky)の初期化処理は、他プラグインが裏で非同期処理の完了を待っている状況を知る術がありません。そのため、API Readyの瞬間に「まだ競合相手(プレイヤーインスタンス)は存在しない」と判断し、一足先に新規プレイヤーを生成(new YT.Player)してしまいます。
非同期処理を終えた他プラグインがプレイヤーを生成しようとする頃には、すでに自プラグインの初期化が終わってしまっているため、同じiframe要素に対して二重初期化のエラーが発生し、相手のプラグインが破綻してしまいます。このため競合を考慮した堅牢な実装を行いたい場合、この onYouTubeIframeAPIReady コールバックを自身の初期化トリガーとして直接使用することはできません。
自プラグイン側はグローバルコールバックへの上書きや代入といった干渉は一切行わず、ただAPIをロードし、裏で「他プラグインの初期化が完了したこと」を監視し、完了後にそこへ安全に相乗りする(バインドする)アプローチをとる必要があります。これこそが、次で解説する「ポーリング」を用いた時間制御が必要になる理由です。
3-2. 解決策: ポーリングによる初期化タイミング制御
ここまでで説明した「ラッパー」「二重ロード防止」「イベントの相乗り」という3つのアプローチをきれいに連動させるには、初期化処理のタイミングのコントロールが非常に重要です。そこで役立つのが、プログラム側からAPIのロード状況やプレイヤーの初期化ステータスを定期的にチェックする 「ポーリング(Polling)」 という設計パターンです。
JavaScriptでは、ポーリングは主に setInterval を用いて実装します。実際の初期化コードは以下のようになります。
function initPlayingMode(iframes) {
// 1. すでに他スクリプトが挿入済みかも含めてチェックし、APIをロード
if ( ! window.YT && ! document.querySelector( 'script[src*="youtube.com/iframe_api"], script[src*="youtube.com/player_api"]' ) ) {
const tag = document.createElement( 'script' );
tag.src = 'https://www.youtube.com/iframe_api';
const firstScriptTag = document.getElementsByTagName( 'script' )[ 0 ];
firstScriptTag.parentNode.insertBefore( tag, firstScriptTag );
}
// 2. APIのロードおよび他プラグインの初期化完了をポーリングして監視
let attempts = 0;
let apiReadyAttempts = 0;
let setupCompleted = false;
const checkInterval = setInterval( () => {
attempts++;
const isApiReady = window.YT && typeof window.YT.Player === 'function';
if ( isApiReady ) {
apiReadyAttempts++;
}
// すべてのiframeに対して他プラグインがインスタンス生成を終えたかチェック
let allFound = false;
if ( isApiReady && typeof window.YT.get === 'function' ) {
allFound = true;
for ( let j = 0; j < iframes.length; j++ ) {
const id = iframes[ j ].id;
const player = ( id ? window.YT.get( id ) : null ) || window.YT.get( iframes[ j ] );
if ( ! player ) {
allFound = false;
break;
}
}
}
// 終了条件の判定
// - 他プラグインによるバインドが完了(allFound)、または
// - APIのロード完了後さらに他プラグインの初期化を待つ猶予時間(300ms)が経過(apiReadyAttempts >= 3)、または
// - 最大タイムアウト時間(3秒)に達した(attempts >= 30)
const shouldStop = allFound || apiReadyAttempts >= 3 || attempts >= 30;
if ( shouldStop ) {
clearInterval( checkInterval );
if ( ! setupCompleted ) {
setupCompleted = true;
// 各iframeに対して「相乗り」か「新規作成」を行う
setupPlayers( iframes );
}
}
}, 100 );
}
APIの準備完了後、このコードではすぐに自前でプレイヤーを初期化するのではなく最大300ms(100msごとに状況を最大3回チェック=ポーリング)の待機時間を設けています。もし他プラグインがAPI Readyの瞬間に非同期でプレイヤーを生成しようとしている場合、その完了を少し待ってから相乗りすることで、二重生成エラーの発生を極力防ぐことが目的です。
ただ待機時間を取りすぎて自プラグインによる初期化がいたずらに遅くなると、今度は動画が再生開始された時点でイベントリスナーが未登録のままになるリスクが出てきてしまいます。その点の兼ね合いを試行錯誤した結果、この300msという時間が今回の開発プロセスにおける経験的マジックナンバー(実用上のベストな境界線)として浮上しました。
ここはプラグインの目的によっても変動が出てくる部分かもしれません。
最終的な問い: 一見完璧なコード同士が出会うとき
ここまでの実装で、「他プラグインのロードを邪魔せず」「他プラグインが作成したプレイヤーに相乗りし」「二重生成を防ぐために300ms待つ」という、おおむね完璧な競合回避コードが完成したように見えます。読者の中にも「なるほど、この実装なら問題なさそうだ」と納得してくださった方もいるかもしれません。
ここで問題です。本記事で説明してきた「完璧な実装」をそれぞれ組み込んだ、別々のプラグインAとプラグインBが同じページで同時にロードされたとき、両者は共存できるでしょうか?
答は残念ながら 「稀にせよ、競合エラーを起こして沈黙(破綻)するケースは存在する」 です。原因は、YouTube APIが持つ「非同期登録のラグ」にあります。実は、new YT.Player() を実行してから、そのプレイヤーインスタンスがAPI内部に登録されて window.YT.get() で取得できるようになるまでには、ミリ秒単位のわずかなタイムラグが存在します。
同じ「300ms待機」のロジックを持つプラグインAとBが全く同じAPI Readyのタイミングでタイマーをスタートさせた場合、300ms後に両者がほぼ同時に YT.get() を実行します。この瞬間、まだどちらもプレイヤーを作っていないため、双方とも戻り値は null となります。そして双方ともが既存プレイヤーなしと判断し、ほぼ同時に new YT.Player() を実行してしまいます。結果として同じ iframe に対する二重生成エラーが走り、破綻します。
「それなら、onReady コールバックの中で二重生成を検知して destroy() で後始末すればいいのでは?」と思われるかもしれません。しかし、YouTube APIの destroy() はDOM要素の書き換えやイベントハンドラのクリアを強制的に行うため、先に正常起動していたプレイヤーまで巻き添えで破壊してしまうという致命的な副作用を持っています。つまり、一度生まれてしまった二重生成は、後から安全に消すことができないのです。
解決策としての「ランダムジッター(揺らぎ)」
YouTube API側に複数プラグインの完全な同期用イベントが用意されていない以上、このタイミングの衝突を「確率0%」にする完璧なコードは存在しません。しかし、この衝突確率を実用上ほぼゼロ(無視できるレベル)に抑え込むための解決策が 「ランダムジッター(揺らぎ)」 の導入です。
やることは非常にシンプルです。300msの譲歩時間が明けた後、すぐにプレイヤーを新規生成するのではなく、実行の手前に「0〜100msのランダムな遅延」 を挟みます。
// 300msの監視終了条件が満たされた後に実行
if ( shouldStop ) {
clearInterval( checkInterval );
if ( ! setupCompleted ) {
setupCompleted = true;
// 0〜100msのランダムなジッター(遅延)を挟んで実行タイミングをばらけさせる
const jitterDelay = Math.random() * 100;
setTimeout( () => {
// ランダム遅延が明けたタイミングでもう一度 window.YT.get() をチェック
setupPlayers( iframes );
}, jitterDelay );
}
}
ランダム遅延を追加することで、同時に動き出したプラグインAとプラグインBの実行タイミングに高い確率でずれが生じます。
たとえばあるとき、ランダム遅延時間がプラグインAにおいて15ms、Bにおいて78msだったとします。15ms後、先にディレイが明けたプラグインAが new YT.Player を作成すると、続いて78ms後にプラグインBが YT.get() チェックを行う際にはすでにプラグインAのインスタンスが検出されるようになっています。このためプラグインBは新規作成をキャンセルしてプラグインAに安全に「相乗り」できます。
この設計の美点として、「すべてのYouTube API依存プラグインがこの『ランダムジッターによる譲歩』の設計を取り入れれば、競合のない共存(相乗り)が自律的にほぼ達成できる」 という点が挙げられるかと思います。いわば譲り合いの精神をコードに落とし込むことで、API側の改善を待つことなく共存を達成するための知恵です。
今回開発した追従プラグイン 「KU Sticky Video for YouTube」(および有料版のPro)にも、このランダムジッターによる衝突回避と共存の設計を組み込んでいます。
おわりに
プラグインにおいてYouTubeのようなメジャーな外部サービスのAPIを使うということは、必然的に競合の可能性を抱え込むことを意味します。
ユーザーは自分の記事にYouTube動画を埋め込み、そこにさまざまなカスタマイズを施す。そのために複数のYouTube系プラグインを併用することはとうぜん想定されます。それを見越して、依存先のAPIを競合なく使える実装を工夫することは不可欠です。
今回開発したKU Sticky Video for YouTube、および有料版のKU Sticky Video for YouTube Proもまた、開発過程でこうした問題を洗い出し、ひとつひとつクリアしてぶじリリースに至りました。YouTube動画をテキストとともに効果的に見せたい場合は、ぜひお試しいただければと思います。
その際は他のYouTubeプラグインとの併用もぜひ試してみてください!きっと問題なく動くはずです(もし問題があった場合は、問い合わせフォームなどからこっそり教えてくださいね)。