PWA系列 -- 分享PWA在阿里体系内的实践经验
2018-11-24
# 前言
Chromium在2014.3已开始研发Service Workers,并于2014.11发布的Chrome for Android release 40正式支持。Alex Russell 2015.6在博客文章 Progressive Web Apps: Escaping Tabs Without Losing Our Soul 中正式提出PWA(Progressive Web App)的概念。Google 2016.12在北京/上海举办的GDD大力推广PWA相关技术,让PWA概念深入人心。在此之后的各种大型技术会议,PWA成了不可或缺的主题。iOS Safari 在2018.2发布的Safari Technology Preview Release 49宣布正式支持Service Workers,扫清了PWA发展的最大障碍。
2018年是PWA快速落地应用的一年。我们在之前的一篇文章中介绍了PWA的支持情况,基础知识以及技术要点。今天,我们会为读者分享该项技术在阿里体系内的实践以及影响。
# 线程启动
SW有非常复杂的注册安装激活流程,启动SW线程有较大的成本。SW线程的启动流程主要有三个步骤非常耗时:
- 分派SW线程。整个分派线程的过程中,会出现频繁的线程切换。该过程会耗时100ms至数百ms。
- 加载sw.js。全新加载会耗时600ms以上,主要消耗在建立https链接。
- 初始化SW线程和执行sw.js。首次执行sw.js时,如果js比较大的情况下会耗时500ms以上。主要耗时在JS解析上。
Chromium内核团队为了快速支持SW功能,选择了一些取巧的方式。例如,直接重用了Loader模块,重用了AppCache模块,重用了Renderrer进程而不是独立的SW进程,但是这些在架构层面并不是很合理,从而也引入了一些性能问题。Chromium内核团队非常关注SW启动性能问题,甚至不惜进行架构层面的巨大调整,以期能彻底解决问题。 在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线程的。
# 缓存主文档
SW可以缓存各种资源,SW缓存与普通Http缓存有什么区别呢?SW缓存在HttpCache的上层,但两者的存储后端都一样,存取性能是一样的。Web引擎会根据Cache-Control去管理HttpCache,按一定的算法淘汰文件;但SW缓存不会过期,需要前端主动使用Cache API去清理。SW缓存的优势在于前端可以精细控制,可以做到更加稳定可靠。
SW的存储大小限制是怎样的呢?SW有两类Cache。一类是SW Script Cache,也就是sw.js。另外一类是CacheStorage,也就是通常说的SW Cache。
SW脚本,所有页面的sw.js是共同存储的,共用同一存储目录,存储大小限制为250M,存储类型为APP_CACHE, Backend类型为CACHE_BACKEND_SIMPLE。
SW缓存在SW层面几乎无限制,Chromium 40内核限制为512M,Chromium 50及以上内核无限制。但Chromium内核对每个域名能使用存储空间有严格的限制,SW缓存也受这个限制。每个域名可使用Temporary类型存储限制可简单理解为磁盘可用空间的1/15,实际上它有更复杂的算法,详细算法如下。
Temporary类型存储限额 = 【系统磁盘可用空间(available_disk_space) + 浏览器全局已使用空间(global_limited_usage)】/ 3
每个域名可使用Temporary类型存储限额 = Temporary类型存储限额 / 5
一个cacheName对应一个SW Cache,一个域名可以有多个SW Cache。这些SW缓存共用域名存储限制,即实际上每个SW缓存能使用的空间更小,但这也足够大了,如果再大可能会影响到其它页面或其它应用。 目前阿里体系内大量业务应用SW缓存。上线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);
}
}
在资源缓存方面,客户端的离线包也是一种非常好的缓存机制。离线包是让关键业务资源,打包进客户端,或者提前下载到客户端。
# 消息推送
前面提到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通道。为什么不在国内直接搭建标准的Web Push Service呢?U4内核在国内首先支持了标准的Web Push Notification,但由于一些政策方面的因素,无法非常有效的进行Notification的审查,从而无法大规模实际应用。另外,很多客户端都有私有的Push通道,出于安全等各方面考虑,客户端往往并不愿意使用通用的Web Push Service。也就是说,标准的Web Push Service在实际应用时会困难重重,而私有Push+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。 从实践的角度,Web引擎在一定的条件下,会延长SW线程的生命周期,比如,还有未完成的Fetch请求,处于devtools调试模式,等等。从国内的实际情况来看,在一些客户端内,还有关联文档的情况下,不主动关闭SW线程,是一个比较好的实践。 前面主要提了使用SW独立线程需要解决的问题,那么,SW独立线程可以应用于哪些场景呢?一个是起后台线程处理事件,比如,Web Push,BG Sync/Fetch,都需要后台起SW线程。第二个是使用SW线程来执行JS,在SW线程执行JS不会阻塞主线程,可以取得较好的性能效果。第三个是共享JS运行环境,PWA页面是app的开发模式,它们往往需要共享JS运行环境,把大部分基础业务逻辑放在SW线程去执行。
# 影响
前面介绍了PWA相关的实践。那么PWA会带来什么影响呢,对Web生态和前端开发者来说,意味着什么样的变化呢?
- PWA带来的第一个影响是,它标志着Web引擎正在快速开放安全高效的底层基础能力。
理论上,原生应用(Native App),或者混合应用(Hybird App),可以直接使用操作系统API和使用设备,但直接使用系统API的开发成本非常大,实际上,这些应用通常都会通过Web Engine去使用系统能力。
Web页面运行在各种客户端上,这些客户端通常会包含Web引擎,Web引擎运行在操作系统上,Web页面的能力由Web Engine导出的能力决定。Web引擎通常是导出高层能力,比如appcache,前端的操控力度非常弱。也就是说,很长一段时间里,Web页面,仅仅能使用Web引擎很少一部分能力。
PWA的Web Apps,标志着Web Engine正转向于导出底层基础能力,让Web Apps能使用大部分底层能力。比如,SW缓存,等同于给前端开放了操作HttpCache级别缓存的能力;Web Push,给前端开放了消息推送的能力。也就是说,PWA的Web Apps会与Native Apps一样,能够通过Web Engine使用绝大部分的系统基础能力。即两者能使用的系统基础能力,会逐步接近,甚至完全一样。
2.PWA带来的第二个影响,WEB正变得无所不能。 提起Web,大家更多想到的是加载慢,渲染慢,JS执行慢,动画卡,滑屏卡,响应卡。Web基本就是性能差,卡顿的代名词。随着Web引擎的发展,特别是渲染引擎和JS引擎的飞速发展,Web引擎的性能和体验已非吴下阿蒙,不可同日而语了。Web技术方案已完全能和基于Native技术的方案一较高下。
现在的Web,前端可以使用WebGL和WebRTC等基础技术实现WebAR/VR。使用Web Assembly进行海量计算,实现复杂算法。使用WebAudio,WebSocket等技术实现Web小游戏,使用PWA做Web小程序,前端已经可以基于Web去做完整的应用程序。 在实践上,我们的一个业务中的复杂电商页面,使用Web技术方案,能在Android下(WiFi+客户端新版本)做到85%的秒开率,能使用页面级别的缓存去实现Native化的底部导航,在不远的将来,甚至可以实现多页面应用的基础JS环境共享,非常值得期待。 Web的能力越来越大,体验越来越好,很多在客户端使用native技术实现的业务,可以逐步转移到web上去实现。前端的价值也会越来越大,当然这个价值也需要前端去维护,在面临技术选型时,不需要崇拜Native技术,可以对Web技术多一点信心,努力去打造完美体验的Web应用。