プログレッシブ ウェブアプリを段階的に強化する

2003 年のように最新のブラウザ向けに構築し、段階的に拡張する

公開日: 2020 年 6 月 29 日

2003 年 3 月に、Nick FinckSteve Champeon は、プログレッシブ エンハンスメントというコンセプトでウェブデザインの世界を驚かせました。これは、ウェブページのコア コンテンツを最初に読み込み、その上に、よりニュアンスのある技術的に厳密なプレゼンテーションと機能のレイヤを段階的に追加していくウェブデザインの戦略です。2003 年当時、プログレッシブ エンハンスメントは、当時の最新の CSS 機能、控えめな JavaScript、さらには Scalable Vector Graphics を使用することでした。2020 年以降のプログレッシブ エンハンスメントは、最新のブラウザ機能を使用することです。

拡張を前提とした、将来を見据えた包括的なウェブデザイン。 Finck と Champeon の元のプレゼンテーションのタイトル スライド。

最新の JavaScript

JavaScript について言えば、最新のコア ES 2015 JavaScript 機能のブラウザ サポート状況は良好です。この新しい標準には、Promise、モジュール、クラス、テンプレート リテラル、アロー関数、letconst、デフォルト パラメータ、ジェネレータ、分割代入、レスト パラメータとスプレッド構文、Map/SetWeakMap/WeakSet などが追加されています。すべてサポートされています

すべての主要ブラウザでサポートされている ES6 機能の CanIUse サポート表。
ECMAScript 2015(ES6)のブラウザ サポート表。(出典

ES 2017 の機能であり、私のお気に入りの 1 つである async 関数は、すべての主要ブラウザで使用できますasync キーワードと await キーワードを使用すると、非同期の Promise ベースの動作をよりクリーンなスタイルで記述できます。Promise チェーンを明示的に構成する必要はありません。

すべての主要ブラウザでサポートされている非同期関数を示す CanIUse のサポート表。
Async 関数のブラウザ サポート表。(出典

オプショナル チェーンnullish coalescing などの ES 2020 言語の追加も、非常に迅速にサポートされています。コア JavaScript 機能に関しては、これ以上の改善は難しいでしょう。

次に例を示します。

const adventurer = {
  name: 'Alice',
  cat: {
    name: 'Dinah',
  },
};
console.log(adventurer.dog?.name);
// Expected output: undefined
console.log(0 ?? 42);
// Expected output: 0
Windows XP の象徴的な緑の草の背景画像。
コア JavaScript 機能に関しては、芝生は緑色です。 (Microsoft 製品のスクリーンショット。許可を得て使用)。

サンプルアプリ: Fugu Greetings

このドキュメントでは、Fugu GreetingsGitHub)という PWA を使用します。このアプリの名前は、ウェブに Android、iOS、パソコン アプリケーションのすべての機能を提供しようとする取り組みである Project Fugu 🐡 に敬意を表したものです。このプロジェクトについて詳しくは、ランディング ページをご覧ください。

Fugu Greetings は、仮想グリーティング カードを作成して大切な人に送ることができるお絵かきアプリです。PWA の基本コンセプトを例示しています。信頼性が高く、完全にオフラインで使用できるため、ネットワークがなくても使用できます。また、デバイスのホーム画面にインストールすることもでき、スタンドアロン アプリケーションとしてオペレーティング システムとシームレスに統合されます。

PWA コミュニティのロゴに似た絵が描かれた Fugu Greetings PWA。
Fugu Greetings サンプルアプリ。

プログレッシブ エンハンスメント

これで準備が整いました。次は、プログレッシブ エンハンスメントについて説明します。MDN Web Docs 用語集では、このコンセプトを次のように定義しています。

プログレッシブ エンハンスメントは、できるだけ多くのユーザーに基本的なコンテンツと機能のベースラインを提供し、必要なコードをすべて実行できる最新のブラウザのユーザーにのみ可能な限り最高のユーザー エクスペリエンスを提供する設計哲学です。

一般的に、機能検出は、ブラウザがより新しい機能を処理できるかどうかを判断するために使用され、ポリフィルは、JavaScript で不足している機能を追加するために使用されます。

[…]

プログレッシブ エンハンスメントは、ウェブ デベロッパーが可能な限り最高のウェブサイトの開発に集中しながら、複数の未知のユーザー エージェントでウェブサイトを動作させることを可能にする便利な手法です。グレースフル デグラデーションは関連していますが、同じものではなく、プログレッシブ エンハンスメントとは逆の方向に向かうものと見なされることがよくあります。実際には、どちらのアプローチも有効であり、相互に補完し合うこともよくあります。

MDN への貢献者

グリーティング カードを毎回ゼロから作成するのは面倒です。ユーザーが画像をインポートして、そこから始められる機能があってもいいのではないでしょうか?従来の方法では、<input type=file> 要素を使用してこれを実現していました。まず、要素を作成し、その type'file' に設定して、MIME タイプを accept プロパティに追加します。次に、プログラムで「クリック」して、変更をリッスンします。画像を選択すると、キャンバスに直接インポートされます。

const importImage = async () => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'image/*';
    input.addEventListener('change', () => {
      resolve(input.files[0]);
    });
    input.click();
  });
};

インポート機能がある場合は、ユーザーがグリーティング カードをローカルに保存できるように、エクスポート機能も用意すべきです。ファイルを保存する従来の方法は、download 属性と、その href として blob URL を使用してアンカーリンクを作成することです。また、プログラムで「クリック」してダウンロードをトリガーし、メモリリークを防ぐために、Blob オブジェクトの URL を取り消すことを忘れないようにします。

const exportImage = async (blob) => {
  const a = document.createElement('a');
  a.download = 'fugu-greeting.png';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', (e) => {
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  a.click();
};

しかし、ちょっと待ってください。心理的には、グリーティング カードを「ダウンロード」したのではなく、「保存」したことになります。ブラウザは、ファイルの保存場所を選択できる [保存] ダイアログを表示する代わりに、ユーザーの操作なしでグリーティング カードを直接ダウンロードし、[ダウンロード] フォルダに保存しました。これは困ります。

もっと良い方法があったらどうでしょうか?ローカル ファイルを開いて編集し、変更内容を新しいファイルに保存したり、最初に開いた元のファイルに保存したりできたらどうでしょうか?実はあります。File System Access API を使用すると、ファイルやディレクトリを開いて作成したり、変更して保存したりできます。

では、API をフィーチャー検出するにはどうすればよいのでしょうか?File System Access API は、新しいメソッド window.chooseFileSystemEntries() を公開します。そのため、このメソッドが利用可能かどうかによって、異なるインポート モジュールとエクスポート モジュールを条件付きで読み込む必要があります。

const loadImportAndExport = () => {
  if ('chooseFileSystemEntries' in window) {
    Promise.all([
      import('./import_image.mjs'),
      import('./export_image.mjs'),
    ]);
  } else {
    Promise.all([
      import('./import_image_legacy.mjs'),
      import('./export_image_legacy.mjs'),
    ]);
  }
};

ただし、File System Access API の詳細に入る前に、プログレッシブ エンハンスメント パターンについて簡単に説明します。File System Access API をサポートしていないブラウザでは、以前のスクリプトを読み込みます。

Safari Web Inspector で、レガシー ファイルが読み込まれている様子が表示されています。
読み込まれているレガシー ファイルを示す Firefox デベロッパー ツール。

ただし、API をサポートするブラウザである Chrome では、新しいスクリプトのみが読み込まれます。これは、最新のすべてのブラウザがサポートしている動的 import() のおかげで、エレガントに実現されています。先ほども言いましたが、最近は芝生がとても緑色です。

最新のファイルが読み込まれていることを示す Chrome DevTools。
Chrome DevTools の [ネットワーク] タブ。

File System Access API

これでこの問題に対処できたので、File System Access API に基づく実際の実装を見ていきましょう。画像をインポートするために、window.chooseFileSystemEntries() を呼び出し、画像ファイルが必要であることを示す accepts プロパティを渡します。ファイル拡張子と MIME タイプの両方がサポートされています。これによりファイル ハンドルが生成され、getFile() を呼び出すことで実際のファイルを取得できます。

const importImage = async () => {
  try {
    const handle = await window.chooseFileSystemEntries({
      accepts: [
        {
          description: 'Image files',
          mimeTypes: ['image/*'],
          extensions: ['jpg', 'jpeg', 'png', 'webp', 'svg'],
        },
      ],
    });
    return handle.getFile();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

画像のエクスポートもほぼ同じですが、今回は 'save-file' の型パラメータを chooseFileSystemEntries() メソッドに渡す必要があります。これにより、ファイルの保存ダイアログが表示されます。ファイルが開いている場合、'open-file' がデフォルトであるため、これは必要ありませんでした。accepts パラメータは以前と同様に設定しましたが、今回は PNG 画像のみに制限しました。ここでもファイル ハンドルが返されますが、今回はファイルを取得するのではなく、createWritable() を呼び出して書き込み可能なストリームを作成します。次に、グリーティング カードの画像である BLOB をファイルに書き込みます。最後に、書き込み可能なストリームを閉じます。

すべてが失敗する可能性があります。ディスクの容量が不足している、書き込みエラーや読み取りエラーが発生している、ユーザーがファイル ダイアログをキャンセルした、などです。そのため、呼び出しは常に try...catch ステートメントでラップします。

const exportImage = async (blob) => {
  try {
    const handle = await window.chooseFileSystemEntries({
      type: 'save-file',
      accepts: [
        {
          description: 'Image file',
          extensions: ['png'],
          mimeTypes: ['image/png'],
        },
      ],
    });
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

File System Access API でプログレッシブ エンハンスメントを使用すると、以前と同じようにファイルを開くことができます。インポートしたファイルがキャンバスに直接描画されます。編集を行い、最後に実際の保存ダイアログで保存できます。このダイアログでは、ファイルの名前と保存場所を選択できます。これで、ファイルを永久に保存する準備が整いました。

ファイルを開くダイアログが表示された Fugu Greetings アプリ。
ファイルを開くダイアログ。
Fugu Greetings アプリにインポートされた画像が表示されています。
インポートされた画像。
変更された画像を含む Fugu Greetings アプリ。
変更した画像を新しいファイルに保存します。

Web Share API と Web Share Target API

attempt-right

永遠に保存するだけでなく、グリーティング カードを共有したい場合もあるでしょう。これは、Web Share APIWeb Share Target API で実現できます。モバイル オペレーティング システムでは、最近ではデスクトップ オペレーティング システムでも、共有メカニズムが組み込まれています。

たとえば、macOS のパソコン版 Safari の共有シートは、ユーザーが自分のブログで [記事を共有] をクリックしたときにトリガーされます。たとえば、macOS のメッセージ アプリを使用して、記事へのリンクを友だちと共有できます。

これを実現するために、navigator.share() を呼び出し、オブジェクト内のオプションの titletexturl を渡します。画像を添付したい場合はどうすればよいですか?ウェブ共有 API のレベル 1 では、まだこの機能はサポートされていません。Web Share Level 2 では、ファイル共有機能が追加されています。

try {
  await navigator.share({
    title: 'Check out this article:',
    text: `"${document.title}" by @tomayac:`,
    url: document.querySelector('link[rel=canonical]').href,
  });
} catch (err) {
  console.warn(err.name, err.message);
}

Fugu グリーティング カード アプリケーションでこれを機能させる方法を説明します。まず、1 つの BLOB で構成される files 配列を含む data オブジェクト、次に titletext を準備する必要があります。次に、ベスト プラクティスとして、名前のとおりの処理を行う新しい navigator.canShare() メソッドを使用します。このメソッドは、共有しようとしている data オブジェクトをブラウザで技術的に共有できるかどうかを返します。navigator.canShare() からデータを共有できると伝えられたら、以前と同様に navigator.share() を呼び出す準備が整います。すべてが失敗する可能性があるため、ここでも try...catch ブロックを使用しています。

const share = async (title, text, blob) => {
  const data = {
    files: [
      new File([blob], 'fugu-greeting.png', {
        type: blob.type,
      }),
    ],
    title: title,
    text: text,
  };
  try {
    if (!(navigator.canShare(data))) {
      throw new Error("Can't share data.", data);
    }
    await navigator.share(data);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

以前と同様に、プログレッシブ エンハンスメントを使用します。'share''canShare' の両方が navigator オブジェクトに存在する場合にのみ、動的 import() を使用して share.mjs を読み込みます。モバイル Safari のように、2 つの条件のうち 1 つしか満たしていないブラウザでは、機能を読み込みません。

const loadShare = () => {
  if ('share' in navigator && 'canShare' in navigator) {
    import('./share.mjs');
  }
};

Fugu Greetings で、Android の Chrome などの対応ブラウザで [共有] ボタンをタップすると、組み込みの共有シートが開きます。たとえば、Gmail を選択すると、メール作成ウィジェットがポップアップ表示され、画像が添付されます。

OS レベルの共有シート。画像を共有できるさまざまなアプリが表示されている。
ファイルを共有するアプリを選択します。
画像が添付された Gmail のメール作成ウィジェット。
Gmail の作成画面で新しいメールにファイルが添付されます。

Contact Picker API

次に、連絡先について説明します。連絡先とは、デバイスのアドレス帳または連絡先管理アプリのことです。グリーティング カードを書くとき、相手の名前を正しく書くのは必ずしも簡単ではありません。たとえば、私の友人の Sergey は、自分の名前をキリル文字で表記することを好みます。ドイツ語の QWERTZ キーボードを使用していますが、名前の入力方法がわかりません。これは、連絡先選択ツール API で解決できる問題です。スマートフォンの連絡先アプリに友だちを保存しているため、Contacts Picker API を使用してウェブから連絡先にアクセスできます。

まず、アクセスするプロパティのリストを指定する必要があります。このケースでは名前のみが必要ですが、他のユースケースでは電話番号、メールアドレス、アバター アイコン、住所が必要になる可能性があります。次に、options オブジェクトを構成し、multipletrue に設定して、複数のエントリを選択できるようにします。最後に、navigator.contacts.select() を呼び出すと、ユーザーが選択した連絡先の理想的なプロパティが返されます。

const getContacts = async () => {
  const properties = ['name'];
  const options = { multiple: true };
  try {
    return await navigator.contacts.select(properties, options);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

おそらく、パターンはもうおわかりでしょう。API が実際にサポートされている場合にのみファイルを読み込んでいます。

if ('contacts' in navigator) {
  import('./contacts.mjs');
}

Fugu Greeting で、[Contacts] ボタンをタップして、親友の Сергей Михайлович Брин劳伦斯·爱德华·"拉里"·佩奇 を選択すると、連絡先選択ツールで名前のみが表示され、メールアドレスや電話番号などのその他の情報は表示されないことがわかります。その後、名前がグリーティング カードに描かれます。

アドレス帳に登録されている 2 人の連絡先の名前が表示されている連絡先ピッカー。
アドレス帳から連絡先選択ツールで 2 つの名前を選択している様子。
グリーティング カードに描かれた、以前に選択した 2 人の連絡先の名前。
2 つの名前がグリーティング カードに描画されます。

非同期クリップボード API

次はコピー&ペーストです。ソフトウェア デベロッパーとして、コピー&ペーストは最もよく使うオペレーションの 1 つです。グリーティング カードの作成者として、同じことをしたい場合があります。作成中のグリーティング カードに画像を貼り付けたり、グリーティング カードをコピーして別の場所から編集を続けたりしたい場合があります。Async Clipboard API は、テキストと画像の両方をサポートしています。Fugu Greetings アプリにコピー&ペーストのサポートを追加した方法について説明します。

システム クリップボードに何かをコピーするには、書き込みが必要です。navigator.clipboard.write() メソッドは、クリップボード アイテムの配列をパラメータとして受け取ります。各クリップボード アイテムは、基本的に値として Blob を持ち、キーとして Blob の型を持つオブジェクトです。

const copy = async (blob) => {
  try {
    await navigator.clipboard.write([
      new ClipboardItem({
        [blob.type]: blob,
      }),
    ]);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

貼り付けるには、navigator.clipboard.read() を呼び出して取得したクリップボード アイテムをループする必要があります。これは、クリップボードに複数のクリップボード アイテムが異なる表現で存在している可能性があるためです。各クリップボード アイテムには、利用可能なリソースの MIME タイプを示す types フィールドがあります。クリップボード アイテムの getType() メソッドを呼び出し、前に取得した MIME タイプを渡します。

const paste = async () => {
  try {
    const clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
      try {
        for (const type of clipboardItem.types) {
          const blob = await clipboardItem.getType(type);
          return blob;
        }
      } catch (err) {
        console.error(err.name, err.message);
      }
    }
  } catch (err) {
    console.error(err.name, err.message);
  }
};

今となっては言うまでもないことですが、これは、サポートされているブラウザでのみ行います。

if ('clipboard' in navigator && 'write' in navigator.clipboard) {
  import('./clipboard.mjs');
}

では、実際にどのように機能するのでしょうか。macOS のプレビュー アプリで画像を開き、クリップボードにコピーします。[貼り付け] をクリックすると、Fugu Greetings アプリから、クリップボードのテキストと画像へのアクセスを許可するかどうかを尋ねられます。

クリップボードの権限プロンプトを表示している Fugu Greetings アプリ。
クリップボードの権限を求めるプロンプト。

最後に、権限を承認すると、画像がアプリに貼り付けられます。逆の場合も同様です。グリーティング カードをクリップボードにコピーします。プレビューを開き、[ファイル]、[クリップボードから新規作成] の順にクリックすると、グリーティング カードが新しい無題の画像に貼り付けられます。

macOS のプレビュー アプリに、タイトルなしで貼り付けられた画像が表示されている。
macOS のプレビュー アプリに貼り付けられた画像。

Badging API

もう 1 つの便利な API は Badging API です。インストール可能な PWA として、Fugu Greetings にはアプリ アイコンがあり、ユーザーはアプリドックやホーム画面に配置できます。API をデモする楽しい方法として、Fugu Greetings でペンストローク カウンタとして使用する方法があります。pointerdown イベントが発生するたびにペンストローク カウンタをインクリメントし、更新されたアイコンバッジを設定するイベント リスナーを追加しました。キャンバスがクリアされるたびに、カウンタがリセットされ、バッジが削除されます。

let strokes = 0;

canvas.addEventListener('pointerdown', () => {
  navigator.setAppBadge(++strokes);
});

clearButton.addEventListener('click', () => {
  strokes = 0;
  navigator.setAppBadge(strokes);
});

この機能はプログレッシブ エンハンスメントであるため、読み込みロジックは通常どおりです。

if ('setAppBadge' in navigator) {
  import('./badge.mjs');
}

この例では、1 から 7 までの数字を 1 つの数字につき 1 回のペンストロークで描画しています。アイコンのバッジ カウンタが 7 になっています。

グリーティング カードに 1 から 7 までの数字が 1 つのペンストロークで描かれています。
7 つのペンストロークを使用して 1 から 7 までの数字を描画します。
Fugu Greetings アプリのバッジ アイコンに数字の 7 が表示されている。
アプリアイコンのバッジの形式のペンストローク カウンタ。

Periodic Background Sync API

毎日新しいことをして、新鮮な気持ちで一日を始めたいですか?Fugu Greetings アプリの便利な機能は、毎朝新しい背景画像でグリーティング カードの作成を始めることができることです。アプリは、Periodic Background Sync API を使用してこれを実現します。

まず、サービス ワーカー登録で定期的な同期イベントを登録します。'image-of-the-day' という同期タグをリッスンし、最小間隔は 1 日です。そのため、ユーザーは 24 時間ごとに新しい壁紙を取得できます。

const registerPeriodicBackgroundSync = async () => {
  const registration = await navigator.serviceWorker.ready;
  try {
    registration.periodicSync.register('image-of-the-day-sync', {
      // An interval of one day.
      minInterval: 24 * 60 * 60 * 1000,
    });
  } catch (err) {
    console.error(err.name, err.message);
  }
};

2 つ目のステップは、サービス ワーカーで periodicsync イベントをリッスンすることです。イベントタグが 'image-of-the-day'(以前に登録されたもの)の場合、getImageOfTheDay() 関数でその日の画像が取得され、結果がすべてのクライアントに伝播されるため、クライアントはキャンバスとキャッシュを更新できます。

self.addEventListener('periodicsync', (syncEvent) => {
  if (syncEvent.tag === 'image-of-the-day-sync') {
    syncEvent.waitUntil(
      (async () => {
        const blob = await getImageOfTheDay();
        const clients = await self.clients.matchAll();
        clients.forEach((client) => {
          client.postMessage({
            image: blob,
          });
        });
      })()
    );
  }
});

これもプログレッシブ エンハンスメントなので、コードはブラウザが API をサポートしている場合にのみ読み込まれます。これは、クライアント コードとサービス ワーカー コードの両方に適用されます。サポートされていないブラウザでは、どちらも読み込まれません。Service Worker では、動的な import()(Service Worker コンテキストではまだサポートされていません)ではなく、従来の importScripts() を使用していることに注目してください。

// In the client:
const registration = await navigator.serviceWorker.ready;
if (registration && 'periodicSync' in registration) {
  import('./periodic_background_sync.mjs');
}
// In the service worker:
if ('periodicSync' in self.registration) {
  importScripts('./image_of_the_day.mjs');
}

Fugu Greetings では、[Wallpaper] ボタンを押すと、Periodic Background Sync API で毎日更新される、その日のグリーティング カード画像が表示されます。

[壁紙] ボタンを押すと、今日の画像が表示されます。

Notification Triggers API

インスピレーションが湧いても、グリーティング カードを完成させるには、ちょっとしたきっかけが必要な場合があります。これは、Notification Triggers API によって有効になる機能です。ユーザーは、グリーティング カードの作成を促す通知を受け取る時間を入力できます。その時間になると、グリーティング カードが届いているという通知が届きます。

目標時間を入力するよう求めるメッセージが表示されたら、アプリケーションは showTrigger を使用して通知をスケジュールします。これは、以前に選択した目標日を含む TimestampTrigger になります。リマインダー通知はローカルでトリガーされるため、ネットワークやサーバー側は必要ありません。

const targetDate = promptTargetDate();
if (targetDate) {
  const registration = await navigator.serviceWorker.ready;
  registration.showNotification('Reminder', {
    tag: 'reminder',
    body: "It's time to finish your greeting card!",
    showTrigger: new TimestampTrigger(targetDate),
  });
}

これまで説明してきた他のすべてのものと同様に、これはプログレッシブ エンハンスメントであるため、コードは条件付きで読み込まれます。

if ('Notification' in window && 'showTrigger' in Notification.prototype) {
  import('./notification_triggers.mjs');
}

Fugu Greetings で [Reminder] チェックボックスをオンにすると、グリーティング カードの作成を完了するタイミングを尋ねるプロンプトが表示されます。

グリーティング カードの作成をいつリマインドするかをユーザーに尋ねるプロンプトが表示された Fugu Greetings アプリ。
グリーティング カードの作成を完了するようリマインダーを送信するローカル通知をスケジュールします。

Fugu Greetings でスケジュール設定された通知がトリガーされると、他の通知と同様に表示されますが、前述のとおり、ネットワーク接続は必要ありません。

トリガーされた通知が macOS の通知センターに表示されます。

Wake Lock API

また、Wake Lock API も含めたいと思います。インスピレーションが湧くまで、画面をじっと見つめるだけでよい場合もあります。最悪の場合、画面がオフになる可能性があります。Wake Lock API を使用すると、この事態を回避できます。

最初の手順は、navigator.wakelock.request method() でウェイクロックを取得することです。文字列 'screen' を渡して、画面ウェイクロックを取得します。次に、ウェイクロックが解除されたときに通知されるようにイベント リスナーを追加します。これは、たとえばタブの可視性が変化したときに発生する可能性があります。この場合、タブが再び表示されたときに、ウェイクロックを再取得できます。

let wakeLock = null;
const requestWakeLock = async () => {
  wakeLock = await navigator.wakeLock.request('screen');
  wakeLock.addEventListener('release', () => {
    console.log('Wake Lock was released');
  });
  console.log('Wake Lock is active');
};

const handleVisibilityChange = () => {
  if (wakeLock !== null && document.visibilityState === 'visible') {
    requestWakeLock();
  }
};

document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('fullscreenchange', handleVisibilityChange);

はい。これはプログレッシブ エンハンスメントなので、ブラウザが API をサポートしている場合にのみ読み込む必要があります。

if ('wakeLock' in navigator && 'request' in navigator.wakeLock) {
  import('./wake_lock.mjs');
}

Fugu Greetings には、オンにすると画面がスリープ状態にならない Insomnia チェックボックスがあります。

[不眠症] チェックボックスをオンにすると、画面がスリープ状態になりません。
[Insomnia] チェックボックスをオンにすると、アプリが起動したままになります。

Idle Detection API

何時間も画面を見つめていても、まったく役に立たず、グリーティング カードをどうすればよいかまったく思いつかないことがあります。アイドル検出 API を使用すると、アプリはユーザーのアイドル時間を検出できます。ユーザーが長時間操作しないと、アプリは初期状態にリセットされ、キャンバスがクリアされます。この API は、通知権限の背後にゲートされています。これは、アイドル状態の検出の多くの本番環境ユースケースが通知に関連しているためです。たとえば、ユーザーがアクティブに使用しているデバイスにのみ通知を送信する場合などです。

通知の権限が付与されていることを確認してから、アイドル検出器をインスタンス化します。ユーザーと画面の状態を含む、アイドル状態の変化をリッスンするイベント リスナーを登録します。ユーザーはアクティブ状態またはアイドル状態のいずれかであり、画面はロック解除またはロックのいずれかです。ユーザーが操作しないと、キャンバスがクリアされます。アイドル検出器に 60 秒のしきい値を設定します。

const idleDetector = new IdleDetector();
idleDetector.addEventListener('change', () => {
  const userState = idleDetector.userState;
  const screenState = idleDetector.screenState;
  console.log(`Idle change: ${userState}, ${screenState}.`);
  if (userState === 'idle') {
    clearCanvas();
  }
});

await idleDetector.start({
  threshold: 60000,
  signal,
});

そして、いつものように、ブラウザがサポートしている場合にのみこのコードを読み込みます。

if ('IdleDetector' in window) {
  import('./idle_detection.mjs');
}

Fugu Greetings アプリでは、[Ephemeral] チェックボックスがオンになっていて、ユーザーが長時間操作していないと、キャンバスがクリアされます。

ユーザーが長時間操作しなかったため、キャンバスがクリアされた Fugu Greetings アプリ。
[一時的] チェックボックスがオンになっていて、ユーザーが長時間操作していない場合、キャンバスはクリアされます。

結び

ふう、すごい経験だった。1 つのサンプルアプリにこれだけの API が含まれています。また、ブラウザがサポートしていない機能のダウンロード費用をユーザーに負担させることはありません。プログレッシブ エンハンスメントを使用することで、関連するコードのみが読み込まれるようにしています。HTTP/2 ではリクエストのコストが低いため、このパターンは多くのアプリケーションでうまく機能しますが、非常に大規模なアプリではバンドラーを検討することをおすすめします。

Chrome DevTools の [ネットワーク] タブに、ブラウザがサポートするコードを含むファイルのリクエストのみが表示されている。

すべてのプラットフォームがすべての機能をサポートしているわけではないため、アプリの見た目はブラウザごとに若干異なる場合がありますが、コア機能は常に存在し、特定のブラウザの機能に応じて段階的に強化されます。アプリがインストールされたアプリとして実行されているか、ブラウザタブで実行されているかによって、同じブラウザでもこれらの機能が異なる場合があります。

Android Chrome で実行されている Fugu Greetings。利用可能な多くの機能が表示されています。
デスクトップ Safari で実行されている Fugu Greetings。利用可能な機能が少ないことがわかります。
パソコン版 Chrome で実行されている Fugu Greetings。利用可能な機能が多数表示されています。

GitHub の Fugu をフォークできます。

Chromium チームは、高度な Fugu API に関して、より良い環境を構築するために懸命に取り組んでいます。アプリの構築時にプログレッシブ エンハンスメントを適用することで、すべてのユーザーに優れたベースライン エクスペリエンスを提供しつつ、より多くのウェブ プラットフォーム API をサポートするブラウザを使用しているユーザーにはさらに優れたエクスペリエンスを提供できます。アプリでプログレッシブ エンハンスメントをどのように活用されるか、楽しみにしています。

謝辞

Fugu Greetings に貢献してくれた Christian LiebelHemanth HM に感謝します。このドキュメントは、Joe MedleyKayce Basques によって校閲されました。Jake Archibald 氏には、サービス ワーカーのコンテキストにおける動的 import() の状況を把握するうえでご協力いただきました。