依據 2003 年的新趨勢,打造新型瀏覽器並逐步升級
2003 年 3 月,Nick Finck 和 Steve Champeon 站在網頁設計世界中,推出漸進式增強的概念。網頁設計策略會優先考慮載入核心網頁內容,然後逐步針對呈現方式和功能,增加更精細且技術層面的內容與功能。2003 年時,循序漸進的強化措施就是當時採用先進的 CSS 功能、不造成乾擾的 JavaScript,甚至是可擴充的向量圖形。2020 年及之後的改進是要使用新型瀏覽器功能。
新型 JavaScript
說到 JavaScript,最新的核心 ES 2015 JavaScript 功能可支援瀏覽器。新標準包含承諾、模組、類別、範本常值、箭頭函式、let
和 const
、預設參數、產生器、解構指派、保留和傳播、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
在本文中,我使用的是簡易的 PWA,稱為 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();
});
};
當 import 功能時,應該有「匯出」功能,以便使用者在本機儲存賀卡。儲存檔案的傳統方法,就是建立含有 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 的網路分頁。
但在 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 電腦版分享工作表。按一下「Share Article」按鈕後,您可以透過 macOS「訊息」應用程式等好友將文章連結分享給好友。
要做到這一點的程式碼相當簡單。我呼叫 navigator.share()
,並在物件中傳遞選擇性的 title
、text
和 url
。但如果想附加圖片,該怎麼做?Web Share API 的第 1 級尚不支援這項功能。好消息是「網頁共用層級 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 卡應用程式執行這項工作。
首先,我需要準備一個包含 blob 的 files
陣列 data
物件,接著是 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) 中輕觸「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 中,我輕觸「聯絡人」按鈕並選取兩名最喜歡的朋友,則「дертей ́имайлович Брин」和「:as·Soüæ from Брин」· 佩佩:會發現聯絡人除了只顯示他們的姓名或其他資訊,還會顯示聯絡人姓名等其他資訊。他們的名字隨後會收錄在我的問候卡上。
非同步剪貼簿 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 預覽應用程式中開啟圖片,然後複製到剪貼簿。當我點選「Paste」時,Fugu Greetings 應用程式接著會詢問我是否要允許應用程式查看剪貼簿中的文字和圖片。
最後,接受權限後,系統就會將圖片貼到應用程式中。反之亦然。我要將賀卡複製到剪貼簿。 當我開啟「預覽」時,依序點選「檔案」和「從剪貼簿新增」,問候語資訊卡會貼到新的未命名圖片中。
標記 API
另一個實用的 API 是 Badging API。
當然,Fugu Greetings 屬於可安裝的 PWA,也會提供可放置在應用程式座架或主畫面的應用程式圖示。示範 API 既有趣又簡單,就是在 Fugu Greetings 中使用筆觸計數器。我已新增事件監聽器,在發生 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。
Periodic Background Sync API
想要每天都有新的內容嗎? Fugu Greetings 應用程式有一項很棒的功能,它可在每天早上使用新的背景圖片開始賀卡來激勵自己。為此,應用程式使用 Periodic Background Sync API 完成這項作業。
第一步是在 Service Worker 註冊中「註冊」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);
}
};
第二個步驟是對 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 更新的當日問候卡圖片。
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()
取得 Wake Lock。我會傳遞 'screen'
字串,取得螢幕 Wake Lock。然後新增事件監聽器,以便在 Wake Lock 釋出時收到通知。
舉例來說,分頁瀏覽權限變更時,就可能發生這種情況。
如果發生這種情況,我可以等到分頁再次出現時,重新取得 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」核取方塊可使螢幕保持喚醒。
閒置偵測 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」(臨時) 核取方塊,且使用者閒置時間過長,面板就會清除。
正在關閉
呼,你知道這趟旅程吧。而且在一個範例應用程式中就能供許多 API 使用。請注意,我從未讓使用者因瀏覽器不支援的功能而支付下載費用。透過循序漸進的增強功能,可以確保只載入相關的程式碼。 由於 HTTP/2 要求的費用很低,此模式應適用於許多應用程式,不過您可以考慮針對超大型應用程式採用組合器。
由於並非所有平台都支援所有功能,核心功能都會持續存在,根據特定瀏覽器的功能逐步強化,因此應用程式在各個瀏覽器上的外觀可能會略有不同。請注意,視應用程式是透過安裝版應用程式執行,還是在瀏覽器分頁中執行而定,這些功能可能會在同一個瀏覽器和同一個瀏覽器中變更。
如果您對 Fugu Greetings 應用程式感興趣,請前往 GitHub 尋找並分支。
Chromium 團隊正努力將草坪打造成更環保的 Fugu API。 藉由在開發應用程式的過程中採用漸進式強化功能,我可確保每個人都能獲得良好且可靠的基準體驗,但如果使用支援更多 Web Platform API 的瀏覽器,就能享有更優質的體驗。期待看到您的應用程式採用漸進式增強功能。
特別銘謝
我感謝 Christian Liebel 和 Hemanth HM 對 Fugu Greetings 所做的貢獻。本文由 Joe Medley 和 Kayce Basques 審查。Jake Archibald 透過在服務工作站的結構定義中使用動態 import()
,讓我知道這個情況。