阿里妹导读:Web 应用在实际体验上和 Native 应用仍然存在非常明显的差距,那么如何低成本地把一个现有的网站改造成类 Native 的体验呢?本文分享一种让网站低成本渐进式实现 Native 化体验的方式——同屏渲染。
作者: 当轩 阿里技术
Web 端体验
在有了 PWA(Progressive web Apps) 之后,Web Application 也具备了添加到桌面和离线访问等能力,但是实际体验上却总是和 Native 应用存在非常明显的差距。
我们可以看一下 Alibaba 的 M 站和 IOS 应用的录屏(左边为 WEB,右边为 iOS APP):
我们可以看到,对于 Web Applicaiton 来说,在页面中来回跳转时访问的总是割裂的,从上一个页面到下一个页面需要等 loading ,返回时很多内存状态又都不在了,导致无法正确定位回之前的列表位置(这一点其实和不同的浏览器以及列表本身的实现方式有关,也有一些方案可以规避这个问题,在这里只是其中一个 case)。
这样对于用户的体验伤害非常明显,他能明确感觉到自己在用的并非一个 Application 而是一个 Website,而且在进行复杂的操作时整个链路也非常容易被中断。
而其实这种体验差异的根源,在于 B/S(Browser/Server)和 C/S(Client/Server)的差异。ServiceWorker 虽然提供了一些方案(例如 App Shell)让我们较低成本的增强原有的体验,但仍然难以解决页面之间的割裂问题,很多相同的代码在不同页面间重复执行,每一次访问内存状态就会丢失。
渲染性能
当我们在说体验的时候会显得有点主观,性能相比之下就容易衡量的多,而页面割裂带来的最为直观的体验差距其实就来自于渲染性能的差异。
在 Web 端一个典型的 CSR(Client Side Rendering)要经过的流程大致如下:
这其中有很多不符合我们预期的地方:
所以理想中的渲染流程应该是下图这样:
其实对于 Native 应用也是如此,用户点击时基本就会开始加载 API 并且执行下个页面的逻辑。其实一个优化的比较好(做了 preload 等)的 SPA 也是类似的效果,我们提前加载好下个页面的 vendor ,点击时直接只执行下个页面的逻辑即可。
然而实际上对于一个较大的现存站点来说(例如 m.alibaba.com ),把整个网站作为一个 SPA 来维护是不太现实的,一方面不能适应当前多人协作的现状,另外一方面稳定性上也不能接受修改一个页面整个网站都要发布的方案。
那么,如何低成本的把一个现有的网站改造成类 Native 的体验呢?
同屏渲染
在有了上面的思考后,我们就在想,有没有一个方案在不做改造的前提下,在用户点击后,立即开始数据的并行加载,同时把下个页面动态的加载进来,选择性的保留上个页面的一些内容(例如正在加载中的数据, jsonp , framework 层的对象等)而隔绝其他部分的干扰。
于是针对我们的场景产出了一个同屏渲染的方案:LightHub,所谓同屏渲染,即渲染过程中页面不需要被卸载,所有的渲染行为都在一个上下文中发生。
这里我们需要几个东西:
沙箱
我们需要一个低成本把页面还原会初始状态、并且允许保留部分对象的沙箱机制,而且最好这个机制是可以直接低成本部署到现有页面上的。其实这里的诉求和微前端碰到的问题类似,我们受 qiankun 的沙箱机制启发,只需要在页面的 <head> 中插入一小段内联 JS 记录:
在我们需要时我们只需要清空页面的 DOM,还原变化的全局变量(这里和 qiankun 一样采用的浅拷贝),eventListener,定时器和 MutationObserver,就能把页面还原到初始状态。
同时,记录的状态也能封存到一个对象中,当用户从下个页面 back 到上个页面时,我们可以直接把状态还原到页面上。
这里就需要在清空页面状态时选择性的保留一些需要保留的对象:例如公共的 Framework,JSONP 请求的标签等。
过渡动画
这一点其实就没有多复杂了,在页面不需要被卸载和重新加载后,我们可以在用户点击后立即展示一个动画。目前采用的只是一个简单的从右侧 slide-in 的动画。
需要注意的是,由于在绘制动画的过程中我们往往正在执行下个页面的逻辑,我们需要注意使用 GPU 来绘制动画,从而确保动画不会被 JS 执行阻塞。这一点对于低端机尤为关键。
API 并行加载
其实在有了上面的沙箱机制后,API 的并行加载就不是难事了,需要注意的是我们需要保护 API 并行加载本身的过程中产生的状态(例如 setTimeout ),我们需要实现一个 runInSharedContext 确保这其中的定时器不会在页面切换时被卸载。
runInSharedContext(() => { // 这里的 setTimeout 不能是被记录 & 清除的 setTimeout setTimeout(() => window.sharedfetchDataPromise = fetch(res));});
而在下个页面消费的只需要 window.sharedfetchDataPromise || fetch(url) 就能直接复用并行加载的 API 请求。
在我们的场景下为了让这个问题更加开发者无感,封装了一个叫做 redfox 的工具库,在同一个页面环境执行多次相同配置的请求会自动复用,不需要开发者手动判断。
按照浏览器行为渲染 HTML
这可能是其中最复杂的部分了,在我们抓到下个页面的 HTML 后,不能只是简单的 document.innerHTML = nextHTML ,这样会导致和普通的浏览器行为完全不一致,样式加载会导致闪屏,脚本的执行顺序不符合预期等等。
所以我们需要自己实现一个 renderHTML ,将抓到的 HTML 解析后模拟浏览器的行为进行渲染。
这个部分的行为比较复杂,需要在较多的场景进行测试,以及有相应的单元测试保障逻辑的正确性。
按浏览器行为触发事件
其实和上面渲染 HTML 相似,在渲染的过程中需要按照浏览器的行为触发相应的事件。
例如上个页面卸载时依次触发 beforeunload => pagehide => unload ,在下个页面加载时先把 readyState 重置,然后按照次序触发 domInteractive defer 的执行和 DOMContentLoaded 。
同样的,单元测试在这个环节是必须的。
分析
Timeline 分析
从 Chrome 最后的 Timeline 看执行逻辑基本是符合我们预期的,点击后的瞬间 API 开始加载并且基本上就开始全力执行下个页面的渲染逻辑。
Framework 层的代码基本也不需要再重复执行。
内存压力
对于这种不卸载页面的方案来说最容易引起担忧的可能就是内存泄露问题,其实按照上面的沙箱机制,只要我们确保 DOM、全局变量、定时器、时间监听等能够被正确清除,与之相关的闭包等就不会赖在内存中不走。
从我们本地多次频繁点击切换页面的反应看,内存随着页面的切换返回也会一次次回到初始状态,从理论上不存在直接导致内存泄露的缺陷。
然而,由于我们允许在页面间保留一部分的公共区域(上面称之为 Service Layer),另外沙箱本身是一个约定沙箱而非安全沙箱(例如往 Element.prototype.xxx 属性写东西就无法被拦截),对于一些不规范的写法仍然存在内存泄露的风险。
这一点可能需要和 Native 端类似的内存压力监控等方式来长期观察。
分阶段打点
由于整个 HTML 渲染过程都是我们自己实现的,所以整个渲染的各个阶段可以自己打点记录一些时间,下面就是一个例子:API 从 JS 请求到拿到耗时 124ms ,而实际上整个取数据(提前并行取的)花了 350ms 。每一个 script 开始执行和执行耗时也可以通过这种方式打上来。
这也可以为我们的页面优化提供一些指导,例如 JS 的执行时间是不是过晚,某段 JS 的执行时间是不是过长。
效果
最终的对比效果如下,左为同屏渲染,右为正常跳转,从线上的数据看性能提升大约从 2.8s => 1.8s 。
除了异步渲染的页面外,我们针对一些原先是 SSR 的页面也做了非常低成本的接入(不需要改造页面,但是享受到的受益相对也更有限)。
但仅仅是上面这种跳转体验和返回体验的改善,就让我们的 Just For U 模块的曝光屏数有稳定 3% 的增长。
总结
总结一下:
局限
上面的方案仍然存在一些局限性,例如前面提到的需要开发者防范内存泄露的问题,同时因为 History API 的限制,页面必须是同域的,否则跳转的 URL 无法满足预期。
未来
关注 Chrome 动态的同学也会了解到 Chrome 最近也退出了一个新的提案:Portal API,就是旨在解决我们上面提到的 Web 体验割裂的问题。
能够提供一个类似 iframe 的沙箱,以较低的成本实现页面间的跳转过渡等。在未来 Protal 普及后(至少 Chrome 发布, Safari 跟进后),我们就可以在新版本的浏览器中抛弃现在使用 JS 实现的沙箱机制,使用更加安全(且炫酷)的 Portal API 来实现同屏渲染。
在 Protal API 的支持下,我们也可以克服无法跨域的问题,按照目前的草案,Portal 是支持跨域跳转的。
拓展阅读
[1]qiankun (https://github.com/umijs/qiankun)
[2]Hands-on with Portals: seamless navigation on the Web
(https://web.dev/hands-on-portals/)