从实际需求入手,一篇文章讲明白 Web Notification

背景

最近接到了一个需求,后端不定时会发告警信号,当前端收到的时候,在地图上显示出来。这是一个很简单的需求,开发出来效果如下:

文章所有示例图片以及代码均在 Github 仓库,大家按需使用。同时在此特别感谢高德 JSSDK 提供的大力支持,需要强调一下 ,源码里面使用了高德 JSSDK 的 key,如果开发者想要自己实现尝试,尽量在高德地图官网去注册一个自己的 key,是免费的!

上面的效果还可以,基本满足了业务需求,但是呢?领导说了,是不是可以将告警数量在浏览器 Tab 上也提示出来,这样的话用户即使在其它页面也可以知道这个页面有告警信息了!!!于是,开始一番探寻,最后效果如下:

完成之后本想着可以交差了,但是!领导看了之后又说,感觉还是不够明显,怕用户在其它页面的时候忽略了 Tab 的提示,因为感觉 icon 比较小,那样就看不到告警了,能不能在其它页面的时候也可以有提示并且提示的更明显一些?

好的,领导,你说的很对,下次别但是了!!!。哈哈哈,我相信很多开发同学应该内心都是这样的想法,但是没办法,咱们底层码农怎么敢这么说呢,只能硬着头皮去解决了,在一番探寻之后,发现了解决方案,开发出来效果如下:

本篇文章就针对这个实际需求入手,详细的讲解每部分对应的前端知识点以及实现细节,希望对大家能有所帮助~

网站 Icon 增加通知提醒

网站 Icon 增加提醒,此类需求常见于 OA 办公类以及 Web 聊天类的网站,比如下面这种:

我一向的原则就是,只要别人能实现,那么就说明有解决方案,那么我要做的就是技术调研最终找到一个合适的解决方案就行了,在这里我不敢称之为最佳实践,因为可能是我搜寻方式不对,这个方案通用具体的名称我也不知道应该叫什么,所以在网上没找到通用的解决方案,这里就算是笔者自己原创研究的 Hack 方案,如果有更好的解决方案欢迎评论区交流告知~

此方案所有代码均在前面提及的 Github 仓库有具体实现,实现源码使用的是 React,因为笔者也不能脱离实际项目来空谈。

监听网站 Tab 的活跃性

领导的需求是,这个网站打开了,那么用户再打开别的网站的时候,在 Tab 上能够提示出来,所以这里需要有一个 API 可以监听到浏览器 Tab 的活跃性,这个 API 就是监听浏览器活跃性的事件 ------ Document: Visibility Event,我们基于此事件就可以实现 Tab 活跃非活跃状态切换的监听~我这边写了一个 Hooks ------ usePageVisibility, 代码如下:

js 复制代码
// src/hooks/useVisibility.tsx
import { useEffect, useState } from "react";

export default function usePageVisibility() {
  const [isVisible, setIsVisible] = useState(!document.hidden);

  useEffect(() => {
    const handleVisibilityChange = () => {
      setIsVisible(!document.hidden);
    };

    document.addEventListener("visibilitychange", handleVisibilityChange);

    return () => {
      document.removeEventListener("visibilitychange", handleVisibilityChange);
    };
  }, []);

  return isVisible;
}

调用这个 Hooks 会返回当前页面是否是活跃的。

js 复制代码
const pageVisible = usePageVisibility();

实现网站隐藏的时候接收通知

前面实现了获取当前页面状态的 Hooks,那么现在的业务逻辑就简单了。

  1. 网站处于活跃状态,无需处理任何内容,因为提醒就直接被用户所看到
  2. 网站处于非活跃状态,当收到后端提醒的时候,更新网站 Icon,让用户看到提醒
  3. 当用户从非活跃点击 Tab 让网站重新变活跃的时候,再次将网站 Icon 更新回原来的即可

上面的逻辑就是一个非常简单的更新步骤,之所以说非常简单是因为其中还有一个技术细节需要深究优化,这里不细说,下面会详细讲解。这里我也封装了一个 Hooks ------ useWebIconToastify,具体代码如下:

  • useWebIconToastify.tsx 代码
js 复制代码
import { useEffect } from "react";
import usePageVisibility from "./usePageVisibility";

// 更新 Favicon 的函数
const updateFavicon = (href: string) => {
  const link: HTMLLinkElement =
    document.querySelector("link[rel*='icon']") ||
    document.createElement("link");
  link.type = "image/x-icon";
  link.rel = "shortcut icon";
  link.href = href;
  document.getElementsByTagName("head")[0].appendChild(link);
};

// 自定义 Hook 来封装更新 icon 的逻辑
function useWebIconTostify(needUpdated: boolean) {
  const pageVisible = usePageVisibility();
  useEffect(() => {
    if (!pageVisible && needUpdated) {
      // 页面不活跃且 needUpdated 为 true 时,更新图标
      updateFavicon("/assets/images/monitor-icon-red.svg");
    } else if (pageVisible) {
      // 页面变活跃时,重置图标
      updateFavicon("/monitor.svg");
    }
  }, [pageVisible, needUpdated]);
}

export default useWebIconTostify;
  • 业务逻辑代码
js 复制代码
// 地图提醒组件

const [updatedIcon, setUpdatedIcon] = useState(false);
useWebIconToastify(updatedIcon);

// 当接收到后端警告的时候,就设置为 true
if (hasWarn) {
  setUpdatedIcon(true);
}

上面的代码很简单,这个 Hooks 接受一个参数 needUpdated,如果这个值为 true,那么就更新 Icon,如果这个值为 false, 那么就还原成原来的 Icon。而业务逻辑部分各位根据自己的实际场景调整,因为每个具体的业务场景都是不一样的,逻辑代码部分我就不做过多介绍了。下面是实际效果:

大家仔细看网站的 Tab 可以看出来,当切换浏览器 Tab 的时候,如果继续收到后端的报警,那么网站的 Icon 会发生变化。至此,我们的网站提醒 Icon 的功能已经实现了一大半,接下来就是来实现我说的细节难题了。

实现 icon 增加提醒

前面我们实现了当页面隐藏的时候,收到提醒更新网站 Icon,关于这一点其实有两种方案:

  • 第一种:简单的方案

第一种方案,比较简单,我们这个提醒就是单纯的提醒,那么我们就不需要针对 Icon 做定制化,什么意思,就是我们只需要准备两个 Icon,可以自己准备,可以让 UI 准备,比如原本正常的 Icon 和一个带提醒的 Icon,这个 Icon 可以是原来的基础之上带了一个小红点的感觉,大概就像下面的意思:

如果你的需求上面这种方式就能满足了,那么你就可以直接往下看了。

  • 第二种方案:复杂到显示具体数量的方案

相信前面的 RocketChat 那个 Icon 提示大家都看到了,如果我有 10 条未读信息,那么 Icon 就会显示 10+,这个在前端里称之为 Badge(译为:徽章),那么这里面其实也是有两种方案。

方案 描述
方案一:提前备好 Icon 与前面简单的方案一样,可以提前准备带右角标的 Icon,但是因为带数量,所以就需要有个限制,比如从 1 - 9 准备 9 个,超过 9 个的通过第十个 Icon:9+ 来表示,这样也是可行的
方案二:动态生成 Icon 这个就属于一劳永逸了,你想显示什么 Icon 自己根据原来的 Icon 进行动态生成就可以了。

这里我们来实现方案二 ------ 动态生成 Icon。虽然名字起的高大上,其实就是用了原生 HTML5 Canvas API 的能力,方案就是通过 Canvas 将原来的 Icon 绘制在画板上,然后再动态更新一个徽章角标,设计一些样式即可,这样你想显示多少就显示多少,想显示什么样的提醒就显示什么样的提醒。废话不多说,直接来看代码:

js 复制代码
// 在网站上动态生成图标
const iconConfig = {
  width: 64,
  height: 64,
  size: 48,
};

// 创建一个 Canvas 元素
const canvas = document.createElement("canvas");
canvas.width = iconConfig.width;
canvas.height = iconConfig.height;
const ctx: CanvasRenderingContext2D = canvas.getContext("2d")!;

// 绘制 SVG
const svgIcon =
  '<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1709792146900" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1536" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48"><path d="M853.333333 454.144a341.333333 341.333333 0 1 0-455.111111 321.479111c-72.647111 25.201778-120.092444 72.533333-120.092444 126.122667h458.24c0-53.646222-44.202667-100.920889-113.777778-126.122667a339.911111 339.911111 0 0 0 230.684444-321.479111z m-334.961777 239.559111a179.655111 179.655111 0 1 1 0-359.310222 179.655111 179.655111 0 1 1 0 359.310222z" fill="#0e932e" p-id="1537" data-spm-anchor-id="a313x.search_index.0.i0.37253a81B17Cz8" class="selected"></path><path d="M512 435.2a90.282667 90.282667 0 0 0-88.405333 88.234667A88.291556 88.291556 0 1 0 512 435.2z" fill="#0e932e" p-id="1538" data-spm-anchor-id="a313x.search_index.0.i5.37253a81B17Cz8" class="selected"></path></svg>';
// 生成 icon blob
const svgBlob = new Blob([svgIcon], {
  type: "image/svg+xml;charset=utf-8",
});
// 创建一个网络可用的 url
const DOMURL = window.URL || window.webkitURL || window;
// 将 svgBlob 生成 DOMURL 之后下面会赋值给 image 进行加载
const url = DOMURL.createObjectURL(svgBlob);
// 通过图像加载器加载图像
const img = new Image();
img.onload = function () {
  const { width, height, size } = iconConfig;
  // 图像加载完成,使用 Canvas 绘制图像
  ctx.drawImage(img, 0, 0, width, height);
  // 绘制红色圆点
  ctx.beginPath();
  ctx.fillStyle = "red";
  ctx.arc(width - size / 2, size / 2, size / 2, 0, 2 * Math.PI);
  ctx.fill();
  // 将 Canvas 转换为 base64 编码的图像
  const base64Image = canvas.toDataURL("image/png");
  // 更新 icon
  updateFavicon(base64Image);
  // 清理资源
  DOMURL.revokeObjectURL(url);
};
img.src = url;

上面的代码我都添加了注释,其实思路打通了就非常简单,动态修改 Icon 肯定不是提前准备好 N 个图标然后替换,而是根据网站图标动态生成新的,网站图标本质就是个图片,我们将它用 Canvas 绘制在画布上,然后再在上面添加提醒就可以了。上面代码的效果就是在网站图标添加一个红色圆点。效果如下:

红点已经绘制成功了,接下来思路已经打开了,我们继续在红点上填充数字就可以了,这里我也封装了一个方法,直接上代码:

js 复制代码
function updateFaviconWithNumber(number: number) {
  // 在需要绘制图标的地方执行以下代码
  const iconConfig = {
    width: 64,
    height: 64,
    size: 48,
  };
  // 获取设备的像素比
  const dpr = window.devicePixelRatio || 1;
  // 创建一个 Canvas 元素
  const canvas = document.createElement("canvas");
  canvas.width = iconConfig.width * dpr; // 实际像素
  canvas.height = iconConfig.height * dpr; // 实际像素
  canvas.style.width = iconConfig.width + "px"; // CSS像素
  canvas.style.height = iconConfig.height + "px"; // CSS像素
  const ctx: CanvasRenderingContext2D = canvas.getContext("2d")!;
  // 根据设备像素比缩放Canvas的绘制内容
  ctx.scale(dpr, dpr);

  // 绘制 SVG
  const svgIcon =
    '<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1709792146900" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1536" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48"><path d="M853.333333 454.144a341.333333 341.333333 0 1 0-455.111111 321.479111c-72.647111 25.201778-120.092444 72.533333-120.092444 126.122667h458.24c0-53.646222-44.202667-100.920889-113.777778-126.122667a339.911111 339.911111 0 0 0 230.684444-321.479111z m-334.961777 239.559111a179.655111 179.655111 0 1 1 0-359.310222 179.655111 179.655111 0 1 1 0 359.310222z" fill="#0e932e" p-id="1537" data-spm-anchor-id="a313x.search_index.0.i0.37253a81B17Cz8" class="selected"></path><path d="M512 435.2a90.282667 90.282667 0 0 0-88.405333 88.234667A88.291556 88.291556 0 1 0 512 435.2z" fill="#0e932e" p-id="1538" data-spm-anchor-id="a313x.search_index.0.i5.37253a81B17Cz8" class="selected"></path></svg>';
  // 生成 icon blob
  const svgBlob = new Blob([svgIcon], {
    type: "image/svg+xml;charset=utf-8",
  });
  const DOMURL = window.URL || window.webkitURL || window;
  // 通过图像加载器加载图像
  const img = new Image();
  // 设置图像加载完成的回调
  const url = DOMURL.createObjectURL(svgBlob);
  img.onload = function () {
    const { width, height, size } = iconConfig;
    // 图像加载完成,使用 Canvas 绘制图像
    ctx.drawImage(img, 0, 0, width, height);
    // 绘制红色圆点
    ctx.beginPath();
    ctx.fillStyle = "red";
    ctx.arc(width - size / 2, size / 2, size / 2, 0, 2 * Math.PI);
    ctx.fill();
    // 绘制完圆点以后,绘制数字
    // 设置文本颜色为白色
    ctx.fillStyle = "white";
    // 根据传入的数字大小设置字体大小
    const fontSize =
      number <= 9 ? 42 * dpr : number <= 99 ? 36 * dpr : 32 * dpr;
    // 设置字体大小和类型
    ctx.font = `bold ${fontSize}px Arial`;
    // 设置文本水平居中
    ctx.textAlign = "center";
    // 设置文本垂直居中
    ctx.textBaseline = "middle";
    // 计算文本的x坐标
    const textX = (iconConfig.width - iconConfig.size / 2) * dpr;
    // 计算文本的y坐标
    const textY = (iconConfig.size / 2) * dpr;
    // 不超过 99 的数字,直接转为字符串,超过 99 的数字,转为 "99+"
    const numStr = number > 99 ? "99+" : number.toString();
    // 在Canvas上绘制文本
    ctx.fillText(numStr, textX, textY);
    // 将 Canvas 转换为 base64 编码的图像
    const base64Image = canvas.toDataURL("image/png");
    // 更新 icon
    updateFavicon(base64Image);
    // 清理资源
    DOMURL.revokeObjectURL(url);
  };
  img.src = url;
}

效果如下:

从上面可以看出来,给网站动态增加 Icon 这个需求是彻底满足了,但是呢我看了一下效果,99 以内的数字,看起来效果还不错,但是超过 99 显示 99+ 的效果就比较差了,这时候我们就可以动用上面的方案,对于 99+ 这种图标给 UI 要一个高保真的效果,两个方案相结合就可以了~完美解决!

【广告】:上面所有方案的设计与前端经验和前端基础都密不可分,无论是 JS 还是 HTML5 API,这里笔者自认为写的免费小册还是不错的,大家感兴趣可以去观看一波:HTML5 入门手册

网站推送通知

前面提到了,在实现完网站 Icon 提示之后,老板觉得这个提示并不明显,于是就有了是否网站可以给用户提供通知的能力,在继续探索实践中发现了 ------ Web Notification API,就有了下半部分文章的内容,请各位耐心观看!

什么是 Web Notification API

Web Notification API 允许网站向用户显示系统级的通知信息,这种信息即使在用户没有打开网站的页面时也可以展示出来。这对于实时应用来说非常有用,比如即时通讯、邮件客户端、社交媒体更新等,可以及时通知用户相关的信息。

为了发送通知,网站首先需要获得用户的同意。以下是一个简单的例子,演示如何请求用户权限并发送通知:

js 复制代码
// 请求通知权限
Notification.requestPermission().then(function (result) {
  if (result === "granted") {
    // 用户同意了通知权限
    const notification = new Notification("您好,我是网站机器人!", {
      body: "这是一个通知示例。",
      icon: "icon-url.png",
    });
  }
});

具体效果如下:

从上图可以看出,在第一次使用 Web Notification API 的时候,需要用户授权浏览器提示才可以正常显示。调用 Notification.requestPermission() 获取的 result 结果可能的值为:

  • default:用户还未被询问是否授权,所以通知不会被显示。

  • granted:表示之前已经询问过用户,并且用户已经授予了显示通知的权限。

  • denied:用户已经明确地拒绝了显示通知的权限。

在 Chrome 浏览器中,可以通过 chrome://settings/content/notifications 进入对应的消息通知管理页面重新设置对应的权限。

结合 Service Worker

通常来说,Web Notification API 很少单独使用,因为单独使用 Web Notification API 只能够做消息推送,用户是否看了,是否关了,用户是否想回到我们的应用页面这些都无从得知,也就是并不能形成业务闭环。通常来说建议 Web Notification API 与 Service Worker 搭配使用,从而可以达到更好的效果,比如在网站已关闭状态下依然可以发出消息提示,再比如如果网站处于未活跃状态,点击通知可以将页面切换回活跃状态等操作!这不正与我们的本来业务需求相契合吗?所以,接着学习探索。

什么是 Service Worker

Service Worker 是一种运行在浏览器背后的脚本,它可以拦截和处理网络请求,包括推送通知。通过结合 Service Worker,你的应用可以在后台发送推送通知,即使网页已经关闭也没问题。而 Web Notification API 与 Service Worker 搭配使用则可以为了提供更好的用户体验和性能。例如:

  • 后台通知处理:Service Worker 可以在浏览器后台运行,即使用户关闭了网页或将其切换到后台,Service Worker 仍然可以接收到通知事件。这使得您可以处理来自服务器的推送通知,而无需依赖打开的网页。这对于实时通知、即时消息或其他需要及时响应的应用程序非常有用。

  • 离线支持:Service Worker 可以缓存资源并在离线时提供通知。这意味着即使用户处于离线状态,他们也可以收到之前通过 Service Worker 缓存的通知。

  • 减少主线程负载:主线程是处理用户交互和渲染的关键线程。如果在主线程中处理通知,可能会导致阻塞用户界面的响应性,尤其是在处理大量通知时。通过将通知的处理逻辑放在 Service Worker 中,可以将主线程从通知处理中解放出来,保持页面的流畅性和响应性。

  • 隐私和权限控制:Service Worker 可以更好地管理通知的权限和用户偏好设置。它可以控制是否显示通知、如何显示通知以及何时清除通知。这样可以提供更好的用户隐私保护和控制。

  • 通知闭环:这也是最重要的一个功能,在 Service Worker 中,我们可以处理消息的点击事件,比如打开新的系统页面和让隐藏的 Tab 变活跃等功能。

需要注意的是,Service Worker 需要适当地设置和使用,因为它是一个独立的线程,具有自己的生命周期和限制。使用 Service Worker 还需要处理权限请求、更新和错误处理等方面的逻辑。

由于本文并不是知识点解析,因此关于 Service Worker 更多知识大家请查阅文档 ------ MDN Service Worker

Notification Action

接下来就是实践环节,会分知识点进行业务需求的详细讲解。

通过 Service Worker 注册消息通知并发送消息

前面的 Web Notification API 我们已经知道了使用方式,那么它是如何与 Service Worker API 进行结合的呢?具体代码如下:

  • HTML 代码增加 Web Notification API 的权限获取逻辑以及 Service Worker 的注册逻辑
js 复制代码
 <script>
  if ('serviceWorker' in navigator) {
    function requestPermission() {
      return new Promise(function (resolve, reject) {
        const permissionResult = Notification.requestPermission(function (result) {
          resolve(result);
        });

        if (permissionResult) {
          permissionResult.then(resolve, reject);
        }
      }).then(function (permissionResult) {
        if (permissionResult !== 'granted') {
            throw new Error('We weren\'t granted permission.');
        }
      });
    }
    window.addEventListener('load', function() {
      navigator.serviceWorker.register('/service-worker.js')
        .then(function(registration) {
          return Promise.all([
            registration,
            requestPermission()
          ]);
        }).then(([registration]) => {
          console.log('Service Worker 注册成功:', registration);
          setTimeout(() => {
            const title = '您好,我是AI助手';
            const options = {
              body: '邀请你一起学习 HTML5 最新知识',
              icon: '/assets/images/AIBot.png',
            };
          registration.showNotification(title, options);
          }, 5000);
        })
        .catch(function(error) {
          // 注册失败
          console.log('Service Worker 注册失败:', error);
        });
    });
  }
</script>

上述代码的逻辑非常简单,我们在页面加载完成后注册了 Service Worker,注册 Service Worker 的逻辑下面会讲解,就是注入一端 JS 代码,在注册完 Service Worker 成功之后的回调里,我们又向用户申请了 Web Notification API 的权限,如果用户同意了授权,那么就立刻弹出一条消息通知。

  • service-worker.js 代码

接下来我们来实现 Service Worker 里面的代码,如果我们就是想要通知,那么这里面代码可以什么都不写,只需要注册就可以了,但是前面提到了,我们希望页面闭环,也就是说希望获取用户点击通知之后的某些行为然后再针对性的做一些响应。所以我们在 Service Worker 里可以书写如下代码。

js 复制代码
// 监听通知点击事件
self.addEventListener("notificationclick", function (e) {
  console.log("notificationclick", e);
});

我们在 service-worker.js 里做了很简单的事情,就是监听 notification 的点击事件,然后控制台输出一条消息。效果如下:

通过图片我们可以看到,已经实现了一个简单的消息通知闭环,Service Worker 注册成功 -> 发送 Web Notification 给用户 -> 系统获取到用户点击 Notification。接下来我们就继续探索点击之后要做的事情。

点击通知打开网站

前面提到了,我要做的是一个警告系统,算是一个强提醒系统,那么当我们注册了消息提醒之后,关闭了页面,因为使用了 Service Worker 就具备了离线提醒的能力,我们这边希望的是如果检测到页面已经关闭,需要新开一个页面进入系统。上面这个需求的逻辑如下:

js 复制代码
// 监听通知点击事件
self.addEventListener("notificationclick", function (e) {
  // 关闭窗口
  e.notification.close();
  // 打开网页
  e.waitUntil(
    // 获取所有clients
    self.clients.matchAll().then(function (clientsList) {
      self.clients.openWindow("http://localhost:5173");
    })
  );
});

效果如下:

注意,如果需要在页面关闭的条件下依然可以接收消息打开页面,需要 Service Worker 与 Web Push API 配合使用才可以,这个不是本文的重点,因此大家自己按需查阅吧~

Tab 未关闭,点击通知激活 Tab

前面实现了用户点击的时候新开一个页面,这样虽然也能很直观的提醒用户有消息了,但是不够优雅,因为如果用户的那个页面已经打开了,只是不活跃了,那么最合理的做法应该是让它变活跃而不是新开一个页面来消耗浏览器性能,所以我们接下来要实现如果 Tab 未关闭,点击激活 Tab。

js 复制代码
// 监听通知点击事件
self.addEventListener("notificationclick", function (e) {
  // 关闭窗口
  e.notification.close();
  // 打开网页
  e.waitUntil(
    // 获取所有clients
    self.clients.matchAll().then(function (clientsList) {
      if (!clients || clients.length === 0) {
        // 当不存在 client 时,打开该网站
        self.clients.openWindow &&
          self.clients.openWindow("http://localhost:5173");
        return;
      }
      // 如果存在 Tab,则切换到该站点的 Tab
      clientsList &&
        clientsList.length &&
        clientsList[0].focus &&
        clientsList[0].focus();
    })
  );
});

来看实际效果:

从上图可以看出,当消息通知出现,如果我们的网站是未活跃状态,那么点击的时候就激活了 Tab,符合预期同时也完成了需求。至此,这个完整的需求就开发完成了,最后我们领导也比较满意效果,理论上来说到这里就可以结束了,但是为了文章的完整性,还有两个小点可以继续讨论一下,不着急的话咱们接着往下看。

更多操作可能性

前面两个需求是属于业务需求,那么对于 Web Notification API + Service Worker 来说,是有无限的扩展性的,比如我们可以对消息进行扩展,增加几个按钮 Actions,每个按钮点击做不同的事情,这些都是可以的,一个简单的例子如下:

js 复制代码
<script>
  if ('serviceWorker' in navigator) {
    function requestPermission() {
      return new Promise(function (resolve, reject) {
        const permissionResult = Notification.requestPermission(function (result) {
          resolve(result);
        });

        if (permissionResult) {
          permissionResult.then(resolve, reject);
        }
      }).then(function (permissionResult) {
        if (permissionResult !== 'granted') {
            throw new Error('We weren\'t granted permission.');
        }
      });
    }
    window.addEventListener('load', function() {
      navigator.serviceWorker.register('/service-worker.js')
        .then(function(registration) {
          return Promise.all([
            registration,
            requestPermission()
          ]);
        }).then(([registration]) => {
          console.log('Service Worker 注册成功:', registration);
          /**
           * 下面是新增的 actions 逻辑
           **/
          setTimeout(() => {
            const title = '您好,我是AI助手';
            const options = {
              body: '邀请你一起学习 HTML5 最新知识',
              icon: '/assets/images/AIBot.png',
              actions: [
                {
                  action: 'show-book',
                  title: '查看文档'
                },
                {
                  action: 'send-email',
                  title: '联系我们'
                }
              ]
            };
            registration.showNotification(title, options);
          }, 1000 * 10);
        })
        .catch(function(error) {
          // 注册失败
          console.log('Service Worker 注册失败:', error);
        });
    });
  }
</script>

上面代码,我们在发送消息的时候新增加了一个 actions 数组,这个 actions 在消息通知里会被渲染成可点击的按钮,然后我们就可以针对按钮事件来实现更多的可能性。

  • 默认点击消息卡片,还是激活原来的 Tab
  • 点击查看文档按钮跳转到文档页面
  • 点击联系我们按钮,调用浏览器发送邮件

Service Worker 与 Client 交互

前面关于 Web Notification API 和 Service Worker 的所有基础知识点都讲解完成了,但是有时候我们会发现,一看就会,一用就废,其实这不怪大家,我这么多年看下来就是所有文档教程都是拆分成一个个小的知识点,看起来很简单,但是实际业务需求的时候是需要组合的,而且使用场景不同也需要单独去处理。就比如,前面的知识点我们看会了,但是当你在实际业务场景中用的时候,我们上面是模拟打开就发送一个通知,然后在里面做一些事,但是实际上是这样吗?实际上是我在 Client 里面收到接口报警,然后我在收到的时候发送一个 Web Notification API,这样才是真实的场景需求。因此,我们就需要 Service Worker 与 Client 来进行通信。

  • 为什么必须是 Service Worker?

在讲解之前,我先来解决大家一个疑问,为什么必须是 Service Worker?我不可以直接在收到通知的时候就激活这个页面吗?结论是,如果你只想新开一个窗口,无论是新的链接还是打开邮件,都是可以的。但是当你想做一些高级的事情的时候,比如你想激活被隐藏的 Tab,或者你想添加几个 actions,那么就必须与 Service Worker 配合使用。比如下面的代码:

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Web Notification API</title>
  </head>
  <body>
    <script>
      // 判断 Notification 对象是否存在,如果存在申请通知
      if ("Notification" in window) {
        // 申请通知权限
        Notification.requestPermission().then((permission) => {
          if (permission === "granted") {
            // 创建通知
            const notification = new Notification("Hello, World!", {
              body: "这是一条通知消息",
              icon: "/assets/images/AIBot.png",
              actions: [
                { action: "show-book", title: "查看文档" },
                { action: "send-email", title: "联系我们" },
              ],
            });

            // 添加点击事件
            notification.onclick = (event) => {
              console.log("通知被点击");
              // 根据 action 属性判断用户点击了哪个按钮
              switch (event.action) {
                case "show-book":
                  console.log("用户点击了查看文档按钮");
                  window.open(
                    "https://juejin.cn/book/7307129524007731238?utm_source=profile_book"
                  );
                  break;
                case "send-email":
                  console.log("用户点击了联系我们按钮");
                  window.open("mailto:zhoudeyou945@gmail.com");
                  break;
                default:
                  console.log("用户点击了通知主体");
                  // 让 Tab 变活跃
                  window.focus();
                  break;
              }
            };

            // 添加关闭事件
            notification.onclose = (event) => {
              console.log("通知被关闭");
            };

            // 添加错误事件
            notification.onerror = (event) => {
              console.log("通知发生错误");
            };
          }
        });
      }
    </script>
  </body>
</html>

我们运行过后就会发现浏览器有一个报错:

因此,这里也印证了前面提到的,Web Notification API 只有与 Service Worker 一起搭配使用才会获取最佳的效果。

  • Service Worker 如何与 Client 交互

我们重写一下前面的例子,一个通知带两个按钮,但是流程上发生变化,我们让 Client 发送通知内容 -> Service Worker 弹出通知 -> 用户行为捕获 -> Service Worker 处理。虽然流程变复杂了,但是这样能让大家更清晰的理解 Service Worker 是如何和客户端进行通信的,也更贴切真实的需求~

html 复制代码
<!-- file: index.html -->
<script>
  if ("serviceWorker" in navigator) {
    function requestPermission() {
      return new Promise(function (resolve, reject) {
        const permissionResult = Notification.requestPermission(function (
          result
        ) {
          resolve(result);
        });

        if (permissionResult) {
          permissionResult.then(resolve, reject);
        }
      }).then(function (permissionResult) {
        if (permissionResult !== "granted") {
          throw new Error("We weren't granted permission.");
        }
      });
    }
    window.addEventListener("load", function () {
      navigator.serviceWorker
        .register("/service-worker.js")
        .then(function (registration) {
          return Promise.all([registration, requestPermission()]);
        })
        .then(([registration]) => {
          console.log("Service Worker 注册成功:", registration);
        })
        .catch(function (error) {
          // 注册失败
          console.log("Service Worker 注册失败:", error);
        });
    });
  }
</script>
js 复制代码
/**
 * file: service-worker.js
 **/
/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable no-undef */
const APP_NAME = "app";
const CACHE_VERSION = "2.3";
const CACHE_NAME = `${APP_NAME}-v${CACHE_VERSION}`;

// 安装 Service Worker
self.addEventListener("install", (event) => {
  // 强制立即接管控制
  event.waitUntil(self.skipWaiting());
  caches.open(CACHE_NAME);
});

// 清理旧的缓存
self.addEventListener("activate", (event) => {
  console.log("service worker activate", event);
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((cacheName) => {
            // 清理以前版本的缓存
            return cacheName.startsWith(APP_NAME) && cacheName !== CACHE_NAME;
          })
          .map((cacheName) => {
            return caches.delete(cacheName);
          })
      );
    })
  );
});

// 拦截网络请求并使用缓存进行响应
self.addEventListener("fetch", (event) => {
  // do something
});

// 处理新的 Service Worker 安装和激活
self.addEventListener("message", (event) => {
  if (event.data && event.data.type === "registerServiceWorker") {
    event.waitUntil(
      self.skipWaiting().then(() => {
        return self.clients.claim();
      })
    );
  }
  // 处理 Client 发来的通知
  if (event.data && event.data.type === "notification") {
    // 如果是显示通知,则显示通知
    const { title, options } = event.data;
    self.registration.showNotification(title, options);
    // 向客户端发 postMessage
    self.clients.matchAll().then((clients) => {
      clients.forEach((client) => {
        client.postMessage({
          type: "notification",
          number: 0,
        });
      });
    });
  }
});

// 监听通知点击事件
self.addEventListener("notificationclick", function (e) {
  const action = e.action;
  e.waitUntil(
    // 获取所有clients
    self.clients.matchAll().then(function (clients) {
      if (!clients || clients.length === 0) {
        return;
      }
      switch (action) {
        case "show-book": {
          self.clients.openWindow(
            "https://juejin.cn/book/7307129524007731238?utm_source=profile_book"
          );
          break;
        }
        case "send-email": {
          self.clients.openWindow("mailto:zhoudeyou945@gmail.com");
          break;
        }
        default: {
          clients && clients[0] && clients[0].focus && clients[0].focus();
          break;
        }
      }
    })
  );
  // 关闭窗口
  e.notification.close();
});
js 复制代码
/**
 * file: client 逻辑代码
 **/
/* eslint-disable @typescript-eslint/ban-ts-comment */
import React, { useEffect } from "react";
import { Outlet } from "react-router-dom";

import usePageVisibility from "@/hooks/usePageVisibility";

interface ILayoutProps {
  children?: React.ReactNode;
}

// 更新favicon的函数
const updateFavicon = (href: string) => {
  const link: HTMLLinkElement =
    document.querySelector("link[rel*='icon']") ||
    document.createElement("link");
  link.type = "image/x-icon";
  link.rel = "shortcut icon";
  link.href = href;
  document.getElementsByTagName("head")[0].appendChild(link);
};

function updateFaviconWithNumber(number: number) {
  // 在需要绘制图标的地方执行以下代码
  const iconConfig = {
    width: 64,
    height: 64,
    size: 48,
  };
  // 获取设备的像素比
  const dpr = window.devicePixelRatio || 1;
  // 创建一个 Canvas 元素
  const canvas = document.createElement("canvas");
  canvas.width = iconConfig.width * dpr; // 实际像素
  canvas.height = iconConfig.height * dpr; // 实际像素
  canvas.style.width = iconConfig.width + "px"; // CSS像素
  canvas.style.height = iconConfig.height + "px"; // CSS像素
  const ctx: CanvasRenderingContext2D = canvas.getContext("2d")!;
  // 根据设备像素比缩放Canvas的绘制内容
  ctx.scale(dpr, dpr);

  // 绘制 SVG
  const svgIcon =
    '<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1709792146900" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1536" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48"><path d="M853.333333 454.144a341.333333 341.333333 0 1 0-455.111111 321.479111c-72.647111 25.201778-120.092444 72.533333-120.092444 126.122667h458.24c0-53.646222-44.202667-100.920889-113.777778-126.122667a339.911111 339.911111 0 0 0 230.684444-321.479111z m-334.961777 239.559111a179.655111 179.655111 0 1 1 0-359.310222 179.655111 179.655111 0 1 1 0 359.310222z" fill="#0e932e" p-id="1537" data-spm-anchor-id="a313x.search_index.0.i0.37253a81B17Cz8" class="selected"></path><path d="M512 435.2a90.282667 90.282667 0 0 0-88.405333 88.234667A88.291556 88.291556 0 1 0 512 435.2z" fill="#0e932e" p-id="1538" data-spm-anchor-id="a313x.search_index.0.i5.37253a81B17Cz8" class="selected"></path></svg>';
  // 生成 icon blob
  const svgBlob = new Blob([svgIcon], {
    type: "image/svg+xml;charset=utf-8",
  });
  const DOMURL = window.URL || window.webkitURL || window;
  // 通过图像加载器加载图像
  const img = new Image();
  // 设置图像加载完成的回调
  const url = DOMURL.createObjectURL(svgBlob);
  img.onload = function () {
    const { width, height, size } = iconConfig;
    // 图像加载完成,使用 Canvas 绘制图像
    ctx.drawImage(img, 0, 0, width, height);
    // 绘制红色圆点
    ctx.beginPath();
    ctx.fillStyle = "red";
    ctx.arc(width - size / 2, size / 2, size / 2, 0, 2 * Math.PI);
    ctx.fill();
    if (number > 0) {
      // 绘制完圆点以后,绘制数字
      // 设置文本颜色为白色
      ctx.fillStyle = "white";
      // 根据传入的数字大小设置字体大小
      const fontSize =
        number <= 9 ? 42 * dpr : number <= 99 ? 36 * dpr : 32 * dpr;
      // 设置字体大小和类型
      ctx.font = `bold ${fontSize}px Arial`;
      // 设置文本水平居中
      ctx.textAlign = "center";
      // 设置文本垂直居中
      ctx.textBaseline = "middle";
      // 计算文本的x坐标
      const textX = (iconConfig.width - iconConfig.size / 2) * dpr;
      // 计算文本的y坐标
      const textY = (iconConfig.size / 2) * dpr;
      // 不超过 99 的数字,直接转为字符串,超过 99 的数字,转为 "99+"
      const numStr = number > 99 ? "99+" : number.toString();
      // 在Canvas上绘制文本
      ctx.fillText(numStr, textX, textY);
    }
    // 将 Canvas 转换为 base64 编码的图像
    const base64Image = canvas.toDataURL("image/png");
    // 更新 icon
    updateFavicon(base64Image);
    // 清理资源
    DOMURL.revokeObjectURL(url);
  };
  img.src = url;
}

const Layout: React.FC<ILayoutProps> = () => {
  const pageVisible = usePageVisibility();
  useEffect(() => {
    /**
     * 模拟客户端收到请求,然后给 Service Worker 发送消息
     */
    setTimeout(() => {
      navigator.serviceWorker.controller?.postMessage({
        type: "notification",
        title: "您好,我是 AI 助手",
        options: {
          body: "邀请你一起学习 HTML5 最新知识",
          icon: "/assets/images/AIBot.png",
          actions: [
            {
              action: "show-book",
              title: "查看文档",
            },
            {
              action: "send-email",
              title: "联系我们",
            },
          ],
        },
      });
    }, 1000 * 5);
    /**
     * 客户端处理 service-worker 发送的消息
     */
    navigator.serviceWorker.addEventListener("message", (e) => {
      console.log(
        `receive post-message from service-worker, action is: `,
        e.data
      );
      if (e.data && e.data.type === "notification") {
        // 修改 icon
        updateFaviconWithNumber(e.data.number);
      }
    });
  }, []);
  useEffect(() => {
    pageVisible && updateFavicon("/monitor.svg");
  }, [pageVisible]);
  return (
    <>
      <Outlet />
    </>
  );
};

export default Layout;

代码较多,如果大家想要看源码,可以去 Github_web-notification 查看。

从上面代码可以看出,其实 Service Worker 与 Client 的交互核心是通过 HTML5 postMessage API 来实现的,具体效果如下:

更新 Service Worker

这里需要提醒第一次使用的各位, 通常情况下,Service Worker 已经在浏览器注册好之后,我们修改 service-worker.js 的代码,不会立刻生效,因为浏览器默认使用此域名下已经注册的 Service Worker。如果想要更新,可以有如下步骤:

  • 用户手动更新或者重新注册
  • 代码中实现

我们的网站应用是给用户用的,因此我们不能指望用户手动去清除缓存,肯定是开发人员在代码里去做的,下面是一个简单的实现,大家可以简单看看:

js 复制代码
// service-worker.js
const APP_NAME = "app";
const CACHE_VERSION = "1.8";
const CACHE_NAME = `${APP_NAME}-v${CACHE_VERSION}`;

// 安装 Service Worker
self.addEventListener("install", (event) => {
  // 强制立即接管控制
  event.waitUntil(self.skipWaiting());
  caches.open(CACHE_NAME);
});

// 清理旧的缓存
self.addEventListener("activate", (event) => {
  console.log("service worker activate", event);
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((cacheName) => {
            // 清理以前版本的缓存
            return cacheName.startsWith(APP_NAME) && cacheName !== CACHE_NAME;
          })
          .map((cacheName) => {
            return caches.delete(cacheName);
          })
      );
    })
  );
});

// 拦截网络请求并使用缓存进行响应
self.addEventListener("fetch", (event) => {
  // do something
});

// 处理新的 Service Worker 安装和激活
self.addEventListener("message", (event) => {
  if (event.data && event.data.type === "registerServiceWorker") {
    event.waitUntil(
      self.skipWaiting().then(() => {
        return self.clients.claim();
      })
    );
  }
});
// 监听通知点击事件
self.addEventListener("notificationclick", function (e) {
  // 关闭窗口
  e.notification.close();
  // 打开网页
  e.waitUntil(
    // 获取所有clients
    self.clients.matchAll().then(function (clientsList) {
      if (!clients || clients.length === 0) {
        // 当不存在 client 时,打开该网站
        self.clients.openWindow &&
          self.clients.openWindow("http://localhost:5173");
        return;
      }
      // 如果存在 Tab,则切换到该站点的 Tab
      clientsList &&
        clientsList.length &&
        clientsList[0].focus &&
        clientsList[0].focus();
    })
  );
});

总结

上面就是一个苦命的前端根据产品需求的不断变更的学习探索过程,在开发完成之后,我又一次感叹前端的伟大,前人的伟大,无论什么刁钻的需求你只要肯想办法,都会找到解决方案的。如果是之前年轻的自己,在接到这一系列需求的时候可能我会第一时间反驳不行,做不了 JS 没那么大能力。但是随着经验的积累,看的项目和案例越来越多,随着心态越来越平和,一方面我知道 Web 原生早就已经支持,另一方面即使不支持,我也愿意去尝试探索一下。在这里各位技术同学一个建议,在嘴上说出方案之前先去调研一波,最后再说是否有解决方案,时间久了以后你会发现无论是产品还有后端还是其他相关的同事,对你的评价都会很好。因为你不是一个遇事就推脱的技术,即使说这个东西做不了,也都是拿出实际的论据来做的。

所以,建议各位前端同学在日常开发过程中充分发挥自己的想象力,没有做不出来的需求,换种思路可能就找到解决问题的答案了~与各位共勉!!!

相关推荐
一颗花生米。2 小时前
深入理解JavaScript 的原型继承
java·开发语言·javascript·原型模式
学习使我快乐012 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19952 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
勿语&3 小时前
Element-UI Plus 暗黑主题切换及自定义主题色
开发语言·javascript·ui
黄尚圈圈3 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水4 小时前
简洁之道 - React Hook Form
前端
正小安6 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch8 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光8 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   8 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发