为现代浏览器构建并像 2003 年那样逐步增强
早在 2003 年 3 月,Nick Finck 和 Steve Champeon 以 渐进增强 的概念震惊了 Web 设计界。渐进增强是一种 Web 设计策略,强调首先加载核心网页内容,然后在内容之上逐步添加更细致和技术上更严谨的演示层和功能。虽然在 2003 年,渐进增强是关于使用当时的现代 CSS 功能、不引人注目的 JavaScript,甚至只是可伸缩矢量图形。但在 2020 年及以后,渐进增强是关于使用 现代浏览器功能。

现代 JavaScript
说到 JavaScript,最新核心 ES 2015 JavaScript 功能的浏览器支持情况非常棒。新标准包括 promise、模块、类、模板字面量、箭头函数、let
和 const
、默认参数、生成器、解构赋值、rest 和 spread、Map
/Set
、WeakMap
/WeakSet
等等。所有这些都受到支持。

Async 函数,一个 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
在本文中,我使用一个简单的 PWA,名为 Fugu Greetings(GitHub)。此应用的名称是对 Project Fugu 🐡 的致敬,Project Fugu 🐡 是一项旨在赋予 Web Android/iOS/桌面应用程序所有功能的努力。您可以在其着陆页上阅读有关该项目的更多信息。
Fugu Greetings 是一个绘图应用程序,可让您创建虚拟贺卡,并将它们发送给您所爱的人。它体现了 PWA 的核心概念。它可靠且完全支持离线,因此即使您没有网络,您仍然可以使用它。它也可以安装到设备的主屏幕,并作为独立应用程序与操作系统无缝集成。

渐进增强
了解了这些之后,现在是时候谈谈渐进增强了。MDN Web Docs 术语表定义了这个概念,如下所示:
渐进增强是一种设计理念,它为尽可能多的用户提供基本内容和功能,同时仅为可以使用所有必需代码的最现代浏览器的用户提供最佳体验。
功能检测通常用于确定浏览器是否可以处理更现代的功能,而 polyfill 通常用于使用 JavaScript 添加缺失的功能。
[…]
渐进增强是一项有用的技术,它允许 Web 开发人员专注于开发最佳网站,同时使这些网站可以在多个未知的用户代理上运行。优雅降级与之相关,但与渐进增强不同,并且通常被视为与渐进增强朝着相反的方向发展。实际上,这两种方法都是有效的,并且通常可以相互补充。
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 URL 作为其 href
的锚链接。您还需要以编程方式“单击”它来触发下载,并且为了防止内存泄漏,希望不要忘记撤销 blob 对象 URL。
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 Messages 应用程序。

实现此目的的代码非常简单。我调用 navigator.share()
并传递给它一个可选的 title
、text
和 url
在一个对象中。但是如果我想附加图像呢?Web Share API 的 Level 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 卡片应用程序中使用此功能。首先,我需要准备一个 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)上点击分享按钮,则会打开内置的共享表单。例如,我可以选择 Gmail,电子邮件撰写器小部件会弹出,并附带该图像。


Contact Picker API
接下来,我想谈谈联系人,即设备的地址簿或联系人管理器应用程序。当您写贺卡时,正确书写某人的名字可能并不总是那么容易。例如,我有一个朋友 Sergey,他更喜欢用西里尔字母拼写他的名字。我使用的是德语 QWERTZ 键盘,不知道如何输入他们的名字。这是 Contact Picker API 可以解决的问题。由于我的朋友存储在我的手机联系人应用程序中,因此通过 Contacts Picker API,我可以从 Web 访问我的联系人。
首先,我需要指定我要访问的属性列表。在本例中,我只想要姓名,但对于其他用例,我可能对电话号码、电子邮件、头像图标或物理地址感兴趣。接下来,我配置一个 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 中,当我点击联系人按钮并选择我的两个最好的朋友 Сергей Михайлович Брин 和 劳伦斯·爱德华·“拉里”·佩奇 时,您可以看到联系人选择器如何仅限于显示他们的姓名,但不显示他们的电子邮件地址或电话号码等其他信息。然后,他们的名字被绘制到我的贺卡上。


Asynchronous 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 Preview 应用程序中打开一个图像并将其复制到剪贴板。当我单击粘贴时,Fugu Greetings 应用程序会询问我是否要允许该应用程序查看剪贴板上的文本和图像。

最后,在接受权限后,图像将粘贴到应用程序中。反过来也行得通。让我将贺卡复制到剪贴板。然后,当我打开 Preview 并单击文件,然后单击从剪贴板新建时,贺卡将粘贴到新的未命名图像中。

Badging API
另一个有用的 API 是 Badging API。作为可安装的 PWA,Fugu Greetings 当然有一个应用程序图标,用户可以将其放置在应用程序坞或主屏幕上。演示该 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');
}
在此示例中,我绘制了从一到七的数字,每个数字使用一个笔画。图标上的徽章计数器现在为七。


Periodic Background Sync API
想每天以新的事物开始新的一天吗?Fugu Greetings 应用程序的一个巧妙功能是,它可以每天早上用新的背景图像来激励您开始制作贺卡。该应用程序使用 Periodic Background Sync API 来实现此目的。
第一步是在 Service Worker 注册中注册定期同步事件。它监听名为 '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 中,我使用经典的 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 中,按下壁纸按钮会显示每日贺卡图像,该图像每天通过 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'
以获得屏幕唤醒锁。然后,我添加一个事件监听器,以便在唤醒锁被释放时收到通知。例如,当选项卡可见性更改时,可能会发生这种情况。如果发生这种情况,当选项卡再次变为可见时,我可以重新获取唤醒锁。
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 目前受 notifications permission 的限制,因为空闲检测的许多生产用例都与通知相关,例如,仅向用户当前正在积极使用的设备发送通知。
在确保授予通知权限后,我实例化空闲检测器。我注册一个事件监听器,监听空闲状态变化,包括用户和屏幕状态。用户可以是活动或空闲状态,屏幕可以是解锁或锁定状态。如果用户处于空闲状态,则画布将被清除。我给空闲检测器设置了 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 上 Fork 它。

Chromium 团队正在努力使高级 Fugu API 方面的情况变得更好。通过在我的应用程序开发中应用渐进式增强,我确保每个人都能获得良好、稳定的基线体验,但使用支持更多 Web 平台 API 的浏览器的人们可以获得更好的体验。我期待看到您在应用程序中如何使用渐进式增强。
致谢
我感谢 Christian Liebel 和 Hemanth HM 都为 Fugu Greetings 贡献了力量。本文由 Joe Medley 和 Kayce Basques 审阅。Jake Archibald 帮助我了解了 service worker 上下文中动态 import()
的情况。