技術をかじる猫

適当に気になった技術や言語、思ったこと考えた事など。

ServiceWorker について(1)

目的

LWC などのフロントエンド技術は、極論調べてフレームワークに従っていれば処理できます。
使うだけならその内部を把握する必要はありませんが、どんな機能をどのように使って処理するのかを把握しておけば、応用が効くかも知れません。

ここでは Service Worker の機能について把握していきます。
代表的な使い方はまずはやはりオフラインキャッシュでしょう。
オフラインキャッシュはネットワークが不安定なモバイル等で特に活躍するもので、接続が途切れようと正しく内容を表示し続けることができます。

試してみる

github.com

こちらにサンプルを見つけましたので、これを実行していきます。
nodejs でも python でも何でもいいので、clone してきてサーバを立ち上げるだけですね。

オーソドックスに npx lite-server で実行します。

するとブラウザが起動して、ダースベイダーさんが見えます。

ではここでサーバを Ctrl + c で停止してみます。

ブラウザに戻って更新ボタンを押しても…正しく内容が表示されます。

コードを読んでいこう

では一緒にコードを読んでいきましょう。
index.html はエントリポイントですね。
やっていることは app.js を読み込んでるだけという。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width" />

    <title>Service worker demo</title>

    <link rel="stylesheet" href="style.css" />
  </head>

  <body>
    <h1>Lego Star Wars gallery</h1>

    <section></section>

    <script type="module" src="app.js"></script>
  </body>
</html>

では app.js の中身を見ていきます。

import { Gallery } from './image-list.js';

const registerServiceWorker = async () => {
  if ('serviceWorker' in navigator) {
    try {
      const registration = await navigator.serviceWorker.register(
        'sw.js',
        {
          scope: './',
        }
      );
      if (registration.installing) {
        console.log('Service worker installing');
      } else if (registration.waiting) {
        console.log('Service worker installed');
      } else if (registration.active) {
        console.log('Service worker active');
      }
    } catch (error) {
      console.error(`Registration failed with ${error}`);
    }
  }
};

const imgSection = document.querySelector('section');

const getImageBlob = async (url) => {
  const imageResponse = await fetch(url);
  if (!imageResponse.ok) {
    throw new Error(
      `Image didn't load successfully; error code: ${
        imageResponse.statusText || imageResponse.status
      }`
    );
  }
  return imageResponse.blob();
};

const createGalleryFigure = async (galleryImage) => {
  try {
    const imageBlob = await getImageBlob(galleryImage.url);
    const myImage = document.createElement('img');
    const myCaption = document.createElement('caption');
    const myFigure = document.createElement('figure');
    myCaption.textContent = `${galleryImage.name}: Taken by ${galleryImage.credit}`;
    myImage.src = window.URL.createObjectURL(imageBlob);
    myImage.setAttribute('alt', galleryImage.alt);
    myFigure.append(myImage, myCaption);
    imgSection.append(myFigure);
  } catch (error) {
    console.error(error);
  }
};

registerServiceWorker();
Gallery.images.map(createGalleryFigure);

上から順に見ていきます。

import { Gallery } from './image-list.js';

画像の定義を読み込んでいます。
中身は下記ですが、特筆するものはなさそうだ。

export const Path = 'gallery/';

export const Gallery = {
  images: [
    {
      name: 'Darth Vader',
      alt: 'A Black Clad warrior lego toy',
      url: 'gallery/myLittleVader.jpg',
      credit:
        '<a href="https://www.flickr.com/photos/legofenris/">legOfenris</a>, published under a <a href="https://creativecommons.org/licenses/by-nc-nd/2.0/">Attribution-NonCommercial-NoDerivs 2.0 Generic</a> license.',
    },

    {
      name: 'Snow Troopers',
      alt: 'Two lego solders in white outfits walking across an icy plain',
      url: 'gallery/snowTroopers.jpg',
      credit:
        '<a href="https://www.flickr.com/photos/legofenris/">legOfenris</a>, published under a <a href="https://creativecommons.org/licenses/by-nc-nd/2.0/">Attribution-NonCommercial-NoDerivs 2.0 Generic</a> license.',
    },

    {
      name: 'Bounty Hunters',
      alt: 'A group of bounty hunters meeting, aliens and humans in costumes.',
      url: 'gallery/bountyHunters.jpg',
      credit:
        '<a href="https://www.flickr.com/photos/legofenris/">legOfenris</a>, published under a <a href="https://creativecommons.org/licenses/by-nc-nd/2.0/">Attribution-NonCommercial-NoDerivs 2.0 Generic</a> license.',
    },
  ],
};

特に言うこと無いJavascriptでデータが入っているだけですね。

const registerServiceWorker = async () => {
  if ('serviceWorker' in navigator) {
    try {
      const registration = await navigator.serviceWorker.register(
        'sw.js',
        {
          scope: './',
        }
      );
      if (registration.installing) {
        console.log('Service worker installing');
      } else if (registration.waiting) {
        console.log('Service worker installed');
      } else if (registration.active) {
        console.log('Service worker active');
      }
    } catch (error) {
      console.error(`Registration failed with ${error}`);
    }
  }
};
// 中略
registerServiceWorker();

何をやっているかといえば、sw.js ファイルを読み込んで、ServiceWorker に登録しているだけですね。

残りの部分を先に見てみると

const imgSection = document.querySelector('section');

const getImageBlob = async (url) => {
  const imageResponse = await fetch(url);
  if (!imageResponse.ok) {
    throw new Error(
      `Image didn't load successfully; error code: ${
        imageResponse.statusText || imageResponse.status
      }`
    );
  }
  return imageResponse.blob();
};

const createGalleryFigure = async (galleryImage) => {
  try {
    const imageBlob = await getImageBlob(galleryImage.url);
    const myImage = document.createElement('img');
    const myCaption = document.createElement('caption');
    const myFigure = document.createElement('figure');
    myCaption.textContent = `${galleryImage.name}: Taken by ${galleryImage.credit}`;
    myImage.src = window.URL.createObjectURL(imageBlob);
    myImage.setAttribute('alt', galleryImage.alt);
    myFigure.append(myImage, myCaption);
    imgSection.append(myFigure);
  } catch (error) {
    console.error(error);
  }
};

// 中略
Gallery.images.map(createGalleryFigure);

image-list.js の定義をループして createGalleryFigure でHTMLのDOM(≒タグ)に変換して表示。
getImageBlobfetch コマンドを使って値を読み込んでいるだけですね。

fetchここ を見ると、サービスワーカーに fetch イベントを飛ばして応答を拾う処理のようです。

肝はサービスワーカー本体

ではここで sw.js (サービスワーカーの本体) を読んでみます。

const addResourcesToCache = async (resources) => {
  const cache = await caches.open('v1');
  await cache.addAll(resources);
};

const putInCache = async (request, response) => {
  const cache = await caches.open('v1');
  await cache.put(request, response);
};

const cacheFirst = async ({ request, preloadResponsePromise, fallbackUrl }) => {
  // First try to get the resource from the cache
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }

  // Next try to use the preloaded response, if it's there
  const preloadResponse = await preloadResponsePromise;
  if (preloadResponse) {
    console.info('using preload response', preloadResponse);
    putInCache(request, preloadResponse.clone());
    return preloadResponse;
  }

  // Next try to get the resource from the network
  try {
    const responseFromNetwork = await fetch(request);
    // response may be used only once
    // we need to save clone to put one copy in cache
    // and serve second one
    putInCache(request, responseFromNetwork.clone());
    return responseFromNetwork;
  } catch (error) {
    const fallbackResponse = await caches.match(fallbackUrl);
    if (fallbackResponse) {
      return fallbackResponse;
    }
    // when even the fallback response is not available,
    // there is nothing we can do, but we must always
    // return a Response object
    return new Response('Network error happened', {
      status: 408,
      headers: { 'Content-Type': 'text/plain' },
    });
  }
};

const enableNavigationPreload = async () => {
  if (self.registration.navigationPreload) {
    // Enable navigation preloads!
    await self.registration.navigationPreload.enable();
  }
};

self.addEventListener('activate', (event) => {
  event.waitUntil(enableNavigationPreload());
});

self.addEventListener('install', (event) => {
  event.waitUntil(
    addResourcesToCache([
      './',
      './index.html',
      './style.css',
      './app.js',
      './image-list.js',
      './star-wars-logo.jpg',
      './gallery/bountyHunters.jpg',
      './gallery/myLittleVader.jpg',
      './gallery/snowTroopers.jpg',
    ])
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    cacheFirst({
      request: event.request,
      preloadResponsePromise: event.preloadResponse,
      fallbackUrl: './gallery/myLittleVader.jpg',
    })
  );
});

100 行行かないのだからコレは読んで行きましょう…。
まず注目するのは最後のほう

const enableNavigationPreload = async () => {
  if (self.registration.navigationPreload) {
    // Enable navigation preloads!
    await self.registration.navigationPreload.enable();
  }
};

self.addEventListener('activate', (event) => {
  event.waitUntil(enableNavigationPreload());
});

self.addEventListener('install', (event) => {
  event.waitUntil(
    addResourcesToCache([
      './',
      './index.html',
      './style.css',
      './app.js',
      './image-list.js',
      './star-wars-logo.jpg',
      './gallery/bountyHunters.jpg',
      './gallery/myLittleVader.jpg',
      './gallery/snowTroopers.jpg',
    ])
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    cacheFirst({
      request: event.request,
      preloadResponsePromise: event.preloadResponse,
      fallbackUrl: './gallery/myLittleVader.jpg',
    })
  );
});

文脈を考えるなら、self はサービスワーカーのインスタンスです。

  • fetchapp.js で見た fetch コマンドの実態。
  • install イベントはサービスワーカーのインストール完了イベントだそうです。メソッド名から addResourcesToCache はリソースの読み込みとキャッシュを行うようですね。
  • activate はサービスワーカーが有効化された際に実行されるイベントです。enableNavigationPreload は「サービスワーカーのナビゲーション先読み」だそうです。

MDN: サービスワーカーのナビゲーション先読み によれば

有効にすると、ナビゲーション先読み機能は、フェッチ要求がなされるとすぐに、サービスワーカーの起動と並行してリソースのダウンロードを開始します。 これにより、サービスワーカーが起動するまで待つ必要がなく、ページへのナビゲーションですぐにダウンロードが開始されるようになります。 この遅延は比較的稀にしか発生しませんが、発生した場合は避けられないものであり、重大なものになる可能性があります。

つまり、fetch を開始した瞬間、fetch のイベント処理だけでなく、リソースの読み込みを並行して行うって事ですね。

では改めて残りを読み込んでみましょう。

初期化&キャッシュ処理

const addResourcesToCache = async (resources) => {
  const cache = await caches.open('v1');
  await cache.addAll(resources);
};

// 中略

self.addEventListener('install', (event) => {
  event.waitUntil(
    addResourcesToCache([
      './',
      './index.html',
      './style.css',
      './app.js',
      './image-list.js',
      './star-wars-logo.jpg',
      './gallery/bountyHunters.jpg',
      './gallery/myLittleVader.jpg',
      './gallery/snowTroopers.jpg',
    ])
  );
});

caches は MDN 曰く CacheStrage の事とのこと。
要するにキャッシュを蓄える入れ物ですね。

  • const cache = await caches.open('v1'); のコマンドで v1 と言うラベルの Cache オブジェクトを取得。Cache オブジェクトの仕様はこちら
  • await cache.addAll(resources);v1 キャッシュに読み込んでキャッシュしたいリソースを列挙しておきます。

サービスワーカーが起動したら、先読み機能を有効化

const enableNavigationPreload = async () => {
  if (self.registration.navigationPreload) {
    // Enable navigation preloads!
    await self.registration.navigationPreload.enable();
  }
};

self.addEventListener('activate', (event) => {
  event.waitUntil(enableNavigationPreload());
});

先程ちょっと説明した機能ですね。
fetch でリソースを要求した瞬間に、キャッシュ処理は行いますが、キャッシュがうまく拾えなかった場合に備えて並行してリソースの再取得をしています。

fetch されたらキャッシュを返す。なかったら get してくる

ちょっと長いのでコメントを書き換える形で説明。

const putInCache = async (request, response) => {
  const cache = await caches.open('v1');
  await cache.put(request, response);
};

const cacheFirst = async ({ request, preloadResponsePromise, fallbackUrl }) => {
  // キャッシュの中に、要求したリソースがないか検索。
  // あればそれを応答
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }

  // キャッシュがなかったので、平行して読み込んでいたリソースを取得
  // 取得できたら `putInCache` でキャッシュしつつ、取得したリソースを応答
  const preloadResponse = await preloadResponsePromise;
  if (preloadResponse) {
    console.info('using preload response', preloadResponse);
    putInCache(request, preloadResponse.clone());
    return preloadResponse;
  }

  // 何かの理由で取得が失敗していた場合の再取得
  try {
    // fetch をして取れれば応答+キャッシュ
    const responseFromNetwork = await fetch(request);
    putInCache(request, responseFromNetwork.clone());
    return responseFromNetwork;
  } catch (error) {
    // fetch エラーがあった。
    // エラー応答のリソースを返すが、無ければ 408 応答。
    const fallbackResponse = await caches.match(fallbackUrl);
    if (fallbackResponse) {
      return fallbackResponse;
    }
    return new Response('Network error happened', {
      status: 408,
      headers: { 'Content-Type': 'text/plain' },
    });
  }
};

// 中略

self.addEventListener('fetch', (event) => {
  event.respondWith(
    cacheFirst({
      request: event.request,
      preloadResponsePromise: event.preloadResponse,
      fallbackUrl: './gallery/myLittleVader.jpg',
    })
  );
});

まとめ

キャッシュを行うサービスワーカーのコードリーディングを通して、サービスワーカーの使い方の一つを学んでいきました。
モバイルだと移動しながらで通信が不安定になることは多いですから、特にアプリケーションの様な多くのリソースを使うものではこの恩恵は大きいと思われる。