PWA 在饿了么的实践经验

5,763 阅读12分钟
原文链接: zhuanlan.zhihu.com

PWA ( Progressive Web Apps,渐进式网页应用)是由谷歌提出的新一代 Web 应用概念,旨在提供可靠、快速、类似 Native 应用的服务方案。

本篇旨在和大家分享「饿了么 M 站」在 PWA 改造中的实践经验。涉及到的方面有:PWA 线上部署的准备工作、多页应用的 prerender 优化、实践过程中踩到的(和推进解决的)坑。而关于 PWA 的一些基础资料,本篇不会多费笔墨,有兴趣深入了解的朋友可查看本文最下面的延伸阅读栏目。

准备工作

提问:做 PWA 第一步要做什么?

A. 写一个简单的 service-worker.js
B. 找一个靠谱的 Service Worker 库
C. 抄一下现成的模板
D. 搞一下 Webpack/Gulp/Grunt 构建
E. 打开冰箱

从技术的角度看,以上的选项都没问题。但从保持业务稳定的基本原则出发,「提供降级方案错误监控以及数据统计」才是在生产环境部署 PWA 的第一步。

正所谓「能力越大,责任越大」,由于 Service Worker (以下简称SW)直接在浏览器网络层工作,SW 内的 bug 很容易被放大:

  1. 由于 SW 缓存策略的作用,页面代码里的 bug 会被缓存,不能及时修复。
  2. 如果 SW 缓存策略有 bug,用户可能无法更新页面,而开发者对此不易察觉。
  3. SW 的错误可能导致所有页面无法工作,对业务造成的影响往往是灾难性的。

国内五花八门的浏览器与四分五裂的系统,对 SW API 的支持情况简直百花齐放,各种意想不到的兼容性问题大大加剧了国内开发者们写出 bug-free 代码的难度(吐血

要是准备工作没做好,P0 事故就离你不远了……

降级方案

业务讲究可靠,而提高可靠性最简单、最粗暴的方法就是提供降级开关,一旦发现事情不简单,开一下降级就可以了。

我们的降级方法很简单:页面先请求开关接口,若降级,则不安装并且注销所有 SW。(很庆幸我们在开始前做了这样的准备,否则……)


if (支持SW) {
  fetch(开关接口)
  .then(() => {
    if (降级) {
      // 注销所有已安装的 SW
    } else {
      // 注册 SW
    }
  })
}

要注意的有几点:

  1. 降级一定要注销掉 SW ,而不是简单地不安装。这是因为降级前可能已经有用户访问过网站,导致 SW 被安装,不注销的话降级开关对这部分用户是不起作用的。
  2. 降级开关需要有即时性,因此服务器和 SW 都不应该缓存该接口。
  3. 出现问题并降级后,可能影响问题的排查,因此可以考虑加入对用户隐蔽的 debug 模式(如 url 传入特定字段),debug 模式中忽略降级接口。

错误监控

SW 运行在 worker 线程里,其抛出的错误在页面是捕捉不到的,因此需要在 SW 里引入错误监控。(感谢题叶老师提供错误监控 SDK 的 SW 版本)


self.addEventListener('error', event => {
  // 上报错误信息
  // 常用的属性:
  // event.message
  // event.filename
  // event.lineno
  // event.colno
  // event.error.stack
})
self.addEventListener('unhandledrejection', event => {
  // 上报错误信息
  // 常用的属性:
  // event.reason
})
  1. SW 大部分 API 都是 promise-based 的,promise 里未处理的错误触发的不是 error 事件,而是 unhandledrejection 事件。
  2. 这两个事件都只能在 worker 线程的 initial 生命周期里注册。(否则会失败,控制台可看到警告)

数据统计

数据统计可以帮助我们更好的理解用户,为业务增长提供数据支撑。同时,其曲线抖动也可以辅助错误监控为生产环境提供监控保障。PWA 中的统计与正常流程无异,这里重点说说 PWA 特有的「添加到主屏」的事件。


// 「弹出添加到主屏对话框」事件
window.addEventListener('beforeinstallprompt', event => {
  // 这个 `event.userChoice` 是一个 Promise ,在用户选择后 resolve
  event.userChoice.then(result => {
    console.log(result.outcome)
    // 'accepted': 添加到主屏
    // 'dismissed': 用户不想理你并向你扔了个取消
  })
})

(添加到主屏的更多高级姿势,如延迟或取消提示可以参考这篇文章

多页应用 PWA 改造实践

准备工作做完之后,就可以放心地开始写代码了。如果你的网站是单页架构,那么恭喜你,你只要:


  • 用几个 SW 的库,如 sw-precache-webpack-plugin
  • 找个 manifest.json 抄一下,比如我们的
  • lighthouse 跑一下,按照提示改进改进;
  • 对比一下谷歌的 PWA Checklist,按照提示改进改进;
  • Debug 一下微信/QQ 浏览器;
  • Debug 一下 UC 浏览器;
  • Debug 一下百度浏览器;
  • Debug 一下 360 浏览器;
  • Debug 一下猎豹浏览器;
  • 找 10 台安卓机 Debug 一下自带的浏览器。

基本上就 OK 了。

不过,如果你的网站像我们一样是多页架构的,你可能会遇到不少额外的麻烦。你可以考虑重构为单页应用,因为单页应用在很多场景可以提供更好的交互体验,但单页应用同样也有自己的缺点,值不值得为其优点为转型,这就需要根据你的需求来做判断了。

单页与多页一直是前端的必“争”之地,其实「饿了么 M 站」曾经就是单页的,那我们为什么转为多页了呢?

从公司业务的角度来说,M 站从最开始仅仅提供 Web 端的外卖服务,慢慢演变成为各种微服务的集合。这些服务之间相对独立,可以单独提供给各类入口(二维码、微信推送、各种 App 接入等等),所以选择了这种将 M 站「服务化」的思路。

从开发模式的角度讲,多页架构意味着较弱的耦合,不同页面(即服务)之间互不影响,可以独立开发、升级。比如在 Vue2 的迁移与 Weex 接入的过程中,我们可以对各个单独的服务逐个迭代,同时保留原始版本用以降级与 A/B Test,符合我们业务所要求的迭代速度与稳定性要求。

多页结构所带来的问题

在改造 PWA 的过程中,多页应用也带来了一些问题:

多页面之间切换成本高,即使对所有资源都进行了缓存,消除了网络延时,但浏览器销毁页面、解析 HTML、执行 JS、渲染新页面等一系列动作的耗时仍然很高,且几乎无法避免。

为了提高用户体验,我们决定对页面的渲染流程进行优化,以尽可能提高首屏渲染的速度。

用 App Shell 提高首屏渲染速度

提高首渲的一个主流方法是使用 "App Shell",所谓的 App Shell 就是一个能被缓存的、轻量级的界面框架,它往往是纯 HTML 片段,只包括内联 CSS 和 base64 图片,不依赖于 JS 框架,可以在加载、解析、执行 JS 之前就渲染出来,几乎消除了白屏时间,大大提高用户体验。

那么,怎样优雅地写一个 App Shell 呢?既然要求在 JS 加载之前渲染,那是不是意味着只能动手写 DOM 而不能用 Vue ?

幸运地是,Vue 2 引入了高贵的服务端渲染 Server Side Rendering,简称SSR(不是手游里的 SSR ),它能够在 Node.js 里渲染 vue 组件并输出为 HTML 片段。因此我们可以在构建阶段调用 Vue SSR 进行 App Shell 的渲染,这也就是所谓的 prerendering。具体的做法可以参考 vue-server-renderervue-hackernews

App Shell 渲染优化

然而在实践中,我们发现 App Shell 的渲染比预计的要慢:它总是在同步的 JS 解析完成之后才渲染。下面的 Demo 反映了这个问题:


<!DOCTYPE html>
<html>
<head></head>
<body>
  <h1>Hello, shell</h1>
  <script>
    // 模拟 new Vue() 初始化渲染的耗时
    for (var i = 0; i < 1000000000; i++);
  </script>
</body>
</html>

从 profile 分析中可以看到,尽管 HTML 片段在 JS 之前出现,浏览器仍在 JS 执行之后进行渲染。事实上,不同浏览器对 HTML 的渲染机制是不一样的,像用 defer/async 加载 JS 的优化方案也不一定凑效,这里不深入展开,只介绍一个简单而行之有效的 hack: 把耗时的操作推迟到 Event Loop 的任务队列中,等待主调用栈清空后才执行。


setTimeout(() => {
  // 把初始化渲染放到 setTimeout 里
  new Vue()
}, 0)

虽然只是几行代码的 hack ,但是对 App Shell 的渲染提升是极大的。下面是 hack 前后 M站 的性能对比:

可以看到,加入 App Shell 并且优化后,在主流手机设备上,首屏 App Shell 的渲染时间在 500ms 以下,再加上 SW 对 HTML 的缓存,页面的切换体验可以比较贴近单页应用了。

踩坑经验

在 PWA 改造过程中,另一个令人头痛的地方,就是各种浏览器 bug ,由于国内浏览器内核版本繁多,加上 PWA 所用新 API 规范的不稳定性,我们踩到了千奇百怪的坑,这些 bug 往往出现在意想不到的地方,导致在某些浏览器下全站白屏(这也说明了降级方案的重要性)。但我们并不仅仅是踩坑,在踩坑的同时,我们还与谷歌、腾讯X5、UC团队积极沟通,推动了许多 bug 的解决,下面是我们遇到的坑和解决方案:

  1. Android WebView 中 UserAgent 不正确,cookies 丢失

在我们实验性地上线 PWA 后,大数据的同事向我们反馈,他们的统计数据中有有一部分「不正常的 UA」涌入,根据来源分析,这部分 UA 应该是「饿了么 APP」的自定义 UA ,而统计到的数据却为安卓系统默认的 WebView UA。

同一时间,我们还在服务监控中,观察到了某些接口的 401 状态异常上涨。而 401 状态意味着用户认证失败,据此我们推断是 SW 导致 cookies 丢失。

后来我们及时降级 PWA,并与谷歌合作排查,最终确定了 bug 的来源,且将 bug 提交给了 Chrome 团队: 698175 - User agent string not set correctly when Service Worker makes a fetch request - chromium - Monorail

在 WebView 修复之前,你可以通过避免在 SW 里代理需要 UA 和 cookies 的请求(通常是API请求)来避开这个 bug。

  1. X5 内核部分请求发送 q-sid 头

在开启 SW 后,微信和 QQ 浏览器都出现了白屏现象。我们利用调试工具观察到部分资源的请求多了一个 q-sid header,这导致浏览器向 CDN 服务器发送 OPTIONS 请求并且遭到拒绝,所以导致页面无法打开。

我们向 X5 内核的团队反馈了这个问题,并且很快得到了技术支持:X5 内核将在新版(4311)中修复这个问题,在此之前,我们可以在服务端设置允许 q-sid 的自定义头部来避开这个问题。

  1. UC 浏览器中 301 跳转问题

同样,我们的页面在 UC 浏览器中也出现了白屏现象,但是 bug 的原因不同:我们发现 SW 抓取的资源中,带 301 跳转的资源请求总是失败的。在向 UC 团队反馈后,我们得到了 bug 的确认,这是内核对 fetch API 的实现基于早期不完善的规范导致的,UC 团队将积极推进内核版本的升级和 bug 的修复。在修复之前,可以采用临时的解决方案:服务端避免 301 跳转,或者 SW 中对存在 301 跳转情况的资源做特殊处理。

  1. 其他细节:
  • 低版本 chromium 不支持 cache.addAll,可以考虑引入带有 polyfill 的库;
  • UC 浏览器不支持 cache.add ,请用 cache.put 代替;
  • 部分低版本微信浏览器中,UA 是 Chrome 30+ 但存在 navigator.serviceWorker,因此不要依赖 isserviceworkerready 用版本检测代替功能检测;

尝试新技术的时候难免会遇到很多 bug,但我们作为前端开发,应该拥抱 Web 的开放精神,不仅仅追求 workaround ,而是积极联系多方,共同推进 bug 的修复,也算是为 Web 做出一点微小的工作。

这不是终点,而是新的起点

我们已经完成了 PWA 的核心工作: SW 的搭建、App Shell 的优化、交互的提升,但这些都只是开始,PWA 还有许多可以大施拳脚的地方:

  • Android 4.4 及之后的版本采用 Chromium 作为 WebView 内核,而 Chromium 从版本 40 之后就支持 Service Worker 了。这意味着传统的 Hybrid App 也能从中获益,APP 内对 WebView 的优化,如预加载,缓存,后台同步等,都可以由 SW 完成。

  • HTTP/2 的 Server Push 与 Service Worker 是天生一对的好搭档,两者的相互协作不仅能大大提升页面的首次和二次加载速度,还能提供更强大的 prefetch 功能。

  • PWA 还能逐步改进为 AMP

对我们来说,PWA 仍是现在进行时,我们将继续探索 PWA 的更多可能性,不断在技术、业务上提升产品体验。

延伸阅读