探索适用于 PWA 的全新与即将推出的浏览器功能:From Fugu With Love

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 中对其进行改进。

Fugu Greetings 基准 PWA,在一个大画布上绘制了“Google”字样。

试试 Fugu Paint

尝试这款应用后,您可以重新合成该应用,以创建自己的副本供您编辑。重新合成的网址与 glitch.com/edit/#!/bouncy-candytuft 类似(“bouncy-candytuft”将是适用于您的其他内容)。这种重新合成的网址可在全球范围内直接访问。登录您的现有帐号或在 Glitch 上新建一个帐号,以保存您的工作成果。点击“🕶 Show”按钮即可查看应用,托管应用的网址类似于 bouncy-candytuft.glitch.me(请注意,顶级域名为 .me 而非 .com)。

现在,您可以修改应用并进行改进了。每当您做出更改时,该应用都会重新加载,并且这些更改会直接显示出来。

显示对 HTML 文档进行修改的 Glitch IDE。

重新合成 Fugu Paint

以下任务最好按顺序完成,但如上所述,如果您没有兼容设备,可以随时跳过某个步骤。请记住,每个任务都标有 🐟(一种无害的淡水鱼)或 🐡(“小心处理”的河豚鱼),用于提醒您相应功能是否处于实验阶段。

请查看开发者工具中的“控制台”,了解当前设备是否支持某个 API。我们还会使用 Glitch,以便您可以在不同设备(例如手机和桌面设备)上轻松检查同一应用。

API 兼容性记录到了开发者工具中的“控制台”。

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.mjsimage_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 上的发布内容。

web.dev 网站的“Capabilities”部分的着陆页。

带有“capabilities”标记的所有文章

但还不止这些。对于尚未公布的动态资讯,您可以访问我们的 Fugu API 跟踪器,其中包含指向以下内容的链接:所有已发布的提案、处于源试用或开发者试用状态的提案、相关工作已开始的所有提案,以及正在考虑但尚未启动的所有提案。

Fugu API 跟踪器网站

Fugu API 跟踪器

此 Codelab 由 Thomas Steiner (@tomayac) 编写,我很乐意为您解答问题,非常期待听到您的反馈!特别感谢 Hemanth H.M (@GNUmanth)、Christian Liebel (@christianliebel)、Sven May (@Svenmay)、Lars Knudsen (@larsgk) 和 Jackie Han (@hanguokai) 对此 Codelab 的大力支持!