阅读 1623

PWA 一隅

第一篇文章,写了很久,挺多的原创部分,希望对你有所帮助。请多指教。

PWA 简介

PWA,全称是 Progressive Web Application,它不特指某一项具体技术,可以看做是一些新技术的集合。PWA 本质上是 Web App,借助新技术,具备了 Native App 的一些特性。

亮点

MDN 上列举了 PWA 的优点,这些优点主要是对目前 Web App 的痛点进行改进。下面列举比较能体现 PWA 特色的几点。

渐进式(Progressive)

各项技术相互之间没有依赖,可以独立实施。如果某项技术在客户端上不支持,那就对其无效,仅此而已。实施新特性无需破坏应用的向后兼容性。

采用渐进式的考虑主要体现在:

  • 可以降低站点改造的代价
  • 新技术标准的支持度还不完全,标准还未最终确定

连接独立性(Connectivity independent)

借助 Service Worker,可以在离线或低速网络状态下工作。

可安装(Installable)

允许用户将应用添加到桌面。

再次访问的吸引力(Re-engageable)

通过 Web Push API 实现消息推送, Notifications API 实现桌面通知,能够吸引用户从浏览器外再次访问。

PWA Checklist

PWA Checklist 给出了 PWA 应用可以参照的标准,除了阅读这个标准外,也可以通过 Lighthouse tool 对 Web 应用进行分析,得到 PWA 的改进建议。

相关核心技术

  • Web App Manifest
  • Service Worker
  • Notifications API
  • Push API

例子 - 豆瓣 PWA

如果你已经阅读了上文的 PWA Checklist,你可能会发现,现有的很多网站已经具备了部分的 PWA 能力(如 HTTPS,响应式)。但作为一个稍微有点追求的程序员,对我来说只有使用上 Service Worker、App Manifest 等核心技术的 Web App 在我心中才有资格称得上是真正的 PWA。

以这个标准来衡量一个 Web App 是否是 PWA 的话,目前国内厂商中使用 PWA 技术的主要有 豆瓣移动版微博移动版饿了么-H5阿里巴巴(国际)-移动版。细心的你可能会发现,这些应用都是移动端的。如果我们考虑 PWA 的出现是为了使 Web App 拥有某些 Native App 的能力(如离线使用、消息推送),而这些能力在移动端能发挥出更大的价值的话,也许就不难理解厂商为什么首先在移动端使用 PWA 技术了。(当然也有像 Vue 官网谷歌邮箱 这样同时在 PC 端提供 PWA 技术的,因为这些技术对于 PC 端同样有帮助,只是在移动端更加明显而已)。

OutwebAppscope 是两个收录 PWA 应用的网站,可以在上面查找 PWA 应用。

下面以 豆瓣移动版 为例,大致地感受一下 PWA 与传统 Web App 之间的区别。

首先让我们用上文提到的 Lighthouse tool 跑个分。

图片标题

嗯,PWA 单项得分 91 分,好像还不错的样子,不过不够直观,所以我们还是看看程序吧。

图片标题

图片标题

如图 (a) - (f) 是使用 Android 的 Chrome 浏览器浏览豆瓣移动版时的截图。这里主要涉及 Manifest(a-e)和 Service Worker(f)两项技术。

第一次进入页面的时候,浏览器会提示将应用添加到主屏幕(a),点击添加(b)后,手机桌面上将生成豆瓣手机版的图标(c),点击桌面图标再次进入页面时,可以看到应用的欢迎界面,浏览器的地址栏也会消失不见(d)。此时,如果查看后台应用,可以发现系统进程中当前页面以 “豆瓣(手机版)” 而不是 “Chrome 浏览器” 的名义显示(e)。关闭移动数据和无线网络,刷新页面后仍可以正常浏览之前浏览过的内容(f)。

图片标题
我们通过 PC 端的 Chrome 浏览器控制台可以看到,首页的推荐信息流以 JSON 的形式被保存在 Cache 中,如果浏览了相关文章,Cache 中也会有相关的 JSON 文件被保存下来。当我们离线使用时,如果命中了相关的 Cache,其中的内容将被取出用于渲染页面,这也是我们为什么能在离线状态下看到(f)的原因。

Service Worker

前身

Service Worker 与另外两项技术有所关联: Web WorkerApplication Cache

浏览器的 JavaScript 都是运行在一个主线程上,随着业务不断复杂,性能问题不断凸显。W3C 提出了 Web Worker API,将一些耗时、耗资源的任务交给这个 API,完成后通过 post Message 方法告诉主线程,主线程通过 onMessage 方法得到反馈结果。但 Web Worker 是临时的,每次进行的操作不能被持久化保存下来,不能解决重复访问时的耗时问题。在此基础上,Service Worker 被提出,在 Web Worker 的基础上增加了持久的离线缓存能力。

Application Cache 则是在 HTML5 早些时候提出的一种应用程序缓存机制。这个标准也试图让应用在离线状态下可用。但是由于开发者无法对缓存进行有效控制,以及其它一些更新逻辑的缺陷,目前已从 Web 标准中移除。

Service Worker 能做什么?

  • 拦截网络请求
  • 缓存可用时返回缓存内容
  • 对缓存内容进行管理
  • 向客户端推送信息
  • 后台数据同步
  • 资源预取

特点

  • 必须在 HTTPS 环境下才能工作
  • 独立的线程,有自己的 worker context
  • 使用时被自动唤醒,不用时自动休眠
  • 不能直接操作 DOM
  • 异步实现,内部大都是通过 Promise 实现

相关依赖

  • HTTPS
  • Promise
  • Fetch API(获取资源)
  • Cache API(缓存)
  • Push API(消息推送)

兼容性

截至目前(2018-08-30),大约 83.89% 的浏览器支持 Service Worker。

图片标题

生命周期方法

image

Service Worker 的生命周期大致可以分为四个阶段。

  • Parse 解析
  • Install 安装
  • Activate 激活
  • Redundant 废弃

Parse

Service Worker 是挂载到 navigator 下的对象,使用前需要检查其可用性,如果可用则进行 Service Worker 的注册。始终要记住的一点是 Service Worker 需要工作在 HTTPS 下,否则会无条件地注册失败。

if ('serviceWorker' in navigator) {
    navigator.serviceWorker
            .register('service-worker.js')
            .then(function() { console.log('Service Worker Registered');
    });
}
复制代码

register() 方法有一个可选的参数 scope,可以指定让 Service Worker 控制缓存哪个目录下的文件(作用域),不填写时默认为 Service Worker 文件所在的目录。

.register('service-worker.js', {scope: '/'})
复制代码

注册成功后在 Chrome 控制台 Application 下的 Service Worker 中可以看到。

Install

const PRECACHE_URLS = ["../", "../styles/index.css", "../scripts/index.js"]

self.addEventListener("install", event => {
  event.waitUntil(
    caches
      .open('shell')
      .then(cache => cache.addAll(PRECACHE_URLS))
      .then(self.skipWaiting())
  )
})
复制代码
  1. self.skipWaiting() 用于跳过等待状态,这个方法主要涉及新的 Service Worker 安装和老的 Service Worker 废弃的过程,即 Service Worker 的更新。一般情况下,新的 Service Worker 安装完成后将会进入等待状态,需要在老的 Service Worker 停止工作(一般是关闭浏览器)后才会取代。
  2. 由于系统会随时休眠 Service Worker,为了防止执行中断,需要使用 event.waitUntil() 进行捕获,它会监听异步请求返回的 promise,如果其中有 reject 的情况,则会导致 Service Worker 开启失败。

为了防止由于某些大文件或不稳定的文件下载失败导致 Service Worker 启动失败,可以只让一部分文件通过 cache.addAll() 返回。

self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open('shell').then(function(cache) {
    // 不稳定文件或大文件加载
      cache.addAll(
        //...
      );
      // 稳定文件或小文件加载
      return cache.addAll(
        //
      );
    })
  );
});
复制代码

其中,第一个 cache.addAll() 将不会被捕获。

Activate

Service Worker 处于 activated 状态下时可以处理事件,如请求拦截与缓存捕获。在这之前,我们可以监听 activate 事件,在回调函数中对旧的无用缓存文件进行清理。

self.addEventListener("activate", event => {
  const currentCaches = [SHELL, RUNTIME];
  event.waitUntil(
    caches
      .keys()
      .then(cacheNames => {
        return cacheNames.filter(
          cacheName => !currentCaches.includes(cacheName)
        );
      })
      .then(cachesToDelete => {
        return Promise.all(
          cachesToDelete.map(cacheToDelete => {
            return caches.delete(cacheToDelete);
          })
        );
      })
      .then(() => self.clients.claim())
  );
});
复制代码

self.clients.claim() 做的是在不重新加载的前提下取得页面控制权。

fetch

下面介绍一下请求拦截与缓存捕获这一步。PWA 最吸引人的地方之一离线能力就是这一部分操作实现的。

这个部分涉及上文提到的两个 API:

  • Fetch API
  • Cache API

下面是一个简单的例子。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        if (response) {
          return response;
        }
        return fetch(event.request);
      }
    )
  );
});
复制代码

首先我们需要监听浏览器本身的 fetch 事件,respondWith 用来响应页面的请求。这里使用了 Catch API 的 match 方法来查找 Cache 中是否存在与 request 请求匹配的缓存,如果不存在则再通过 Fetch API 进行远程请求。

如果我们在 install 时把页面和相关的资源缓存下来,在这一步已经能够实现页面的离线访问了。

这段代码可以进行优化,当没有命中 cache 进行远程请求后,可以将 fetch 的内容加入缓存中,这样这些资源在下一次访问的时候就可以直接使用了。

self.addEventListener("fetch", event => {
  if (event.request.url.startsWith(self.location.origin)) {
    event.respondWith(
      caches.open(RUNTIME).then(function(cache) {
        return cache.match(event.request).then(function(response) {
          var fetchPromise = fetch(event.request).then(function(networkResponse) {
            cache.put(event.request, networkResponse.clone());
            return networkResponse;
          })
          return response || fetchPromise;
        })
      })
    );
  }
});
复制代码

值得注意的是这里的 response 需要给浏览器进行渲染,并同时保存的缓存中。由于 caches.put 使用的是文件的响应流,一旦使用就会造成 response 无法访问(可以理解为破坏性读出),所以需要事先使用 clone 方法复制一份。

我们再捋一下上面代码的逻辑。

  1. 监听浏览器 fetch 事件,拦截原本的请求,
  2. 检查 cache 中是否存在将要请求的资源,有则返回缓存,无则进入下一步,
  3. 远程请求资源,将资源缓存后返回。

这是典型的 “缓存优先” 策略。事实上,通过 Fetch API 和 Cache API 顺序的排列组合,在拦截浏览器 fetch 事件后可以实现多种策略。

  • 缓存优先
  • 网络优先
  • 仅使用缓存
  • 仅使用网络
  • 速度优先

如果对策略的具体实现有所疑惑,可以参考 Service Worker最佳实践 - 腾讯浏览服务,此处不再赘述。

Redundant

新的 Service Worker 进入 activated 状态后,老的 Service Worker 将被废弃,即 redundant 状态。

Service Worker 更新

更新 Service Worker 只需直接改动对应的 JavaScript 文件即可。浏览器会自动检测差异性进行获取。

当新的 Service Worker 被下载并 install 后,将进入 waiting 状态。此时两个 Service Worker 同时存在,仍由老的 Worker 控制页面。只有当老的 Service Worker 停止工作时,新的 Service Worker 才会进入 activated 状态并掌管页面。

Web App Manifest

注意这里的 ManifestApp Cache 中的 Manifest 完全不同,后者已从 Web 标准中移除,并由 Service Worker 替代。

JSON ? meta?

Web App Manifest(Web 应用程序清单)概括地说是一个以 JSON 形式集中书写页面相关信息和配置的文件。这种清单的形式早在 浏览器插件开发中就已经出现(也许 PWA 中的 Manifest 是借鉴了其中的形式,只是属性有所区别)。

W3C 上提到了 Manifest 采用 JSON 形式的外置文件的一些考虑。总结一下主要有以下几点:

  1. 解耦。无需在各个页面重复声明 meta 标签,利于维护。
  2. 可缓存。HTML 可能经常变动,意味着用户代理通常需要下载整个 HTML 文件。使用外置的 Manifest 文件能够更好地利用缓存。
  3. 书写更灵活。这点的考虑其实可以借鉴 XML 与 JSON 的比较。标签化的结构适合 UI,而像 JSON 这样的结构更适合数据。Manifest 就是应用程序的一些数据,从这个角度看 JSON 比 meta 标签更合适。

用法

Manifest 的使用方法非常简单。首先需要在 head 中引用。

<head>
    <link rel="manifest" href="/manifest.json" />
</head>
复制代码

在 Manifest 文件中,用 JSON 的形式书写应用的相关信息。

{
  "name": "App name",
  "short_name": "App short name",
  "description": "Here is the description",
  "start_url": ".",
  "display": "standalone",
  "background_color": "#fff",
  "icons": [
      {
        "src": "images/homescreen.png",
        "sizes": "48x48 72x72 96x96 128x128 256x256",
        "type": "image/png"
      },
      {
        "src": "icon/logo.ico",
        "sizes": "96x96"
      }
  ]
}
复制代码

相关字段说明

下面介绍几个常用的字段。

name

Web App 的全名,作为 App 图标的文字标签。

short_name

为 Web App 提供简短易读的名称,以便在没有足够空间显示应用程序全名时使用。

description

有关 Web App 的描述信息。

start_url

设置用户从主屏启动 App 时加载的 URL(用户在详情页将应用添加到首屏,此时若 start_url 为空,则用户从首屏打开应用时打开的页面将是详情页)。

scope

定义 Web 应用程序上下文的导航范围,如果用户在范围之外浏览应用程序,则返回正常的网页。

display

定义应用程序的首选显示模式,拥有四个可选的值,目前使用 PWA 的网站用得比较多的是 standalone 。

  • fullscreen 全屏显示,不显示状态栏
  • standalone 像一个独立的应用程序,具有不同的窗口、图标,浏览器用于控制导航的 UI 将被移除,可能包含其它 UI 元素(如状态栏)
  • minimal-ui 像一个独立的应用程序,但包含浏览器地址栏
  • browser(默认) 以传统浏览器标签或新窗口形式打开

background_color

使浏览器可以在 CSS 加载前绘制 Web App 的背景颜色。

icons

指定各种环境下,应用程序图标的的图像对象数组。


除此之外,还有其它的属性,如需查看它们的用法可以参考 MDN 文档W3C文档

Manifest 更新

一旦用户将应用 icon 添加到桌面,之后 icon 将不能被更新,除非用户将其删除后重新添加到桌面。

额外需要考虑的问题

Service Worker 启动性能

在 W3C 的 Github 上关于 Service Worker的讨论中,可以看到目前 Chrome 中 Service Worker 的启动耗时大概在 200ms 左右,这意味着如果使用 Service Worker 之后减少的加载时间如果不足 200ms,反而会延长页面的加载时间。但这个问题这有望在今年 Chrome 后续版本的更新中得到改善。

无法优化 “首次加载” 速度

从 PWA 的流程上可以发现,PWA 不能彻底优化 “首屏加载” 的性能问题(如白屏)。当新用户 “首次加载” 或用户清除浏览器缓存之后进入页面,到真正的使用上某个文件的缓存,需要经过三次网络请求。第一次是请求 Service Worker 所在的脚本文件,第二次是请求这个需要缓存的文件本身,到了第三次请求的时候,这个缓存才能真正生效。如果想要让用户在“首次加载”的时候同样拥有流畅的体验,单靠 PWA 是不够的。

优化“首次加载”的速度,首先想到的方式可能是使用 SSR (Server Side Render,浏览器端渲染)代替 CSR (Client Side Render,客户端渲染)。Vue 甚至有官方的 SSR 指南 介绍使用过程。这里分享一个腾讯视频前端团队的演讲 —— 《极致流畅的移动 Web 应用解决方案》,演讲中详细比较了 SSR 和 CSR 的关键渲染路径及相关性能。下图摘自演讲 PPT ,从图中可以看出 SSR 相比 CSR 节省的主要时间来自 JS 生成 HTML 以及请求数据填充内容的过程。

图片标题
值得一提的是,这个演讲中个人感觉最有趣的一点是他们使用了 Web Socket 代替 AJAX 请求数据,减少了重复创建连接所消耗的时间。

对于 CSR,同样有相应的优化措施。比如,可以在 webpack 打包的过程中,使用 prerender-spa-plugin 在构建时生成页面首屏,也可以大大减少 FCP(First Contentful Paint)时间,达到优化首次加载的目的。

国内 PWA 生态环境

在 PWA 技术推广上,谷歌表现得较为积极,其 PWA 生态也较为完整。但由于众所周知的原因,谷歌提供的服务在国内无法正常使用。比如,在消息推送上,谷歌提供了FCM 服务,但国内没有相关的可以替代的基础设施,导致 PWA 的这一功能在国内实施起来较有难度。

另一方面, PWA 技术的讨论和试验也比较活跃。也许是因为其渐进式(Progressive)的思想,其中一部分特性现在已有比较广泛的应用。比如 Service Worker,当你打开浏览器控制台后你会发现,像淘宝QQ音乐百度网盘 这样耳熟能详的产品网站已经应用了相关的技术,以优化页面资源下载时间。

浏览器之间存在差异

由于本人精力及设备有限,仅测试了 2018 年 7 月的 艾瑞数据 中排名靠前并且能够在应用市场下载使用的浏览器。

说明:

  1. 测试环境: Android 7.0.0
  2. 测试网站: 豆瓣
  3. 测试标准: Service Worker 仅对其离线能力(有或无)进行测试,Manifest 部分由于各家没有统一的表现,在这各家中 Chrome 浏览器的功能点最多,故以 Chrome 浏览器为基准,从五个方面进行评估。
  4. “添加到桌面” 一项中,“手动” 指的是在浏览器中可以找到添加到桌面的入口并将网页手动添加到桌面;“提示” 指的是进入页面时,浏览器提示用户将应用添加到桌面,虽然之后确认添加这一过程仍需手动点击,但免去了寻找添加入口的冗余操作步骤。
  5. “地址栏自动隐藏” 指的是从桌面点击进入应用之后的全过程,浏览器会根据 Manifest 配置决定是否隐藏地址栏。有些浏览器会有其它触发隐藏地址栏的设定,但这种触发跟用户操作相关(如上滑和下滑),而不会在全过程隐藏地址栏。
浏览器名称 离线能力 添加到桌面 桌面图标及名称与配置一致 欢迎屏幕 地址栏自动隐藏 后台驻留以应用形式显示
Chrome(68.0.3440.70) 支持 提示 + 手动
QQ 浏览器(8.8.0.4420) 不支持 手动
UC 浏览器(12.1.0.990) 支持 手动
360 浏览器(8.2.0.128) 支持 提示 + 手动
百度浏览器(7.18.20.0) 支持 提示 + 手动
三星浏览器(7.2.10.33) 支持 手动
搜狗浏览器(5.15.15) 支持 手动

从上表可以看出,国内主要浏览器厂商对于 Service Worker 的离线能力支持度还是很高的,也许是因为这些浏览器基于 Chromium 内核进行开发。QQ 浏览器由于使用的是 X5 内核,表现上与 Chromium 内核存在差异。至于 Manifest,其功能点可能更多的属于软件本身实现而不是内核的问题,各家的表现差异较大。

小结

PWA 虽然不是完美的最终解决方案,并且目前在国内实现其所有特性尚有难度,但其中包含的很多技术,如对缓存的精细控制、消息推送等已经填补了之前 Web 生态的空白,瑕不掩瑜或许是对其最准确的评价。对我来说,至少 “渐进式” 的思想足以让我相信并期待着 PWA 乃至整个 Web App 的美好未来。Make Web App great again!

相关链接

PWA 网址导航

国内部分 PWA

工具

改造案例

参考

关注下面的标签,发现更多相似文章
评论