简单介绍
PWA(Progressive web App)渐进式 Web App, 它并不是单只某一项技术, 而是一系列技术综合应用的结果, 其中主要包含的相关技术就是 Service Worker,Cache API,Fetch API,Push API,Notification API 和 postMessage API. 使用 PWA 可以给我们带来什么好处呢? 主要体现在如下几方面
1 离线缓存
2 Web 页面添加桌面快速入口
3 消息推送
相关知识
Service Worker
简单来说, Service Worker 是一个可编程的 Web Worker, 它就像一个位于浏览器与网络之间的客户端代理, 可以拦截, 处理, 响应流经的 HTTP 请求. 它没有调用 DOM 和其他页面 API 的能力, 但他可以拦截网络请求, 包括页面切换, 静态资源下载, Ajax 请求所引起的网络请求. Service Worker 是一个独立于 JavaScript 主线程的浏览器线程. Service Worker 有如下特性:
必须在 HTTPS 环境下才能工作(在开发模式下 http://localhost 也可以工作)
不能直接操作 DOM,(但是可以通过 postMessage 发送某些信号, 主进程根据信号类型, 进行不同的操作)
一个独立的 worker 线程, 独立于当前网页进程, 有自己独立的 worker context.
运行于浏览器后台, 可以控制打开的作用域范围下所有的页面请求
Service Worker 必须要在主线中进行注册
一旦被 install, 就永远存在, 除非被手动 unregister
用到的时候可以直接唤醒, 不用的时候自动睡眠
注册 Service Work
我们需要在主线程中注册 Service Worker, 并且一般是在页面触发 load 事件之后进行注册. 当 Service Worker 注册成功后便会进入其生命周期. scope 代表 Service Worker 控制该路径下的所有请求, 如果请求路径不是在该路径之下, 则请求不会被拦截.
- // 注册 service worker
- Windows.addEventListener('load', function () {
- navigator.serviceWorker.register('/sw.js', {scope: '/'})
- .then(function (registration) {
- // 注册成功
- console.log('ServiceWorker registration successful with scope:', registration.scope);
- })
- .catch(function (err) {
- // 注册失败:(
- console.log('ServiceWorker registration failed:', err);
- });
- });
Service Worker 生命周期
Service Worker 生命周期大致如下
install -> installed -> actvating -> Active -> Activated -> Redundant
Service Worker 生命周期图
在 Service Worker 注册成功之后就会触发 install 事件, 在触发 install 事件后, 我们就可以开始缓存一些静态资. waitUntil 方法确保所有代码执行完毕后, Service Worker 才会完成 Service Worker 的安装. 需要注意的是只有 CACHE_LIST 中的资源全部安装成功后, 才会完成安装, 否则失败, 进入 redundant 状态, 所以这里的静态资源最好不要太多. 如果 sw.JS 文件的内容有改动, 当访问网站页面时浏览器获取了新的文件, 它会认为有更新, 于是会安装新的文件并触发 install 事件. 但是此时已经处于激活状态的旧的 Service Worker 还在运行, 新的 Service Worker 完成安装后会进入 waiting 状态. 直到所有已打开的页面都关闭, 旧的 Service Worker 自动停止, 新的 Service Worker 才会在接下来打开的页面里生效. 为了能够让新的 Service Worker 及时生效, 我们使用 skipWaiting 直接使 Service Worker 跳过等待时期, 从而直接进入下一个阶段.
- const CACHE_NAME = 'cache_v' + 2;
- const CACGE_LIST = [
- '/',
- '/index.html',
- '/main.CSS',
- '/app.js',
- '/icon.png'
- ];
- function preCache() {
- // 安装成功后操作 CacheStorage 缓存, 使用之前需要先通过 caches.open() 打开对应缓存空间.
- return caches.open(CACHE_NAME).then(cache => {
- // 通过 cache 缓存对象的 addAll 方法添加 precache 缓存
- return cache.addAll(CACGE_LIST);
- })
- }
- // 安装
- self.addEventListener('install', function (event) {
- // 等待 promise 执行完
- event.waitUntil(
- // 如果上一个 serviceWorker 不销毁 需要手动 skipWaiting()
- preCache().then(skipWaiting)
- );
- });
在安装成功后, 便会触发 activate 事件, 在进入这个生命周期后, 我们一般会删除掉之前已经过期的版本(因为默认情况下浏览器是不会自动删除过期的版本的), 并更新客户端 Service Worker(使用当前处于激活状态的 Service Worker).
- // 删除过期缓存
- function clearCache() {
- return caches.keys().then(keys => {
- return Promise.all(keys.map(key => {
- if (key !== CACHE_NAME) {
- return caches.delete(key);
- }
- }))
- })
- }
- // 激活 activate 事件中通常做一些过期资源释放的工作
- self.addEventListener('activate', function (e) {
- e.waitUntil(
- Promise.all([
- clearCache(),
- self.clients.claim()
- ])
- );
- });
在这里还有一个问题就是 sw.JS 文件有可能会被浏览器缓存, 所以我们一般需要设置 sw.JS 不缓存或者较短的缓存时间
更多详细参考 如何优雅的为 PWA 注册 Service Worker
Service Worker 拦截请求
之前说过, Service Worker 是可以拦截请求的, 那么一定就会存在一个拦截请求的事件 fetch. 我们需要在 sw.JS 去监听这个事件.
- self.addEventListener('fetch', function (event) {
- event.respondWith(
- caches.open(CACHE_NAME).then(cache => {
- return cache.match(event.request).then(function (response) {
- // 如果 Service Worker 有自己的返回, 就直接返回, 减少一次 http 请求
- if (response) {
- console.log('cache 缓存', event.request.url, response);
- return response;
- } else {
- if (navigator.online) {
- return fetch(event.request).then(function(response) {
- console.log('network', event.request.url, response);
- // 由于响应是一个 JavaScript 或者 HTML, 会认为这个响应为一个流, 而流是只能被消费一次的, 所以只能被读一次
- // 第二次就会报错 参考文章 https://jakearchibald.com/2014/reading-responses/
- cache.put(event.request, response.clone());
- return response;
- }).catch(function(error) {
- console.error('请求失败', error);
- throw error;
- });
- } else {
- // 断网处理
- offlineRequest(fetchRequest);
- }
- }
- });
- })
- );
- });
这里我们在 fetch 事件中监听请求事件, 我们通过 cache.match 来进行请求的比较, 如果存再这个请求的响应我们就直接返回缓存结果, 否则就去请求. 在这里我们通过 cache.add 来添加新的缓存, 他实际上内部是包含了 fetch 请求过程的(注意: Cache.put, Cache.add 和 Cache.addAll 只能在 GET 请求下使用). 在 match 的时候, 需要请求的 url 和 header 都一致才是相同的资源, 可以设定第二个参数 ignoreVary:true.caches.match(event.request, {ignoreVary: true})
表示只要请求 url 相同就认为是同一个资源. 另外需要提到一点, Fetch 请求默认是不附带 Cookies 等信息的, 在请求静态资源上这没有问题, 而且节省了网络请求大小. 但对于动态页面, 则可能会因为请求缺失 Cookies 而存在问题. 此时可以给 Fetch 请求设置第二个参数. 示例: fetch(fetchRequest, { credentials: 'include' } );
Cache API
Cache API 不仅在 Service Worker 中可以使用, 在主页面中也可以使用. 我们通过 caches.open(cacheName)来打开一个缓存空间, 在, 默认情况下, 如果我们不手动去清除这个缓存空间, 这个缓存会一直存在, 不会过期. 在使用 Cache API 之前, 我们都需要通过 caches.open 先去打开这个缓存空间, 然后在使用相应的 Cache 方法. 这里有几个注意点:
Cache.put, Cache.add 和 Cache.addAll 只能在 GET 请求下使用
自 Chrome 46 版本起, Cache API 只保存安全来源的请求, 即那些通过 HTTPS 服务的请求.
Cache API 不支持 HTTP 缓存头
在使用 cache.add 和 cache.addAll 的时候, 是先根据 url 获取到相应的 response, 然后再添加到缓存中. 过程类似于调用 fetch(), 然后使用 Cache.put() 将 response 添加到 cache 中
详细 MDN 文档
Fetch API
Fetch API 不仅可以在主线程中进行使用, 也可以在 Service Worker 中进行使用. fetch 和 XMLHttpRequest 有两种方式不同:
当接收到一个代表错误的 HTTP 状态码时, 从 fetch()返回的 Promise 不会被标记为 reject, 即使该 HTTP 响应的状态码是 404 或 500. 相反, 它会将 Promise 状态标记为 resolve (但是会将 resolve 的返回值的 ok 属性设置为 false ), 仅当网络故障时或请求被阻止时, 才会标记为 reject.
默认情况下, fetch 不会从服务端发送或接收任何 cookies, 如果站点依赖于用户 session, 则会导致未经认证的请求(要发送 cookies, 必须设置 credentials 选项)
- // Example POST method implementation:
- postData('http://example.com/answer', {answer: 42})
- .then(data => console.log(data)) // JSON from `response.json()` call
- .catch(error => console.error(error))
- function postData(url, data) {
- // Default options are marked with *
- return fetch(url, {
- body: JSON.stringify(data), // must match 'Content-Type' header
- cache: 'no-cache', //*default, no-cache, reload, force-cache, only-if-cached
- credentials: 'same-origin', // include(始终携带), same-origin(同源携带 cookie), omit(始终不携带)
- headers: {
- 'user-agent': 'Mozilla/4.0 MDN Example',
- 'content-type': 'application/json'
- },
- method: 'POST', //*GET, POST, PUT, DELETE, etc.
- mode: 'cors', // no-cors, cors, *same-origin
- redirect: 'follow', // manual, *follow, error
- referrer: 'no-referrer', //*client, no-referrer
- })
- .then(response => response.JSON()) // parses response to JSON
- }
更多信息请查阅: 使用 Fetch
Notification
Notification API 用来进行浏览器通知, 当用户允许时, 浏览器就可以弹出通知. 这个 API 在主页面和 Service Worker 中都可以使用, MDN 文档
在主页面中使用
- // 先检查浏览器是否支持
- if (!("Notification" in Windows)) {
- alert("This browser does not support desktop notification");
- }
- // 检查用户是否同意接受通知
- else if (Notification.permission === "granted") {
- // If it's okay let's create a notification
- new Notification(title, {
- body: desc,
- icon: '/icon.png',
- requireInteraction: true
- });
- }
- // 否则我们需要向用户获取权限
- else if (Notification.permission !== 'denied') {
- Notification.requestPermission(function (permission) {
- // 如果用户同意, 就可以向他们发送通知
- if (permission === "granted") {
- new Notification(title, {
- body: desc,
- icon: '/icon.png',
- requireInteraction: true
- });
- } else {
- console.warn('用户拒绝通知');
- }
- });
- }
在 Service Worker 中使用
- // 发送 Notification 通知
- function sendNotify(title, options={}, event) {
- if (Notification.permission !== 'granted') {
- console.log('Not granted Notification permission.');
- // 通过 post 一个 message 信号量, 来在主页面中询问用户获取页面通知权限
- postMessage({
- type: 'applyNotify'
- })
- } else {
- // 在 Service Worker 中 触发一条通知
- self.registration.showNotification(title || 'Hi:', Object.assign({
- body: '这是一个通知示例',
- icon: '/icon.png',
- requireInteraction: true
- }, options));
- }
- }
我们可以看见当我们在 Service Worker 中进行消息提示时, 用户可能关闭了消息提示的功能, 所以我们首先要再次询问用户是否开启消息提示的功能, 但是在 Service Worker 中是不能够直接询问用户的, 我们必须要在主页面中去询问, 这个时候我们可以通过 postMessage 去发送一个信号量, 根据这个信号量的类型, 来做响应的处理(例如: 询问消息提示的权限, DOM 操作等等)
- function postMessage(data) {
- self.clients.matchAll().then(clientList => {
- clientList.forEach(client => {
- // 当前打开的标签页发送消息
- if (client.visibilityState === 'visible') {
- client.postMessage(data);
- }
- })
- })
- }
在这里我们只向打开的标签页发送该信号量, 避免重复询问
message 事件
由于 Service Worker 是一个单独的浏览器线程, 与 JavaScript 主线程互不干扰, 但是我们还是可以通过 postMessage 实现通信, 而且可以通过 post 特定的消息, 从而让主线程去进行相应的 DOM 操作, 实现间接操作 DOM 的方式.
页面发送消息给 Service Worker
在页面上通过 navigator.serviceWorker.controller 获得 ServiceWorker 的句柄. 但只有 ServiceWorker 注册成功后该句柄才会存在.
- function sendMsg(msg) {
- const controller = navigator.serviceWorker.controller;
- if (!controller) {
- return;
- }
- controller.postMessage(msg, []);
- }
- // 在 serviceWorker 注册成功后, 页面上即可通过 navigator.serviceWorker.controller 发送消息给它
- navigator.serviceWorker
- .register('/test/sw.js', {scope: '/test/'})
- .then(registration => console.log('ServiceWorker 注册成功! 作用域为:', registration.scope))
- .then(() => sendMsg('hello sw!'))
- .catch(err => console.log('ServiceWorker 注册失败:', err));
在 ServiceWorker 内部, 可以通过监听 message 事件即可获得消息:
- self.addEventListener('message', function(ev) {
- console.log(ev.data);
- });
Service Worker 发送消息给页面
- // self.clients.matchAll 方法获取当前 serviceWorker 实例所接管的所有标签页, 注意是当前实例 已经接管的
- self.clients.matchAll().then(clientList => {
- clientList.forEach(client => {
- client.postMessage('Hi, I am send from Service worker!');
- })
- });
在主页面中监听
- navigator.serviceWorker.addEventListener('message', event => {
- console.log(event.data);
- });
- Client.postMessage
- manifest
3 manifest.JSON 作用
PWA 添加至桌面的功能实现依赖于 manifest.JSON, 也就是说如果要实现添加至主屏幕这个功能, 就必须要有这个文件
- {
- "short_name": "短名称",
- "name": "这是一个完整名称",
- "icons": [
- {
- "src": "icon.png",
- "type": "image/png",
- "sizes": "144x144"
- }
- ],
- "start_url": "index.html"
- }
- <link rel="manifest" href="path-to-manifest/manifest.json">
name -- 网页显示给用户的完整名称
short_name -- 当空间不足以显示全名时的网站缩写名称
description -- 关于网站的详细描述
start_url -- 网页的初始 相对 URL(比如 /)
scope -- 导航范围. 比如,/App / 的 scope 就限制 App 在这个文件夹里.
background-color -- 启动屏和浏览器的背景颜色
theme_color -- 网站的主题颜色, 一般都与背景颜色相同, 它可以影响网站的显示
orientation -- 首选的显示方向: any, natural, landscape, landscape-primary, landscape-secondary, portrait, portrait-primary, 和 portrait-secondary.
display -- 首选的显示方式: fullscreen, standalone(看起来像是 native App),minimal-ui(有简化的浏览器控制选项) 和 browser(常规的浏览器 tab)
icons -- 定义了 src URL, sizes 和 type 的图片对象数组.
详细配置
MDN 详细配置
manifest 验证
相关问题
对于不同的资源, 我们可能有不同的缓存策略, 怎么方便的去实现这些复杂的场景
使用 workbox, 如果使用 webpack 进行项目打包, 我们可以使用 workbox-webpack-plugin 插件
为什么不适用其他的本地缓存方案
Web Storage(例如 LocalStorage 和 SessionStorage)是同步的, 不支持网页工作线程, 并对大小和类型 (仅限字符串) 进行限制. Cookie 具有自身的用途, 但它们是同步的, 缺少网页工作线程支持, 同时对大小进行限制. WebSQL 不具有广泛的浏览器支持, 因此不建议使用它. File System API 在 Chrome 以外的任意浏览器上都不受支持. 目前正在 File and Directory Entries API 和 File API 规范中改进 File API, 但该 API 还不够成熟也未完全标准化, 因此无法被广泛采用.
同步的问题 就是负担大, 如果有大量请求缓存在本地缓存中, 如果是同步, 可能负担重
在将相应存在 cache 中并返回给浏览器报错
resulted in a network error response: a Response whose "body" is locked cannot be used to respond to a request
这是因为在使用 put 的时候, 是流的一个 pipe 操作, 流是只能被消费一次的. 我们可以 clone 这个 response 或者 reques 参考文章
在经过 webpack 打包后, 所有的静态资源都会带有 hash 值, 怎么办
使用某些 webpack 插件, 例如 offline-plugin 或者 webpack-workbox-plugin
代码示例
- pwa-study
- pwa-webpack-study
参考资料
pwa 系列参考文章
百度 Lavas 教程
workbox-3 博客文档
workbox 官方文档
网站渐进式增强体验 (PWA) 改造: Service Worker 应用详解
如何优雅的为 PWA 注册 Service Worker
Universal PWA Builder
HTML5 桌面通知: Notification API
Service Worker 学习与实践(三)-- 消息推送
PWA - 推送技术
最后(欢迎大家关注我)
DJL 箫氏个人博客
博客 GitHub 地址(欢迎 star)
简书
掘金
来源: http://www.jianshu.com/p/ae2dbbed40c0