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 AdSense のコードに関しては、「自動挿入広告」機能にお任せにしているモノだ。script 要素には async 属性が付いているので、JS コードはバックグラウンドで読み込み、ロードが完了したら (DOMContentLoaded イベントを待つことなく) 即時実行される、という発火タイミングになる。

というワケで、両方とも async 属性を付けて外部ドメインから JS ファイルを読み込むという処理であり、ページの初期読み込み中に発生するタスクが多いことは分かる。

defer・requestIdleCallback・イベントリスナーを併用してできるだけ遅延させる

今回の対処法としては、以下のとおり。

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秒) 経過しても実行されていないようだったら、忙しくてもその時点で必ず実行してね」という指定だ。

こうして 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 のパフォーマンススコアの観点ではこの遅延処理が一番効果が高かった。

コレで PageSpeed Insights のパフォーマンススコアが100点になった

DOMContentLoaded 後に、requestIdleCallback でさらに遅延。そのうえでさらに、ユーザ操作のイベントを監視して、何らかの画面操作があったらようやくお目当ての処理を実行する、という徹底した遅延実行を実装した。

この遅延実行の処理は GTM・AdSense の遅延読み込みとしてサイト全体に適用した他、トップページに関してはページ下部に表示されるアクセうカウンタやバナー画像の表示を遅らせるためにも利用している。トップページでの HTML 挿入は insertAdjacentHTML を使って横着しているが、レイアウトのために class 指定が必要なので、HTML 構造が目視できる方が間違いにくいため insertAdjacentHTML にしている。それに、挿入する HTML には <img> 要素が複数あり、画像ファイルを複数読み込むので、総処理コストが高いのはもう仕方ないとして割り切っている。画像には <img loading="lazy"> と Lazy 指定を書いてさらに誤魔化してあるおかげもあり、自分が試した感じでは特に動作不備はなく上手く行っている。

以上。