🛫

Vueアプリケーションでオフラインで動画を再生できるようにする

2024/12/04に公開

こんにちは、RUN.EDGE株式会社の桑原です。

今回は以下の2つ

  • Cache API
  • Service Worker

を使って、オフラインで動画を再生する機能の実装方法の紹介をしたいと思います。

また、今回はサンプルとしてVueアプリケーションを使っています。Reactなど、他のフレームワークを利用する場合は適宜読み替えてください。

サンプルコードは以下においておきます。
https://github.com/kuwabataK/video-cache-sample/tree/main/vue-sample

Cache APIについて

Cache APIは、特定のURLのレスポンスをブラウザ内部にキャッシュとして保持する機能を持つAPIで、2015年頃からブラウザ実装され始めました。
今日ではモバイル端末を含むほとんどの環境で利用することができるAPIです。

Cache APIを使うと、動画を含んだあらゆるファイルリソースをブラウザのキャッシュストレージに保存することができます。
これによって、ネットワークが繋がっていない環境でもキャッシュストレージ上に保存されたリソースにアクセスできるようになったり、あるいは読み込みに時間がかかるファイルをあらかじめキャッシュしておくことによって、初期表示を高速にすることができます。

Cache APIの使い方については、
https://developer.mozilla.org/ja/docs/Web/API/Cache

https://chancodeblog.com/cache-api/
に詳しくが書かれていますが、仕様としては非常にシンプルで、主に

  • 指定したURLから取得されたデータをキャッシュに保存する
  • URLを指定してキャッシュデータを取得する
  • URLを指定してキャッシュデータを削除する

のような機能を提供しています。
Service Workerと合わせて使うことが多いこのCache APIですが、このAPI自体はService Worker関係なく単体で動作させることができます。

例えば以下のようなVueコンポーネントを書いてみます。

このVueのコンポーネントでは、

  • cacheVideo ボタンを押すことで、指定されたURL (ここでは自身と同じドメインに置かれたmp4ファイルのパス)のファイルをキャッシュに保存する
  • loadVideoボタンを押すことで、キャッシュされたデータをresponseとして取得し、ObjectURLに変換して、videoタグのsrcに設定し、mp4ファイルを再生できるようにする
  • deleteVideo ボタンを押すことでキャッシュされたデータを削除し、容量をあける

というような動作を行います。

chromeのdevtoolを使って、ネットワークタブを開くと、cache Videoボタンを押すことで、該当のmp4ファイルの取得処理が走っているのがわかるかと思います。

ここで取得されたファイルは、アプリケーションタブのキャッシュストレージを開くことで確認することができます。

loadVideoボタンを押すと、このキャッシュを読み込み、responseをObjectURLに変換してvideoタグのsrcに設定します。これによって動画を再生することができるようになります。

deleteVideoボタンを押すと、キャッシュストレージから、mp4ファイルのキャッシュが消えることが確認できると思います。

これらのAPIを使って、キャッシュ容量が膨らみすぎないように気を使いつつ、ネットワークがない環境でも動画を再生できる仕組みを作っていきます。

Cache APIの問題点

このCache API、非常に便利なのですが、いくつか問題があります。

まず、

  • Cache APIを実行するコードが書かれたjsファイルをキャッシュすることができない。

というのが挙げられます。より正確に書くと、

  • Cache APIを実行するコードが書かれたjsファイルをキャッシュすることはできるけれど、そのコードをオフライン状態で呼び出すことができない。

でしょうか。
上記の動画ファイルを読み込む処理も、そもそもVueアプリケーションが動作しているからできるのであって、オフライン状態でVueアプリケーションそのものが動かなければ意味がないということですね。

また、

  • キャッシュされたファイルとリモートサーバーに置かれたリソースの2つを透過的に扱えない

という問題もあります。
例えば、動画ファイルの場合、

  • <video>のsrcにURLを設定すれば、そのURLにマッチしたキャッシュがあればそれを再生するし、なければ通常通りサーバーからファイルを取得して再生する。

というような処理をしたいわけですが、これはCache APIだけではできません。
Cache APIでキャッシュしたデータとキャッシュ元のリソースはあくまで別物なので、それらを透過的に扱うためにはそれらをどのようなキャッシュ戦略で扱うかを決めて、そのロジックを実装する必要があります。
(ちなみに上記のキャッシュがあればそれを返すし、なければリモートからデータを取ってくるという動きは CacheFirst というキャッシュ戦略になります)

これらの問題点を解決できるのが、service workerという機能です。

Service Workerについて

詳しい説明はMDNのサイトなどにおまかせしますが、
https://developer.mozilla.org/ja/docs/Web/API/Service_Worker_API

主な機能としては

  • オフラインでの動作や読み込みの高速化を目的としたWebアプリケーションのキャッシング
  • Push通知のサポート
  • 様々なリクエストをトラップし、適宜キャッシュを返したり、失敗したリクエストの自動リトライなどを行うプロキシ機能

などがあります。

Service Workerの導入(Workboxの導入)

素のservice-workerを使ってもよいのですが、今回はGoogle謹製のservice-worker生成ライブラリであるWorkBoxを使いたいと思います。
WorkBoxの導入方法にはいくつかありますが、今回は使用しているフレームワークに依存せず利用できる workbox-buildを利用したいと思います。
https://www.npmjs.com/package/workbox-build

まず、workbox-buildパッケージをプロジェクトに追加します。

npm i workbox-build --dev

プロジェクト直下に workbox-build.jsファイルを作成します。ここに生成されるservice-workerの情報を定義していきます。

workbox-build.js
import { generateSW } from "workbox-build";

generateSW({
  // ここに設定を書いていく
  cacheId: "web-cache-test", // キャッシュ名
  swDest: "dist/service-worker.js", // 作成されたService Workerのファイルの出力先
  globDirectory: "dist", // 事前キャッシュ対象ディレクトリ
  globPatterns: ["**/*.{js,html,css,png,jpg,woff2,woff,ttf,svg,ico}"], // キャッシュ対象のファイルの条件
  skipWaiting: true, // 新しいService Workerを即座にアクティブにする
  clientsClaim: true, // 新しいService Workerがページを直ちにコントロールする
  maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // キャッシュするファイルの最大値。デフォルトは2MB。プロジェクトの規模に合わせて必要なサイズに設定する
  navigateFallback: '/index.html', // 存在しないルートにアクセスしたときに返すファイル
  // 動的キャッシングの設定
  runtimeCaching: [
    {
      // urlPatternに一致するリクエストが来た場合(この場合は末尾が.mp4のリクエスト)に処理する
      urlPattern: ({ url }) => url.pathname.endsWith(".mp4"),
      handler: "CacheFirst", // キャッシュ戦略。今回はCacheFirstを指定
      options: {
        cacheName: "video-cache", // キャッシュ名。Cache APIで利用するcacheの名前を指定する
        rangeRequests: true, // レンジリクエストをサポートする
        cacheableResponse: {
          statuses: [200], // キャッシュ対象のレスポンスコードを指定
        },
      },
    },
  ],
}).then(({ count, size, warnings }) => {
  if (warnings.length > 0) {
    console.warn(
      "Warnings encountered while generating a service worker:",
      warnings.join("\n")
    );
  }

  console.log(
    `Generated a service worker, which will precache ${count} files, totaling ${size} bytes.`
  );
});

  • cacheId - キャッシュストレージのprefixに使われるIDです。
  • swDest - 作成されるservice-workerファイルの出力先です。今回はdist以下に配置します。
  • globDirectory - 事前キャッシュ対象のディレクトリです。今回はdist以下に作成されるvueのビルド成果物をキャッシュ対象にし、オフラインでも動作できるようにします。
  • globPatterns - キャッシュ対象のファイルの種類を定義します。
  • skipWaiting - アプリケーションの更新などによりservice-workerに更新があった場合、そのservice-workerを即座にアクティブにします。下のclientsClaimと同時に使用されることが多いです。
  • clientsClaim - 新しいservice-workerがアクティブになった場合、ユーザーがページをリロードしなくても、新しいservice-workerが現在開いているページで動作するようになります。
  • maximumFileSizeToCacheInBytes - 事前キャッシュ対象のファイルの最大サイズを定義します。デフォルトは2MBなので、プロジェクトに合わせて適切な値を設定してください。ちなみにここで設定された値はCache APIを使ったキャッシュには適用されないので、動画ファイルのサイズを考慮する必要はありません。
  • navigateFallback - 存在しないルートにアクセスしたときに返すファイルです。SPAの場合、例えば /app/video/:id のようなページに直接遷移された場合、service-workerは /app/video/:id/index.html を探しますが、そのようなページは存在しないため、エラーを返します。このような問題を防ぐため、存在しないページへのリクエストが発生した場合のフォールバック先をここで設定しておきます。
  • runtimeCaching - アプリケーション実行中に発生したネットワークリクエストをトラップし、その値を決められたルールに応じてキャッシュするための項目です。今回はCache Firstというキャッシュ戦略を用い、キャッシュストレージにキャッシュされた動画が存在する場合は、それをそのまま返すようにします。注意点として、動画や音声といったメディアファイルをブラウザが取得する際には HTTP 範囲リクエスト
    と呼ばれる方法でリクエストを行うため、それに対応できるよう、rangeRequests: true を設定する必要があることに注意してください。

次に、アプリケーション起動時にservice-workerを登録する処理を main.tsに記載します。今回は簡単のために、Vueアプリケーション起動時に必ず実行されるmain.tsに登録処理を記載していますが、 App.vueのマウント時に実行するなど、タイミングはいくつかあると思うので、適切なものを利用してください。

また、キャッシュした動画ファイルなどを永続化するためのリクエスト処理も合わせて行っています。
この永続化リクエストは、環境によっては通らないこともあるのですが、リクエストが許可されることでブラウザがキャッシュを不意に削除する可能性を減らせるので、やっておくとよいかと思います。

main.ts
...
import { Workbox } from 'workbox-window'

// ページのロード時に Service Worker の更新をチェックする
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    const wb = new Workbox('/service-worker.js')
    await wb.register({ immediate: true })
  })
}

/**
 * キャッシュストレージの永続化をリクエストする
 * @returns {Promise<boolean>} 永続化が成功したかどうか
 */
const requestPersistentStorage = async (): Promise<boolean> => {
  if (navigator.storage && navigator.storage.persist) {
    return navigator.storage.persist()
  }
  return false
}

requestPersistentStorage().then((result) => {
  if (!result) {
    console.warn('Failed to request persistent storage.')
  } else {
    console.log('Successfully requested persistent storage.')
  }
})
...
const app = createApp(App)

最後にビルドコマンドにservice-workerの生成処理を含めます。

"build": "run-p type-check \"build-only {@}\" -- && node workbox-build.js",

ではこれで実際にオフラインでアプリケーションが動作するか確認してみましょう。
一度ページを開いたあとにオフライン状態でもう一度ページを開いても、問題なくアプリケーションが表示されるようになると思います。

(余談)キャッシュの保存先について

この事前キャッシュされたビルド成果物も、CacheAPIを使ってキャッシュした動画ファイルと同様にキャッシュストレージに保存されます。Workboxを使う場合はストレージ名は、workboxが一意なものをつけてくれるので、Cache APIを使ったキャッシュと混ざる心配はありません。

runtimeCachingの動作確認

先ほど、Cache APIのみを使った場合の問題点として、

  • キャッシュされたファイルとリモートサーバーに置かれたリソースの2つを透過的に扱えない

があるということを書きました。
今回、runtimeCachingを設定したことによって、この問題が解決しています。
オフライン状態で/sample-video.mp4へのリクエストをservice-workerがトラップしてキャッシュを返してくれるようになったので、
上記で紹介したVueファイルのsrcを以下のように書き換えても、オフラインで問題なく動画を再生できるようになりました。

index.html
<video controls type="video/mp4" src="/sample-video.mp4"></video>

まとめ

今回はVueのサンプルアプリケーションを用いて簡単な動画のオフライン再生の実装方法を紹介しました。

実際のプロダクトでは、単に動画だけをキャッシュすればよいというものではなく、オフラインでも問題なくアプリケーションが動作するように様々な機能を開発する必要がありますが、基本的な原理はここで紹介した通りなので、同様の機能を開発しようとしている開発者の皆さんの参考になれば幸いです。

RUN.EDGE株式会社

Discussion