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

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

2003 年 3 月、Nick Finck 氏と Steve 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 の機能で、個人的にも気に入っている機能の一つです。すべての主要ブラウザで使用できますasync キーワードと await キーワードを使用すると、非同期の Promise ベースの動作をよりクリーンなスタイルで記述できるため、Promise チェーンを明示的に構成する必要がなくなります。

すべての主要なブラウザでのサポート状況を示す、非同期関数の CanIUse サポート表。
非同期関数のブラウザ サポート表。(出典

オプショナル チェーン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 のコア機能に関しては、Google が優位です。 (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 をサポートしていないブラウザでは、従来のスクリプトを読み込みます。Firefox と Safari のネットワーク タブは次のとおりです。

以前のファイルが読み込まれている様子を示す Safari ウェブ インスペクタ。
Safari ウェブ インスペクタの [ネットワーク] タブ。
以前のファイルが読み込まれている様子を示した Firefox デベロッパー ツール。
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

保存するだけでなく、実際にグリーティング カードを共有したい場合。 これは、Web Share APIWeb Share Target API で実現できます。モバイル オペレーティング システム、最近ではデスクトップ オペレーティング システムにも、共有メカニズムが組み込まれています。たとえば、以下は、私のブログの記事からトリガーされた macOS 版デスクトップ Safari の共有シートです。[記事を共有] ボタンをクリックすると、記事へのリンクを友だちと共有できます(例: macOS メッセージ アプリ経由)。

記事の共有ボタンからトリガーされた macOS 版 Safari の共有シート
macOS のデスクトップ Safari の Web Share API。

これを実現するコードは非常に簡単です。navigator.share() を呼び出し、オブジェクト内のオプションの titletexturl を渡します。では、画像を添付したい場合はどうすればよいでしょうか?Web Share 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 配列、titletext を含む data オブジェクトを準備する必要があります。次に、ベスト プラクティスとして、新しい 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 つの条件のいずれかしか満たしていないため、この機能は読み込まれません。

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 キーボードを使用していますが、名前を入力する方法がわかりません。これは、Contact Picker 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 で [連絡先] ボタンをタップして、2 人の親友である Сергей Михайлович Брин劳伦斯·爱德华·"拉里"·佩奇 を選択すると、連絡先選択ツールに名前のみが表示され、メールアドレスや電話番号などの情報は表示されません。名前がグリーティング カードに描かれます。

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

非同期クリップボード API

次は、コピーと貼り付けです。ソフトウェア デベロッパーにとって、コピーと貼り付けはよく使う操作の一つです。グリーティング カードの作成者として、私も同じことをしたいことがあります。 作成中のグリーティング カードに画像を貼り付けたり、グリーティング カードをコピーして別の場所で編集を続けたりしたい場合があります。非同期クリップボード 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 フィールドがあります。前に取得した MIME タイプを渡して、クリップボード アイテムの getType() メソッドを呼び出します。

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

Badging API も便利な API です。インストール可能な PWA として、Fugu Greetings には、ユーザーがアプリドックまたはホーム画面に配置できるアプリアイコンがあります。API を簡単に試すには、Fugu Greetings でペンストロークのカウンタとして API を(不正に)使用します。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 本のペンストロークを描画しています。アイコンのバッチ カウンタが 7 になりました。

1 ~ 7 の数字が、それぞれ 1 本のペンで描かれたグリーティング カード。
7 本のペンストロークで 1 ~ 7 の数字を描画します。
番号 7 が表示された Fugu Greetings アプリのバッジ アイコン。
アプリアイコンのバッジ形式のペンストロークのカウンタ。

Periodic Background Sync API

毎日新しいことをして、フレッシュな気持ちでスタートしたいですか? Fugu Greetings アプリの便利な機能として、毎朝新しい背景画像が表示され、グリーティング カードの作成をサポートします。アプリは Periodic Background Sync API を使用してこれを実現します。

最初のステップは、Service Worker の登録で定期的な同期イベントをregisterすることです。'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 つ目のステップは、Service Worker で 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 で [壁紙] ボタンを押すと、その日のグリーティング カードの画像が表示されます。この画像は、Periodic Background Sync API を介して毎日更新されます。

1 日の新しいグリーティング カードの画像が表示された Fugu Greetings アプリ。
[壁紙] ボタンを押すと、その日の画像が表示されます。

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 のメッセージカードで [リマインダー] チェックボックスをオンにすると、メッセージカードの作成を完了するためのリマインダーをいつ表示するかを尋ねるメッセージが表示されます。

グリーティング カードの作成を完了するリマインダーをいつ表示するかをユーザーに尋ねるメッセージが表示された Fugu Greetings アプリ。
グリーティング カードの作成を完了するためのリマインダーとしてローカル通知をスケジュール設定。

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

Fugu Greetings からトリガーされた通知が表示された macOS の通知センター。
トリガーされた通知が 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

何時間も画面を見つめていても、何の役にも立たず、グリーティング カードをどうすればよいか、さっぱり思いつきません。Idle Detection 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 の [Network] パネル。
Chrome DevTools の [Network] タブ。現在のブラウザでサポートされているコードを含むファイルのリクエストのみが表示されています。

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

Android Chrome で動作する Fugu Greetings の画面。利用可能な多くの機能が表示されています。
Android Chrome で実行されている Fugu Greetings
パソコン版 Safari で実行されている Fugu Greetings で、利用可能な機能が少なくなっています。
Fugu Greetings がパソコンの Safari で実行されています。
パソコン版 Chrome で実行されている Fugu Greetings の画面。利用可能な多くの機能が表示されています。
デスクトップ版 Chrome で実行中の Fugu Greetings

Fugu Greetings アプリに興味をお持ちの場合は、GitHub でフォークしてください。

GitHub の Fugu Greetings リポジトリ。
GitHub の Fugu Greetings アプリ。

Chromium チームは、高度な Fugu API の改善に取り組んでいます。アプリの開発でプログレッシブ エンハンスメントを適用することで、すべてのユーザーに優れたベースライン エクスペリエンスを提供しながら、より多くのウェブ プラットフォーム API をサポートするブラウザを使用しているユーザーには、さらに優れたエクスペリエンスを提供できます。アプリでプログレッシブ エンハンスメントを活用していただけることを楽しみにしています。

謝辞

Christian LiebelHemanth HM の両氏に感謝します。両氏は「Fugu Greetings」に貢献しています。この記事は、Joe MedleyKayce Basques が確認しました。Jake Archibald は、サービス ワーカーのコンテキストで動的 import() の状況を把握するのに役立ちました。