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 試用應用程式。這是基本的繪圖應用程式 🎨,您會在程式碼研究室中加以改良。
試用應用程式後,請重新混音應用程式,建立自己的副本並進行編輯。混音的網址看起來會像 glitch.com/edit/#!/bouncy-candytuft (「bouncy-candytuft」會是其他名稱)。全球使用者都能直接存取這項重混作品。登入現有帳戶或在 Glitch 上建立新帳戶,即可儲存作品。按一下「🕶 Show」按鈕即可查看應用程式,而代管應用程式的網址會類似 bouncy-candytuft.glitch.me (請注意,頂層網域是 .me
而不是 .com
)。
現在可以編輯及改善應用程式。每當您進行變更,應用程式就會重新載入,並直接顯示變更。
建議依序完成下列工作,但如上所述,如果無法存取相容裝置,隨時可以略過步驟。請注意,每項工作都會標示 🐟 (無害的淡水魚) 或 🐡 (需要謹慎處理的河豚),提醒您功能是否為實驗性質。
在開發人員工具中查看主控台,確認目前裝置是否支援 API。我們也使用 Glitch,方便您在不同裝置上輕鬆查看同一個應用程式,例如在手機和桌上型電腦上。
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.mjs
和 export_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 網站上發布的內容。
但這還沒結束。如要瞭解尚未公開的更新,請前往 Fugu API 追蹤器,查看所有已發布、處於 Origin 試用或開發人員試用階段的提案,以及已開始作業和正在考慮但尚未開始作業的所有提案。
本程式碼研究室由 Thomas Steiner (@tomayac) 編寫,我很樂意回答您的問題,並期待收到您的意見回饋!特別感謝 Hemanth H.M (@GNUmanth)、Christian Liebel (@christianliebel)、Sven May (@Svenmay)、Lars Knudsen (@larsgk) 和 Jackie Han (@hanguokai) 協助製作本程式碼研究室!