🐠

【Next.js 11】next/script には JavaScript の基本がつまっていた

2021/06/17に公開

修正(2021/06/17)
ツイッターでご指摘をいただき、一部修正を加えました🙇

はじめに

2021/06/16 未明に Next.js の新メジャーバージョン v11 がリリースされました。
https://nextjs.org/blog/next-11

ほぼ同じタイミングで Next.js Conf (Next.js のカンファレンス)が開催されており、Zenn ユーザの中にはリアルタイムで見ていた人も多いのではないでしょうか。

Core Web Vitals をはじめとした 、パフォーマンス改善に関する話題や新機能が多く、Google のチームが Next.js で最適化のトライを行いながら、Nuxt や Angular に反映していくというのが印象的でした。最先端の取り組みが、普段メインで使用している Next.js で行われているということで、非常に嬉しい限りです。
https://web.dev/conformance/

Next.jd 11 全体のまとめは今後誰かが書いてくれると思いますので、この記事では新機能の一つである next/script に限定して、ソースコードを交えながら解説していきます。Script Optimization

next/script のソースコードはシンプルに書かれていて読みやすい上に、JavaScript(Node) がジョブを実行する仕組みを学習・復習できるので、普段 Next.js 以外を書いている人も是非読んでみると良いと思います。

モチベーション

基本的にウェブサイト・ウェブページというものは、製作者自身が書いたコード単体で成り立っていることは少なく、3rd パーティーのスクリプトを読み込んでいることが大半です。
例えば、ユーザのエンゲージメントを計測するためにほとんどの方が Google Analytics を導入していると思います。 また、問い合わせ対応のために HubSpot を入れていたり、広告成果計測のために Google/Yahoo/Facebook などの広告用のタグを入れているということが当たり前になっています。

これらのスクリプトそのものはビジネスを最適化するために非常に重要です。しかし、サイトの読み込みパフォーマンスの面ではマイナス要因になることが多く、LCP低下によりユーザ体験を損ねてしまう可能性があります。私自身もパフォーマンス改善活動の中で、何度も外部スクリプトによって苦しめられてきました。

今回新しく提供される、next/script はその問題の改善をサポートするものです。
しかし、開発者自身が使用を誤れば、期待した効果が出ないどころか、ビジネス面でマイナスになることもあり得ます。エンジニアたるもの挙動や仕組みを把握しておくことが大切です

next/script

まず、next/script の使用例はこんな感じです。

import Script from 'next/script'
// 省略

<Script
  src="https://polyfill.io/v3/polyfill.min.js?features=Array.prototype.map"
  strategy="beforeInteractive"
/>

一見、普通のスクリプトタグのよう見も見えますが、strategy という props が出現しています。
取りうる値は下記の3つで、それぞれの意味を公式のブログから日本語訳して引用させていただきます。Script Optimization

  • beforeInteractive:ボットの検出や同意管理など、ページがインタラクティブになる前にフェッチして実行する必要がある重要なスクリプトの場合。これらのスクリプトは、サーバーから最初のHTMLに挿入され、セルフバンドルされたJavaScriptが実行される前に実行されます。
  • afterInteractive(デフォルト):タグマネージャーや分析など、ページがインタラクティブになった後にフェッチして実行できるスクリプトの場合。これらのスクリプトはクライアント側に挿入され、ハイドレーション後に実行されます。
  • lazyOnload: チャットサポートやソーシャルメディアウィジェットなど、アイドル時間中にロードを待機できるスクリプトの場合。

上から順番に、ページが表示される前ページが表示された直後ページが表示されてアイドル状態になった後に、スクリプトがロードされると覚るとよいかと思います。

また、onLoad にコールバックを渡して、スクリプトのロードが完了してから、実行する処理も書けます。

ソースコードを読む

その前に

早速実装を確認したいところですが、ちょっと待ってください。ソースコードを読む前に実装方法を想像し、答え合わせをしながら読むようにすると良いです。いざ自分が同じような実装をしなければいけない時に、その思考力・想像力が大切になってきます。

私の場合は次のように予想しました。

  • メイン処理は、createElement + appendChild
    • React で 外部スクリプトを読み込むときは、<script />をレンダーしても、ソースのダウンロードが行われない。結構一般的
  • afterInteractive のときは、上の処理を useEffect 内で行う
    • 「ハイドレーション後」とドキュメントにかかれているので、マウントを検知して実行されているはず
  • lazyOnload は useEffect 内で setInterval を実行し、アイドル状態になるまで数ミリ秒間隔で計測を行って、アイドル状態になったら上の処理を実行
    • ただ、何をもってアイドル状態とするのかはわからない。。。
    • しかもポーリングは効率悪そう
  • beforeInteractive は useEffect の外に書く
    • コンポネント読み込み時に上の処理が実行されるようにして、ページが表示される前のロードを実現しているはず
  • Script が再レンダリングされることを考慮し、ロードを開始したかどうかのステートを持って重複ロードを抑制

ソースコードを読みすすめる

それでは実際のソースコードを読んで実装を確認していきましょう。コードはここに書かれています。
https://github.com/vercel/next.js/blob/canary/packages/next/client/script.tsx

解説しやすいように一部の記述を省略したり、順序を入れ替えています。

loadScript

冒頭の import や 型定義は読み飛ばして、まず目に入るのは、loadScriptです。
こちらが、script タグを生成し外部スクリプトをロードするための基本関数です。

loadScript()
const ScriptCache = new Map()
const LoadCache = new Set()

const loadScript = (props: Props): void => {
  // 省略 (props の展開)

  const cacheKey = id || src
  if (ScriptCache.has(src)) {
    if (!LoadCache.has(cacheKey)) {
      LoadCache.add(cacheKey)
      // Execute onLoad since the script loading has begun
      ScriptCache.get(src).then(onLoad, onError)
    }
    return
  }

  const el = document.createElement('script')

  const loadPromise = new Promise((resolve, reject) => {
    // 省略 (スクリプトをロードするためのプロミスの定義)
  })

  if (src) {
    ScriptCache.set(src, loadPromise)
    LoadCache.add(cacheKey)
  }

  if (dangerouslySetInnerHTML) {
    // 省略 (dangerouslySetInnerHTML にスクリプトが渡されたらエスケープせずに付与)
  } else if (src) {
    el.src = src
  }

  for (const [k, value] of Object.entries(props)) {
    if (value === undefined || ignoreProps.includes(k)) {
      continue
    }

    const attr = DOMAttributeNames[k] || k.toLowerCase()
    el.setAttribute(attr, value)
  }

  document.body.appendChild(el)
}

id もしくは src をキャッシュのキーとし、対象のスクリプトがロード済みであるかどうかを判定しています。ロード済みであれば処理を中断して、重複ロードを避けています。

  const cacheKey = id || src
  if (ScriptCache.has(src)) {
    if (!LoadCache.has(cacheKey)) {
      LoadCache.add(cacheKey)
      // Execute onLoad since the script loading has begun
      ScriptCache.get(src).then(onLoad, onError)
    }
    return
  }
  // 省略
  if (src) {
    ScriptCache.set(src, loadPromise)
    LoadCache.add(cacheKey)
  }

重複ロードの抑制に関しては、コンポネント内のステートで管理することを予想していたので、いきなり予想が外れました。
ソースコードのような実装では、キャシュをコンポネント間で共有できるので、同じ src の Script が誤って記述されたとしても、重複ロードを抑制できます。また、何らかの理由でコンポネント内のステートがリセットされても、同じように問題を回避できます。
ということは Link で遷移した先でもスクリプトを読み込む必要がなくなるわけです。
すごくよく考えて実装されています。

続いて、script タグの生成です。
ここは予想があたっていました。基本的に React で script タグを生成してスクリプトをロードする場合には、この createElement + appendChild の方法が取られます。

const el = document.createElement('script')
// 省略
for (const [k, value] of Object.entries(props)) {
  if (value === undefined || ignoreProps.includes(k)) {
    continue
  }

  const attr = DOMAttributeNames[k] || k.toLowerCase()
  el.setAttribute(attr, value)
}

document.body.appendChild(el)

loadLazyScript

続いて loadLazyScript です。この関数は strategy="lazyOnload" と指定したときに処理される関数で、内部では前述の loadScript をコールしています。

function loadLazyScript(props: Props) {
  if (document.readyState === 'complete') {
    requestIdleCallback(() => loadScript(props))
  } else {
    window.addEventListener('load', () => {
      requestIdleCallback(() => loadScript(props))
    })
  }
}

requestIdleCallback に関しては非常に重要な処理であるため、後に詳しく解説します。ここでは一旦、アイドル状態を待ってからコールバックを実行する関数であると捉えてください。つまりアイドル状態になってから loadScript を呼び出しているのです。

document.readyStateloading => interactive => complete の3つの状態を遷移します。ここで出てくる complete は document と css や 画像などの読み込みが完了しており、load イベントが発火する直前の状態を指します。
https://developer.mozilla.org/ja/docs/Web/API/Document/readyState

つまり、loadLazyScript 実行時点でページの読み込みが完了していれば、そのまま requestIdleCallback を実行。完了していなければイベントリスナを登録してページの読み込みの完了を待って、requestIdleCallback を実行します。

Script Component

続いてコンポネント本体の実装です。

Script
function Script(props: Props): JSX.Element | null {
  // 省略 (propsの展開)

  // Context is available only during SSR
  const { updateScripts, scripts } = useContext(HeadManagerContext)

  useEffect(() => {
    if (strategy === 'afterInteractive') {
      loadScript(props)
    } else if (strategy === 'lazyOnload') {
      loadLazyScript(props)
    }
  }, [props, strategy])

  if (strategy === 'beforeInteractive') {
    if (updateScripts) {
      scripts.beforeInteractive = (scripts.beforeInteractive || []).concat([
        {
          src,
          onLoad,
          onError,
          ...restProps,
        },
      ])
      updateScripts(scripts)
    }
  }

  return null
}

予想通り、afterInteractivelazyOnload は useEffect の中で実行されていました。それぞれ、前述の loadScriptloadLazyScript を呼び出し、マウント後にスクリプトをロードしています。
props を deps に入れているので、もし何らか処理により src が変わることがあれば、そのタイミングでもスクリプトの読み込みが行われることになります。

  useEffect(() => {
    if (strategy === 'afterInteractive') {
      loadScript(props)
    } else if (strategy === 'lazyOnload') {
      loadLazyScript(props)
    }
  }, [props, strategy])

beforeInteractive も予想通り、useEffect 外で処理が実行されていました。
ただし冒頭のコメントにもある通り、この処理に使用される HeadManagerContext.Provider はサーバサイドでしか出現しません。updateScripts の解説はここでは行いませんが、サーバサイドでの HTML 生成時に、head 内に対象の script タグを登録してくれます。そうすることでクライアント側で HTML の解析をするときにスクリプトのロードが実行されます。

  // Context is available only during SSR
  const { updateScripts, scripts } = useContext(HeadManagerContext)
  // 省略

  if (strategy === 'beforeInteractive') {
    if (updateScripts) {
      scripts.beforeInteractive = (scripts.beforeInteractive || []).concat([
        {
          src,
          onLoad,
          onError,
          ...restProps,
        },
      ])
      updateScripts(scripts)
    }
  }

next/script 本体の実装はこれで終わりです。シンプルな実装になっていることが伺えました。

requestIdleCallback

loadLazyScript の解説で requestIdleCallback をあえて飛ばしました。この関数がアイドル状態を待って処理を実行するという重要な関数です。
「setInterval を用いて数ミリ秒間隔でポーリングし、アイドル状態(正体不明)を検知後にインターバルを打ち切って本来の処理を実行する」というのが私の予想ですが、実際はどうでしょうか?

requestIdleCallback の実装は別のファイルで行われています。
https://github.com/vercel/next.js/blob/canary/packages/next/client/request-idle-callback.ts

export const requestIdleCallback =
  (typeof self !== 'undefined' && self.requestIdleCallback) ||
  function (
    cb: (deadline: RequestIdleCallbackDeadline) => void
  ): NodeJS.Timeout {
    let start = Date.now()
    return setTimeout(function () {
      cb({
        didTimeout: false,
        timeRemaining: function () {
          return Math.max(0, 50 - (Date.now() - start))
        },
      })
    }, 1)
  }

少し複雑に見えますが、重要なところだけを抽出するとこんなな感じです。

const requestIdleCallback = function (callback) {
  return setTimeout(callback, 1)
}

予想は見事に外れていました。使用されているのは setTimeout でした。しかも、タイムアウトは 1ms であり、渡されたコールバックをそのまま実行しているだけです。
これでアイドル状態を検知しているとは思えません。「requestIdleCallback とは名ばかりで、ほんのわずか一瞬だけ待ってコールバックを実行しているだけじゃないか。しかもコールバックを呼び出すときに残り時間を計算しているが、常に 49msでしょ!?」と考えてしまうのですが、実はこれが非常に奥深く、そして賢くアイドル状態を待つ方法なのです。

これを理解するには、JavaScript がどのようにジョブを処理しているのか理解する必要があります。

JavaScript のジョブの扱いと setTimeout

JavaScript はスレッドを一つしか持ちません(Web Workers稼働時を除く)。しかし、実際には Promise や asycn によって非同期処理が可能になっています。これはマルチスレッドで処理を行っているわけではなく、メインスレッドで処理を開始した後にキューに退避させ、処理完了後にもう一度メインスレッドに戻させていることで実現しています。

こちらの記事で解説されているので、詳しく知りたい方は読んでみてください。わかりやすく図解されています。
https://qiita.com/ryosuketter/items/dd467f827c1b93a74d76

setTimeout も実行と同時にキューに退避されます。その後指定秒数後にメインスレッドに戻させて、コールバック関数を実行することで、指定時間待ったように振る舞わせます。
しかし、キューに退避させた処理がメインスレッドに戻る際に、割り込みができるわけでは有りません。メインスレッドが何らかの処理で専有されているのであれば、その処理の完了を待つ必要があるのです。これに関しては Node.js のドキュメントでも解説されています。

設定されたタイムアウト間隔は、 その正確なミリ秒数の後に実行することに依存することはできません。 これは、イベントループをブロックまたは保留している他の実行コードがタイムアウトの実行を遅らせるためです。 唯一保証されているのは、タイムアウトが宣言されたタイムアウト間隔より早く実行されないということです。

https://nodejs.org/ja/docs/guides/timers-in-node/

CPU がせかせかと忙しく処理している間は、タイムアウトを 1ms に設定していたとしても、その時間通りにコールバックを実行することができません。

requestIdleCallback は、キューからの復帰が可能 = メインスレッドに処理がない期間 = アイドル状態と定義し、setTimeout で強制的にメインスレッドから外れて、次にメインスレッドに戻ってこれる最短のタイミングでコールバックを実行するだけです。

結果的に、無駄のないアイドル状態の検知になっていることがわかりました。

修正(2021/06/17)

https://twitter.com/ka2n/status/1405375822114869249

Twitter にて 上の loadLazyScript そのものは polyfill であることを教えていただきました。
無意識に読み飛ばしてしまっていましたが、定義の冒頭はこのような記述になっていますので、確かに上の実装は polyfill でした。

export const requestIdleCallback =
  (typeof self !== 'undefined' && self.requestIdleCallback) ||
  function (

モダンブラウザではデフォルトのAPIに組み込まれております。
https://developer.mozilla.org/ja/docs/Web/API/Window/requestIdleCallback

window.requestIdleCallback() メソッドを利用すると、ブラウザーがアイドル状態の時に実行される関数をキューに登録できます。これにより、アニメーションや入力への応答など、遅延が問題となる処理に影響を与えることなく、優先度の低いバックグラウンド処理をメインスレッド内で実行させられます。キューに登録された関数は、関数登録時に設定したタイムアウト時間(timeout)に達していない限り、登録順に呼び出されます。

こんな便利なAPIがあったとは。。。知らなかったのでお恥ずかしい限りです。
内部実装まではわかりませんが、コンセプト自体は一致していますので、おそらく処理の構造も似ていると思われます。

@ka2nさん、ご指摘いただきありがとうございます🙇

next/script の実装まとめ

  • メインは createElement + appendChild で script タグを生成&挿入する処理
  • beforeInteractive: サーバサイドでのみ処理され、最初の HTML に script タグが挿入される
  • afterInteractive: useEffect 内で script タグの生成&挿入を行う
  • lazyOnload: は useEffect 内でページの表示 + アイドル状態を待つ処理をはさみ、タグの生成&挿入を行う
    • readyState === 'complete' と 1ms の setTimeout
  • Script の再レンダリング、同一 src のコンポネントの発生に備え、 id もしくは src でキャシュすることで、ページ全体を通じて、外部スクリプトの重複ロードが抑制されている。

補足

requestIdleCallback の説明であえて読み飛ばしましたが、実際には、コールバックに対して次のようなオブジェクトを引数に渡して実行しています。

timeRemaining: function () {
  return Math.max(0, 50 - (Date.now() - start))
},

どういったシーンでこの timeRemaining を使用するかは読み取れませんでしたが(Next.js プロジェクト全体を検索しても、これを使用しているところはなさそう)、おそらくデバッグ用のユーティリティだと思います。
この 50(ms) を基準に残り時間を計算するのには意味があります。
https://web.dev/long-tasks-devtools/
google は 50ms 以上スレッドを占有する処理を Long Task と定めており、これが発生しないように最適化することを推奨しています。
つまり、requestIdleCallback を発動してから、timeRemaining() の返り値が 0 であった場合、遅く長いタスクによってスレッドが専有されていた、ということを示すためのユーティリティであると思われます。

GitHubで編集を提案

Discussion