《Web 推送通知》系列翻译 | 第九篇:通知行为 && 第十篇:常用的通知模式

avatar
前端工程师 @上海阅文信息技术有限公司

第九篇:通知行为

原文地址:notification behaviour

译文地址:通知行为

译者:任家乐

校对者:刘文涛杨芯芯

到此为止,我们已经浏览了可以改变通知样式的选项,除了样式,我们还可以通过一些选项来改变通知的行为。

默认情况下,如果只设置视觉相关选项,调用 showNotification() 会出现以下行为:

  • 点击通知不会触发任何事件。
  • 每个新的通知会逐一有序地展示,浏览器不会以任何方式叠加展示通知。
  • 系统会以音效或震动的方式提示用户(具体方式则取决于设备系统)。
  • 在某些系统上,通知会在短时间展示后消失,而其他系统则会一直展示通知直到用户对其进行操作。(可以对比安卓和桌面的通知行为)

在这一节中,我们会探讨如何单独使用一些选项改变默认的通知行为,这相对来说比较容易实施和利用。

通知的点击事件

当用户点击通知时,默认不会触发任何事件,它并不会关闭或移除通知。

通知点击事件的常见用法是调用它来关闭通知、同时执行一些其他的逻辑(例如,打开一个窗口或对应用程序进行一些API调用)

为此,我们需要在 service worker 中添加一个 “notificationclick” 事件监听器。 这个事件将在点击通知时被调用。

self.addEventListener('notificationclick', function(event) {
  const clickedNotification = event.notification;
  clickedNotification.close();

  // 点击通知后做些什么
  const promiseChain = doSomething();
  event.waitUntil(promiseChain);
});

正如你在此示例中所看到的,被点击的通知可以通过 event.notification 参数来访问。通过这个参数我们可以获得通知的属性和方法,因此我们能够调用通知的 close() 方法,同时执行一些额外的操作。

提示:在程序运行高峰期,你仍然需要调用 event.waitUntil() 保证 service worker 的持续运行。

Actions

相比于之前的普通点击行为,actions 的使用可以提供给用户更高级别的交互体验。

在上一节中,我们知道了如何调用 showNotification() 来定义 actions

    const title = 'Actions Notification';
    const options = {
      actions: [
        {
          action: 'coffee-action',
          title: 'Coffee',
          icon: '/images/demos/action-1-128x128.png'
        },
        {
          action: 'doughnut-action',
          title: 'Doughnut',
          icon: '/images/demos/action-2-128x128.png'
        },
        {
          action: 'gramophone-action',
          title: 'gramophone',
          icon: '/images/demos/action-3-128x128.png'
        },
        {
          action: 'atom-action',
          title: 'Atom',
          icon: '/images/demos/action-4-128x128.png'
        }
      ]
    };

    const maxVisibleActions = Notification.maxActions;
    if (maxVisibleActions < 4) {
      options.body = `This notification will only display ` +
        `${maxVisibleActions} actions.`;
    } else {
      options.body = `This notification can display up to ` +
        `${maxVisibleActions} actions.`;
    }

    registration.showNotification(title, options);

如果用户点击了 action 按钮,通过 notificationclick 回调中返回的 event.action 就可以知道被点击的按钮是哪个。

event.action 会包含所有选项中有关 action 的值的集合。在上面的例子中,event.action 的值则会是: “coffee-action”、 “doughnut-action”、 “gramophone-action” 或 “atom-action” 的其中一个。

因此通过 event.action,我们可以检测到通知或 action 的点击,代码如下:

self.addEventListener('notificationclick', function(event) {
  if (!event.action) {
    // 正常的通知点击事件
    console.log('Notification Click.');
    return;
  }

  switch (event.action) {
    case 'coffee-action':
      console.log('User ❤️️\'s coffee.');
      break;
    case 'doughnut-action':
      console.log('User ❤️️\'s doughnuts.');
      break;
    case 'gramophone-action':
      console.log('User ❤️️\'s music.');
      break;
    case 'atom-action':
      console.log('User ❤️️\'s science.');
      break;
    default:
      console.log(`Unknown action clicked: '${event.action}'`);
      break;
  }
});
Logs for action button clicks and notification click.

Tag(标签)

tag 选项的本质是一个字符串类型的 ID,以此将通知 “分组” 在一起,并提供了一种简单的方法来向用户显示多个通知,这里可能用示例来解释最为简单:

让我们来展示一个通知,并给它标记一个 tag,例如 “message-group-1”。 我们可以按照如下代码来展示这个通知:

    const title = 'Notification 1 of 3';
    const options = {
      body: 'With \'tag\' of \'message-group-1\'',
      tag: 'message-group-1'
    };
    registration.showNotification(title, options);

这会展示我们定义好的第一个通知。

First notification with tag of message group 1.

我们再用一个新的 tag “message-group-2” 来标记并展示第二个通知,代码如下:

        const title = 'Notification 2 of 3';
        const options = {
          body: 'With \'tag\' of \'message-group-2\'',
          tag: 'message-group-2'
        };
        registration.showNotification(title, options);

这样会展示给用户第二个通知。

Two notifications where the second tag is message group 2.

现在让我们展示第三个通知,但不新增 tag,而是重用我们第一次定义的 tag “message-group-1”。这样操作会关闭之前的第一个通知并将其替换成新定义的通知。

        const title = 'Notification 3 of 3';
        const options = {
          body: 'With \'tag\' of \'message-group-1\'',
          tag: 'message-group-1'
        };
        registration.showNotification(title, options);

现在即使我们连续 3 次调用 showNotification() 也只会展示 2 个通知。

Two notifications where the first notification is replaced by a third notification.

tag 这个选项简单来看就是一个用于信息分组的方式,因此在新通知与已有通知标记为同一个tag时,当前被展示的所有旧通知将会被关闭。

使用 tag 有一个容易被忽略的小细节:当它替换了一个通知时,是没有音效和震动提醒的。

此时 Renotify 选项就有了用武之地。

Renotify(是否替换之前的通知)

在写此文时,这个选项大多数应用于移动设备。通过设置它,接收到新的通知时,系统会震动并播放系统音效。

某些场景下,你可能更希望替换通知时能够提醒到用户,而不是默默地进行。聊天应用则是一个很好的例子。这种情况你需要同时使用 tagRenotify 选项。

        const title = 'Notification 2 of 2';
        const options = {
          tag: 'renotify',
          renotify: true
        };
        registration.showNotification(title, options);

TypeError: Failed to execute 'showNotification' on 'ServiceWorkerRegistration':
Notifications which set the renotify flag must specify a non-empty tag

注意: 如果你设置了 Renotify: true 但却没有设置标签,会出现以下报错信息:

类型错误:不能够在 “ServiceWorkerRegistration” 上执行 “showNotification” 方法:设置了 renotify 标识的通知必须声明一个不为空的标签。(TypeError: Failed to execute 'showNotification' on 'ServiceWorkerRegistration':Notifications which set the renotify flag must specify a non-empty tag)

Silent(静音)

这一选项可以阻止设备震动、音效以及屏幕亮起的默认行为。如果你的通知不需要立马让用户注意到,这个选项是最合适的。

    const title = 'Silent Notification';
    const options = {
      silent: true
    };
    registration.showNotification(title, options);

注意: 如果同时设置了 silentRenotify,silent 选项会取得更高的优先级。

与通知进行交互

桌面 chrome 浏览器会展示通知一段时间后将其隐藏,而安卓设备的 chrome 浏览器不会有这种行为,通知会一直展示,直到用户对其进行操作。

如果要强制让通知持续展示直到用户对其操作,需要添加 requireInteraction 选项,此选项会展示通知直到用户消除或点击它。

    const title = 'Require Interaction Notification';
    const options = {
      body: 'With "requireInteraction: \'true\'".',
      requireInteraction: true
    };
    registration.showNotification(title, options);

请谨慎使用这个选项,因为一直展示通知、并强制让用户停下手头的事情来忽略通知可能会干扰到用户。

在下一节中,我们会浏览一些 web 上适用的用于管理通知的常见模式,以及如何执行一些常见的 actions,例如在点击通知时执行打开网页的行为。

第十篇:常用的通知模式

原文地址:common notification patterns

译文地址:常用的通知模式

译者:任家乐

校对者:刘文涛杨芯芯

此篇我们将会探索 Web 推送的一些常用模式,包括使用一些 service worker 提供的 API。

通知的关闭事件

在上一篇中,我们了解了如何监听 notificationclick 事件。

除了 notificationclick 事件,我们还可以监听 notificationclose 事件,它会在用户忽略其中一个通知(例如,用户点击了关闭按钮或划掉了通知,而不是点击了它)时被调用。

这个事件通常被用作数据分析,以此追踪用户与通知的互动情况。

self.addEventListener('notificationclose', function(event) {
  const dismissedNotification = event.notification;

  const promiseChain = notificationCloseAnalytics();
  event.waitUntil(promiseChain);
});

给通知添加数据

当收到推送的信息时,通常只需要获取用户点击后的有用数据。例如,获取用户点击通知时打开的页面地址。

如果需要将推送事件中获取的数据传递给通知,最简单的方式就是在调用 showNotification() 时,给参数 options 对象添加一个 data 属性,其值为对象类型,例如以下所示:

    const options = {
      body: 'This notification has data attached to it that is printed ' +
        'to the console when it\'s clicked.',
      tag: 'data-notification',
      data: {
        time: new Date(Date.now()).toString(),
        message: 'Hello, World!'
      }
    };
    registration.showNotification('Notification with Data', options);

在点击事件的回调内,可以通过 event.notification.data 来获取数据,例如:

  const notificationData = event.notification.data;
  console.log('');
  console.log('The data notification had the following parameters:');
  Object.keys(notificationData).forEach((key) => {
    console.log(`  ${key}: ${notificationData[key]}`);
  });
  console.log('');

打开一个窗口

对一个通知来说,打开指定地址的窗口/标签页可以说是一种最常见的反馈,这个我们可以通过 clients.openWindow() 来实现。

notificationclick 事件中,我们会运行类似下面的代码来实现以上需求:

  const examplePage = '/demos/notification-examples/example-page.html';
  const promiseChain = clients.openWindow(examplePage);
  event.waitUntil(promiseChain);

在下一节中,我们会看下如何检测用户点击通知后跳转的页面是否已被打开,如果已被打开,我们可以直接呼起已打开的标签页,而不是打开一个新的标签页。

呼起一个已打开的窗口

如果可能,我们应该呼起一个已打开的窗口,而不是在每次用户点击通知时都打开一个新的窗口。

在我们探索如何实现之前,值得提醒的是你只能够在与通知同域名的页面实现这个需求。因为我们只能检测我们自己站点的页面是否已被打开,这也避免了 开发者看到用户正在浏览的所有站点。

再来看下之前的例子,我们会对代码稍作调整来检测页面 '/demos/notification-examples/example-page.html' 是否已经被打开。

  const urlToOpen = new URL(examplePage, self.location.origin).href;

  const promiseChain = clients.matchAll({
    type: 'window',
    includeUncontrolled: true
  })
  .then((windowClients) => {
    let matchingClient = null;

    for (let i = 0; i < windowClients.length; i++) {
      const windowClient = windowClients[i];
      if (windowClient.url === urlToOpen) {
        matchingClient = windowClient;
        break;
      }
    }

    if (matchingClient) {
      return matchingClient.focus();
    } else {
      return clients.openWindow(urlToOpen);
    }
  });

  event.waitUntil(promiseChain);

让我们逐步浏览下代码。

首先,我们将示例中目标页面的地址传递给 URL API。这是我从 Jeff Posnick 那学到的一个巧妙的计策。 调用 new URL() 并传入 location 对象,如果传入的第一个参数是相对地址,则会返回页面的绝对地址(例如,“/” 会变成 “https://站点域名” )。

我们将地址转成了绝对地址则是为了之后与窗口的地址作对比。

  const urlToOpen = new URL(examplePage, self.location.origin).href;

之后,我们会通过调用 matchAll() 得到一系列 WindowClient 对象,包含了当前打开的标签页和窗口。(记住,这些标签页只是你域名下的页面)

  const promiseChain = clients.matchAll({
    type: 'window',
    includeUncontrolled: true
  })

matchAll 方法中传入的 options 对象则告诉浏览器我们只想获取 “window” 类型的对象(例如,只查看标签页、窗口,不包含 web workers [浏览器的其他工作线程])。 includeUncontrolled 属性表示我们只能获取没有被当前 service worker 控制的所有标签页(本域下),例如 service worker 正在运行当前代码。一般来说,在调用 matchAll() 时,你通常会将 includeUncontrolled 设置为 true。

我们 以promiseChain(promise 链式调用)的形式捕获返回的 promise 对象,因此之后可以将其传 入event.waitUntil() 方法中以此保持我们的 service worker 持续工作。

当上一步的 matchAll() 返回的 promise 对象已完成异步操作,我们就可以开始遍历返回的 window 对象,并将这些对象的 URL 和想要打开的目标 URL 进行对比,如果发现有匹配的,则调用 matchingClient.focus() 方法,它会呼起匹配的窗口,引起用户的注意。

如果没有与之匹配的 URL,我们则采用上一节的方式新开窗口打开地址。

  .then((windowClients) => {
    let matchingClient = null;

    for (let i = 0; i < windowClients.length; i++) {
      const windowClient = windowClients[i];
      if (windowClient.url === urlToOpen) {
        matchingClient = windowClient;
        break;
      }
    }

    if (matchingClient) {
      return matchingClient.focus();
    } else {
      return clients.openWindow(urlToOpen);
    }
  });

注意: 我们会返回 matchingClient.focus()clients.openWindow() 方法执行后返回的 promise 对象, 这样 promise 对象就可以组成我们的 promise 调用链了。

合并通知

我们已经看到,给一个通知添加标签后会导致用同一个标签标识的已有通知被替代。

但通过使用通知相关的 API,你可以更灵活地覆盖展示通知。比如一个聊天应用,开发者可能更希望用新的通知来展示"你有 2 条未读信息"等类似信息,而不是只展示最新接收到的信息。

你可以利用新的通知,或以其他方式操作当前已有通知,使用 registration.getNotifications()API 能够获得到你 APP 中所有当前展示的通知。

让我们看看如何使用这个 API 去实现刚说的聊天应用的例子。

在聊天应用中,我们假设每个通知都有一些包含用户名的数据。

我们要做的第一件事就是在所有已打开的通知中找到带有具体用户名的用户。首先调用 registration.getNotifications() 方法,之后遍历其结果检测 notification.data 中是否有具体用户名。

    const promiseChain = registration.getNotifications()
    .then(notifications => {
      let currentNotification;

      for(let i = 0; i < notifications.length; i++) {
        if (notifications[i].data &&
          notifications[i].data.userName === userName) {
          currentNotification = notifications[i];
        }
      }

      return currentNotification;
    })

下一步就是用新的通知来替换上一步中获得的通知。

在这个虚拟的消息应用中,我们会给新的通知添加一个累计新通知数量的数据,每产生新的通知都会累加这个计数,以此来记录用户收到的新信息的数量。

    .then((currentNotification) => {
      let notificationTitle;
      const options = {
        icon: userIcon,
      }

      if (currentNotification) {
        // 我们有一个已经打开的通知,让我们利用它来做些什么
        const messageCount = currentNotification.data.newMessageCount + 1;

        options.body = `You have ${messageCount} new messages from ${userName}.`;
        options.data = {
          userName: userName,
          newMessageCount: messageCount
        };
        notificationTitle = `New Messages from ${userName}`;

        // 记得关闭旧的通知
        currentNotification.close();
      } else {
        options.body = `"${userMessage}"`;
        options.data = {
          userName: userName,
          newMessageCount: 1
        };
        notificationTitle = `New Message from ${userName}`;
      }

      return registration.showNotification(
        notificationTitle,
        options
      );
    });

我们会累加当前展示的通知的信息数,同时依据这个数据来设置通知的主题和内容信息。如果当前没有展示通知,我们则会展示一个新的通知,其数据中 newMessageCount 的值为 1。

那么第一条信息的通知会是以下这样:

第二条通知会以这样的方式覆盖已有的通知:

Second notification with merging.

这种方法的好处是,如果你的用户目睹了通知一个接着一个的出现,相比于只用最新的信息替代当前的通知内容,消息看起来则更紧密相连。

各种规则的例外

我一直强调的是,你必须在收到推送信息时展示通知,这个规则在大多数情况下是正确的。只有在一种场景下你不需要展示通知, 那就是用户已经打开了你的站点,并且站点已经是呼起的状态。

在你的推送事件中,你可以通过检测目标窗口是否已被打开并呼起,来决定是否需要展示通知。

获得浏览器所有窗口、查询当前已呼起窗口的代码可以参考如下:

function isClientFocused() {
  return clients.matchAll({
    type: 'window',
    includeUncontrolled: true
  })
  .then((windowClients) => {
    let clientIsFocused = false;

    for (let i = 0; i < windowClients.length; i++) {
      const windowClient = windowClients[i];
      if (windowClient.focused) {
        clientIsFocused = true;
        break;
      }
    }

    return clientIsFocused;
  });
}

我们一般使用 clients.matchAll() 来获得当前浏览器下所有窗口对象, 然后遍历其结果去检查 focused 参数。

在推送事件内,我们会使用如下方法来决定是否展示通知:

  const promiseChain = isClientFocused()
  .then((clientIsFocused) => {
    if (clientIsFocused) {
      console.log('Don\'t need to show a notification.');
      return;

    }

    // 窗口并没有被呼起,我们需要展示通知
    return self.registration.showNotification('Had to show a notification.');
  });

  event.waitUntil(promiseChain);

通过推送事件给页面发送消息

我们已知,可以在用户正在浏览我们站点的时候不进行通知。但是,如果你仍然想让用户知道这个推送事件已经发生,但又觉得进行通知太过强硬,应该如何处理?

其中一个方法就是利用 service worker 给页面发送消息,这种情况下页面能够给用户展示通知或更新,以此让用户知晓到这个推送事件的发生。当然,只有当用户对于轻量级通知感到更友好时,这种做法才有用。

如果我们接收到了一个推送,并且检测到了我们的 APP 已经被打开了,那么我们就可以"发送消息"给每个打开的页面,就像以下这样:

  const promiseChain = isClientFocused()
  .then((clientIsFocused) => {
    if (clientIsFocused) {
      windowClients.forEach((windowClient) => {
        windowClient.postMessage({
          message: 'Received a push message.',
          time: new Date().toString()
        });
      });
    } else {
      return self.registration.showNotification('No focused windows', {
        body: 'Had to show a notification instead of messaging each page.'
      });
    }
  });

  event.waitUntil(promiseChain);

在每个页面中,我们通过监听消息(message)事件来接收消息:

    navigator.serviceWorker.addEventListener('message', function(event) {
      console.log('Received a message from service worker: ', event.data);
    });

在这个消息监听器中,你可以做任何你想做的事,例如展示自定义的视图,或者完全忽略这个消息。

值得注意的是,如果你没有在你的网页中定义消息监听器,那么 service worker 推送的消息将不会做任何事。

缓存页面和窗口对象

有一个场景可能超出了这本书的范畴,但是依然值得探讨,那就是缓存你希望用户点击通知后访问的网页,以此来提升web应用的整体用户体验。

这就需要设置你的 service worker 来处理这些 fetch 事件,但如果你监听了 fetch 事件,请确保在展示你的通知之前,通过缓存你需要的页面和资源来充分利用它在 push 事件中的优势。

想要了解更多缓存相关信息,请参考服务工作线程:简介

更多分享,请关注YFE:

上一篇:《Web 推送通知》系列翻译 | 第七篇:推送事件 && 第八篇:显示一个通知

下一篇:《Web 推送通知》系列翻译 | 第十一篇:FAQ && 第十二篇:常见问题以及错误反馈