Google Tag Manager・Google AdSense のスクリプトを遅延読み込みしてページ初期表示を速くする
このサイトには、Google Tag Manager (GTM) を経由して Google Analytics と Microsoft Clarity というアクセス解析用のスクリプトがロードされるようにしていた。その他に Google AdSense のコードも埋め込んでいた。
これらのせいで、PageSpeed Insights の診断で初期表示が遅いという指摘をされていたので、今回はコレを改善してみようというお話。
元のコードと動作の仕組み
HTML に埋め込んでいた元々のコードはこんな感じ。いずれも、「このコードを貼り付けてください」と公式の管理画面で表示されるコードそのままだ。
<head>
<!-- Google Tag Manager のコード -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','GTM-XXXXXXX');</script>
<!-- Google AdSense のコード -->
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX" crossorigin="anonymous"></script>
</head>
<body>
<!-- Google Tag Manager (JS が無効な場合のフォールバック) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
</body>
GTM のコードは見てのとおり、インライン JS で書かれているので、head 要素内で即時実行になる。やっていることは、window.dataLayer というグローバル変数を用意して、gtm.js というエントリファイルを読み込む <script> タグを挿入する、といったモノになっている。読み込みたい gtm.js には async 属性を指定するようにはなっている。
- Google Tag Manager は管理画面で「All Pages」というトリガーを設定していた。コレは GTM のコードが読み込めたら、正確には
event: 'gtm.js'というイベントを検知できたら、即座に Google Analytics や Clarity といったアクセス解析用のスクリプトをロードし始めるというモノ。イメージ的には DOMContentLoaded イベントの前後で処理が始まる感じなので、まぁ初期表示が遅くなるのも納得 - 「All Pages」から「Window Loaded (ウィンドウ読み込み)」というトリガーに変更するだけ、という対応も一つ遅延読み込みする手段ではあるが、コレは
window.onloadイベント時に相当するので、例えば画像データの読み込みとかがいつまでも終わらないと、それがスクリプト実行をブロックしてしまい、アクセス解析データとしては欲しいモノが取れない可能性があるというのがトレードオフ事項 - ついでに、ほぼ使用されないであろう
noscript要素とiframe要素によるフォールバックコードもある
Google AdSense のコードに関しては、「自動挿入広告」機能にお任せにしているモノだ。script 要素には async 属性が付いているので、JS コードはバックグラウンドで読み込み、ロードが完了したら (DOMContentLoaded イベントを待つことなく) 即時実行される、という発火タイミングになる。
deferは「DOMContentLoaded の直前」というタイミングで必ず発火するように指定する属性だが、async属性の場合は「JS ファイルのロードが早く終われば、DOMContentLoaded よりも前に実行されうる」点が異なる- なお、仮に
asyncとdeferを併記した場合、モダンブラウザはasyncの指示のみが採用されdeferは無視される。古いブラウザではasync属性のことを知らず、defer属性のみを理解して扱う。併記したからといって「async+deferな第3の挙動」を見せるワケではない
というワケで、両方とも async 属性を付けて外部ドメインから JS ファイルを読み込むという処理であり、ページの初期読み込み中に発生するタスクが多いことは分かる。
defer・requestIdleCallback・イベントリスナーを併用してできるだけ遅延させる
今回の対処法としては、以下のとおり。
defer属性を付けた JS ファイルを読み込むようにし、この JS ファイルに GTM と AdSense の遅延読み込み処理を実装するdefer属性は前述のとおり、JS ファイルのロードがバックグラウンドで行われ、かつ DOMContentLoaded イベントの直前に確実に発火するよう実行タイミングが調整されるモノ- よって、この JS ファイルの中では
document.addEventListener('DOMContentLoaded')を書いても書かなくても、DOM 操作に関しては全く同じように動作する。今回は念のため、全コードをdocument.addEventListener('DOMContentLoaded', () => { });の中にラップするようにする
requestIdleCallbackを使って、メインスレッドがアイドル状態になったら処理を行うようにするrequestIdleCallbackの詳細は後述する。Safari などの一部ブラウザはまだ対応していないので、その場合setTimeout()を代用することで、タスクキューとして遅らせるようにする
- イベントリスナーを設定して、閲覧者がマウス操作、キーボード操作、タップ操作のいずれかを行った瞬間に初めて AdSense のコードを読み込み始めるようにする
3つ目のイベントリスナーがキモの部分。なお、GTM の noscript によるフォールバックは完全に取り外してしまった。
HTML 部分はこうなる。
<head>
<script src="/scripts.js" defer></script>
</head>
defer 付きの、自作の JS ファイルを読み込む設定のみにする。
読み込む scripts.js の中身は次のとおり。
// 万が一 `defer` 属性が理解されずに即時読み込みされても、DOMContentLoaded 後に処理を開始するよう全体をラップする
document.addEventListener('DOMContentLoaded', () => {
// `requestIdleCallback` がない環境では `setTimeout` で代用する
const safeIdle = window.requestIdleCallback || (callback => setTimeout(callback, 1));
safeIdle(() => {
// Google Tag Manager (GTM) のインラインで行っていたコード相当を再実装する
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
// GTM のスクリプトを挿入する
let gtmScript = document.createElement('script');
gtmScript.async = true;
gtmScript.src = 'https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX';
document.head.appendChild(gtmScript);
// 複数イベントが重複実行しないようにフラグでチェックする
let isTriggered = false;
/** 画面操作があったタイミングで動かす関数 */
const triggerFunction = () => {
// フラグを見て重複実行を防ぐ
if(isTriggered) return;
isTriggered = true;
// GTM に画面操作があったことを通知する・このタイミングで初めて、GTM 内で紐付けている Google Analytics・Microsoft Clarity のロードが発火する
window.dataLayer.push({ event: 'user_interacted' });
// イベントを解除する
removeListeners();
// Google AdSense 相当のコードをココで初めて挿入する
let adsenseScript = document.createElement('script');
adsenseScript.crossOrigin = 'anonymous';
adsenseScript.src = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX';
document.head.appendChild(adsenseScript);
};
// ページロード直後に行われるユーザ操作のイベント名を定義する
const listenerTypes = ['scroll', 'keydown', 'touchstart', 'mousemove'];
// 上述のどれか1つのイベントが発火したら、この関数を呼び出すことで、登録しておいた全てのイベントを解除する
const removeListeners = () => listenerTypes.forEach(type => window.removeEventListener(type, triggerFunction));
// イベントを登録する
listenerTypes.forEach(type => window.addEventListener(type, triggerFunction, { passive: true }));
}, { timeout: 2000 });
});
詳細を説明していこう。
2重の遅延処理
JS ファイルの中身は、requestIdleCallback のフォールバックを取り除いて分かりやすくすると、以下のように2重にラップされている。
document.addEventListener('DOMContentLoaded', () => {
window.requestIdleCallback(() => {
// ようやく初期表示時の処理をココに書く
}, { timeout: 2000 });
});
requestIdleCallback は前述のとおり Safari など一部のブラウザにはまだ実装されていないので、const safeIdle という変数にて必要に応じて setTimeout にフォールバックしている。setTimeout の場合はテキトーに setTimeout({}, 1); = 1ms 以上の遅延、という処理にしている。
requestIdleCallback についてだが、「ブラウザのメインスレッドがアイドル状態になったら、関数を実行する」というモノだ。つまり初期読み込みなどでブラウザが忙しくしている間は、関数が実行されない。素直にこのままにすると、例えば低性能な PC なんかでなかなか「メインスレッドがアイドル状態」と判定されず、処理開始が無限に遅れてしまう可能性もある。そこで、第2引数で timeout: 2000 と設定している。コレは「アイドル状態になったら…とは言ったものの、2000ms (2秒) 経過しても実行されていないようだったら、忙しくてもその時点で必ず実行してね」という指定だ。
- あえて
setTimeoutで比較するなら、setTimeout({}, 2000);と書いたのと同様に、「2秒後には必ず実行される」ワケである - ただし前提として、
requestIdleCallbackは「メインスレッドが忙しくなくなったらその場で実行」であり、setTimeoutのように固定値で遅延させるのとは柔軟性が違う
こうして requestIdleCallback でラップした「遅延させたい初期処理」では、まず gtm.js のコードを読み込むための処理をしている。インラインコードで短縮・難読化されていたコードと同じことをしている。GTM は window.dataLayer という配列のグローバル変数を作っていて、そこに「どんなイベントが発生しました」という通知を突っ込んでおくことで、gtm.js がそれを監視して処理する仕組みになっている。gtm.js のロードには変わらず async 属性を付けているものの、コレでひとまず、「head 要素に登場した瞬間から gtm.js をロード」から、「DOMContentLoaded イベント後、requestIdleCallback で待機された後に gtm.js をロード」という風に、初期読み込み自体を遅延させることができている。
イベントリスナーによる、さらなる遅延処理
そしてココからがイベントリスナーによる、さらなる遅延処理となる。
let isTriggered などの定義を一旦すっ飛ばして、先にココを見る。
const listenerTypes = ['scroll', 'keydown', 'touchstart', 'mousemove'];
listenerTypes.forEach(type => window.addEventListener(type, triggerFunction, { passive: true }));
配列をベタ書きに直すと、こういうことをしている。
window.addEventListener('scroll' , triggerFunction, { passive: true });
window.addEventListener('keydown' , triggerFunction, { passive: true });
window.addEventListener('touchstart', triggerFunction, { passive: true });
window.addEventListener('mousemove' , triggerFunction, { passive: true });
スクロールが発生した、キー押下された、タッチ操作が始まった、マウスが動いた、のイベントごとに、triggerFunction という関数を実行するようにイベントを登録している。
passive: true は、動作がカクつくことを防ぐための指定。正確には、「ココで登録した関数 (triggerFunction) は event.preventDefault() を実行しませんよ」という意思表明になる。preventDefault() は、そのイベントによる本来の動作をキャンセルするモノなので、例えば「スクロールイベントを検知したけど、スクロールをあえて封じる」みたいなことが出来てしまう。passive: true と書くことで、「そういうことは絶対しません。もし関数内に event.preventDefault() が書いてあったとしても、ブラウザはそれを無視していいです」と表明できる。コレによりブラウザは「ただ関数を実行すればいい」と判断できるようになって、スクロールやキー操作などのイベントを関数が邪魔しなくなり、動作がカクつくことを防げる。
指定した4つのイベントは、「ユーザがページを読み込んだ後に最初に行う動作のどれか」に該当するようになっている。クリックの前には mousemove ないしは touchstart が必ず動くし、「Tab キーでページ内の要素を移動する」ような操作も keydown や scroll イベントが発火する。逆にいえば、「ページを開いた瞬間から、ウィンドウ外にマウスを置いておくなどして一切の操作をしない」という行動を取ると、triggerFunction はいつまでも実行されないことになる。まぁどのイベントも発生しないなら、ユーザとしてはファーストビューの瞬間から一切ページを見ていないと判断できるので、コレでいい。
んで、マウス操作なりタッチ操作なりがありました、となると triggerFunction が実行される。この関数の先頭では、フラグ変数を読み取って triggerFunction 関数が2回以上実行されないように制御している。
let isTriggered = false;
const triggerFunction = () => {
// 2回目以降に実行された場合は、ココでフラグを見て処理を抜ける
if(isTriggered) return;
// 1回目の発火では上のガード節をすり抜けるので、ココでフラグを立てる
isTriggered = true;
// イベントを解除する
removeListeners();
// 以降、処理…
};
removeListeners() 関数は、先程 window.addEventListener() した4つのイベントを全部解除する処理。addEventListener() には once: true という「1回実行したら removeEventListener() を呼ばなくてもイベントが解除される」オプションがあるものの、今回は4つのイベントのどれかが1回だけ実行されれば良い、という条件なので、once: true 任せにしてしまうと最大で4回、同じ triggerFunction の呼び出しが発生してしまって無駄が多い。なので予め関数化しておいた removeListeners() を使って、1回到達したら4つ全てのイベントを明示的に解除しているワケだ。
で、triggerFunction 内でやっていることは以下のとおり。
// GTM に画面操作があったことを通知する
window.dataLayer.push({ event: 'user_interacted' });
// Google AdSense 相当のコードを挿入する
let adsenseScript = document.createElement('script');
adsenseScript.crossOrigin = 'anonymous';
adsenseScript.src = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX';
document.head.appendChild(adsenseScript);
GTM の管理画面で、トリガーを「All Pages」や「Window Loaded (ウィンドウ読み込み)」ではなく、「カスタムイベント」というモノに変更して、イベント名を「user_interacted」に設定しておく必要がある。コレをやらないと意味がない。
カスタムイベントをトリガーにしておくと、window.dataLayer.push({ event: 'user_interacted' }); というコードが出てきた時に初めて gtm.js がさらなる外部コードをロードし始めるタイミングとして設定できる。今回でいえば、この時に初めて Google Analytics や Clariry といったアクセス解析用のスクリプトがロードされ始める。開発者コンソールの「Network」タブで実際にどんなファイルのロードが始まるか、動きを見てみると分かりやすい。
そして GTM のついでに Google AdSense の自動挿入広告用のスクリプトも、ココで初めて読み込んでいる。ということはつまり、「ページを開いた瞬間から、マウス操作などを一切行っていない」というファーストビューではまだ triggerFunction 関数が発火していないので、Google AdSense の広告は表示されない、というか AdSense が広告を自動挿入するための JS ファイル自体がロードされていない、という状態にできるワケだ。ユーザがスクロール操作などを始めた時にようやく JS ファイルを読み込んで、そこから広告の挿入が始まることになる。PageSpeed Insights のパフォーマンススコアの観点ではこの遅延処理が一番効果が高かった。
- ちなみに、
document.createElement()とappendChild()で要素を挿入しているが、コレはinsertAdjacentHTMLよりも高速であるため insertAdjacentHTMLやinnerHTMLなどは HTML の文字列をそのまま投げ込めるので、実装側としては可読性が高いのだが、実行パフォーマンスの観点では「ブラウザが文字列を HTML・DOM としてパースする」処理が増えるため、比較して動作が重く・遅くなるので今回は避けている
コレで PageSpeed Insights のパフォーマンススコアが100点になった
DOMContentLoaded 後に、requestIdleCallback でさらに遅延。そのうえでさらに、ユーザ操作のイベントを監視して、何らかの画面操作があったらようやくお目当ての処理を実行する、という徹底した遅延実行を実装した。
この遅延実行の処理は GTM・AdSense の遅延読み込みとしてサイト全体に適用した他、トップページに関してはページ下部に表示されるアクセうカウンタやバナー画像の表示を遅らせるためにも利用している。トップページでの HTML 挿入は insertAdjacentHTML を使って横着しているが、レイアウトのために class 指定が必要なので、HTML 構造が目視できる方が間違いにくいため insertAdjacentHTML にしている。それに、挿入する HTML には <img> 要素が複数あり、画像ファイルを複数読み込むので、総処理コストが高いのはもう仕方ないとして割り切っている。画像には <img loading="lazy"> と Lazy 指定を書いてさらに誤魔化してあるおかげもあり、自分が試した感じでは特に動作不備はなく上手く行っている。
以上。