PWA 在饿了么的实践经验

PWA 在饿了么的实践经验

王亦斯王亦斯

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 抄一下,比如我们的;(或者用这个超棒的 App Manifest 生成器
  • 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 不正确

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

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

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

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

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

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

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

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

4. 其他细节:

  • 低版本 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 的更多可能性,不断在技术、业务上提升产品体验。

延伸阅读

「感谢打赏~」
7 人赞赏
微信用户
mage3k
任光辉
胖茶
sofish
Andself
袁斌
47 条评论