针对现代浏览器构建应用,并像 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
等。全部受支持。
异步函数是 ES 2017 的一项功能,也是我个人最喜欢的功能之一,可在所有主流浏览器中使用。async
和 await
关键字可让您以更简洁的方式编写基于 Promise 的异步行为,从而避免明确配置 Promise 链。
即使是最近才添加的 ES 2020 语言功能(例如可选链接和nullish 合并),也很快就获得了支持。您可以在下方查看代码示例。 就核心 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(GitHub)的简单 PWA。此应用的名称是对 Project Fugu 🐡 的致敬,旨在赋予 Web 应用 Android/iOS/桌面应用的所有功能。您可以访问该项目的着陆页,详细了解该项目。
Fugu Greetings 是一款绘图应用,可让您制作虚拟贺卡并将其发送给亲朋好友。它体现了 PWA 的核心概念。它可靠且完全支持离线使用,因此即使您没有网络连接,也能使用它。它还可安装到设备的主屏幕上,并作为独立应用与操作系统无缝集成。
采用渐进增强的方式
了解完这些后,我们来谈谈渐进增强。 MDN 网页文档术语表对此概念进行了定义:
渐进式增强是一种设计理念,旨在为尽可能多的用户提供基本内容和功能的基准,同时仅为可以运行所有所需代码的最新浏览器用户提供尽可能出色的体验。
功能检测通常用于确定浏览器是否可以处理更现代的功能,而 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 网址作为其 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 API 和 Web Share Target API
除了永久存储之外,我可能还想分享贺卡。 Web Share API 和 Web Share Target API 可以帮助我实现这一点。移动操作系统和最近的桌面操作系统都获得了内置的分享机制。例如,以下是 macOS 上桌面版 Safari 的 Share Sheet,由我博客上的一篇文章触发。点击 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 贺卡应用中正常运行。
首先,我需要准备一个 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
接下来,我想谈谈通讯录,也就是设备的地址簿或通讯录管理应用。在写贺卡时,正确写出某人的名字并不总是容易的事。例如,我的朋友塞吉希望他的名字拼写为西里尔字母。我使用的是德语 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 中,当我点按联系人按钮并选择我的两个好友 Сергей Михайлович Брин 和 劳伦斯·爱德华·"拉里"·佩奇 时,您可以看到联系人选择器仅会显示他们的姓名,而不会显示他们的电子邮件地址或电话号码等其他信息。然后,系统会将他们的名字绘制到我的贺卡上。
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“预览”应用中打开了一个图片,并将其复制到剪贴板。 当我点击粘贴后,Fugu Greetings 应用会询问我是否要允许该应用查看剪贴板中的文字和图片。
最后,在接受权限后,图片将粘贴到该应用中。 反之亦然。我将一张贺卡复制到剪贴板。 然后,我打开“预览”,依次点击文件和从剪贴板中新建,系统会将贺卡粘贴到一个新的未命名图片中。
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');
}
在此示例中,我使用 1 到 7 之间的数字绘制数字,每个数字用一次笔触。 图标上的标记计数器现在显示为 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);
}
};
第二步是在 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 时才会加载代码。
这适用于客户端代码和服务工作器代码。在不支持的浏览器中,系统不会加载这两种脚本。请注意,在服务工作器中,我使用的是传统的 importScripts()
,而不是动态 import()
(目前尚不支持在服务工作器上下文中使用动态 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 每天更新。
通知触发器 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 中,有一个失眠复选框,选中该复选框后,屏幕会保持唤醒状态。
空闲检测 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 请求开销很低,因此此模式应该适用于许多应用,不过对于非常大的应用,您可能需要考虑使用捆绑器。
由于并非所有平台都支持所有功能,因此应用在每款浏览器中的显示效果可能略有不同,但核心功能始终存在,并且会根据特定浏览器的功能逐步增强。请注意,即使在同一浏览器中,这些功能也可能会发生变化,具体取决于应用是作为已安装的应用运行还是在浏览器标签页中运行。
如果您对 Fugu Greetings 应用感兴趣,请前往 GitHub 找到该应用并在 GitHub 上对其进行分叉。
Chromium 团队正努力使高级 Fugu API 使草地更加环保。 通过在应用开发过程中应用渐进式增强功能,我可以确保所有用户都能获得良好的基础体验,同时使用支持更多 Web 平台 API 的浏览器的用户还能获得更出色的体验。期待您在应用中使用渐进式增强功能取得的成效。
致谢
非常感谢 Christian Liebel 和 Hemanth HM 为 Fugu Greetings 做出的贡献。本文由 Joe Medley 和 Kayce Basques 审核。Jake Archibald 帮助我了解了服务工件上下文中动态 import()
的情况。