PWA 介绍

  1. 随着时代的发展,出于对低成本开发与维护复杂跨平台应用的追求,前端技术正在突破浏览器束缚,开始向桌面、移动等领域扩张。其中出现了以 ElectronCordovaReactNativeWeex 为代表的混合应用开发架构方案,这些方案虽然解决了传统 Web 无法与底层系统深层次交互、原生应用更新繁琐、应用开发维护成本过高等问题,但其依旧无法满足用户零安装、零更新、用完即走的需求。<br /> <br /> 当我们回顾技术发展史会发现,目前唯一能够打破平台限制、满足零安装更新等需求的正是不经任何加工的传统 Web 技术,得益于前端工程化体系的逐步完善,构建大型 Web 应用的开发维护成本已经得到大幅度降低,但由于 Web 自身的一些原因导致它无法替代平台原生应用:
  • 应用入口依赖于浏览器。
  • 不具备离线处理能力。
  • 不具备与底层系统深层次交互能力。

    也正因为此,Google 的工程师于 2015 年提出了 PWA(Progressive Web App) 架构,它旨在不丢失 Web 开放特性的前提下,让 Web 应用能够以渐进的形式撕掉浏览器的标签,最终抹平与原生应用的差异。其主要特点有:

  • 可通过 Manifest 配置文件实现将应用添加到主屏幕,以解决应用入口依赖浏览器的问题。

  • 借助 Service Worker、离线存储、后台同步等技术来提供离线处理能力。
  • 通过推送通知、蓝牙、支付等新接口来突破浏览器限制,从而达到集成底层系统功能。


    为了能够让大家更好地理解并掌握 PWA,我将个人在工作学习中的一些心得整理成小册《深入浅出 PWA》,希望通过学习,大家可以熟练掌握并使用 PWA 架构及其相关技术来构建高可用的现代化 Web 应用。

PWA 利用现代 Web 功能#

PWA 提供了一组最佳实践和现代 Web API,旨在通过使您的网站快速、可安装、可靠和引人入胜来满足客户的需求。
例如,使用 Service Worker缓存您的资源并进行预测性预取可以使您的站点更快、更可靠。使您的网站可安装为您的客户提供了一种直接从其主屏幕或应用程序启动器访问它的简便方法。新的 API(如Web 推送通知)可以更轻松地通过个性化内容重新吸引用户以产生忠诚度。

PWA 渐进式的web应用

介绍

我将带你快速熟悉一个pwa应用的基本使用
本文中涉及到的内容:

  • fetch
  • CacheStorage
  • service worker
  • SyncMannager
  • postManager 与 messageChannal
  • manifest.json
  • 消息推送

    Fetch

    Fetch 提供了许多与XMLHttpRequest相同的功能,为什么要在这里提及这个。
    因为在我们在service worker环境中是不能去使用XMLHttpRequest对象的,故而这是一个非常重要的api。
    Fetch 的核心在于对 HTTP 接口的抽象,包括 Request,Response,Headers,Body这些接口

    基本用法

    Promise fetch(input [, init]);
    https://www.yuque.com/lijunyang/dk90s4/wqg7ts

    CacheStorage

    这是一个可以缓存浏览器缓存的接口,离线应用的核心就靠他了

    CacheStorage常用方法介绍

    | name | 描述 | | —- | —- | | Cache.match(request, options) | 该方法会检查是否存在该request的缓存,返回 Promise对象,resolve的结果是跟 Cache 对象匹配的第一个已经缓存的请求。 | | Cache.matchAll(request, options) | 同上,不同的是resolve的结果是跟Cache对象匹配的所有请求组成的数组。 | | Cache.add(request) | 抓取这个URL, 检索并把返回的response对象添加到给定的Cache对象.这在功能上等同于调用 fetch(), 然后使用 Cache.put() 将response添加到cache中. | | Cache.addAll(requests) | 抓取一个URL数组,检索并把返回的response对象添加到给定的Cache对象。 | | Cache.put(request, response) | 同时抓取一个请求及其响应,并将其添加到给定的cache。 | | Cache.delete(request, options) | 搜索key值为request的Cache 条目。如果找到,则删除该Cache 条目,并且返回一个resolve为true的Promise对象;如果未找到,则返回一个resolve为false的Promise对象。 | | Cache.keys(request, options) | 返回一个Promise对象,resolve的结果是Cache对象key值组成的数组。 |

options参数的解释:

  • ignoreSearch(Boolean): 忽略url中的query部分(?后面的参数)默认值:false
  • ignoreMethod(Boolean): 如果设置为 true在匹配时就不会验证 Request 对象的http 方法 (通常只允许是 GET 或 HEAD 。) 默认值:false
  • ignoreVary(Boolean): 该值如果为 true 则匹配时不进行 VARY 部分的匹配。默认值:false
  • cacheName(DOMString): 代表一个具体的要被搜索的缓存。注意该选项被 Cache.match()方法忽略。

    service worker

    这是我们要花时间最多的地方!

    生命周期

  1. installing(正在安装)

当你调用 navigator.serviceWorker.register 注册一个新的 service worker ,service worker 代码就会被下载、解析、进入installing阶段。
若安装成功则进入installed,失败则进入redundant

  1. installed/waiting(已安装/等待中)

installed 状态下的service worker会判断自己是否已经被注册过,如果是第一次注册将进入activating状态,如果发现自己被注册且处于activated状态,那么将进入waiting状态

  1. activating(激活中)

此状态下的sw将进入activated(必将进入activated状态)

  1. activated(已激活)

一旦 service worker 激活,它就准备好接管页面并监听功能性事件了(例如 fetch 事件)

  1. redundant(废弃)

当sw注册失败或者被新的sw替换将进入此状态(只有这两种情况会进入redundant)

image.png

结合代码理解service worker生命周期

  1. if("serviceWorker" in navigator){
  2. window.onload = function(){
  3. navigator.serviceWorker.register("/sw.js").then((registration)=>{
  4. var sw = null, state;
  5. if(registration.installing) { // 正在安装
  6. sw = registration.installing;
  7. state = 'installing';
  8. } else if(registration.waiting) { // 等待着
  9. sw = registration.waiting;
  10. state = 'installed'
  11. } else if(registration.active) { // 激活中 || 激活完成
  12. sw = registration.active;
  13. state = 'activated'
  14. }
  15. state && console.log(`sw state is ${state}`);
  16. if(sw) {
  17. sw.onstatechange = function() {
  18. console.log(`sw state is ${sw.state}`);
  19. }
  20. }
  21. }).catch(()=>{
  22. console.log('sw fail');
  23. })
  24. }
  25. }
  1. // sw.js
  2. self.addEventListener('install',function () {
  3. console.log('install callback');
  4. })
  5. self.addEventListener('activate',function () {
  6. console.log('activate callback');
  7. })
  8. self.addEventListener('fetch',function () {
  9. console.log('fetch callback');
  10. })

首次刷新页面控制台输出:
install callback
sw state is installing
sw state is installed
activate callback
sw state is activating
sw state is activated
再次刷新页面控制台输出:
sw state is activated
fetch callback

install,与active事件仅仅执行一次,fetch在首次刷新是不请求的

更新 service worker

image.png
当更新sw时,刷新页面,此时:

  1. 新的sw的install回调触发,进入installing
  2. 新的sw发现上一个sw还处于actived状态则进入waiting状态
  3. 此时调用slkipwating方法或者关闭浏览器再次打开浏览器,会使旧的sw进入redundant,新的sw进入actived

代码此处可以自己尝试

waitUntil延长生命周期

waitUntil函数传入promise作为参数,当promise执行完成才会接着往下走。

在install事件中调用该方法

加入成功的回调:

  1. self.addEventListener('install',function (event) {
  2. event.waitUntil(new Promise(function(resolve) {
  3. setTimeout(() => {
  4. console.log('install 2s')
  5. resolve()
  6. }, 2000)
  7. }))
  8. })
  9. /*
  10. sw state is installing
  11. install 2s
  12. sw state is installed
  13. activate callback
  14. sw state is activating
  15. sw state is activated
  16. */

加入失败的回调:

  1. self.addEventListener('install',function (event) {
  2. event.waitUntil(new Promise(function(resolve,reject) {
  3. setTimeout(() => {
  4. console.log('install 2s')
  5. reject()
  6. }, 2000)
  7. }))
  8. })
  9. /*
  10. sw state is installing
  11. install 2s
  12. sw state is redundant
  13. Uncaught (in promise) undefined
  14. */

sw直接进入redundant阶段

在activate事件中调用该方法

与install大致都一样,不同的是当你调用reject时,sw不会进入redundant阶段,而是最终还是进入actived阶段

生命周期常见应用

  1. 一般我们会在install中缓存请求,这是为了能够在下一次请求中使用到这些缓存。
  2. 在active中我们应该清除旧的缓存。
  3. 在fetch中我们便可以使用这些缓存,并且更新他们

    fetch 事件 网络劫持

    fetch事件被触发于页面对网络发起任意请求,当然我们前面已经提过:
    1)必须在激活事件触发后service worker接管网络请求;
    2)请求的目标url必须位于注册的资源范围(scope)内。
    1. // 此时 scope 范围 是 "/" , 请注意,最好放到根目录中,否则有可能导致
    2. // self.addEventListener("fetch") 不被触发
    3. navigator.serviceWorker.register("/sw.js")
    4. // 此时 scope 范围 是 "/js"
    5. navigator.serviceWorker.register("/js/sw.js")

    service worker的作用域范围?

    service worker只能捕获当前目录及其子目录下的请求!
    比如:
    当service worker在/pwa/sw.js下时那么只能捕获/pwa/*的请求,所以一般我们都应该将sw.js放置于/根目录下。

每个 Service Worker 都有自己的作用域,它只会处理自己作用域下的请求,而 Service Worker 的存放位置就是它的最大作用域

代码示例

  1. /*
  2. self: 表示 Service Worker 作用域, 也是全局变量
  3. caches: 表示缓存
  4. skipWaiting: 表示强制当前处在 waiting 状态的脚本进入 activate 状态
  5. clients: 表示 Service Worker 接管的页面
  6. */
  7. // 缓存静态资源的key
  8. // 当页面发生修改时, 要同时对 cacheStorageKey 进行修改
  9. // 然后重新打开一次页面, 这个时候渲染的页面依然是旧的, 不过可以从 DevTools 看到 此 service work 被安装和激活。
  10. // 之后关闭页面, 再次打开, 就可以见到新的内容了。
  11. const cacheStorageKey = 'v1'
  12. const cacheList = [
  13. '/',
  14. '/js/resize.js'
  15. ]
  16. // sw.js self === window
  17. self.addEventListener('install', function (e) {
  18. // 缓存静态资源
  19. e.waitUntil(
  20. caches.open(cacheStorageKey)
  21. .then(cache => cache.addAll(cacheList))
  22. .then(() => self.skipWaiting())
  23. )
  24. })
  25. self.addEventListener('activate', function (e) {
  26. console.log('activate callback')
  27. // 更新缓存资源,清除缓存
  28. const promises = caches.keys().then(cacheNames => {
  29. console.log('cacheNames', cacheNames)
  30. return cacheNames.map(name => {
  31. console.log('name', name)
  32. if (name !== cacheStorageKey) {
  33. return caches.delete(name)
  34. }
  35. return Promise.resolve()
  36. })
  37. })
  38. e.waitUntil(
  39. promises.then(list => {
  40. return Promise.all(
  41. list
  42. )
  43. }).then(() => {
  44. // 使页面受控
  45. return self.clients.claim()
  46. })
  47. )
  48. })
  49. self.addEventListener('fetch', (event) => {
  50. // caches 不能处理post 请求,需要使用 indexDB 保存post请求内容
  51. if (event.request.method !== 'GET') return
  52. let url = event.request.url
  53. event.respondWith(
  54. caches.open(cacheStorageKey)
  55. .then(cache => {
  56. return cache.match(event.request)
  57. .then(response => {
  58. if (response) {
  59. // return cached file
  60. console.log('cache fetch: ' + url)
  61. return response
  62. }
  63. // make network request
  64. return fetch(event.request)
  65. .then(newreq => {
  66. console.log('network fetch: ' + url)
  67. if (newreq.ok) cache.put(event.request, newreq.clone())
  68. return newreq
  69. })
  70. // app is offline
  71. .catch(function (error) {
  72. console.error('Fetching failed:', error)
  73. throw error
  74. })
  75. })
  76. })
  77. )
  78. })
  79. self.addEventListener('error', event => {
  80. // 上报错误信息
  81. // 常用的属性:
  82. // event.message
  83. // event.filename
  84. // event.lineno
  85. // event.colno
  86. // event.error.stack
  87. console.log('sw:event.message', event.message, event.filename)
  88. })
  89. self.addEventListener('unhandledrejection', event => {
  90. // 上报错误信息
  91. // 常用的属性:
  92. // event.reason
  93. console.log('sw:event.reason', event.reason)
  94. })

离线开发的策略

  1. 仅网络
  2. 先网络后缓存:当我们希望用户看见内容一直是最新的,那么可以使用这种模式,在fetch中更新缓存数据
  3. 缓存后网络:当不需要用户看见最新的内容,我们可以先将缓存呈现给用户,在fetch中更新缓存,下一次用户刷新可以看见最新的缓存。(以上代码采取的就是这种)

    syncManager 后台同步

    在用户使用web app时,网页可能会被关闭,用户连接可能会断开,甚至服务器有时候也会故障。但是,只要用户设备上安装了浏览器,后台同步中的操作就不会消失,直到它成功完成为止。

    注册后台同步事件

    在页面中:

    1. navigator.serviceWorker.ready.then(function(registration) {
    2. registration.sync.register('send-messages');
    3. });

    监听sync事件

    在sw.js中

    1. self.addEventListener("sync", function(event) {
    2. if (event.tag === "send-messages") {
    3. event.waitUntil(function() {
    4. var sent = sendMessages();
    5. if (sent) {
    6. return Promise.resolve();
    7. }else{
    8. return Promise.reject();
    9. }
    10. });
    11. }
    12. });

    sync事件何时结束?

    当后台多次尝试不成功时,那么sync事件也会提供结束时的标识符event.lastChance

    1. self.addEventListener("sync", event => {
    2. if (event.tag == "add-reservation") {
    3. event.waitUntil(
    4. addReservation()
    5. .then(function() {
    6. return Promise.resolve();
    7. }).catch(function(error) {
    8. if (event.lastChance) {
    9. return removeReservation();
    10. } else {
    11. return Promise.reject();
    12. }
    13. })
    14. );
    15. }
    16. });

    postMessage

    当我们将逻辑代码放入service worker中时,我们就一定会有页面与service worker通信的需求,此时postMessage便是这么一个担任通信的角色。

    1. 窗口向service worker通信

    页面代码:

    1. navigator.serviceWorker.controller.postMessage( {
    2. arrival: "05/11/2022",
    3. nights: 3,
    4. guests: 2
    5. })

    service worker代码:

    1. self.addEventListener("message", function (event) {
    2. console.log(event.data);
    3. });

    2. service worker向所有打开的窗口通信

    页面代码:

    1. navigator.serviceWorker.addEventListener("message", function (event) {
    2. console.log(event.data);
    3. });

    service worker代码:

    1. self.clients.matchAll().then(function(clients) {
    2. clients.forEach(function(client) {
    3. if (client.url.includes("/my-account")) {
    4. client.postMessage("Hi client: "+client.id);
    5. }
    6. });
    7. });

    3. service worker向特定窗口通信

    service worker代码:

    1. //当你可以获得某个客户端id时便可以向特定的客户端发送消息
    2. self.clients.get("d2069ced-8f96-4d28").then(function(client) {
    3. client.postMessage("Hi window, you are currently " +
    4. client.visibilityState);
    5. });

    获得特定的客户端id

    ```javascript

//通过clients对象获取客户端id self.clients.matchAll().then(function(clients) { clients.forEach(function(client) { self.clients.get(client.id).then(function(client) {
client.postMessage(“Messaging using clients.matchAll()”); }); }); });

//通过event.sourse.id 获得·客户端id self.addEventListener(“message”, function(event) { self.clients.get(event.source.id).then(function(client) { client.postMessage(“Messaging using clients.get(event.source.id)”); }); });

//简化写法 self.clients.matchAll().then(function(clients) { clients.forEach(function(client) { client.postMessage(“Messaging using clients.matchAll()”); }); });

  1. <a name="mkaj8"></a>
  2. # MessageChannel
  3. 在介绍第四种通信方式(窗口间通信)时,我想先插入介绍一下MessageChannel这个对象,他是实现我们service worker与窗口间相互通信的一种有效的技术手段。
  4. <a name="VX1PI"></a>
  5. ## 演示代码
  6. ```javascript
  7. // 窗口代码
  8. var msgChan = new MessageChannel();
  9. msgChan.port1.onmessage = function(event) {
  10. console.log("Message received in page:", event.data);
  11. };
  12. var msg = {action: "triple", value: 2};
  13. //这里可以是postMessage的第二个参数
  14. navigator.serviceWorker.controller.postMessage(msg, [msgChan.port2]);
  15. // service worker代码
  16. self.addEventListener("message", function (event) {
  17. var data = event.data;
  18. var openPort = event.ports[0];
  19. if (data.action === "triple") {
  20. openPort.postMessage(data.value*3);
  21. }
  22. });

4. 窗口间的通信

窗口间通信方式有多种,你如localStorage这些都可以实现,这里还是应该思考如何使用service worker来进行通信,这里先不再赘述。

manifest.json

当我们的web应用已经使用以上技术做到了一系列的离线优化后,我们可以考虑将我们的应用安装在本地。

  • 页面引入:

    1. <link rel="manifest" href="/manifest.json">
  • manifest.json:

    1. {
    2. "short_name": "Gotham Imperial",
    3. "name": "Gotham Imperial Hotel",
    4. "description": "Book your next stay, manage reservations, and explore Gotham", "start_url": "/my-account?utm_source=pwa",
    5. "scope": "/",
    6. "display": "fullscreen",
    7. "icons": [
    8. {
    9. "src": "/img/app-icon-192.png", "type": "image/png",
    10. "sizes": "192x192"
    11. }, {
    12. }
    13. ],
    14. "theme_color": "#242424",
    15. "background_color": "#242424"
    16. }

    属性介绍

  • name与/或short_name:
    name 是应用的全名。当空间足够长时,就会使用这个字段作为显示名称,short_name 可以作为短 名的备选方案

  • start_url:
    当用户点击图标时,打开的 URL。可以是根域名,也可以是内部页面。
  • icon:
    包含了一个或多个对象的数组,对象属性:src(图标的绝对路径或者相对路径)、type(文件类型)和 sizes(图片的像素尺寸)。要触发Web应用安装横条,清单中至少要包含一个图标, 尺寸至少是 144像素×144像素。 由于每个设备都会根据设备分辨率,从这个数组中选择最佳的图标尺寸,因此建议至少 包含 192×192 的图标和 512×512 的图标,以覆盖大多数的设备和用途。
  • display:
  1. browser——在浏览器中打开应用。
  2. standalone——打开应用时不显示浏览器栏(不显示浏览器界面,例如地址栏)。
  3. fullscreen——打开应用时不显示浏览器栏和设备栏(例如在安卓设备上,这意味着 同时隐藏浏览器界面和屏幕顶部的状态栏)。
  • description:
    应用的描述。
  • orientation:
    允许你强制指定某个屏幕方向。
  • theme_color
    主题颜色可以让浏览器和设备调整 UI 以匹配你的网站(见图 9-5)。这个颜色的选择会 影响浏览器地址栏颜色、任务切换器中的应用颜色,甚至是设备状态栏的颜色。主题颜色也可以通过页面的meta标签进行设置(例如:)。如果页面带有 theme-color 的 meta 标签,则该设置会覆盖清单 中的 theme_color 设置。请注意,虽然 meta 标签可以让你设置或者覆盖单个页面的主 题颜色,但是清单文件中的 theme_color 设置是会影响整个应用的。
  • background_color
    设置应用启动画面的颜色以及应用加载时的背景色。一旦加载后,页面中定义的任何背 景色(通过样式表或者内联 HTML 标签设置)都会覆盖这一设置;但是,通过将其设 置为与页面背景色相同的颜色,就可以实现从页面启动的瞬间到完全渲染之间的平滑过 渡。如果不设置这一颜色,页面就会从白色背景启动,随后被页面的背景色替换。

更新中

作者:QLQ
链接:https://juejin.cn/post/6844903982561755150
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。