探索 PWA 的新瀏覽器功能:From Fugu With Love

1. 事前準備

漸進式網頁應用程式 (PWA) 是一種透過網路提供的應用程式軟體,採用常見的網頁技術建構而成,包括 HTML、CSS 和 JavaScript。這些 API 適用於使用符合標準的瀏覽器的任何平台。

在本程式碼研究室中,您會先從基準 PWA 開始,然後探索新的瀏覽器功能,最終讓 PWA 擁有超能力 🦸。

許多新瀏覽器功能仍在開發中,且尚未標準化,因此有時您需要設定瀏覽器標記才能使用這些功能。

必要條件

在本程式碼研究室中,您應熟悉新版 JavaScript,尤其是 Promise 和 async/await。由於並非所有平台都支援本程式碼研究室的所有步驟,因此如果您手邊有其他裝置 (例如 Android 手機或使用不同作業系統的筆電,與您編輯程式碼的裝置不同),有助於進行測試。除了使用真實裝置,您也可以嘗試使用模擬器 (例如 Android 模擬器) 或線上服務 (例如 BrowserStack),從目前的裝置進行測試。你也可以略過任何步驟,這些步驟彼此不相依。

建構項目

您將建構賀卡網頁應用程式,並瞭解如何運用瀏覽器的新功能和即將推出的功能,提升應用程式的效能,在特定瀏覽器上提供進階體驗,同時確保應用程式在所有現代瀏覽器上都能正常運作。

您將瞭解如何新增支援功能,例如檔案系統存取權、系統剪貼簿存取權、聯絡人擷取、定期背景同步、螢幕喚醒鎖定、分享功能等。

完成程式碼研究室後,您將充分瞭解如何運用新的瀏覽器功能逐步強化網頁應用程式,同時不會對使用不相容瀏覽器的部分使用者造成下載負擔,最重要的是,不會將他們排除在應用程式之外。

軟硬體需求

目前完全支援的瀏覽器包括:

建議使用特定開發人員管道。

2. Project Fugu

漸進式網頁應用程式 (PWA) 採用現代 API 建構及強化,提供更強大的功能、穩定性和可安裝性,讓您不受裝置與地點的限制,觸及全球各地的網路使用者。

其中有些 API 功能非常強大,如果處理不當,可能會出錯。就像河豚 🐡 一樣,切對了是美味佳餚,切錯了則可能致命 (但別擔心,這個程式碼實驗室不會發生任何問題)。

這就是 Web 功能專案的內部代號 (相關公司正在開發這些新 API) 為 Project Fugu 的原因。

無論是大型或小型企業,都能運用現有的網路功能建構純瀏覽器解決方案,與平台專屬途徑相比,通常能以較低的開發成本更快部署。

3. 開始操作

下載任一瀏覽器,然後前往 about://flags 設定下列執行階段標記 🚩,這在 Chrome 和 Edge 中都適用:

  • #enable-experimental-web-platform-features

啟用後,請重新啟動瀏覽器。

您將使用 Glitch 平台,因為這個平台可讓您代管 PWA,而且編輯器相當不錯。Glitch 也支援匯入及匯出至 GitHub,因此不必受制於特定廠商。前往 fugu-paint.glitch.me 試用應用程式。這是基本的繪圖應用程式 🎨,您會在程式碼研究室中加以改良。

Fugu Greetings 基準 PWA,畫布上繪有「Google」字樣。

試用應用程式後,請重新混音應用程式,建立自己的副本並進行編輯。混音的網址看起來會像 glitch.com/edit/#!/bouncy-candytuft (「bouncy-candytuft」會是其他名稱)。全球使用者都能直接存取這項重混作品。登入現有帳戶或在 Glitch 上建立新帳戶,即可儲存作品。按一下「🕶 Show」按鈕即可查看應用程式,而代管應用程式的網址會類似 bouncy-candytuft.glitch.me (請注意,頂層網域是 .me 而不是 .com)。

現在可以編輯及改善應用程式。每當您進行變更,應用程式就會重新載入,並直接顯示變更。

Glitch IDE 顯示 HTML 文件的編輯畫面。

建議依序完成下列工作,但如上所述,如果無法存取相容裝置,隨時可以略過步驟。請注意,每項工作都會標示 🐟 (無害的淡水魚) 或 🐡 (需要謹慎處理的河豚),提醒您功能是否為實驗性質。

在開發人員工具中查看主控台,確認目前裝置是否支援 API。我們也使用 Glitch,方便您在不同裝置上輕鬆查看同一個應用程式,例如在手機和桌上型電腦上。

API 相容性記錄在開發人員工具的控制台中。

4. 🐟 新增 Web Share API 支援

如果沒有人欣賞,再精彩的繪圖也會變得無趣。新增一項功能,讓使用者以電子賀卡的形式,與全世界分享自己的畫作。

Web Share API 支援分享檔案,您可能還記得,File 只是特定類型的 Blob。因此,在名為 share.mjs 的檔案中,匯入分享按鈕和便利函式 toBlob(),該函式會將畫布內容轉換為 Blob,並按照下方程式碼新增分享功能。

如果您已導入這項功能,但沒有看到按鈕,表示您的瀏覽器未導入 Web Share API。

import { shareButton, toBlob } from './script.mjs';

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);
  }
};

shareButton.style.display = 'block';
shareButton.addEventListener('click', async () => {
  return share('Fugu Greetings', 'From Fugu With Love', await toBlob());
});

5. 🐟 新增 Web Share Target API 支援

現在使用者可以分享使用應用程式製作的電子賀卡,您也可以允許使用者將圖片分享到應用程式,並將圖片轉換為電子賀卡。為此,您可以使用 Web Share Target API

在網頁應用程式資訊清單中,您需要告知應用程式可接受的檔案類型,以及在共用一或多個檔案時,瀏覽器應呼叫的網址。檔案 manifest.webmanifest 的以下摘錄內容顯示了這點。

{
  "share_target": {
    "action": "./share-target/",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
      "files": [
        {
          "name": "image",
          "accept": ["image/jpeg", "image/png", "image/webp", "image/gif"]
        }
      ]
    }
  }
}

服務工作人員接著會處理收到的檔案。網址 ./share-target/ 實際上不存在,應用程式只會在 fetch 處理常式中對其採取行動,並新增查詢參數 ?share-target,將要求重新導向至根網址:

self.addEventListener('fetch', (fetchEvent) => {
  /* 🐡 Start Web Share Target */
  if (
    fetchEvent.request.url.endsWith('/share-target/') &&
    fetchEvent.request.method === 'POST'
  ) {
    return fetchEvent.respondWith(
      (async () => {
        const formData = await fetchEvent.request.formData();
        const image = formData.get('image');
        const keys = await caches.keys();
        const mediaCache = await caches.open(
          keys.filter((key) => key.startsWith('media'))[0],
        );
        await mediaCache.put('shared-image', new Response(image));
        return Response.redirect('./?share-target', 303);
      })(),
    );
  }
  /* 🐡 End Web Share Target */

  /* ... */
});

應用程式載入後,會檢查是否已設定這個查詢參數,如果已設定,就會將共用圖片繪製到畫布上,並從快取中刪除該圖片。所有這些作業都會在 script.mjs 中進行:

const restoreImageFromShare = async () => {
  const mediaCache = await getMediaCache();
  const image = await mediaCache.match('shared-image');
  if (image) {
    const blob = await image.blob();
    await drawBlob(blob);
    await mediaCache.delete('shared-image');
  }
};

應用程式初始化時,就會使用這個函式。

if (location.search.includes('share-target')) {
  restoreImageFromShare();
} else {
  drawDefaultImage();
}

6. 🐟 新增匯入圖片支援

從頭開始繪製所有內容並不容易。新增功能,讓使用者將裝置中的本機圖片上傳至應用程式。

首先,請詳閱畫布的 drawImage() 函式。接著,請熟悉 <​input
type=file>
元素。

掌握這項知識後,您就可以編輯名為 import_image_legacy.mjs 的檔案,並加入下列程式碼片段。在檔案頂端,您會匯入匯入按鈕和便利函式 drawBlob(),方便您在畫布上繪製 Blob。

import { importButton, drawBlob } from './script.mjs';

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

importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
  const file = await importImage();
  if (file) {
    await drawBlob(file);
  }
});

7. 🐟 新增匯出圖片支援

使用者如何將應用程式建立的檔案儲存到裝置?傳統上,這項功能是透過 <​a
download>
元素達成。

export_image_legacy.mjs 檔案中,加入下列內容。匯入匯出按鈕,以及將畫布內容轉換為 Blob 的 toBlob() 便利函式。

import { exportButton, toBlob } from './script.mjs';

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

exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
  exportImage(await toBlob());
});

8. 🐟 新增 File System Access API 支援

分享是美德,但使用者可能還是想將最佳作品儲存到自己的裝置。新增可讓使用者儲存 (及重新開啟) 繪圖的功能。

先前您是使用<​input type=file>舊版方法匯入檔案,以及<​a download>舊版方法匯出檔案。現在,您將使用 File System Access API 提升體驗。

這個 API 可讓您開啟及儲存作業系統檔案系統中的檔案。編輯 import_image.mjsexport_image.mjs 這兩個檔案,加入下列內容。如要載入這些檔案,請從 script.mjs 移除 🐡 表情符號。

取代這行程式碼:

// Remove all the emojis for this feature test to succeed.
if ('show🐡Open🐡File🐡Picker' in window) {
  /* ... */
}

...改為:

if ('showOpenFilePicker' in window) {
  /* ... */
}

import_image.mjs 中:

import { importButton, drawBlob } from './script.mjs';

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

importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
  const file = await importImage();
  if (file) {
    await drawBlob(file);
  }
});

export_image.mjs 中:

import { exportButton, toBlob } from './script.mjs';

const exportImage = async () => {
  try {
    const handle = await window.showSaveFilePicker({
      suggestedName: 'fugu-greetings.png',
      types: [
        {
          description: 'Image file',
          accept: {
            'image/png': ['.png'],
          },
        },
      ],
    });
    const blob = await toBlob();
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
  await exportImage();
});

9. 🐟 Add Contacts Picker API Support

使用者可能會想在電子賀卡中加入訊息,並親自向某人問候。新增功能,讓使用者選取一或多位本機聯絡人,並將對方的名稱加入分享訊息。

在 Android 或 iOS 裝置上,聯絡人挑選器 API 可讓您從裝置的聯絡人管理應用程式挑選聯絡人,並將這些聯絡人傳回應用程式。編輯 contacts.mjs 檔案,並新增下列程式碼。

import { contactsButton, ctx, canvas } from './script.mjs';

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);
  }
};

contactsButton.style.display = 'block';
contactsButton.addEventListener('click', async () => {
  const contacts = await getContacts();
  if (contacts) {
    ctx.font = '1em Comic Sans MS';
    contacts.forEach((contact, index) => {
      ctx.fillText(contact.name.join(), 20, 16 * ++index, canvas.width);
    });
  }
});

10. 🐟 新增 Async Clipboard API 支援

使用者可能想將其他應用程式的圖片貼到您的應用程式,或是將您應用程式的繪圖複製到其他應用程式。請新增這項功能,讓使用者在應用程式內外複製及貼上圖片。非同步剪貼簿 API 支援 PNG 圖片,因此您現在可以讀取及寫入剪貼簿的圖片資料。

找出 clipboard.mjs 檔案並新增下列內容:

import { copyButton, pasteButton, toBlob, drawImage } from './script.mjs';

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

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);
  }
};

copyButton.style.display = 'block';
copyButton.addEventListener('click', async () => {
  await copy(await toBlob());
});

pasteButton.style.display = 'block';
pasteButton.addEventListener('click', async () => {
  const image = new Image();
  image.addEventListener('load', () => {
    drawImage(image);
  });
  image.src = URL.createObjectURL(await paste());
});

11. 🐟 新增 Badging API 支援

使用者安裝應用程式後,主畫面就會顯示圖示。你可以使用這個圖示傳達有趣資訊,例如特定繪圖的筆觸數量。

新增一項功能,讓使用者每畫一筆,徽章就會增加計數。徽章 API 可在應用程式圖示上設定數字徽章。每當發生 pointerdown 事件 (即筆觸出現時),您都可以更新徽章,並在畫布清除時重設徽章。

將下列程式碼放入 badge.mjs 檔案:

import { canvas, clearButton } from './script.mjs';

let strokes = 0;

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

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

12. 🐟 新增 Screen Wake Lock API 支援

有時使用者可能需要盯著圖畫看一會兒,讓靈感湧現。新增可讓螢幕保持喚醒狀態,並停止啟動螢幕保護程式的功能。螢幕喚醒鎖定 API 可防止使用者螢幕進入睡眠模式。發生「網頁可見度」定義的可見度變更事件時,喚醒鎖定會自動釋放。因此,網頁返回檢視畫面時,必須重新取得喚醒鎖定。

找出 wake_lock.mjs 檔案,並新增下列內容。如要測試這項功能是否正常運作,請將螢幕保護程式設定為一分鐘後顯示。

import { wakeLockInput, wakeLockLabel } from './script.mjs';

let wakeLock = null;

const requestWakeLock = async () => {
  try {
    wakeLock = await navigator.wakeLock.request('screen');
    wakeLock.addEventListener('release', () => {
      console.log('Wake Lock was released');
    });
    console.log('Wake Lock is active');
  } catch (err) {
    console.error(err.name, err.message);
  }
};

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

document.addEventListener('visibilitychange', handleVisibilityChange);

wakeLockInput.style.display = 'block';
wakeLockLabel.style.display = 'block';
wakeLockInput.addEventListener('change', async () => {
  if (wakeLockInput.checked) {
    await requestWakeLock();
  } else {
    wakeLock.release();
  }
});

13. 🐟 新增 Periodic Background Sync API 支援

從空白畫布開始可能會很無聊。您可以使用 Periodic Background Sync API,每天為使用者的畫布初始化新圖片,例如 Unsplash 的每日河豚相片

這需要兩個檔案:一個是註冊週期性背景同步的檔案 periodic_background_sync.mjs,另一個是處理下載每日圖片的檔案 image_of_the_day.mjs

periodic_background_sync.mjs 中:

import { periodicBackgroundSyncButton, drawBlob } from './script.mjs';

const getPermission = async () => {
  const status = await navigator.permissions.query({
    name: 'periodic-background-sync',
  });
  return status.state === 'granted';
};

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);
  }
};

navigator.serviceWorker.addEventListener('message', async (event) => {
  const fakeURL = event.data.image;
  const mediaCache = await getMediaCache();
  const response = await mediaCache.match(fakeURL);
  drawBlob(await response.blob());
});

const getMediaCache = async () => {
  const keys = await caches.keys();
  return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};

periodicBackgroundSyncButton.style.display = 'block';
periodicBackgroundSyncButton.addEventListener('click', async () => {
  if (await getPermission()) {
    await registerPeriodicBackgroundSync();
  }
  const mediaCache = await getMediaCache();
  let blob = await mediaCache.match('./assets/background.jpg');
  if (!blob) {
    blob = await mediaCache.match('./assets/fugu_greeting_card.jpg');
  }
  drawBlob(await blob.blob());
});

image_of_the_day.mjs 中:

const getImageOfTheDay = async () => {
  try {
    const fishes = ['blowfish', 'pufferfish', 'fugu'];
    const fish = fishes[Math.floor(fishes.length * Math.random())];
    const response = await fetch(`https://source.unsplash.com/daily?${fish}`);
    if (!response.ok) {
      throw new Error('Response was', response.status, response.statusText);
    }
    return await response.blob();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const getMediaCache = async () => {
  const keys = await caches.keys();
  return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};

self.addEventListener('periodicsync', (syncEvent) => {
  if (syncEvent.tag === 'image-of-the-day-sync') {
    syncEvent.waitUntil(
      (async () => {
        try {
          const blob = await getImageOfTheDay();
          const mediaCache = await getMediaCache();
          const fakeURL = './assets/background.jpg';
          await mediaCache.put(fakeURL, new Response(blob));
          const clients = await self.clients.matchAll();
          clients.forEach((client) => {
            client.postMessage({
              image: fakeURL,
            });
          });
        } catch (err) {
          console.error(err.name, err.message);
        }
      })(),
    );
  }
});

14. 🐟 新增 Shape Detection API 支援

有時,使用者繪製的內容或使用的背景圖片可能含有條碼等實用資訊。形狀偵測 API (特別是條碼偵測 API) 可協助您擷取這項資訊。新增一項功能,嘗試從使用者的繪圖中偵測條碼。找出 barcode.mjs 檔案,並新增下列內容。如要測試這項功能,只要在畫布上載入或貼上含有條碼的圖片,你可以從圖片搜尋 QR code 中複製條碼範例。

/* global BarcodeDetector */
import {
  scanButton,
  clearButton,
  canvas,
  ctx,
  CANVAS_BACKGROUND,
  CANVAS_COLOR,
  floor,
} from './script.mjs';

const barcodeDetector = new BarcodeDetector();

const detectBarcodes = async (canvas) => {
  return await barcodeDetector.detect(canvas);
};

scanButton.style.display = 'block';
let seenBarcodes = [];
clearButton.addEventListener('click', () => {
  seenBarcodes = [];
});
scanButton.addEventListener('click', async () => {
  const barcodes = await detectBarcodes(canvas);
  if (barcodes.length) {
    barcodes.forEach((barcode) => {
      const rawValue = barcode.rawValue;
      if (seenBarcodes.includes(rawValue)) {
        return;
      }
      seenBarcodes.push(rawValue);
      ctx.font = '1em Comic Sans MS';
      ctx.textAlign = 'center';
      ctx.fillStyle = CANVAS_BACKGROUND;
      const boundingBox = barcode.boundingBox;
      const left = boundingBox.left;
      const top = boundingBox.top;
      const height = boundingBox.height;
      const oneThirdHeight = floor(height / 3);
      const width = boundingBox.width;
      ctx.fillRect(left, top + oneThirdHeight, width, oneThirdHeight);
      ctx.fillStyle = CANVAS_COLOR;
      ctx.fillText(
        rawValue,
        left + floor(width / 2),
        top + floor(height / 2),
        width,
      );
    });
  }
});

15. 🐡 新增 Idle Detection API 支援

假設您的應用程式在類似資訊亭的設定中執行,在一段時間沒有活動後重設畫布會是實用的功能。閒置偵測 API 可偵測使用者何時停止與裝置互動。

找到 idle_detection.mjs 檔案,並貼上下列內容。

import { ephemeralInput, ephemeralLabel, clearCanvas } from './script.mjs';

let controller;

ephemeralInput.style.display = 'block';
ephemeralLabel.style.display = 'block';

ephemeralInput.addEventListener('change', async () => {
  if (ephemeralInput.checked) {
    const state = await IdleDetector.requestPermission();
    if (state !== 'granted') {
      ephemeralInput.checked = false;
      return alert('Idle detection permission must be granted!');
    }
    try {
      controller = new AbortController();
      const idleDetector = new IdleDetector();
      idleDetector.addEventListener('change', (e) => {
        const { userState, screenState } = e.target;
        console.log(`idle change: ${userState}, ${screenState}`);
        if (userState === 'idle') {
          clearCanvas();
        }
      });
      idleDetector.start({
        threshold: 60000,
        signal: controller.signal,
      });
    } catch (err) {
      console.error(err.name, err.message);
    }
  } else {
    console.log('Idle detection stopped.');
    controller.abort();
  }
});

16. 🐡 新增 File Handling API 支援

如果使用者只要按兩下圖片檔案,您的應用程式就會彈出,File Handling API 可讓您達成這個目標。

您必須將 PWA 註冊為圖片的檔案處理常式。這會發生在網頁應用程式資訊清單中,下方摘錄的 manifest.webmanifest 檔案會顯示這項資訊。(這已是資訊清單的一部分,不需要自行新增)。

{
  "file_handlers": [
    {
      "action": "./",
      "accept": {
        "image/*": [".jpg", ".jpeg", ".png", ".webp", ".svg"]
      }
    }
  ]
}

如要實際處理開啟的檔案,請將下列程式碼新增至 file-handling.mjs 檔案:

import { drawBlob } from './script.mjs';

const handleLaunchFiles = () => {
  window.launchQueue.setConsumer((launchParams) => {
    if (!launchParams.files.length) {
      return;
    }
    launchParams.files.forEach(async (handle) => {
      const file = await handle.getFile();
      drawBlob(file);
    });
  });
};

handleLaunchFiles();

17. 恭喜

🎉 太棒了,你成功了!

在 Project Fugu 🐡 的背景下,有許多令人興奮的瀏覽器 API 正在開發中,本程式碼研究室僅觸及主題的面貌。

如要深入瞭解或只是想知道更多資訊,請追蹤我們在 web.dev 網站上發布的內容。

網站 web.dev 的「功能」部分到達網頁。

但這還沒結束。如要瞭解尚未公開的更新,請前往 Fugu API 追蹤器,查看所有已發布、處於 Origin 試用或開發人員試用階段的提案,以及已開始作業和正在考慮但尚未開始作業的所有提案。

Fugu API 追蹤工具網站

本程式碼研究室由 Thomas Steiner (@tomayac) 編寫,我很樂意回答您的問題,並期待收到您的意見回饋!特別感謝 Hemanth H.M (@GNUmanth)、Christian Liebel (@christianliebel)、Sven May (@Svenmay)、Lars Knudsen (@larsgk) 和 Jackie Han (@hanguokai) 協助製作本程式碼研究室!