1. 准备工作
渐进式 Web 应用 (PWA) 是一种通过 Web 呈现的应用软件,使用常见的 Web 技术(包括 HTML、CSS 和 JavaScript)构建而成。它们适合在使用符合标准的浏览器的任意平台上运行。
在此 Codelab 中,您将从基准 PWA 入手,然后探索最终会赋予您 PWA 超能力 🦸 的新浏览器功能。
这些新的浏览器功能中有很多正处于试验阶段,并且仍在进行标准化,因此有时您需要设置浏览器标记才能使用它们。
前提条件
对于此 Codelab,您应该熟悉现代 JavaScript,具体而言是 promise 和 async/await。由于并非所有平台都支持此 Codelab 的所有步骤,因此,如果您手头有其他设备(例如所用操作系统与用来修改代码的设备不同的 Android 手机或笔记本电脑),则有利于测试。除了实体设备之外,您还可以尝试使用模拟器(如 Android 模拟器)或通过在线服务(如 BrowserStack)从当前设备中进行测试。或者,您也可以直接跳过任何步骤,它们并不互相依赖。
构建内容
您将构建一个贺卡 Web 应用,并了解全新与即将推出的浏览器功能如何增强该应用,使其能在特定浏览器中提供高级体验(但在所有现代浏览器上都能正常使用)。
您将学习如何添加支持功能,例如文件系统访问权限、系统剪贴板访问权限、联系人检索、定期后台同步、屏幕唤醒锁定、共享功能等。
完成此 Codelab 后,您将对如何使用新的浏览器功能逐步增强 Web 应用有深刻的了解,同时还不会给部分碰巧使用不兼容浏览器的用户带来下载负担,最重要的是,不会一开始就将他们从您应用的目标对象中排除。
所需条件
目前完全受支持的浏览器包括:
建议使用特定的开发渠道。
2. Project Fugu
渐进式 Web 应用 (PWA) 使用现代 API 构建而成并得到改进,可提供增强的功能,并具有可靠性和可安装性,能覆盖全球各地使用任意类型的设备的任何用户。
其中一些 API 功能非常强大,如果处理不正确,可能会出现问题。就像河豚鱼 🐡 一样:如果切对了就是一道美味,但如果切错了则可能会致命(不必担心,此 Codelab 实际上不会出现任何中断)。
这就是 Web Capabilities 项目(参与此项目的公司正在其中开发这些新 API)的内部代号起名为 Project Fugu 的原因。
如今,无论企业规模如何,都可以利用 Web 功能开发完全基于浏览器的解决方案。与采用特定于平台的路线相比,这些方案通常可让您缩短部署时间,并降低开发成本。
3. 开始使用
下载任一浏览器,然后转到 about://flags
(同时适用于 Chrome 和 Edge),设置以下运行时标记 🚩:
#enable-experimental-web-platform-features
启用此标记后,重启浏览器。
您将使用 Glitch 平台,因为它允许您托管 PWA,并且它具有合适的编辑器。此外,Glitch 还支持导入和导出至 GitHub,因此没有供应商锁定。转到 fugu-paint.glitch.me 试用该应用。它是一个基本的绘图应用 🎨,您将在此 Codelab 中对其进行改进。
尝试这款应用后,您可以重新合成该应用,以创建自己的副本供您编辑。重新合成的网址与 glitch.com/edit/#!/bouncy-candytuft 类似(“bouncy-candytuft”将是适用于您的其他内容)。这种重新合成的网址可在全球范围内直接访问。登录您的现有帐号或在 Glitch 上新建一个帐号,以保存您的工作成果。点击“🕶 Show”按钮即可查看应用,托管应用的网址类似于 bouncy-candytuft.glitch.me(请注意,顶级域名为 .me
而非 .com
)。
现在,您可以修改应用并进行改进了。每当您做出更改时,该应用都会重新加载,并且这些更改会直接显示出来。
以下任务最好按顺序完成,但如上所述,如果您没有兼容设备,可以随时跳过某个步骤。请记住,每个任务都标有 🐟(一种无害的淡水鱼)或 🐡(“小心处理”的河豚鱼),用于提醒您相应功能是否处于实验阶段。
请查看开发者工具中的“控制台”,了解当前设备是否支持某个 API。我们还会使用 Glitch,以便您可以在不同设备(例如手机和桌面设备)上轻松检查同一应用。
4. 🐟 添加 Web Share API 支持
如果没有人欣赏,即使创作出最令人惊叹的绘图作品,也是无趣的。添加一项功能,让用户以贺卡的形式与全世界分享他们的绘图。
Web Share API 支持共享文件,而且您可能记得,File
只是一种特定类型的 Blob
。因此,在名为 share.mjs
的文件中,导入“分享”按钮和便捷函数 toBlob()
,以便将画布的内容转换为 blob,并根据以下代码添加分享功能。
如果您已实现此行为,但没有看到该按钮,则表明您的浏览器未实现 Web Share API。
import { shareButton, toBlob } from './script.mjs';
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);
}
};
shareButton.style.display = 'block';
shareButton.addEventListener('click', async () => {
return share('Fugu Greetings', 'From Fugu With Love', await toBlob());
});
5. 🐟 添加 Web Share Target API 支持
现在,用户可以分享使用该应用制作的贺卡,但您也可以让用户将图片分享到您的应用中并将其变为贺卡。为此,您可以使用 Web Share Target API。
在 Web 应用清单中,您需要告知应用您可以接受的文件类型,以及在共享一个或多个文件时浏览器应调用的网址。文件 manifest.webmanifest
的以下摘录展示了这一点。
{
"share_target": {
"action": "./share-target/",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"files": [
{
"name": "image",
"accept": ["image/jpeg", "image/png", "image/webp", "image/gif"]
}
]
}
}
}
然后,Service Worker 会处理收到的文件。网址 ./share-target/
实际上并不存在,该应用仅在 fetch
处理程序中对其执行操作,并通过添加查询参数 ?share-target
将请求重定向到根网址:
self.addEventListener('fetch', (fetchEvent) => {
/* 🐡 Start Web Share Target */
if (
fetchEvent.request.url.endsWith('/share-target/') &&
fetchEvent.request.method === 'POST'
) {
return fetchEvent.respondWith(
(async () => {
const formData = await fetchEvent.request.formData();
const image = formData.get('image');
const keys = await caches.keys();
const mediaCache = await caches.open(
keys.filter((key) => key.startsWith('media'))[0],
);
await mediaCache.put('shared-image', new Response(image));
return Response.redirect('./?share-target', 303);
})(),
);
}
/* 🐡 End Web Share Target */
/* ... */
});
应用加载后,它会检查此查询参数是否已设置;如果已设置,则将分享的图片绘制到画布上,并将其从缓存中删除。所有这些都发生在 script.mjs
中:
const restoreImageFromShare = async () => {
const mediaCache = await getMediaCache();
const image = await mediaCache.match('shared-image');
if (image) {
const blob = await image.blob();
await drawBlob(blob);
await mediaCache.delete('shared-image');
}
};
然后,系统会在应用初始化时使用此函数。
if (location.search.includes('share-target')) {
restoreImageFromShare();
} else {
drawDefaultImage();
}
6. 🐟 添加图片导入功能
从头开始绘制所有内容并非易事。添加一项功能,让用户可以将本地图片从其设备上传到应用中。
首先,研究画布的 drawImage()
函数。接下来,熟悉 <input
元素。
type=file>
掌握这些信息后,您就可以编辑名为 import_image_legacy.mjs
的文件并添加以下代码段。在该文件的顶部,您可以导入“导入”按钮和便捷函数 drawBlob()
,以便将 blob 绘制到画布上。
import { importButton, drawBlob } from './script.mjs';
const importImage = async () => {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png, image/jpeg, image/*';
input.addEventListener('change', () => {
const file = input.files[0];
input.remove();
return resolve(file);
});
input.click();
});
};
importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
const file = await importImage();
if (file) {
await drawBlob(file);
}
});
7. 🐟 添加图片导出功能
用户如何将在应用中创建的文件保存到其设备?一直以来,这都是通过 <a
元素实现的。
download>
在 export_image_legacy.mjs
文件中添加如下所示的内容。导入“导出”按钮和一个将画布内容转换为 blob 的便捷函数 toBlob()
。
import { exportButton, toBlob } from './script.mjs';
export const exportImage = async (blob) => {
const a = document.createElement('a');
a.download = 'fugu-greeting.png';
a.href = URL.createObjectURL(blob);
a.addEventListener('click', (e) => {
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
});
setTimeout(() => a.click(), 0);
};
exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
exportImage(await toBlob());
});
8. 🐟 添加 File System Access API 支持
分享就是关爱,但用户可能会希望将其最佳作品保存到自己的设备中。添加一项功能,让用户可以保存(并重新打开)绘图。
以前,您使用 <input type=file>
旧版方法导入文件,并使用 <a download>
旧版方法导出文件。现在,您将使用 File System Access API 改善这一体验。
借助此 API,您可以从操作系统的文件系统中打开和保存文件。添加下方内容,分别修改 import_image.mjs
文件和 export_image.mjs
文件。如需加载这些文件,请从 script.mjs
中移除 🐡 表情符号。
将以下行:
// Remove all the emojis for this feature test to succeed.
if ('show🐡Open🐡File🐡Picker' in window) {
/* ... */
}
...替换为以下行:
if ('showOpenFilePicker' in window) {
/* ... */
}
在 import_image.mjs
中:
import { importButton, drawBlob } from './script.mjs';
const importImage = async () => {
try {
const [handle] = await window.showOpenFilePicker({
types: [
{
description: 'Image files',
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.avif', '.webp', '.svg'],
},
},
],
});
return await handle.getFile();
} catch (err) {
console.error(err.name, err.message);
}
};
importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
const file = await importImage();
if (file) {
await drawBlob(file);
}
});
在 export_image.mjs
中:
import { exportButton, toBlob } from './script.mjs';
const exportImage = async () => {
try {
const handle = await window.showSaveFilePicker({
suggestedName: 'fugu-greetings.png',
types: [
{
description: 'Image file',
accept: {
'image/png': ['.png'],
},
},
],
});
const blob = await toBlob();
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
} catch (err) {
console.error(err.name, err.message);
}
};
exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
await exportImage();
});
9. 🐟 添加 Contacts Picker API 支持
用户可能希望向其贺卡添加消息,并私下发送给某人。添加一项功能,让用户可以选择一位或多位本地联系人,并将他们的姓名添加到分享消息中。
在 Android 或 iOS 设备上,您可以通过 Contact Picker API 从设备的通讯录管理器应用中选择联系人,然后将他们返回给应用。修改文件 contacts.mjs
并添加以下代码。
import { contactsButton, ctx, canvas } from './script.mjs';
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);
}
};
contactsButton.style.display = 'block';
contactsButton.addEventListener('click', async () => {
const contacts = await getContacts();
if (contacts) {
ctx.font = '1em Comic Sans MS';
contacts.forEach((contact, index) => {
ctx.fillText(contact.name.join(), 20, 16 * ++index, canvas.width);
});
}
});
10. 🐟 添加 Async Clipboard API 支持
用户可能希望将其他应用中的照片粘贴到您的应用中,或将您应用中的绘图复制到其他应用中。添加一项功能,让用户可以将图片复制并粘贴到您的应用中,以及将您应用中的绘图复制并粘贴到别处。Async Clipboard API 支持 PNG 图片,因此您现在可以读取图片数据并将其复制到剪贴板。
找到 clipboard.mjs
文件并添加以下代码:
import { copyButton, pasteButton, toBlob, drawImage } from './script.mjs';
const copy = async (blob) => {
try {
await navigator.clipboard.write([
/* global ClipboardItem */
new ClipboardItem({
[blob.type]: blob,
}),
]);
} catch (err) {
console.error(err.name, err.message);
}
};
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);
}
};
copyButton.style.display = 'block';
copyButton.addEventListener('click', async () => {
await copy(await toBlob());
});
pasteButton.style.display = 'block';
pasteButton.addEventListener('click', async () => {
const image = new Image();
image.addEventListener('load', () => {
drawImage(image);
});
image.src = URL.createObjectURL(await paste());
});
11. 🐟 添加 Badging API 支持
当用户安装您的应用时,其主屏幕上会显示一个图标。您可以使用此图标传达趣味信息,例如指定绘图的笔触数。
添加一项功能,该功能会在每次用户生成新笔触时为标记计数。Badging API 允许在应用图标上设置数字标记。您可以在发生 pointerdown
事件(即有笔触生成时)时更新此标记,并在画布被清空时重置此标记。
将以下代码添加到 badge.mjs
文件中:
import { canvas, clearButton } from './script.mjs';
let strokes = 0;
canvas.addEventListener('pointerdown', () => {
navigator.setAppBadge(++strokes);
});
clearButton.addEventListener('click', () => {
strokes = 0;
navigator.setAppBadge(strokes);
});
12. 🐟 添加 Screen Wake Lock API 支持
有时,用户可能需要片刻时间来目视绘图,待获得灵感后才执行操作。添加一项可让屏幕保持唤醒状态并阻止屏保启动的功能。Screen Wake Lock API 可防止用户屏幕进入休眠状态。当发生由 Page Visibility 定义的可见性更改事件时,系统会自动解除唤醒锁定。因此,当页面恢复可见时,必须重新获取唤醒锁定。
找到 wake_lock.mjs
文件并添加下方内容。若要测试此功能是否正常运行,请将屏保配置为在一分钟后显示。
import { wakeLockInput, wakeLockLabel } from './script.mjs';
let wakeLock = null;
const requestWakeLock = async () => {
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => {
console.log('Wake Lock was released');
});
console.log('Wake Lock is active');
} catch (err) {
console.error(err.name, err.message);
}
};
const handleVisibilityChange = () => {
if (wakeLock !== null && document.visibilityState === 'visible') {
requestWakeLock();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
wakeLockInput.style.display = 'block';
wakeLockLabel.style.display = 'block';
wakeLockInput.addEventListener('change', async () => {
if (wakeLockInput.checked) {
await requestWakeLock();
} else {
wakeLock.release();
}
});
13. 🐟 添加 Periodic Background Sync API 支持
从空白的画布开始着手可能会很乏味。借助 Periodic Background Sync API,您可以每天用一张新图片(例如 Unsplash 的每日河豚照片)初始化用户的画布。
这需要两个文件:periodic_background_sync.mjs
和 image_of_the_day.mjs
,一个用于注册 Periodic Background Sync API,另一个用于处理当日图片的下载。
在 periodic_background_sync.mjs
中:
import { periodicBackgroundSyncButton, drawBlob } from './script.mjs';
const getPermission = async () => {
const status = await navigator.permissions.query({
name: 'periodic-background-sync',
});
return status.state === 'granted';
};
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);
}
};
navigator.serviceWorker.addEventListener('message', async (event) => {
const fakeURL = event.data.image;
const mediaCache = await getMediaCache();
const response = await mediaCache.match(fakeURL);
drawBlob(await response.blob());
});
const getMediaCache = async () => {
const keys = await caches.keys();
return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};
periodicBackgroundSyncButton.style.display = 'block';
periodicBackgroundSyncButton.addEventListener('click', async () => {
if (await getPermission()) {
await registerPeriodicBackgroundSync();
}
const mediaCache = await getMediaCache();
let blob = await mediaCache.match('./assets/background.jpg');
if (!blob) {
blob = await mediaCache.match('./assets/fugu_greeting_card.jpg');
}
drawBlob(await blob.blob());
});
在 image_of_the_day.mjs
中:
const getImageOfTheDay = async () => {
try {
const fishes = ['blowfish', 'pufferfish', 'fugu'];
const fish = fishes[Math.floor(fishes.length * Math.random())];
const response = await fetch(`https://source.unsplash.com/daily?${fish}`);
if (!response.ok) {
throw new Error('Response was', response.status, response.statusText);
}
return await response.blob();
} catch (err) {
console.error(err.name, err.message);
}
};
const getMediaCache = async () => {
const keys = await caches.keys();
return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};
self.addEventListener('periodicsync', (syncEvent) => {
if (syncEvent.tag === 'image-of-the-day-sync') {
syncEvent.waitUntil(
(async () => {
try {
const blob = await getImageOfTheDay();
const mediaCache = await getMediaCache();
const fakeURL = './assets/background.jpg';
await mediaCache.put(fakeURL, new Response(blob));
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage({
image: fakeURL,
});
});
} catch (err) {
console.error(err.name, err.message);
}
})(),
);
}
});
14. 🐟 添加 Shape Detection API 支持
有时,用户的绘图或使用过的背景图片可能包含实用信息,例如条形码。借助 Shape Detection API(具体而言是 Barcode Detection API),您可以提取此信息。添加一项尝试检测用户绘图中的条形码的功能。找到 barcode.mjs
文件并添加下方内容。若要测试此功能,只需将包含条形码的图片加载或粘贴到画布中即可。您可以从二维码的图片搜索结果中复制一个示例二维码。
/* global BarcodeDetector */
import {
scanButton,
clearButton,
canvas,
ctx,
CANVAS_BACKGROUND,
CANVAS_COLOR,
floor,
} from './script.mjs';
const barcodeDetector = new BarcodeDetector();
const detectBarcodes = async (canvas) => {
return await barcodeDetector.detect(canvas);
};
scanButton.style.display = 'block';
let seenBarcodes = [];
clearButton.addEventListener('click', () => {
seenBarcodes = [];
});
scanButton.addEventListener('click', async () => {
const barcodes = await detectBarcodes(canvas);
if (barcodes.length) {
barcodes.forEach((barcode) => {
const rawValue = barcode.rawValue;
if (seenBarcodes.includes(rawValue)) {
return;
}
seenBarcodes.push(rawValue);
ctx.font = '1em Comic Sans MS';
ctx.textAlign = 'center';
ctx.fillStyle = CANVAS_BACKGROUND;
const boundingBox = barcode.boundingBox;
const left = boundingBox.left;
const top = boundingBox.top;
const height = boundingBox.height;
const oneThirdHeight = floor(height / 3);
const width = boundingBox.width;
ctx.fillRect(left, top + oneThirdHeight, width, oneThirdHeight);
ctx.fillStyle = CANVAS_COLOR;
ctx.fillText(
rawValue,
left + floor(width / 2),
top + floor(height / 2),
width,
);
});
}
});
15. 🐡 添加 Idle Detection API 支持
假设您的应用在类似自助服务终端的设置中运行,当用户处于非活动状态一段时间后,您可以重置画布。借助 Idle Detection API,您可以检测用户何时不再与其设备互动。
找到 idle_detection.mjs
文件并粘贴以下内容。
import { ephemeralInput, ephemeralLabel, clearCanvas } from './script.mjs';
let controller;
ephemeralInput.style.display = 'block';
ephemeralLabel.style.display = 'block';
ephemeralInput.addEventListener('change', async () => {
if (ephemeralInput.checked) {
const state = await IdleDetector.requestPermission();
if (state !== 'granted') {
ephemeralInput.checked = false;
return alert('Idle detection permission must be granted!');
}
try {
controller = new AbortController();
const idleDetector = new IdleDetector();
idleDetector.addEventListener('change', (e) => {
const { userState, screenState } = e.target;
console.log(`idle change: ${userState}, ${screenState}`);
if (userState === 'idle') {
clearCanvas();
}
});
idleDetector.start({
threshold: 60000,
signal: controller.signal,
});
} catch (err) {
console.error(err.name, err.message);
}
} else {
console.log('Idle detection stopped.');
controller.abort();
}
});
16. 🐡 添加 File Handling API 支持
如果用户只需双击某个图片文件,您的应用就会弹出来,该怎么实现?借助 File Handling API,您可以做到这一点。
您需要将该 PWA 注册为图片的文件处理程序。这发生在 Web 应用清单中,文件 manifest.webmanifest
的以下摘录展示了此功能。(这已是清单的一部分,无需您自行添加。)
{
"file_handlers": [
{
"action": "./",
"accept": {
"image/*": [".jpg", ".jpeg", ".png", ".webp", ".svg"]
}
}
]
}
若要实际处理已打开的文件,请将以下代码添加到 file-handling.mjs
文件中:
import { drawBlob } from './script.mjs';
const handleLaunchFiles = () => {
window.launchQueue.setConsumer((launchParams) => {
if (!launchParams.files.length) {
return;
}
launchParams.files.forEach(async (handle) => {
const file = await handle.getFile();
drawBlob(file);
});
});
};
handleLaunchFiles();
17. 恭喜
🎉 哇,您终于成功了!
在 Project Fugu 🐡 的背景下,我们开发了很多令人兴奋的浏览器 API,此 Codelab 仅仅是蜻蜓点水。
如需深入了解或只是了解详情,请关注网站 web.dev 上的发布内容。
但还不止这些。对于尚未公布的动态资讯,您可以访问我们的 Fugu API 跟踪器,其中包含指向以下内容的链接:所有已发布的提案、处于源试用或开发者试用状态的提案、相关工作已开始的所有提案,以及正在考虑但尚未启动的所有提案。
此 Codelab 由 Thomas Steiner (@tomayac) 编写,我很乐意为您解答问题,非常期待听到您的反馈!特别感谢 Hemanth H.M (@GNUmanth)、Christian Liebel (@christianliebel)、Sven May (@Svenmay)、Lars Knudsen (@larsgk) 和 Jackie Han (@hanguokai) 对此 Codelab 的大力支持!