為新式瀏覽器建構應用程式,並逐步改善,就像 2003 年一樣
2003 年 3 月,Nick Finck 和 Steve Champeon 提出漸進式增強概念,震驚了整個網頁設計界。這項網頁設計策略強調先載入核心網頁內容,然後再逐步在內容上方加入更精細且技術嚴謹的呈現層和功能。在 2003 年,漸進式改善是指使用當時的現代 CSS 功能、不顯眼的 JavaScript,甚至只是可調整向量圖形。2020 年及以後的漸進式改善功能,主要是使用新式瀏覽器功能。
新潮 JavaScript
談到 JavaScript,瀏覽器支援最新的 ES 2015 JavaScript 核心功能的情況非常理想。新標準包含承諾、模組、類別、範本文字常值、箭頭函式、let
和 const
、預設參數、產生器、結構重組指派、rest 和 spread、Map
/Set
、WeakMap
/WeakSet
等等。所有語言都支援。
非同步函式是 ES 2017 的功能,也是我個人最喜歡的功能之一,可在所有主要瀏覽器中使用。async
和 await
關鍵字可讓您以更簡潔的風格編寫非同步的承諾行為,避免明確設定承諾鏈結。
甚至連最近新增的 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
範例應用程式:Fugu Greetings
在本文中,我會使用名為 Fugu Greetings 的簡易 PWA (GitHub)。這個應用程式的名稱是向 Project Fugu 致敬 🐡?,Project Fugu 是為了讓網頁擁有 Android/iOS/電腦應用程式的所有功能。如要進一步瞭解這項專案,請前往到達網頁。
Fugu Greetings 是一款繪圖應用程式,可讓您製作虛擬賀卡,並傳送給親朋好友。這項範例展現了 PWA 的核心概念。這項服務可靠且完全支援離線使用,因此即使沒有網路,您還是可以使用這項服務。這個應用程式也可以安裝到裝置的主畫面,並以獨立應用程式的形式與作業系統完美整合。
漸進增強
說明完這點,我們就來談談漸進式強化。MDN 網路文件字典定義這個概念如下:
漸進式增強是一種設計理念,可為盡可能多的使用者提供基本內容和功能,同時只為可執行所有必要程式碼的最新瀏覽器使用者提供最佳體驗。
功能偵測通常用於判斷瀏覽器是否可處理較新式功能,而polyfill通常用於透過 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
屬性建立錨點連結,並將 blob 網址設為 href
。您也可以透過程式輔助方式「點選」該按鈕,觸發下載作業。此外,為了避免記憶體流失,請務必記得撤銷 Blob 物件網址。
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 的網路分頁。
不過,在支援 API 的 Chrome 瀏覽器上,系統只會載入新的指令碼。這項功能之所以能順利實現,要歸功於所有新式瀏覽器都支援的動態 import()
。如我先前所說,最近草地相當茂盛。
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 使用漸進式增強功能,我可以像以前一樣開啟檔案。匯入的檔案會直接繪製到畫布上。我可以編輯內容,最後透過真正的儲存對話方塊儲存內容,並選擇檔案名稱和儲存位置。檔案現在已準備就緒,可永久保存。
Web Share 和 Web Share Target API
除了永久儲存之外,我可能還想分享賀卡。 這正是 Web Share API 和 Web Share Target API 可讓我執行的操作。行動版和最近的電腦作業系統已獲得內建的分享機制。舉例來說,以下是 macOS 電腦版 Safari 的分享頁面,是由我的網誌上的文章觸發。點選「分享文章」按鈕後,你可以透過 macOS 訊息應用程式等方式,與朋友分享文章連結。
實現這項功能的程式碼相當簡單。我呼叫 navigator.share()
,並在物件中傳遞選用的 title
、text
和 url
。但如果我想附加圖片,該怎麼做?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 賀卡應用程式中使用這項功能。首先,我需要準備 data
物件,其中包含由一個 Blob 組成的 files
陣列,然後再準備 title
和 text
。接下來,我會使用新的 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 等只符合上述兩個條件之一的瀏覽器上,我不會載入這項功能。
const loadShare = () => {
if ('share' in navigator && 'canShare' in navigator) {
import('./share.mjs');
}
};
在 Fugu Greetings 中,如果我在 Android 上支援的瀏覽器 (例如 Chrome) 中輕觸「Share」按鈕,內建的分享工作表就會開啟。舉例來說,我可以選擇 Gmail,電子郵件撰寫工具小工具就會彈出,並附上圖片。
Contact Picker API
接下來,我想談談聯絡人,也就是裝置的電話簿或聯絡人管理應用程式。在寫賀卡時,不一定能正確寫出對方的姓名。舉例來說,我有一位朋友 Sergey,他偏好使用西里爾字母拼寫自己的名字。我使用德文 QWERTZ 鍵盤,不知道該如何輸入名稱。這正是 Contact Picker API 可以解決的問題。由於我將朋友儲存在手機的聯絡人應用程式中,因此我可以透過「Contacts Picker」API,在網路上存取聯絡人。
首先,我需要指定要存取的屬性清單。在本例中,我只需要名稱,但在其他用途中,我可能會想取得電話號碼、電子郵件、顯示圖片圖示或實際地址。接下來,我會設定 options
物件,並將 multiple
設為 true
,以便選取多個項目。最後,我可以呼叫 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 中,當我輕觸「聯絡人」按鈕並選取兩位好友 Сергей Михайлович Брин 和 劳伦斯·爱德华·"拉里"·佩奇 時,您可以看到聯絡人挑選器只會顯示他們的姓名,而不會顯示電子郵件地址或電話號碼等其他資訊。然後將他們的名字繪製在賀卡上。
非同步 Clipboard API
接下來要介紹複製和貼上功能。我們軟體開發人員最常用的操作之一就是複製和貼上。身為賀卡作者,我有時也會想這麼做。我可能想將圖片貼到正在製作的賀卡中,或是複製賀卡,以便在其他位置繼續編輯。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()
取得的剪貼簿項目。這是因為剪貼簿中可能有多個剪貼簿項目,且這些項目的呈現方式也不同。每個剪貼簿項目都有一個 types
欄位,可告知可用資源的 MIME 類型。我呼叫剪貼簿項目的 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 應用程式會詢問我是否允許應用程式查看剪貼簿中的文字和圖片。
最後,在接受權限後,圖片就會貼到應用程式中。反之亦然。我要將賀卡複製到剪貼簿。接著,我開啟「預覽」並依序點選「檔案」和「從剪貼簿新增」,賀卡就會貼到新未命名的圖片中。
Badging API
另一個實用的 API 是 Badging API。作為可安裝的 PWA,Fugu Greetings 當然有應用程式圖示,可供使用者放置在應用程式匣或主畫面上。您可以透過有趣又簡單的方式,在 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 的數字。圖示上的徽章計數器現在顯示為 7。
Periodic Background Sync API
想每天都能有新鮮感嗎? Fugu Greetings 應用程式提供的實用功能,是每天早上都能為您帶來靈感,提供新的背景圖片,讓您開始製作賀卡。應用程式會使用 Periodic Background Sync API 達成這項目標。
第一步是在服務工作者註冊程序中register定期同步處理事件。它會監聽名為 'image-of-the-day'
的同步標記,且最短間隔為一天,因此使用者每 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);
}
};
第二步是監聽服務 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 中使用的是傳統的 importScripts()
,而非動態 import()
(Service Worker 目前不支援動態 import()
)。
// 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 中觸發時,系統會顯示通知,就像其他通知一樣。但如同我先前所述,這項功能不需要網路連線。
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」核取方塊,勾選後螢幕就會保持喚醒狀態。
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」核取方塊,且使用者閒置太久,畫布就會清除。
Closing
呼,真是場精彩的比賽!一個範例應用程式就包含這麼多 API。請注意,我絕不會讓使用者為瀏覽器不支援的功能支付下載費用。透過漸進增強,我可以確保只載入相關程式碼。由於 HTTP/2 的請求成本低廉,因此這類模式應可適用於許多應用程式,但您可能會考慮為非常大型的應用程式使用 bundler。
由於並非所有平台都支援所有功能,因此應用程式在各瀏覽器上的外觀可能略有不同,但核心功能始終存在,並會根據特定瀏覽器的功能逐步強化。請注意,即使在同一個瀏覽器中,這些功能也可能會因應用程式是以已安裝的應用程式或在瀏覽器分頁中執行而有所不同。
如果您對 Fugu Greetings 應用程式有興趣,請前往 GitHub 分支。
Chromium 團隊正努力改善進階 Fugu API 的效能。在應用程式開發過程中,我會套用漸進式強化功能,確保所有使用者都能享有良好且穩固的基本體驗,而使用支援更多網頁平台 API 的瀏覽器的使用者,則能享有更優質的體驗。期待看到您在應用程式中使用漸進式增強功能的成果。
特別銘謝
我要感謝 Christian Liebel 和 Hemanth HM,他們為 Fugu Greetings 做出了貢獻。本文由 Joe Medley 和 Kayce Basques 審查。Jake Archibald 協助我找出服務工作者情境中動態 import()
的問題。