前言
注:本文是2018 GMTC PWA专题演讲内容。
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的呢?
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在特殊场景下依然能提供非常可靠的服务。
PWA在阿里系的实践
在简单介绍了SW特性之后,我们再分享一些PWA在阿里系的实践,让大家更深入的理解PWA。
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线程,这也是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启动性能问题,甚至不惜进行架构层面的巨大调整,以期能彻底解决问题。
在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可以缓存各种资源,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去使用的。页面请求经过SW,SW请求通过shouldInterceptRequest走到客户端,客户端通过离线包返回本地资源。其中,SW Cache是在shouldInterceptRequest的上层,如果资源在SW Cache,就不会再走shouldInterceptRequest。
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反而是成本非常低的实际应用方式。
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 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 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 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线程去执行。
PWA的影响
前面介绍了PWA相关的实践,那么,PWA会带来什么影响呢,对Web生态和前端开发者来说,意味着什么样的变化呢?
(1)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能使用大部分底层能力。比如,SWC,等同于给前端开放了操作HC级别缓存的能力,Web Push,给前端开放了消息推送的能力。也就是说,PWA的Web Apps会与Native Apps一样,能够通过Web Engine使用绝大部分的系统基础能力。即两者能使用的系统基础能力,会逐步接近,甚至完全一样。
Integrating Progressive Web Apps deeply into Android ,PWA应用也能出现在系统设置里面,这是非常不可思议的!Google手握Android系统和Chromium Blink内核,在Web生态具有非常大的话语权。从Google对PWA的态度来看,只要Native Apps具备的能力,它都愿意给PWA Web Apps开放,愿意为PWA应用开放系统基础能力。
(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应用。