ThinkChat2.0新版上线,更智能更精彩,支持会话、画图、阅读、搜索等,送10W Token,即刻开启你的AI之旅 广告
>[success]部分内容来源:《PWA 实战》 [TOC] 推荐阅读: [PWA 详解](https://lzw.me/a/pwa-service-worker.html) [PWA 国内外应用分析报告](https://36kr.com/p/5124142) # 第 1 章 PWA 的关键:Service Worker,Service Worker 可以让你全权控制网站发起的每一个请求,它可以重定向你的请求,甚至彻底停止。 其具有以下几个特点: - 运行在它自己的全局脚本上下文中 - 不绑定到具体的网页 - 无法修改网页中的元素,因此它无法访问 DOM - 只能使用 HTTPS(除非是本地调试,如域名使用 localhost) Service Worker 运行在 worker 上下文中,这意味着它无法访问 DOM,它与应用的主要 JavaScript 运行在不同的线程中,所以它不会被阻塞。它们是完全异步的,因此你无法使用诸如同步 XHR 和 localStorage 之类的功能。 ## Service Worker 的生命周期 1.调用 register() 函数,浏览器会下载、解析并执行 Service Worker,如果此步骤中出现任何错误,register() 返回的 Promise 都会执行 reject 操作,并且 Service Worker 会被废弃。 2.一旦 Service Worker 成功执行了,安装事件就会激活(Service Worker 是基于事件的) 3.一旦完成安装,Service Worker 便会激活并发挥其作用(如拦截 HTTP 请求等) ![](https://box.kancloud.cn/4685dc3b0a1ac67a39df8541b6d96637_587x550.png =300x) ![](https://box.kancloud.cn/ec258cdadfed27a01a44a837212f47ea_1161x543.png ) ## 基础示例 我们先写一个 index.html: ```html <!DOCTYPE html> <html lang="en"> <head> ... <title>service worker test</title> </head> <body> <script> // 注册 service worker if ('serviceWorker' in navigator) { // 检查当前浏览器是否支持 Service Worker // 如果支持,注册一个叫做 sw.js 的 Service Worker 文件 navigator.serviceWorker.register('/sw.js').then(function (registration) { // 注册成功 console.log('ServiceWorker registration successful with scope: ', registration.scope) // github Page 是 https,向 http 协议的服务器发起请求会被 block fetch('https://chenmingk.github.io./images/img2.jpg').then(response => { console.log(response) }) }).catch(function (err) { // 注册失败 console.log('ServiceWorekr registration failed: ', err) }) } </script> </body> </html> ``` 这里使用 github pages 来做测试,自行百度。其逻辑是打开访问网页时先检查浏览器是否支持 Service Worker,如果支持,注册一个叫做 sw.js 的 Service Worker 文件,然后我们尝试发起一个 fetch 请求,注意我们要拿的是 img2.jpg,然后我们写一个 sw.js 文件。 ```js // 为 fetch 事件添加事件监听器 // self 为当前 scope 内的上下文 self.addEventListener('fetch', function (event) { // 检查传入的 HTTP 请求 URL 是否请求以 .jpg 结尾的文件 console.log(event.request.url) if (/\.jpg$/.test(event.request.url)) { console.log('拦截 fetch 请求') // 尝试获取某个图片并用它作为替代图片来响应请求 event.respondWith(fetch('https://chenmingk.github.io./images/img.jpg')) } }) ``` 我们监听 fetch 事件,拦截 url 以 .jpg 结尾的 http 请求,并指定拦截后的操作。这里我们获取 images 目录下的名为 img 的图片来响应请求。 然后访问 `https://chenmingk.github.io./`,得到如下结果: ![](https://box.kancloud.cn/eb2aedb9386bf3160fd7aafe019ffd2b_652x345.png) 可以看到我们的 fetch 请求被拦截了,即我们本来要拿的是 img2 现在变成了 img。 我们的 githubPage 目录结构是这样的: ![](https://box.kancloud.cn/e14cc9be56242ec16b0b663d40397cba_1224x258.png) # 第 2 章 构建 PWA 的前端架构方式 ## 应用外壳架构 使用 Service Worker 缓存,可以缓存网站的 UI 外壳,以便用户重复访问。所谓 UI 外壳,即用户界面所必需的最小化的 HTML、CSS 和 JavaScript。它可能会是类似网站头部、底部和导航这样没有任何动态内容的部分。 当用户访问网站时,我们可以让 UI 外壳立即呈现,然后使用一个 “loading” 标识表示一些资源正在动态加载,这比等待一个空白页面要好多了。 ## 缓存 Service Worker 缓存和 HTTP 缓存并不是同一概念。 参考链接:[https://zhuanlan.zhihu.com/p/28113197](https://zhuanlan.zhihu.com/p/28113197) [https://www.cnblogs.com/kenkofox/p/8732428.html](https://www.cnblogs.com/kenkofox/p/8732428.html) ## 离线浏览 如果你身处某个区域,网络信号很弱,甚至会掉线。那么手机浏览网页时,就会显示无法连接或某些错误。使用 Service Worker 缓存可以将网站资源保存到用户的设备上,可以拦截任何 HTTP 请求并直接用设备上缓存的资源进行响应。甚至不需要访问网络就可以获取缓存的资源。考虑到这一点,我们可以构建离线页面,为用户展示一个自定义的离线页面。 # 第 3 章 缓存 使用 HTTP 缓存最大的一个问题就是资源更新不同步的情况,因为其依赖服务器来告诉何时缓存资源以及资源何时过期。现在一般的做法是每次更新版本时使用哈希的方式重新生成文件名。 Service Worker 缓存的不同之处在于,无须再由服务器来告知浏览器资源要缓存多久,它赋予了你程序式的精准控制能力。Service Worker 缓存是对 HTTP 缓存的增强,并可以与之配合使用。 不啰嗦了直接上代码:[https://github.com/ChenMingK/ChenMingK.github.io/tree/master/chapter3](https://github.com/ChenMingK/ChenMingK.github.io/tree/master/chapter3) 首先是 index.html: ```html <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Hello Caching World!</title> </head> <body> <!-- Image --> <img width="300" height="300" src="./images/hello.png" /> <!-- JavaScript --> <script async src="./js/script.js"></script> <script> // Register the service worker if ('serviceWorker' in navigator) { // 尝试注册名为 service-worker.js 的文件 navigator.serviceWorker.register('./service-worker.js').then(function(registration) { // Registration was successful console.log('ServiceWorker registration successful with scope: ', registration.scope) }).catch(function(err) { // registration failed :( console.log('ServiceWorker registration failed: ', err) }); } </script> </body> </html> ``` 然后我们写这个 service-worker.js 文件: ```js var cacheName = 'helloWorld' // install 事件发生在浏览器安装并注册 Service Worker 时 // 这是把后面阶段可能会用到的资源添加到缓存中的绝佳时间 self.addEventListener('install', event => { // event.waitUntil() 方法返回一个 Promise 对象 event.waitUntil( caches.open(cacheName) // 使用指定的缓存名称来打开缓存 .then(cache => cache.addAll([ // addAll() 方法传入一个文件数组 './js/script.js', './images/hello.png' ])) ) }) self.addEventListener('fetch', function(event) { event.respondWith( caches.match(event.request) // 检查传入的请求 URL 是否匹配当前缓存中存在的任何内容 .then(function(response) { if (response) { // 如果有 response 并且它不是未定义的或空的,就将它返回 return response } // 否则,通过网络读取预期的资源 return fetch(event.request) }) ) }) ``` 打开我们的网页看看报不报错: ![](https://box.kancloud.cn/d3f4c91f64d69680bd7aea7df618aff7_1094x369.png) 检查 Cache Storage 中是否缓存了对应的资源: ![](https://box.kancloud.cn/be3df2d0ee232b80459978519468eb47_687x569.png ) ## 拦截并缓存 上面的缓存方式可以称为预期缓存,即你完全知到你要缓存的资源,并在 Service Worker 安装期间缓存这些资源。但是资源可能是动态的,或者你可能对资源完全不了解,Service Worker 如何缓存这些资源呢? 因为 Service Worker 能够拦截 HTTP 请求,这意味着我们可以先请求资源,然后立即将其缓存起来,然后对于同一资源的下一次 HTTP 请求,就可以立即将其从 Service Worker 缓存中取出。 代码清单:[https://github.com/ChenMingK/ChenMingK.github.io/tree/master/chapter3/cachefirst](https://github.com/ChenMingK/ChenMingK.github.io/tree/master/chapter3/cachefirst) 其他部分差不多,这里介绍如何动态地向缓存中添加资源。 ```js var cacheName = 'helloWorld'; self.addEventListener('fetch', function(event) { event.respondWith( caches.match(event.request) .then(function(response) { if (response) { // 如果匹配,就此返回缓存并不再执行 return response; } // 复制请求。请求是一个流,只能使用一次。 var fetchRequest = event.request.clone(); // 尝试按照预期发起原始的 HTTP 请求 return fetch(fetchRequest).then( function(fetchResponse) { // 如果请求失败或服务器响应了错误代码,则立即返回错误消息 if(!fetchResponse || fetchResponse.status !== 200) { return fetchResponse; } // 再一次复制了响应,因为你需要将其添加到缓存中,而且它还将用于最终返回响应 var responseToCache = fetchResponse.clone(); caches.open(cacheName) // 打开名为 helloWorld 的缓存 .then(function(cache) { cache.put(event.request, responseToCache); // 将响应添加到缓存中 }); return fetchResponse; } ); }) ); }); ``` 需要注意的是,需要复制请求和响应。请求是一个流,它只能使用一次,因为已经通过缓存使用了一次请求,接下来发起 HTTP 请求还要再用一次,所以需要复制请求。响应也是同理。 ## 推荐工具 WebPagetest.org 用于网站性能测试 Workbox (https://workboxjs.org)帮助我们快速创建自己的 Service Worker ```js // Service Worker 可以访问一个叫作 importScripts() 的全局函数 // 该函数可以将同一域名下的脚本导入至它们的作用域 importScripts('workbox-sw.prod.v1.1.0.js') // 加载 Workbox 库 const workboxSW = new self.workboxSW() workboxSW.router.registerRoute( 'https://test.org/css/(.*)', // 开始缓存匹配'/css'路径的任何请求 workboxSW.strategies.cacheFirst() ) ``` # 第 4 章 fetch 事件 fetch API 这里就不再介绍了,用一个 POST 请求的格式简单过一下: ```js fetch('/some/url', { // 请求的 URL method: 'POST', // 请求中包含的 header headers: { 'auth': '1234' }, // POST 请求的 body body: JSON.stringify({ name: 'dean', login: 'dean123' }) }) .then(function (data) { // 成功的回调 console.log('Request success: ', data) }) .catch(function (error) { // 失败的回调 console.log('Request failure', error) }) ``` 以上示例我们监听的都是 fetch 事件,Service Worker 可以拦截浏览器发出的任何 HTTP 请求,属于此 Service Worker 作用域内的每个 HTTP 请求都会触发 fetch 事件(而不是一定要用 fetch 发送的请求)。我们可以拦截 HTTP 请求转而检查 Service Worker 缓存中是否有该资源,也可以自定义 HTTP 响应,代码如下。 ```js self.addEventListener('fetch', function (event) { if (/\.jpg$/.test(event.request.url)) { event.respondWith( new Response('<p>This is a response that comes from your service worker!</p>', { headers: { 'Content-Type': 'text/html' } // 自定义 Response }) ) } }) ``` 可以说 Service Worker 的权限太大了,同时我们也可以理解为什么它一定需要通过 HTTPS 提供请求了。 ## 首次访问的问题 当用户第一次访问网站时,并不会有激活的 Service Worker 来控制页面,只有当 Service Worker 安装完成并且用户刷新了页面或跳转至网站的其他页面时,Service Worker 才会激活并开始拦截请求。如果我们希望 Service Worker 能尽快开始工作,包括在其未激活期间所发起的请求该怎么办? 书中提供了一个小技巧: ```js self.addEventListener('install', function (event) { event.waitUntil(self.skipWaiting()) }) ``` skipWaiting() 函数强制等待中的 Service Worker 成为激活的 Service Worker,self.skipWaiting() 函数还可以与 self.clients.claim() 一起使用,以确保底层 Service Worker 的更新立即生效。 ```js self.addEventListener('active', function (event) { event.waitUntil(self.clients.claim()) }) ``` 同时使用以上两段代码,可以立即激活 Service Worker。 ## 使用 WebP 图片的示例 WebP 图片格式相比 PNG、JPEG 等有文件体积小且图像质量没有显著差异的优点,目前只有 Chrome、Opera 和 Andorid 支持 WebP 图片,Safari、Firefox 和 IE 还不支持。 支持 WebP 格式的浏览器会在每个 HTTP 请求中添加:accept:image/webp 请求头来告知服务端它支持 WebP 格式。 我们可以添加如下代码来实现对图片请求的拦截同时返回 WebP 格式的图片(如果浏览器支持) 代码清单:[https://github.com/ChenMingK/ChenMingK.github.io/tree/master/chapter-4/WebP-Images](https://github.com/ChenMingK/ChenMingK.github.io/tree/master/chapter-4/WebP-Images) 可以通过访问:[https://chenmingk.github.io/chapter-4/WebP-Images/](https://chenmingk.github.io/chapter-4/WebP-Images/) 打开调试工具来检查 ```js // Listen to fetch events self.addEventListener('fetch', function(event) { // Check if the image is a jpeg if (/\.jpg$|.png$/.test(event.request.url)) { // Inspect the accept header for WebP support var supportsWebp = false; if (event.request.headers.has('accept')) { supportsWebp = event.request.headers .get('accept') .includes('webp'); } // If we support WebP if (supportsWebp) { // Clone the request var req = event.request.clone(); // Build the return URL var returnUrl = req.url.substr(0, req.url.lastIndexOf(".")) + ".webp"; event.respondWith( fetch(returnUrl, { mode: 'no-cors' }) ); } } }); ``` > 运行后发现,确实能拦截到请求并得到 WebP 格式的图片,但是显示在网页上的仍然是 .jpg 格式,暂时不知道为什么。 # 第 5 章 Web 应用清单(manifest.json) Web 应用清单是简单的 JSON 文件,它在文本文件中提供了应用相关的有用信息(如应用的名称、作者、图标和描述)。其还可以使用户将 Web 应用安装到设备的主屏幕上,并允许开发者自定义启动页面、模板颜色,甚至打开 URL。 先来看这个 manifest.json 示例: ```json { "name": "Progressive Times web app", "short_name": "Progressive Times", "start_url": "./index.html", "theme_color": "#FFDF00", "background_color": "#FFDF00", "display": "standalone", "icons": [ { "src": "./images/homescreen.png", "sizes": "192x192", "type": "image/png" }, { "src": "./images/homescreen-144.png", "sizes": "144x144", "type": "image/png" } ] } ``` - name:表示当提示用户安装应用时出现的文本 - short_name:表示当应用安装后出现在用户主屏幕上的文本 - start_url:决定了当用户从设备的主屏幕开启 Web 应用时所出现的第一个页面 - display:根据构建的 Web 应用类型,可能需要预设如何首次加载。display 字段表示开发者希望他们的 Web 应用如何向用户展示 - theme_color:该字段可以对浏览器地址栏着色,以符合网站的主色调 - icons:表示在设备主屏幕上的图标 为了引用清单文件,需要为 Web 应用的所有页面都添加如下的 link 标签 ```html <link rel="manifest" href="./manifest.json"> ``` 你可以用手机打开浏览器访问:[https://chenmingk.github.io/chapter-5/look-and-feel/](https://chenmingk.github.io/chapter-5/look-and-feel/)不出意外的话应该能看到添加到主屏幕的提示。 > PS:QQ 浏览器不行,使用 Chrome 浏览器... 另外注意,要显示添加到主屏幕的提示,需要满足几个条件: - 需要 manifest.json 文件 - 清单文件需要启动 URL - 需要 144X144 像素的 PNG 图标 - 网站必须使用通过 HTTPS 运行的 Service Worker - **用户需要至少访问过网站两次,并且两次访问间隔在 5min 以上** 这些要求是浏览器内置的,这也可以理解,如果访问过的网站都显示添加到主屏幕的提示那很快就会令人反感。 关于 display 有以下模式可供选择: - fullscreen:打开 Web 应用并占用整个可用的显示区域 - standalone:打开 Web 应用以看起来像一个独立的原生应用。在此模式下,用户代理将排除诸如 URL 栏等标准浏览器 UI 元素,但可以包括诸如状态栏和系统返回按钮的其他系统 UI 元素。 - minimal-ui:此模式类似于 fullscreen,但为用户提供了最小 UI 元素集合。 - browser:使用操作系统内置的标准浏览器来打开 Web 应用。(默认) ## 控制默认行为 ### 取消提示 可以通过如下代码取消添加到主屏幕的提示: ```js window.addEventListener('beforeinstallprompt', function (e) { e.preventDefault() return false }) ``` ### 判断使用情况 监听 beforeinstallprompt 事件,可以判断出用户是否决定添加 Web 应用到主屏幕或者直接关掉提示,可以根据这些反馈来跟踪此功能的使用情况。 ```js window.addEventListener('beforeinstallprompt', function (event) { event.userChoice.then(function (result) { // 判断用户的选择并返回 Promise console.log(result.outcome) if (result.outcome === 'dismissed') { // 发送数据以进行分析 } else { // ... } }) }) ``` ### 推迟提示 如果我们想让用户通过单击某个按钮来将网站添加到主屏幕上可以这么做: ```js window.addEventListener('beforeinstallprompt', function (e) { e.preventDefault() btnSave.removeAttribute('disabled') // 此时,启用按钮 savedPrompt = e // 将事件保存在变量中,这样在稍后可以触发它 return false }) btnSave.addEventListener('click', function () { if (savedPrompt !== undefined) { savedPrompt.prompt() // 用户与应用进行了积极的交互,所系显示提示 savedPrompt.userChoice.then(function (result) { if (result.outcome === 'dismissed') { // ... } else { // ... } savedPrompt = null // 不再需要提示,所以清除它 }) } }) ``` ## 调试文件清单 可以通过浏览器调试工具查看: ![](https://box.kancloud.cn/c7f9c17f4ea5a97113c9d4b3e22a6a1c_816x695.png) 或者打开 manifest-validator.appspot.com 向验证工具提供 URL 或粘贴 Web 应用清单的内容进行验证 # 第 6 章 推送通知 推送通知的最大的优点是即使用户没有浏览你的网站也会收到这些通知内容,这种体验类似于原生应用,而且即使浏览器没有运行也可以工作。一旦用户接收或屏蔽推送通知提示,提示就不会再出现,需要注意:只有当站点通过 HTTPS 运行时,同时有一个注册过的 Service Worker,并且已经为其编写好了代码,才会出现此提示。 推送通知相比添加主屏幕就麻烦多了,需要前后端联调(需要搭一个推送通知服务器),还需要遵守一定的规范(VAPID),暂时不做记录...... 现在有一些现成的第三方解决方案来实现将推送通知发送到多种不同浏览器的业务。[https://onesignal.com/](https://onesignal.com/) # 第 7 章 离线应用 使用 Service Worker,可以判断出用户是否在离线状态下获取资源,我们可以检查任何失败的请求,然后返回用户要查看的页面的缓存版本。 首先需要将离线页面添加到 Service Worker 缓存中: ```js const cacheName = 'latestNews-v1'; const offlineUrl = 'offline-page.html'; // Cache our known resources during install self.addEventListener('install', event => { event.waitUntil( caches.open(cacheName) .then(cache => cache.addAll([ './js/main.js', './js/article.js', ..., offlineUrl ])) ); }); ``` 上述代码在 Service Worker 安装阶段将名为`offline-page.html` ......等会,webpack 好像提供了插件帮我们完成离线功能......[https://www.webpackjs.com/guides/progressive-web-application/](https://www.webpackjs.com/guides/progressive-web-application/) >TODO:第 6 章、第 7 章补全 # 其他 书中更偏向于底层实现,现在都是各种配置项直接写,关于 vue 项目配置 pwa 可以参考这篇文章: [https://juejin.im/post/5ba3d205e51d450e8477af33#heading-16](https://juejin.im/post/5ba3d205e51d450e8477af33#heading-16) 话虽这么说,但是知道原理后我们可以更灵活地应用而不是总是 Google 找各种配置。