為新式瀏覽器建構,並像 2003 年一樣逐步強化
發布日期:2020 年 6 月 29 日
2003 年 3 月,Nick Finck 和 Steve Champeon 提出漸進式強化的概念,震驚了網頁設計界。這項網頁設計策略強調先載入核心網頁內容,然後在內容上逐步加入更細緻且技術嚴謹的呈現和功能層。2003 年時,漸進式強化是指使用當時的現代 CSS 功能、不顯眼的 JavaScript,甚至是可縮放向量圖形。2020 年以後的漸進式強化技術,是使用新式瀏覽器功能。

新式 JavaScript
說到 JavaScript,最新核心 ES 2015 JavaScript 功能的瀏覽器支援情況相當良好。新標準包含 Promise、模組、類別、樣板字面值、箭頭函式、let
和 const
、預設參數、產生器、解構指派、餘數和擴展、Map
/Set
、WeakMap
/WeakSet
等等。所有版本都支援。

非同步函式是 ES 2017 的功能,也是我個人最喜歡的功能之一,可在所有主要瀏覽器中使用。async
和 await
關鍵字可讓您以更簡潔的風格編寫非同步、以 Promise 為基礎的行為,不必明確設定 Promise 鏈。

甚至連 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
GitHub這個應用程式的名稱是向 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 的瀏覽器上,我會載入舊版指令碼。


不過,在支援 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 Greeting Card 應用程式使用這項功能。
首先,我需要準備 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);
}
};
與先前一樣,我使用漸進式增強功能。
如果 navigator
物件上同時存在 'share'
和 'canShare'
,我才會繼續使用動態 import()
載入 share.mjs
。在僅符合其中一項條件的瀏覽器 (例如行動版 Safari) 上,我不會載入這項功能。
const loadShare = () => {
if ('share' in navigator && 'canShare' in navigator) {
import('./share.mjs');
}
};
在 Fugu Greetings 中,如果我在 Android 上的 Chrome 等支援的瀏覽器中輕觸「分享」按鈕,系統會開啟內建的分享功能表。 舉例來說,我可以選擇 Gmail,然後電子郵件撰寫小工具就會彈出,並附上圖片。


Contact Picker API
接下來,我想談談聯絡人,也就是裝置的通訊錄或聯絡人管理應用程式。撰寫賀卡時,正確寫出對方的名字可能並不容易。舉例來說,我的朋友 Sergey 偏好以西里爾字母拼寫自己的名字。我使用德文 QWERTZ 鍵盤,完全不知道如何輸入對方的名字。聯絡人挑選器 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 中,當我輕觸「Contacts」按鈕並選取兩位好友「Сергей Михайлович Брин」和「劳伦斯·爱德华·"拉里"·佩奇」時,您會發現聯絡人挑選器只會顯示他們的姓名,不會顯示電子郵件地址或其他資訊 (例如電話號碼)。然後將他們的名字畫在我的賀卡上。


非同步剪貼簿 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。
Fugu Greetings 是可安裝的 PWA,因此當然有應用程式圖示,使用者可以將圖示放在應用程式 Dock 或主畫面上。在 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');
}
在本例中,我畫出從一到七的數字,每個數字都用一筆畫完。圖示上的徽章計數器現在顯示為 7。


Periodic Background Sync API
想每天都以新內容展開一天嗎?Fugu Greetings 應用程式的實用功能之一,就是每天早上提供新的背景圖片,讓您製作獨一無二的問候卡。應用程式會使用 Periodic Background Sync API 達成此目的。
第一步是在服務工作人員註冊中註冊定期同步事件。這個服務會監聽名為 '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);
}
};
第二步是在服務工作人員中監聽 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 環境尚不支援)。
// 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 中勾選「提醒」核取方塊後,系統會提示我何時要完成製作問候卡片。

在 Fugu Greetings 中觸發排定的通知時,系統會像顯示其他通知一樣顯示通知,但如我先前所述,這不需要網路連線。

Wake Lock API
我也想加入 Wake Lock API。 有時你只需要盯著螢幕夠久,靈感就會降臨。最糟的情況就是螢幕關閉。Wake Lock API 可避免這種情況發生。
第一步是使用 navigator.wakelock.request method()
取得喚醒鎖定。我將字串 'screen'
傳遞給這個方法,以取得螢幕 Wake Lock。
接著,我會新增事件監聽器,以便在喚醒鎖定解除時收到通知。
舉例來說,分頁的顯示設定變更時,就會發生這種情況。如果發生這種情況,當分頁再次顯示時,我可以重新取得喚醒鎖定。
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。

由於並非所有平台都支援所有功能,應用程式在不同瀏覽器上可能會略有不同,但核心功能一律存在,並會根據特定瀏覽器的功能逐步強化。即使是同一個瀏覽器,這些功能也可能會有所不同,取決於應用程式是以安裝的應用程式形式執行,還是以瀏覽器分頁的形式執行。



您可以在 GitHub 上 Fork Fugu。
Chromium 團隊正努力改善進階 Fugu API,在建構應用程式時套用漸進式強化功能,可確保每個人都能獲得優質的基礎體驗,但使用支援更多 Web 平台 API 的瀏覽器時,體驗會更上一層樓。期待看到您在應用程式中運用漸進式強化功能。
特別銘謝
感謝 Christian Liebel 和 Hemanth HM 對 Fugu Greetings 的貢獻。本文由 Joe Medley 和 Kayce Basques 審查。Jake Archibald 協助我瞭解服務工作人員環境中的動態 import()
情況。