Web应用的实际体验和Native应用的差距还是非常明显的,那么如何低成本的将一个已有的网站改造成Native-like的体验呢?本文分享一种低成本、渐进式实现Native体验优化的方式——同屏渲染。Web端体验有了PWA(Progressivewebapps),WebApplication也具备了添加到桌面和离线访问的能力,但是实际体验和Native应用总有非常明显的差距。我们可以看一下阿里M站和iOS应用的录屏(左边是WEB,右边是iOSAPP):可以看到,对于Web应用,在来回跳转时访问总是碎片化页。从上一页加载到下一页需要等待,返回时很多内存状态都没有了,导致无法正确定位上一个列表位置(这个其实和浏览器不同以及列表本身的方式有关实现了,也有一些解决方案可以规避这个问题,这里只是其中一种情况)。这对用户体验是非常有害的。他能清楚地感觉到自己用的不是Application而是Website,而且整个链接在进行复杂的操作时非常容易被打断。其实造成这种体验差异的根源在于B/S(Browser/Server)和C/S(Client/Server)的区别。虽然ServiceWorker提供了一些解决方案(比如AppShell)让我们能够以更低的成本提升原有的体验,但是仍然很难解决页面之间的拆分问题。很多相同的代码在不同页面之间重复执行,每次访问内存状态都会丢失。渲染性能在谈体验的时候有点主观,性能更容易衡量,分页带来的最直观的体验差距其实来自于渲染性能的差异。一个典型的Web端CSR(ClientSideRendering)流程大致如下:有很多地方不符合我们的预期:HTML/JS在被点击后才开始加载(这个可以通过预加载的方式解决,其中HTML预加载可以通过ServiceWorker完成)。framework之类的js总是在不同的页面之间反复执行。加载API的时机很晚,加载API一般是耗时较长的部分,可以并行化。理论上,加载时机越早越好。所以理想的渲染流程应该如下图所示:其实对于Native应用来说也是如此。当用户点击时,API基本上会开始加载并执行下一页的逻辑。事实上,经过良好优化(预加载等)的SPA具有类似的效果。我们提前加载下一页的vendor,只有点击的时候才执行下一页的逻辑。但是,实际上,对于一个已经存在的大型站点(比如m.alibaba.com)来说,将整个站点维护成一个SPA是不太现实的。一方面不能适应多人协作的现状;在整个网站上修改发布页面的方案也是不可接受的。那么,如何低成本地将现有网站改造成Native-like体验呢?经过以上的思考,我们想知道是否有一种解决方案可以让用户无需修改就可以使用。点击后,会立即开始并行加载数据,同时动态加载下一页,有选择地保留上一页的部分内容(如正在加载的数据、jsonp、framework层对象等)并隔离其他部分的干扰。于是针对我们的场景,产生了同屏渲染方案:LightHub,所谓同屏渲染,即在渲染过程中不需要卸载页面,所有的渲染行为都发生在同一个上下文中。这里我们需要一些东西:直接附加到现有页面以将页面恢复到其原始状态(同时允许保留一些共享部分)的沙箱事件沙箱我们需要一种低成本的沙箱机制,将页面恢复到初始状态,并允许保留一些对象,最好是这种机制可以低成本地直接部署到现有页面上。其实这里的诉求和微前端遇到的问题类似。受qiankun沙箱机制的启发,我们只需要在页面中插入一个小的inlineJS记录:全局变量window/documenteventListenertimeronwindow:setInterval/setTimeout/requestAnimationFrame/requestIdleCallbackMutationObserver当我们需要的时候,只需要清除页面的DOM,恢复变化的全局变量(这里使用的浅拷贝和qiankun一样),eventListener,timer和MutationObserver,页面可以恢复到初始状态。同时,记录的状态也可以存储在一个对象中。当用户从下一页回到上一页时,我们可以直接将状态恢复到页面。这里需要选择性的保留一些清除页面状态时需要保留的对象:比如publicFramework,JSONP请求的标签等等。过渡动画其实并没有那么复杂。页面不需要卸载和重新加载后,我们可以在用户点击后立即显示一个动画。目前它只是一个简单的从右边滑入的动画。需要注意的是,由于我们在动画绘制过程中经常会执行下一页的逻辑,所以需要注意使用GPU来绘制动画,保证动画不会被JS执行阻塞。这对于低端机器尤其重要。API并行加载其实有了上面的沙箱机制,API并行加载并不困难。需要注意的是,我们需要保护API并行加载本身过程中产生的状态(比如setTimeout),我们需要实现一个runInSharedContext,保证里面的timer在页面切换的时候不会被卸载。runInSharedContext(()=>{//这里的setTimeout不能记录&清除setTimeoutsetTimeout(()=>window.sharedfetchDataPromise=fetch(res));});下一页消费只需要window.sharedfetchDataPromise||fetch(url)可以直接复用并行加载的API请求。在我们的场景中,为了让开发者更加不敏感这个问题,封装了一个工具库redfox。在同一个页面环境多次执行同一个配置的请求会自动复用,??不需要开发者手动判断。根据浏览器行为呈现HTML可能是其中最复杂的部分。在我们抓取下一页的HTML之后,我们不能简单地使用document.innerHTML=nextHTML,这将导致与正常浏览器行为完全不一致,样式Loading导致闪屏,脚本不按预期顺序执行,等等所以我们需要自己实现一个renderHTML,将抓取的HTML解析出来,模拟浏览器的行为进行渲染。播放动画,先通过style隐藏body并异步appendCSS到页面,等到head中的CSS加载完动画播放完毕,取消body的隐藏,根据内联appendJS到页面typeandorder&normallyblock后续的DOM和JSappendDefer被扔进defer队列异步执行async,不会按顺序阻塞defer队列后续执行。这部分延迟队列的行为比较复杂。需要在更多的场景下进行测试,并有相应的单元测试来保证逻辑的正确性。根据浏览器行为触发事件,其实和上面渲染HTML类似。在渲染过程中,需要根据浏览器的行为触发相应的事件。比如上一页卸载时,依次触发beforeunload=>pagehide=>unload。当下一个页面加载时,先重置readyState,然后依次触发domInteractivedefer和DOMContentLoaded的执行。同样,单元测试在这个环节也是必不可少的。Timeline分析从Chrome最终的Timeline可以看出,执行逻辑基本符合我们的预期。API被点击的那一刻,API开始加载,基本开始全力执行下一页的渲染逻辑。Framework层的代码基本不需要重复执行。内存压力可能是这种不卸载页面的方案最担心的问题。其实根据上面的沙箱机制,只要我们保证DOM、全局变量、定时器、时间监控等能够被正确清除,与之相关的闭包是不会留在内存中的。从我们多次频繁点击切换页面的局部反应来看,随着页面切换回来,内存会一次次回到初始状态。理论上不存在直接导致内存泄漏的缺陷。但是,由于我们允许在页面之间保留一部分公共区域(上面称为ServiceLayer),并且沙箱本身是一个约定沙箱而不是安全沙箱(例如,向Element.prototype.xxx属性写入一些东西)cannotbeInterception),对于一些不规范的写法,还是存在内存泄漏的风险。这一点可能需要通过类似于Native端的内存压力监控等方式进行长期观察。阶段性管理由于整个HTML渲染过程都是我们自己实现的,所以我们可以对整个渲染的每个阶段记录一些时间。下面是一个例子:API从JS请求中获取数据需要124ms,但实际上整个数据获取(提前并行获取)花费了350ms。每个脚本的开始和执行时间也可以通过这种方式进行标记。这也可以为我们的页面优化提供一些指导,比如JS的执行时间是否太晚,或者某段JS的执行时间是否过长。最终效果对比如下。左边是同屏效果图,右边是正常跳转。从网上的数据来看,性能提升大概是2.8s=>1.8s。除了异步渲染的页面,我们还对一些原本是SSR的页面做了非常低成本的接入(不需要修改页面,但是享受的收益比较有限)。但仅仅是上述跳跃体验和返回体验的提升,就让我们JustForU模组的曝光画面数量稳步提升了3%。总结一下:类似SPA体验的客户端渲染,可以让Web体验更接近Native。同屏渲染是网站以低成本逐步实现Native体验的一种方式。更加身临其境的体验,确实会让用户更愿意浏览。局限性上面的方案还是有一些局限性的,比如前面提到的开发者需要防止内存泄露的问题,而且由于HistoryAPI的局限性,页面必须在同一个域中,否则重定向的URL不能满足预期。以后关注Chrome动态的同学也会知道,Chrome最近退出了一个新提案:PortalAPI,旨在解决我们上文提到的网页体验碎片化的问题。它可以提供一个类似iframe的沙箱,以低成本实现页面之间的转换。未来Portal普及之后(至少Chrome发布,Safari跟进之后),我们可以放弃新版浏览器中JS实现的沙盒机制,使用更安全(更酷)的PortalAPI来实现同屏渲染。有了PortalAPI的支持,我们也可以克服无法跨域的问题。按照目前的草案,Portal是支持跨域跳转的。
