前言
注: 本文是 2018 GMTC PWA 专题演讲内容.
Chromium 在 2014.3 已开始研发 Service Workers, 并于 2014.11 发布的 Chrome for Android release 40 https://www.chromestatus.com/feature/6561526227927040 正式支持. Alex Russell 2015.6 在博客文章 Progressive web Apps: Escaping Tabs Without Losing Our Soul https://infrequently.org/2015/06/progressive-apps-escaping-tabs-without-losing-our-soul/ 中正式提出 PWA(Progressive Web App https://developers.google.com/web/progressive-web-apps/)的概念. Google 2016.12 在北京 / 上海举办的 GDD 大力推广 PWA 相关技术, 让 PWA 概念深入人心. 在此之后的各种大型技术会议, PWA 成了不可或缺的主题. iOS Safari 在 2018.2 发布的 Safari Technology Preview Release 49 https://webkit.org/blog/8088/release-notes-for-safari-technology-preview-49/ 宣布正式支持 Service Workers, 扫清了 PWA 发展的最大障碍.
2018 年将会是 PWA 快速落地应用的一年, 那么, 作为浏览器内核的开发者, 是怎么看待 PWA 的呢?
https://www.atatech.org/articles/111519#1 PWA 的关键技术
PWA 的关键技术是 Service Workers.SW 有一些重要的特性, SW 是事件驱动的 Worker, 具有与文档无关的生命周期, 可以拦截注册 Scope 下的所有请求和响应, 具有 Reliable 的能力.
(1)事件驱动, 是指 Web 引擎 (浏览器内核) 收到事件会触发 SW 线程启动.
那么, 收到事件, 为什么 SW 线程必须启动呢? 因为事件处理的代码是运行在 sw.js(SW 注册时指定的脚本), 而 sw.js 是运行在 SW 线程. 举个例子, Web 引擎收到 install 事件, 会启动 SW 线程, SW 线程在初始化时会执行 sw.js, 前端可以在 sw.js 监听 install 事件, install 事件触发时事件处理函数就会执行. 这就是事件驱动的过程. 理解事件驱动, 是理解 SW 很多特性的基础, 比如, 页面文档未关闭, SW 线程为什么会关闭.
为什么 SW 是事件驱动的, 而不是常驻内存的呢? 事件驱动, 除了收到事件 SW 线程要启动, 也意味着事件处理完成, SW 线程是需要关闭的. SW 有独立的 GlobalScope, 独立的 Isolate, 独立的 JS 运行环境, SW 线程的资源消耗是非常大的, 事件驱动是减少 SW 线程资源消耗的一种有效的方式, 这就是 SW 被设计成事件驱动的原因.
(2)SW 具有与文档无关的生命周期.
通常, Web 页面的生命周期非常依赖文档, 文档持有大量关键对象, 比如, Parser 解析器, Loader 加载器, JS 控制器. 页面通过这些对象去使用网络和使用 JS 引擎执行 JS. 页面关闭时, 这些关联对象会析构, 页面无法再执行 JS. 而 SW 线程有独立的 JS 引擎实例, 有独立的 JS 运行环境, 可以独立执行 JS, 不需要依赖页面文档的环境. 页面关闭时, 页面的 JS 执行环境就销毁了, 但 SW 的 JS 环境不受影响, 可以继续执行 JS.
SW 的生命周期包含两部分, 一部分是 SW 线程, 另外一部分是 SW 脚本. SW 脚本的状态是存储在数据库的, 打开页面时, 会先从数据库中读取当前页面 activated 状态的 SW 脚本, 然后再派发 Fetch 事件去启动 SW 线程. SW 要控制页面, 脚本是 activated 状态, 线程是 Running 状态, 两者缺一不可, 而这两者的生命周期都与页面文档无关. 这就是 SW 文档无关生命周期的内在涵义.
SW 线程启动后, 处于 Running 状态, sw js 的代码就可以被执行. 如果仅仅期望 sw js 代码被执行, 那么 SW 线程 Running 就可以了, SW 脚本不一定要处于 Activated 状态. SW 脚本处于 Activated 状态, 页面 Fetch 请求就会受它控制, 由它决定请求是走 SW 还是走网络. SW 控制页面请求的过程是这样的, SW 脚本激活之后会存储相关信息到 LevelDB 数据库, 再次访问页面时, 可以直接从注册数据库里读取信息, 然后派发 Fetch 事件去启动 SW 线程, SW 线程启动完成之后, 所有的 Fetch 请求都会触发 fetch 事件, 前端可以监听 fetch 事件, 按照各种策略去获取资源.
(3)SW 可以拦截注册 Scope 下所有的请求和响应.
SW 控制的页面, 所有的请求, 都会经过 SW, 由 ServiceWorkerContextRequestHandler 负责处理. ServiceWorkerContextRequestHandler 会检查资源是否在 SWCache, 如果在 SWCache, 就会创建 ServiceWorkerReadFromCacheJob, 直接从 SWCache 读取; 如果不在 SWCache, 就会创建 ServiceWorkerWriteToCacheJob, 继续走到 HttpCache 或 Network, 并将结果写入 SWCache. 如果请求不受 SW 控制, 会直接 FallbackToNetwork, 走正常请求的流程.
(4)SW 具有 Reliable 的能力.
Web 通常是不可靠的, 网络不可靠, 用户停留时间不可靠. PWA 要解决的问题就是提供可靠的 Web 服务. SW 是实现 Reliable 的关键技术. SW 为什么可以提供可靠的 Web 服务呢? 前端可使用 SW Cache, 精细控制每个资源的缓存, 让资源缓存更可靠; 前端可以使用 Push 预加载, 让 Web 应用能更可靠的获取到资源; 前端可以使用 Background Sync, 触发后台更新资源; 前端可以使用 Background Fetch, 后台上传或下载大文件; 也就是说, 前端可以使用 SW 相关的一系列技术, 让 Web 在特殊场景下依然能提供非常可靠的服务.
https://www.atatech.org/articles/111519#2 PWA 在阿里系的实践
在简单介绍了 SW 特性之后, 我们再分享一些 PWA 在阿里系的实践, 让大家更深入的理解 PWA.
https://www.atatech.org/articles/111519#3 SW 线程启动
SW 有非常复杂的注册安装激活流程, 启动 SW 线程有较大的成本. SW 线程的启动流程主要有三个步骤是特别耗时的, 分别是 (a)分派 SW 线程,(b)加载 sw.js,(c)初始化 SW 线程和执行 sw.js.
(a)分派 SW 线程, 会经过 IO --> UI --> IO --> UI --> IO 的过程, 有频繁的线程切换, 一般 UI 线程都比较繁忙, IO 切 UI 的过程可能会非常耗时. 也就是说, 在分派 SW 线程的过程中, 如果能避免 IO 到 UI 的线程抛转, 可节省 100ms 以上, UI 特别繁忙时甚至可节省几百 ms.
那么怎么避免 IO 到 UI 的线程抛转呢? 一种做法是在单 Renderrer 进程架构下可以考虑直接在 IO 线程去获取 SW 进程 ID,SW 线程后续的启动流程也仅仅需要用到这个进程 ID. 另外一种做法是将 SW 相关代码移至 UI 线程 https://docs.google.com/document/d/1hqkxASTcy4DFVh8n7hmiP3ZKKXoi2MxgpwLxXIHLqmI/edit , 这也是 Chromium 正在做的事情, 会涉及 Chromium 内核架构层面的调整, 有非常大的难度, 预计在 2018Q3 完成.
(b)加载 sw.js, 也可能非常耗时, 全新加载, 一般耗时 600ms 以上, 主要消耗在建立 https 连接. 那么, 怎么避免全新加载 sw.js 呢? 在打开页面时, 如果 sw.js 的 URL 发生了变化(比如, 后面加了随机数), 属于不同的 SW, 每次都必须全新加载; 而 sw.js 的 URL 不变时(比如, 加固定的版本号, 设置 no-cache,max-age=0), 会直接从缓存读取. 在打开页面的过程中, 还会触发一次 sw.js 的更新请求, 这是一个普通的请求, 会根据 Cache-Control 来决定是使用 HttpCache 还是走网络.
(c)初始化 SW 线程时会执行 sw.js, 首次执行 sw.js, 如果 js 比较大, 可能会超过 500ms. 主要耗时在 JS 解析. 一般 100K 的原始 JS 大小, 在 Nexus 5 手机下一般需要 100ms 以上的 JS 解析时间. 也就是说, 降低 sw.js 大小, 可以显著降低 SW 线程初始化的时间.
Chromium 内核团队为了快速支持 SW 功能, 选择了一些取巧的方式, 比如, 直接重用了 Loader 模块, 重用了 AppCache 模块, 重用了 Renderrer 进程而不是独立的 SW 进程, 但是这些在架构层面并不是很合理, 从而也引入了一些性能问题. Chromium 内核团队非常关注 SW 启动性能问题 https://bugs.chromium.org/p/chromium/issues/detail?id=561209 , 甚至不惜进行架构层面的巨大调整 https://docs.google.com/document/d/1hqkxASTcy4DFVh8n7hmiP3ZKKXoi2MxgpwLxXIHLqmI/edit , 以期能彻底解决问题.
在 Chromium 还未彻底解决问题之前, 在实践层面, 我们建议从两个方向去优化, 一个是 SW 线程启动的时机, 一个是 SW 线程启动的频率.
SW 线程启动时机方面, 我们不建议在打开页面时立刻注册 SW, 建议在页面 onload 时注册 SW, 甚至在 onload 时 setTimeOut 进一步延迟注册时机, 避免影响当前页面的展现.
SW 线程启动频率方面, 按照标准, SW 是事件驱动的, 事件来了, SW 线程就需要启动, 事件处理完成了, SW 线程就需要关闭. 举个例子, Fetch 事件过来了, 就需要启动 SW 线程, 处理事件, 事件处理完成就处于空闲状态. Web 引擎会定期关闭空闲的 SW 线程, 检查的时机是, 在启动 SW 线程之后, 每 30s 检查一次, 空闲超过 30s 的 SW 线程就会被关闭掉.
那么 Web 引擎是否可以不关闭 SW 线程? 关闭 SW 线程主要出于节省内存的考虑. 这个问题我们与 Chromium 官方讨论过, Chromium 可考虑在当前页面未关闭时不关闭 SW 线程, 但不能接受后台页面未关闭时也保留 SW 线程. Chrome 浏览器同时存在多个 Tab 的情况非常普遍, 但国内 App 存在多个 Tab 的情况非常少, 即国内客户端上是可以考虑在文档未关闭时不关闭 SW 线程的.
https://www.atatech.org/articles/111519#4 SW 缓存主文档
SW 可以缓存各种资源, SW 缓存与普通 Http 缓存有什么区别呢? SW Cache 在 HttpCache 的上层, 但两者的存储后端都一样, 存取性能是一样的. Web 引擎会根据 Cache-Control 去管理 HttpCache, 按一定的算法淘汰文件; 但 SW Cache 不会过期, 需要前端主动使用 Cache API 去清理. SW Cache 的优势在于前端可以精细控制, 可以做到更加稳定可靠.
SW 的存储大小限制是怎样的呢? SW 有两类 Cache. 一类是 SW Script Cache, 也就是 sw.js. 另外一类是 CacheStorage, 也就是通常说的 SW Cache.
SW 脚本, 所有页面的 sw.js 是共同存储的, 共用同一存储目录, 存储大小限制为 250M, 存储类型为 APP_CACHE, Backend 类型为 CACHE_BACKEND_SIMPLE.
SW Cache 在 SW 层面几乎无限制, Chromium 40 内核限制为 512M,Chromium 50 及以上内核无限制. 但 Chromium 内核对每个域名能使用存储空间有严格的限制, SW Cache 也受这个限制. 每个域名可使用 Temporary 类型存储限制可简单理解为磁盘可用空间的 1/15, 实际上它有更复杂的算法, 详细算法如下,
Temporary 类型存储限额 = [系统磁盘可用空间(available_disk_space) + 浏览器全局已使用空间(global_limited_usage)] / 3
每个域名可使用 Temporary 类型存储限额 = Temporary 类型存储限额 / 5
SW Cache 也属于 Temporary 类型存储, 它也受每个域名对 Temporary 类型存储的限制, 即简单理解最大不能超过磁盘可用空间的 1/15. 一个 cacheName 对应一个 SW Cache, 一个域名可以有多个 SW Cache. 这些 SW Cache 共用域名存储限制, 即实际上每个 SW Cache 能使用的空间更小, 但这也足够大了, 如果再大可能会影响到其它页面或其它应用.
SW 缓存能带来什么样的效果呢? 我们在大量的业务应用过 SW Cache. 比如, 天猫互动吧, 天猫超市生鲜, 在国际端, 天猫海外, 9apps, 国际视频等业务, 还有, 超级大型的业务 UC 信息流. 上线 SW Cache, 不作特别的调优, 效果并不特别明显, 有些有小幅优化, 有些没有优化. 那么, 怎么使用 SW Cache 才能带来较明显的优化效果呢?
我们在天猫互动吧上做过实验, 不缓存主文档, 上线 SW 前后的性能变化不大(说明一下, 上 SW 之前页面文档也是不可缓存的). 天猫互动吧使用 SW 缓存主文档之后, 有较明显的效果, 有 200ms 以上的提升. 不同的业务场景, 提升的幅度不一样, 效果依赖于上线 SW 之前主文档的缓存情况. 这里其实有一个问题, 为什么上 SW 之前文档不能缓存, 上 SW 之后却能缓存呢? 上 SW 之前, 前端对缓存的控制力度太弱, 如果线上出问题基本没有解决方案, 但 SW 的缓存, 前端可以进行非常精细的控制, 不用担心出现无法解决的问题.
在缓存策略方面, 后置验证的缓存策略效果比较好. 优先从缓存获取, 如果在缓存, 立刻返回缓存里的响应. 然后延迟 2s 去更新和缓存主文档. 如果不在缓存, 立刻去更新和缓存主文档. 延迟 2s 再去更新文档, 是为了避免同一时间有两个主文档在加载, 互相抢占主线程, 影响页面首屏渲染. 详细实现方式如下,
- function staleWhileRevalidate() {
- const response = getResponseFromCache;
- if (response) {
- setTimeout(fetchAndCache, 2000);
- event.respondWith(response);
- } else {
- event.respondWith(fetchAndCache);
- }
- }
在资源缓存方面, 客户端的离线包也是一种非常好的缓存机制. 离线包是让关键业务资源, 打包进客户端, 或者提前下载到客户端, 在用户访问页面时, 通过标准的 shouldInterceptRequest 拦截请求, 直接返回离线包本地文件作为响应. SW 其实也可以使用离线包, 它是通过标准的 ServiceWorkerClient 的 shouldInterceptRequest https://developer.android.com/reference/android/webkit/ServiceWorkerClient.html#shouldInterceptRequest(android.webkit.WebResourceRequest) 去使用的. 页面请求经过 SW,SW 请求通过 shouldInterceptRequest 走到客户端, 客户端通过离线包返回本地资源. 其中, SW Cache 是在 shouldInterceptRequest 的上层, 如果资源在 SW Cache, 就不会再走 shouldInterceptRequest.
https://www.atatech.org/articles/111519#5 SW Push 预加载
前面提到 SW 缓存主文档, 它怎么保证主文档能够得到及时更新呢? 一种思路是使用 SW Push 预加载. 我们先看看标准 Web Push 的流程.
页面向 Web 引擎注册 SW.
页面向 Web 引擎订阅消息, Web 引擎向 Push 服务器 (GCM/FCM) 订阅消息, Push 服务器返回订阅结果 (Push Subscription, 服务器地址). 页面将订阅结果(Push Subscription) 发送给页面服务器.
页面服务器向 Push 服务器推送消息, Push 服务器向 Web 引擎推送消息, Web 引擎唤醒 SW, 触发 SW 的 onpush. 页面处理 onpush 消息, 比如, 提前预加载资源.
Web Push 原理不复杂, 但应用起来却不容易, 主要是因为 Push Service(GCM/FCM)在国内是不可用的. 但是在国内, 客户端一般都有私有的 Push 通道. 那么我们是否可以利用私有的 Push 通道去实现预加载呢? 是可以的. 页面服务器通过私有 Push 通道推送消息给客户端, 客户端向 Web 引擎推送消息, Web 引擎唤醒 SW 和触发 onpush. 页面处理 onpush, 提前 fetch 预加载资源. 与标准 Web Push 相比, 实现私有 Push 预加载, 需要 Web 引擎进行简单的扩展, 一个是改变页面消息订阅的方式, 另外一个是客户端通过扩展接口推送消息给 Web 引擎.
Push 预加载, 可以让资源提前下载到本地, 天猫超市上线 Push 预加载, 就可以下线页面的离线包缓存. 预加载的时机, 可以是客户端一些场景触发, 也可以是页面发版时触发, 可以根据自己业务的实际情况决定.
为什么不在国内直接搭建标准的 Web Push Service 呢? UC 浏览器也在这个方向做过尝试, U4 2.0 在国内首先支持了标准的 Web Push Notification, 但由于一些政策方面的因素, 无法非常有效的进行 Notification 的审查, 从而无法大规模实际应用. 另外, 很多客户端都有私有的 Push 通道, 出于安全等各方面考虑, 客户端往往并不愿意使用通用的 Web Push Service. 也就是说, 标准的 Web Push Service 在实际应用时会困难重重, 而私有 Push+SW 反而是成本非常低的实际应用方式.
https://www.atatech.org/articles/111519#6 SW 独立线程
SW 有独立的 JS 运行环境, 独立的运行线程, 而且线程的生命周期是与页面文档无关的, 这个特性是非常革命性的, 让很多事情可以脱离页面文档的环境去实现, 提供了非常多的可能性. 使用独立线程运行 SW, 需要解决两类问题, 一类是 SW 与客户端交互的问题, 比如, 使用客户端基础 API; 另外一类是 SW 与页面交互的问题.
SW 可以通过 JSBridge 与客户端交互, 使用基础 API, 获取客户端各种信息. SW 可以通过 SIR 与离线包交互, 获取本地资源. SW 可以通过 MessageChannel 与页面双向通信.
我们看看 MessageChannel 的基本用法,
- function ListenSWMessage() {
- if (navigator.serviceWorker.controller) {
- var messageChannel = new MessageChannel();
- messageChannel.port1.onmessage = function(event) {
- console.log("Response from SW :", event.data.message);
- }
- navigator.serviceWorker.controller.postMessage({
- "command": "MessageFromPage",
- "message": "Send to SW"
- }, [messageChannel.port2]);
- }
- }
页面 new MessageChannel, 监听 messageChannel.port1.onmessage, 接受来自 SW 的消息. 页面 postMessage 传递 messageChannel.port2 给 SW.
- self.addEventListener('message', function(event) {
- var data = event.data;
- if (data.command == "MessageFromPage") {
- event.ports[0].postMessage({
- "message": "Send to Page"
- });
- }
- });
SW 监听 message 事件, 接收来自页面的消息. SW 通过 event.ports[0].postMessage, 发送消息给页面. event.ports[0]就是页面传递过来的 messageChannel.port2.
从上面可以看到, MessageChannel 是通过两个 MessagePort 来实现双向通信的. MessageChannel 的原理并不复杂, 它会有什么陷阱吗?
SW 的 StopWorker 会引起 MessagePort 的 close,MessagePort 在 close 不能收发消息, ServiceWorker 在 restart 时并不能重建原来的 MessageChannel. 也就是说, 页面文档还没有关闭, 页面与 SW 双向通信的通道 MessageChannel 就已经关闭, 而且无法重建.
这个问题需要怎么解决呢? 从前端的角度, 可以每次通信都新建 Messagechannel, 但这样使用并不方便. 从 Web 引擎的角度, 在页面文档未关闭时就不要 Stop SW.
SW 线程可以随时被 Stop, 对前端来说, 是一个非常大的坑. 从规范的角度, 的确是允许 Web 引擎随时 Start/Kill SW 线程的( Service workers https://w3c.github.io/ServiceWorker/#dfn-service-worker may be started by user agents without an attached document and may be killed by the user agent at nearly any time. Conceptually, service workers https://w3c.github.io/ServiceWorker/#dfn-service-worker can be thought of as Shared Workers that can start, process events, and die without ever handling messages from documents. Developers are advised to keep in mind that service workers https://w3c.github.io/ServiceWorker/#dfn-service-worker may be started and killed many times a second.). 但从实践的角度, Web 引擎在一定的条件下, 会延长 SW 线程的生命周期, 比如, 还有未完成的 Fetch 请求, 处于 devtools 调试模式, 等等. 从国内的实际情况来看, 在一些客户端内, 还有关联文档的情况下, 不主动关闭 SW 线程, 是一个比较好的实践.
前面主要提了使用 SW 独立线程需要解决的问题, 那么, SW 独立线程可以应用于哪些场景呢? 一个是起后台线程处理事件, 比如, Web Push,BG Sync/Fetch, 都需要后台起 SW 线程. 第二个是使用 SW 线程来执行 JS, 在 SW 线程执行 JS 不会阻塞主线程, 可以取得较好的性能效果. 第三个是共享 JS 运行环境, PWA 页面是 app 的开发模式, 它们往往需要共享 JS 运行环境, 把大部分基础业务逻辑放在 SW 线程去执行.
https://www.atatech.org/articles/111519#7
来源: https://yq.aliyun.com/articles/608837