ABEMA Web では、Polyfill をブラウザごとに出し分けるため Financial Times が提供している polyfill-library を使っています。
polyfill-library を使うようにしたことで、タイトルの通りモダンブラウザでは Polyfill のコードを 99 % 削減できました。

この記事では、なぜ Polyfill の出し分けに polyfill-library を使うようにしたのか、適用後にどういう結果が得られたかを書きます。

Polyfill の出し分けを実装する前の状態

まずは Polyfill の出し分けを実装する前の、ABEMA Web における Polyfill の配信について説明します。

以前は、Polyfill の出し分けをおこなわず、全てのブラウザで同じ Polyfill を読み込むようにしていました。

具体的にはエントリーポイントとなるファイルで polyfill.jspolyfill_ie11.js をインポートしていました。

インポートしていた Polyfill は次の通りです。

この中で Element.prototype.matches は特定のパッケージを使っていたわけではなく、自前で Polyfill を書いてました。

// Element.prototype.matches の Polyfill
if (!Element.prototype.matches) {
  Element.prototype.matches =
    Element.prototype.msMatchesSelector ||
    Element.prototype.webkitMatchesSelector;
}

Polyfill を一律で全てのブラウザで読み込むようにしていたため、次の問題が起こっていました。

  • ネイティブ実装の関数を使って良い環境でも Polyfill が実行されていた
  • バンドルした JavaScript に Polyfill を含むため、バンドル後の JavaScript のサイズが膨らんでいた
  • 複数の Polyfill を使っているため、管理がやや大変だった

Polyfill について理想状態の定義

抱えていた複数の問題を解消すべく、ABEMA Web における Polyfill の理想状態を次のように定義しています。

  • Polyfill を別ファイルにして各ブラウザごとに出し分けることで、モダンブラウザにおいて配信される JavaScript のサイズを減らす
  • Polyfill を一元管理できるようにする
  • サービスの SLI / SLO を Polyfill にも適用できる
  • できるだけコストがかからないようにする

それぞれの理想状態の詳細は次の通りとなります。

Polyfill を別ファイルにして各ブラウザごとに出し分けることで、モダンブラウザにおいて配信される JavaScript のサイズを減らす

今までエントリーポイントのファイルで各 Polyfill を読み込み、それらの Polyfill が webpack の設定で vendor.bundle.js というファイルにまとめられていました。
これによりモダンブラウザでは必要のないコードが vendor.bundle.jsに含まれてしまい、バンドルしたファイルの肥大化と必要のない Polyfill の実行がおこなわれていました。

これらを解消することで、コードを読み込んでから実行するまでの時間を早める狙いがありました。

Polyfill を一元管理できるようにする

今まで Polyfill 用のライブラリを更新するときに、各 Polyfill ライブラリの更新情報を見る必要がありました。
Polyfill 用のライブラリの更新情報をそれぞれ見るのは正直大変でした。

なので、こういった確認や管理をまとめて簡単にできる方法を探しました。

サービスの SLI / SLO を Polyfill にも適用できる

ABEMA はテレビ&ビデオエンターテイメントを標榜しています。視聴者が見たいと思っている番組や動画を見ている最中にサービスが落ちてしまうのは避けないといけません。
そのため ABEMA が掲げている SLI / SLO に準拠できる仕組みを採用する必要がありました。

できるだけコストがかからないようにする

新たに導入した仕組みによって、サーバーのネットワーク転送量が増えすぎたり、何らかの SaaS を使って料金がかかりすぎたりしまうことは避けたいと考えていました。

こうした理想状態を出した上で、これらを満たすためにいくつか方法を検討しました。

Polyfill の出し分けを実装するにあたって検討したもの

Polyfill の出し分けの実装をするにあたって、次の 3 通りのやり方を検討しました。

  • Polyfill.io が提供している JavaScript を読み込む
  • ブラウザごとに bundle する Polyfill を変更する
  • polyfill-library を使う

それぞれについて利点・欠点を並べた上で採用しなかった理由を書きます。

Polyfill.io が提供している JavaScript を読み込む

Polyfill.io が提供している JavaScript を読み込む形式です。

https://polyfill.io/v3/polyfill.min.js もしくは、https://polyfill.io/v3/url-builder/ から bundle したい Polyfill を選択して生成された URL を script 要素の src 属性に指定する方法です。

利点

この方法は理想状態に挙げたことをほとんど解決できます。

  • Polyfill を各ブラウザごとに出し分けできる
  • モダンブラウザでは無駄な Polyfill を読み込まなくて済む
  • Polyfill 用に webpack で生成していた bundle を作らなくて済むので、ビルド周りが簡潔になる
  • 読み込ませたい Polyfill を変更したいときは URL の一部分を書き換えるだけでいいので管理が楽になる
  • 読み込ませる Polyfill をカスタマイズしたいときは https://polyfill.io/v3/url-builder/ を使ってカスタマイズできる

欠点

一見良さそうに見えますが、この方法には Polyfill.io が SLA を提供していないという欠点があります。

そのため、Polyfill.io がなんらかの障害で使えなくなったとしても Financial Times 側は関知しないことになります。
Polyfill.io は無料で提供しているサービスなので SLA や SLI / SLO を求めるのは酷でしょう。

しかし古いブラウザで ABEMA にアクセスした場合、Polyfill.io が落ちている間は意図しない挙動になると見込まれます。場合によっては視聴障害に繋がる可能性もあります。

ここで地上デジタル放送などを提供している事業者の話をすると、総務省の放送設備における安全・信頼性の確保という PDF の資料で、地上デジタル放送などを提供している事業者は 15 分以上放送停止した場合に、総務大臣に放送停止した旨とその理由を報告しなければならないと書かれています。

これを SLI に落とし込むと「1 年のうち設備に起因する放送の停止やその他重大な事故が起きなかった時間の割合」になると思います。
この SLI を SLO として表した場合は (総配信時間 − 無効配信時間) / 総配信時間 となるでしょう。

この SLO を ABEMA に当てはめてみると、ABEMA は 24 時間 365 日何かしらの放送をしているため (525600 - 15) / 525600 という式になります。
つまり地上波デジタル放送品質で放送する場合に SLO は 99.99715% 以上にしなくてはいけないことになります。

ここから SLA や SLI / SLO を明示していないサービスを使うのは品質面でリスクが高いと判断し、選択肢から外しました。

ブラウザごとに bundle する Polyfill を変更する

各ブラウザの API の対応状況を見て、適宜 Polyfill を追加・削除する形式も考えました。ただ、この方法は次の欠点があります。

  • ビルド周りがより複雑になる
  • サービスのブラウザ対応状況に合わせて、API の対応状況を確認して Polyfill を追加・削除しないといけないので手間が増える

「ABEMA に特化した Polyfill を作れる」「ABEMA の SLI / SLO を適用できる」という点は利点ですが、無視できない欠点があるためこの手段は取らないことにしました。

polyfill-library を使う

Financial Times が提供している polyfill-library を使って、BFF 側で Polyfill 用のコードを生成しそれを返すようにする方式です。

polyfill-library を使う方法は、今までの「Polyfill.io が提供している JavaScript を読み込む」や「ブラウザごとに bundle する Polyfill を変更する」といった方法と比較すると、金銭コスト以外では他の選択肢より優位であると判断しました。

機能 Polyfill.io を使う bundle する Polyfill の変更 polyfill-library を使う
ブラウザごとに Polyfill の出し分け
ABEMA の SLI / SLO 適用 ×
ビルド周りの簡略化 ×
管理の容易性 ×
金銭コスト

これらを踏まえて polyfill-library を使って Polyfill の出し分けをすることが最適解だと確信しました。

Polyfill の出し分けの実装方法

最初に実装したコードの全体像を見せると次の通りになります。それぞれの変数や関数についての説明は後ほどおこないます。

import crypto from "crypto";

import { Response, Request } from "express";
import { getPolyfillString } from "polyfill-library";
import polyfillLibraryPackageJson from "polyfill-library/package.json";

const FEATURES_OPTION = { flags: ["gated"] };

const FEATURES = {
  es2015: FEATURES_OPTION,
  es2016: FEATURES_OPTION,
  es2017: FEATURES_OPTION,
  es2018: FEATURES_OPTION,
  "Element.prototype.matches": FEATURES_OPTION,
  IntersectionObserver: FEATURES_OPTION,
  ResizeObserver: FEATURES_OPTION,
  URL: FEATURES_OPTION,
  fetch: FEATURES_OPTION,
  globalThis: FEATURES_OPTION,
};

let hashCache: string | null = null;

function generateHashForPolyfillJs(): string {
  if (hashCache !== null) {
    return hashCache;
  }

  const version = polyfillLibraryPackageJson.version ?? "unknown";
  const featureVector = {
    version,
    features: FEATURES,
  };
  const featureVectorStr = JSON.stringify(featureVector);
  const hash = crypto
    .createHash("sha256")
    .update(featureVectorStr, "utf8")
    .digest("hex");
  const hash20 = hash.slice(0, 20);

  hashCache = hash20;

  return hash20;
}

export function createPathForPolyfillJs(disableHashing = false): string {
  if (disableHashing) {
    return "/polyfill.js";
  }

  const hash = generateHashForPolyfillJs();
  return `/polyfill.${hash}.js`;
}

function isRequestedJsOutdated(requestedHash?: string): boolean {
  if (requestedHash === undefined || requestedHash === "") {
    return true;
  }

  const currentHash = generateHashForPolyfillJs();
  const isOutdated = currentHash !== requestedHash;

  return isOutdated;
}

export function getNormalizedUserAgent(req: Request): string {
  const uaString =
    req.header("Normalized-User-Agent") ?? req.header("User-Agent") ?? "";
  return uaString;
}

export async function polyfillHandler(req: Request, res: Response) {
  const hash = req.params.hash;

  if (isRequestedJsOutdated(hash)) {
    res.status(404).send("");
    return;
  }

  const uaString = getNormalizedUserAgent(req);

  getPolyfillString({
    uaString,
    minify: true,
    features: FEATURES,
  })
    .then((bundleString: string) => {
      res.type("application/javascript");
      res.status(200).send(bundleString);
    })
    .catch(() => {
      res.status(404).send("");
    });
}

polyfill-library のオプション指定

polyfill-library に渡すオプションを指定している箇所では、ABEMA Web で使われている機能だけを Polyfill するようにオプションを設定しています。

features のオプションとして { flags: ['gated'] } を追加していますが、これはPolyfill.io の API Reference に書かれている通り、ブラウザにネイティブ API の実装がない場合のみ、Polyfill を実行するオプションです。これを指定することで、不要な Polyfill の実行を抑制できます。

const FEATURES_OPTION = { flags: ["gated"] };

const FEATURES = {
  es2015: FEATURES_OPTION,
  es2016: FEATURES_OPTION,
  es2017: FEATURES_OPTION,
  es2018: FEATURES_OPTION,
  "Element.prototype.matches": FEATURES_OPTION,
  IntersectionObserver: FEATURES_OPTION,
  ResizeObserver: FEATURES_OPTION,
  URL: FEATURES_OPTION,
  fetch: FEATURES_OPTION,
  globalThis: FEATURES_OPTION,
};

ハッシュ値周りの処理

Polyfill 用のファイル名に付けるハッシュ値を取得する

polyfill-library のバージョンや Polyfill する機能を元にハッシュ値を作り、生成された Polyfill のファイル名に付けます。
この仕組みについては MDN が HTTP キャッシュのページ内で Revving を適用したリソースという項目を設けて説明しています。

こうすることで次のメリットを得られます。

  • polyfill-library を更新したあとでもブラウザ側にキャッシュが残る問題を解消し、クライアント側で確実に最新の Polyfill が配信されるようにする
  • キャッシュの有効期限を長い時間にできる
function generateHashForPolyfillJs(): string {
  if (hashCache !== null) {
    return hashCache;
  }

  const version = polyfillLibraryPackageJson.version ?? "unknown";
  const featureVector = {
    version,
    features: FEATURES,
  };
  const featureVectorStr = JSON.stringify(featureVector);
  const hash = crypto
    .createHash("sha256")
    .update(featureVectorStr, "utf8")
    .digest("hex");
  const hash20 = hash.slice(0, 20);

  hashCache = hash20;

  return hash20;
}

リクエストされた Polyfill のファイル名に付いたハッシュ値と現在のハッシュ値を比較する

システム上、デプロイ中にデプロイ後のシステムからデプロイ前のシステムへリクエストが飛んでしまうことがあり、その結果デプロイ前の状態がキャッシュされ続けてしまう問題を防ぐための関数です。

この関数を使って、リクエストされたハッシュ値とレスポンスを返す時点でのハッシュ値が違う場合は、404 エラーにした上で空文字を返すようにしてキャッシュさせないようにしています。

function isRequestedJsOutdated(requestedHash?: string): boolean {
  if (requestedHash === undefined || requestedHash === "") {
    return true;
  }

  const currentHash = generateHashForPolyfillJs();
  const isOutdated = currentHash !== requestedHash;

  return isOutdated;
}

Express のルーティング部分とつなぎこみ

いよいよ Express 側のルーティング部分と Polyfill をつなぎこむための関数を紹介します。主にやっていることは次の 2 つです。

  • Polyfill のファイル名のハッシュ値に差分がないことの確認
  • 正規化したユーザーエージェントを元に Polyfill を配信
export async function polyfillHandler(req: Request, res: Response) {
  const hash = req.params.hash;

  if (isRequestedJsOutdated(hash)) {
    res.status(404).send("");
    return;
  }

  const uaString = getNormalizedUserAgent(req);

  getPolyfillString({
    uaString,
    minify: true,
    features: FEATURES,
  })
    .then((bundleString: string) => {
      res.type("application/javascript");
      res.status(200).send(bundleString);
    })
    .catch(() => {
      res.status(404).send("");
    });
}

「Polyfill のファイル名のハッシュ値に差分がないことの確認」については先ほど説明した通り、変なキャッシュを残さないように実行しているものです。

説明していないものとして、コードの途中に getNormalizedUserAgent() という関数があります。

ABEMA Web では Polyfill.io User Agent normaliser を使って Fastly 上でユーザーエージェントを正規化し、Normalized-User-Agent というヘッダーでオリジンサーバーに渡しています。
getNormalizedUserAgent() はこの Normalized-User-Agent ヘッダーを取得するためのものです。

ここまで読んで、polyfill-library の内部で polyfill-useragent-normaliser を使ってユーザーエージェントを正規化しているので、わざわざ Fastly 上でユーザーエージェントを正規化する必要はないと思われた方もいるかと思います。

ここでなぜ Fastly 上で正規化したヘッダーを BFF 側で使うようにしているのかを説明します。

ABEMA Web では CDN キャッシュにまつわるバグを防ぐため、BFF 上でコンテンツの出し分けをするときは、Vary に指定している値を参照するようにしています。

今回の場合は、ユーザーエージェントごとに Polyfill を出し分ける必要があるため、Vary に User-Agent を指定する必要があるでしょう。

しかし、Fastly が公開している Best practices for using the Vary headerという記事内で User-Agent を Vary に追加するのはキャッシュヒット率を下げるバッドプラクティスと書かれています。

そのため Normalized-User-Agent を Vary に指定して、polyfill-library に渡すようにして、キャッシュヒット率を犠牲にしない形で Polyfill の出し分けをおこなっています。

ただし、開発環境では Fastly を通していないため Normalized-User-Agent がヘッダーに付いていません。
そのため User-Agent ヘッダーにフォールバックする設定も書いています。

export function getNormalizedUserAgent(req: Request): string {
  const uaString =
    req.header("Normalized-User-Agent") ?? req.header("User-Agent") ?? "";
  return uaString;
}

HTML での読み込み

バックエンド側でルーティング設定した Polyfill をクライアント側で読み込むようにします。
クライアント側で Polyfill を読み込むときの注意点は、 polyfill.js を先頭で読み込むことと、全ての script 要素に defer 属性を指定することが挙げられます。

defer 属性がついた script 要素はドキュメントに出てきた順に読み込まれます

なので polyfill.js を先に読み込むことで、polyfill.js 以降のコードでは Polyfill が適用された状態でコードを実行できるようになります。

<script src="/polyfill.js" defer></script>
<script src="/vendor.js" defer></script>
<script src="/app.js" defer></script>

なお、Internet Explorer でも Can I use… 上でバージョン 10 以降であれば仕様に準拠していると書かれていて、実際に IE11 上で問題なく動いています。

Polyfill の出し分けを適用した結果

実際に Polyfill の出し分けを実装してみた結果を書きます。

アセットのサイズ

各ページごとの first-party JS (abema.tv から配信されている JavaScript で minified & gzipped されたもの) を測定した結果が次の通りです。
なおデータ自体は Polyfill の出し分けを適用した直後に測定したため、さまざまな変更が加えられた現在はサイズが上下している可能性が高いです。

ブラウザ(デスクトップ)

Page Before (byte) After (byte) Diff (byte) Diff (%)
entrance 656324 619868 -36456 -5.6
episode 1209304 1171727 -37577 -3.2
genre 697052 659706 -37346 -5.4
linear 980204 943929 -36275 -3.8
premium 696391 661358 -35033 -5.1
ranking 656324 619868 -36456 -5.6
search 656324 619868 -36456 -5.6
timetable 656324 619868 -36456 -5.6
video_top 1136994 723547 -413447 -36.4
account 1678219 1752425 +74206 4.4
about/terms 1607614 1681886 +74272 4.6

ブラウザ(モバイル)

Page Before (byte) After (byte) Diff (byte) Diff (%)
entrance 588145 550900 -37245 -6.4
episode 765542 727174 -38368 -5.1
genre 628575 590438 -38137 -6.1
premium 628212 592390 -35822 -5.8
ranking 613781 575558 -38223 -6.3
search 615642 578606 -37036 -6.1
timetable 588145 550900 -37245 -6.4
video_top 693232 654279 -38953 -5.7

全体的に見ると、約 34 〜 37 KB の削減に成功したことが分かります。一部のページで逆にバイト数が増加していたり、video_top (https://abema.tv/video) が大幅に減少していたりといった顕著な変化が見られますが、この期間中に出された Pull Request を追った限りではここまでの変化が起きた理由が分かりませんでした。

とはいってもアセットのサイズ削減は Polyfill だけとは限らず、他のコードの削除やリファクタリングなどによって削減できた可能性もあります。
なので Polyfill だけに注目して、どれだけサイズを減らせたかを見ていきます。

JavaScript の bundle size の削減量

まずは Polyfill の出し分けをおこなう前の状態を見てみましょう。node_modules 以下をまとめた bundle ファイルを見てみると core-js だけでも Gzipped の状態で 33.92 KB 配信されていることが分かります。

core-js だけでも Gzipped の状態で 33.92 KB 配信されている

core-js 以外の Polyfill も含めたサイズだとPolyfill の出し分けを適用する前の vendor.bundle.js は 259.22 KB になります。

変更前の vender.bundle.js のサイズ (259.22 KB)

ここから Polyfill の出し分けを適用した後の vendor.bundle.js のサイズは 232.26 KB になります。つまり vendor.bundle.js だけ見ても約 26 KB 減ったことになります。

変更後の vender.bundle.js のサイズ (232.26 KB)

また Polyfill の出し分けを実装したことで Google Chrome や Firefox では Polyfill が配信されなくなりました。

Chrome で見た polyfill.js の中身。コメント以外は何も書かれていない

Safari では Resize Observer の Polyfill が配信されていますが、polyfill-library 側で Safari の全てのバージョンに Resize Observer の Polyfill を適用する設定になっているため、polyfill-library 側に Issue や Pull Request を作って、今後は Safari でもPolyfill が配信されないようにしていきます。

Real User Monitoring (RUM) の数値

ユーザーがアクセスしたことで得られたデータを元に、自分たちで定義した指標がどのように変わったか前後比較をおこないました。
なお検証はユーザー数をサンプリングしているのと、ブラウザは ABEMA が推奨しているものに絞っています。

ブラウザ(デスクトップ)

ラベル 画像
適用前 onStartAppBootstrap 2.40秒, onStartFirstReactRender 4.50秒, pageLoaded 4.77秒, onCompleteFirstReactRender 4.73秒, onCompletePlayerLoading 6.05秒, onCompletePlayerInitialization 6.18秒, onReadyToStartPlayingVideo 8.04秒
適用後 onStartAppBootstrap 2.25秒, onStartFirstReactRender 4.63秒, pageLoaded 5.10秒, onCompleteFirstReactRender 4.68秒, onCompletePlayerLoading 6.03秒, onCompletePlayerInitialization 6.16秒, onReadyToStartPlayingVideo 8.20秒

デスクトップブラウザに関してはアプリケーションを起動するまでの時間は減っていますが、その後の指標に関してはあまり変わらない、もしくは遅くなっています。
ただし React の描画処理が終わる時間や動画を再生するまでの時間は変わりないため、問題ないレベルと言えます。

ブラウザ(モバイル)

ラベル 画像
適用前 onStartAppBootstrap 1.90秒, onStartFirstReactRender 4.02秒, pageLoaded 4.06秒, onCompleteFirstReactRender 4.18秒, onCompletePlayerLoading 5.12秒, onCompletePlayerInitialization 5.35秒, onReadyToStartPlayingVideo 9.78秒
適用後 onStartAppBootstrap 1.78秒, onStartFirstReactRender 3.85秒, pageLoaded 3.87秒, onCompleteFirstReactRender 4.02秒, onCompletePlayerLoading 5.02秒, onCompletePlayerInitialization 5.10秒, onReadyToStartPlayingVideo 9.08秒

モバイルはデスクトップブラウザと違い、全体的に大きく速度が向上しています。
これはモバイルのほうがデスクトップと比較して通信速度が遅い傾向にあるため、Polyfill のロードを減らせたのが顕著に効いたと言えそうです。

まとめ

今回の Polyfill の出し分けを実装をするにあたって、次の理想を掲げた上でこれらを満たすものを実装しました。

  • Polyfill を別ファイルにして各ブラウザごとに出し分けることで、モダンブラウザにおいて配信される JavaScript のサイズを減らす
  • Polyfill を一元管理できるようにする
  • サービスの SLI / SLO を Polyfill にも適用できる
  • できるだけコストがかからないようにする

古いブラウザへの対応も妥協せず、モダンブラウザにはより良い体験を提供するという点において、今回は成功だったと言えます。
実際にパフォーマンスが向上したことで、視聴ページ数・直帰率・5分視聴化率などが改善しました。

なお Polyfill の出し分けは 1 年以上運用していますが、今のところ特に問題が出ていません。

この記事が、SLI / SLO を重視する一方で手軽に Polyfill の出し分けをしたい場合に polyfill-library を使う選択肢を取れる助けになれれば幸いです。