像 2003 年那样构建适用于现代浏览器并逐步增强的版本
早在 2003 年 3 月,Nick Finck 和 Steve Champeon 通过渐进式增强这一概念让网页设计界感到惊叹不已。渐进式增强是一种网页设计策略,其重点是先加载核心网页内容,然后在内容之上逐步添加更精细、技术上更严谨的呈现层和功能。在 2003 年,渐进式增强适用于当时使用现代 CSS 功能、不显眼的 JavaScript,甚至只使用可缩放矢量图形。2020 年及以后的渐进式增强旨在使用现代浏览器功能。
现代 JavaScript
说到 JavaScript,浏览器对最新的 ES 2015 JavaScript 功能的支持情况还算不错。新标准包含 promise、模块、类、模板字面量、箭头函数、let
和 const
、默认参数、生成器、解构赋值、Rust 和 Spa 及 Map
/Set
、WeakMap
/WeakSet
等。全部受支持。
异步函数是 ES 2017 的一项功能,也是我个人最爱的功能之一,它可用于所有主流浏览器。
async
和 await
关键字能够以更简洁的样式编写基于 promise 的异步行为,而无需明确配置 promise 链。
甚至在 ES 2020 新增的一些超级新增功能(如可选链和 null 合并)中,也迅速获得了支持。您可以查看以下代码示例。 对于核心 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 Web 文档术语表对相关概念进行了如下定义:
渐进式增强是一种设计理念,旨在为尽可能多的用户提供基本内容和功能的基准,同时仅为能够运行所有必需代码的最现代浏览器用户提供尽可能最佳的体验。
功能检测通常用于确定浏览器是否可以处理更现代的功能,而 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();
});
};
当具有导入功能时,可能应该提供导出功能,以便用户可以将贺卡保存在本地。importimport保存文件的传统方法是创建一个具有 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 分享表单。点击 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 卡应用执行此操作。首先,我需要准备一个 data
对象,其中 files
数组包含一个 blob,然后是 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”,此时电子邮件撰写器微件会弹出,并附上图片。
联系人选择器 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 中,当我点按 Contacts(联系人)按钮并选择我的两个最佳伙伴(接 ПиOpenSSLайловиpreferences CONTACTрин和伦斯·爱德华·“拉里”·佩奇)后,您可以看到联系人选择器如何被限定为仅显示他们的姓名,而不显示其电话号码或其他信息。然后,他们的名字就会绘制到我的贺卡上。
异步剪贴板 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 预览版应用中打开了一个映像,并将其复制到剪贴板。当我点击粘贴时,Fug Greetings 应用会询问我是否允许该应用查看剪贴板中的文本和图片。
最后,在接受权限后,图片会粘贴到应用中。 反之亦然。 我把一张贺卡复制到剪贴板。 当我打开“预览”并点击文件,然后点击从剪贴板新建时,贺卡会粘贴到新的无标题图片中。
Badging API
另一个实用的 API 是 Badging API。当然,作为可安装的 PWA,Fug 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 之间的数字,每个数字使用一笔笔画。 图标上的勋章计数器现在为七。
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 代码。在不支持的浏览器上,这两者都不会加载。
请注意,在 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'
以获取屏幕唤醒锁定。然后,我添加一个事件监听器,以便在唤醒锁定被释放时收到通知。例如,当标签页可见性发生变化时,就会发生这种情况。如果发生这种情况,我可以在标签页再次变得可见时重新获取唤醒锁定。
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 平台 API 的浏览器的用户则可以获得更好的体验。期待看到您利用渐进式增强应用取得的成果。
致谢
感谢 Christian Liebel 和 Hemanth HM,他们为 Fugu Greetings 做出过贡献。本文由 Joe Medley 和 Kayce Basques 审核。
Jake Archibald 帮助我在 Service Worker 环境中发现了动态 import()
的情况。