ESR 在首屏性能优化上的尝试与突破
NO.1
前言
前端性能优化可谓是一个老生常谈的话题,有的时候一个页面的首开率和白屏率确实能影响一个产品的用户留存。
说实话,H5的页面性能比不上原生native,但H5的高效开发又是一个无比巨大的优势都不想抛弃这个优势。淘宝也在性能上做了很多尝试,比如:近几年大家所熟知的SSR,NSR/端侧的预请求预渲染,都是为了借用native或者service的能力去尽可能提高页面首屏性能。
那在整个页面访问的节点链路上,我们除了借用native或service的能力,是不是还可以借助其他节点的能力去做一些尝试和突破呢?前端性能是不是已经到瓶颈了呢?
本文希望通过我们新技术在双十一期间的落地情况,能给大家提供一种新的思考方向。
NO.2
思考
业务背景
达人主页是达人运营自己内容的主要场地之一,从直播间、视频页等途径进入达人主页如果首屏渲染时间太久导致用户流失,会影响达人的运营意愿及运营收益。而在双十一的直播热潮下,达人主页也是主播关注&预告订阅的重要一环。如果秒开率无法达到预期,双十一作为主播重要的涨粉时刻,影响可能是巨大的。
对于我们前端同学来说,如果无法解决首屏秒开的问题,一旦产品上要求较好的秒开体验,H5可能就是被放弃的一个。这里我不说极致体验,极致体验真的只有native能做到了,但如何提高首屏效率确实是我们前端领域亟待解决的问题。
于是,我们开始针对现有的达人主页进行性能改造。
性能指标术语说明
这里先解释一下文章中会提到的几个性能指标术语:
由于ESR方案页面直出的特性,本文统计指标按 「首次有效绘制 (FMP)」+「可交互 (TTI)」两个维度来展现,另外对于 「首次有效绘制 (FMP)」&「可交互 (TTI)」 时间都会加上WebView启动时间。
改造思路
要想优化首屏性能,我们还是得从整个页面访问请求的链路上看,下面是常规端上H5页面访问链路示意:
从图中我们能看到一个H5页面的首屏渲染,会多次通过HTTP去请求资源和数据,这其中的网络链接的耗时是非常长且不可控的,所以很多人会想到用SSR来渲染实现页面直出。
SSR(Server Side Render)顾名思义为服务端渲染,我们知道页面document是由服务端(或CDN)下发,页面数据也需要向服务端请求。为了简化这些HTTP请求所带来的网络耗时,在document请求时,服务端直接拿到首屏页面数据并绘制好首屏的dom结构直接返回,这样既能节省一次HTTP的数据请求,又能节省一次js render的白屏消耗。
SSR的缺点也比较明显,对于距离服务端远,或者服务端处理时间较长的场景,用户依然会看到较长时间的白屏。
既然这样,结合 云-边-端 的链路,CDN节点在整个网络链路上来说是离用户更近的一个节点,我们能不能在CDN上做类似SSR的事情呢?这时,我们发现阿里云推出了CDN轻量编程环境——EdgeRoutine,这为我们提供了一个新的尝试方向。
我们可以在CDN节点去做提前渲染的事情,如果请求到达服务端再返回需要300ms,那么请求到cdn就直接返回耗时可能只要100ms。CDN的访问策略是会去寻找离用户最近的节点,就想快递运输的最后一公里一样,总会派送到离客户最近的分拨点,这么看来页面的网络调度时长是非常有优化空间的。并且我们还可以利用CDN节点资源共享的特性,将部分数据缓存到CDN节点上,减少远程的数据请求。
看下整个timeline的对比示意图:
由于页面首屏内容可以直出,图片资源的请求可提前到页面 js 逻辑执行前,大量串行逻辑变成并行执行,节省FMP时间。(这里需要说明:加上边缘计算后document的加载会变长,这里就需要CDN的缓存逻辑了。后面会讲到。)
NO.3
ESR流式渲染
开始介绍我们改造方案前,我们需要了解一下CDN相关新名词:ER 和 ESR。
什么是ER?什么是ESR?
什么是ER?EdgeRoutine(下面简称ER) 是阿里云CDN团队开发的边缘Serverless计算环境,支持在CDN边缘执行客户编写/编译的JavaScript或者WebAssembly代码(未来会推出)。关于ER的详细介绍,大家可以去看看阿里云的官方文档(https://developer.aliyun.com/article/757950),本文就不多加赘述。
那什么是ESR呢?我翻译为边缘渲染,Edge Side Render。
得益于阿里云的CDN团队ER的特性:提供了完整JavaScript环境,支持ES6语法;Web标准Service Worker API支持,前端应用无需更改即可适配。前端可以在 CDN 上做类似于SSR的事情,可对请求和响应做灵活的编程。自此我们开始了ESR流式渲染的尝试和改造。
ESR
我们尝试在ER上进行数据请求,并利用类似SSR工程化在CDN上执行 renderToHTMLString 的处理,实现了ESR的页面直出效果。下面是一些伪代码的示例:
// 前端代码 app.jsx
function Root(props = {}) {
const { data1 } = props;
return (
<div className="root-demo">
这是页面内容:{data1}
</div>
);
};
// er测接收html请求时的处理response的逻辑 er.js
async function handleRequest = () => {
// 这里模拟一个 fetch
const data = await fetchData();
const contentElement = createElement(Root, { data });
const html = renderer.renderToString(contentElement);
const esrHtml = `
<html>
<head>
<title>伪代码测试</title>
</head>
<body>
${html}
</body>
</html>
`;
return new Response(esrHtml, {
status: 200,
statusText: 'OK',
headers: new Headers({ 'Content-Type': 'text/html; charset=utf-8', 'Content-Encoding': 'none', 'streaming-parser': 'open'})
});
}
到这里,我们确认了ESR是可行的。但对于用户来说,这和SSR有一样的问题,也就是在response返回之前,用户都处于白屏等待的时间。
这里就要提出另一个改造点:流式渲染。
流式渲染
首先我们来看一下达人页的页面结构,基本上可以拆成两块,一个头部,一个内容流(红色粉丝等级相关部分属于动态性较强,千人千面的内容,这里在流式渲染中不做考虑,具体会在后面的「缓存管理部分讲到」)。
两个模块的数据获取,在服务端的rt是不同的。头部只是基础信息查询,服务端rt大概在10ms左右;内容流需要查询各种上下游依赖数据,还涉及算法引擎,服务端rt更长在60ms以上。rt的差距在到达前端后加上网络耗时可能请求时长会相差100-200ms。
那我们是不是可以在第一个比较快的请求返回时,就先输出一部分内容给用户呢?假设头部数据请求耗时150ms,作品数据耗时300ms,那么当头部接口数据返回CDN先返回这部分的dom结构,那么用户就能在150ms的时候看到了页面头部内容的输出,然后看到作品内容的输出。用户体感会比等待300ms然后看到整个页面直出要好很多。
这里放一段ER执行脚本的最小伪代码:
/**
* Make a response to client
* @param {Request} request
*/
async function handleRequest(request) {
const { writable, readable } = new TransformStream();
const writer = writable.getWriter();
const modsList = ['a', 'b'];
let modsListStatus = {};
let hasClose = false;
// 复写 close
const writerClose = async () => {
if (!hasClose && writer) {
await writer.close();
}
};
const streamHandle = async (content, taskName) => {
modsListStatus[taskName] = true;
await writer.write(content);
if (
(Object.keys(modsListStatus).length == modsList.length) &&
!hasClose && writer
) {
await writer.close();
hasClose = true;
}
};
// 第一个请求
const a = new Promise((resolve)=>{
setTimeout(() => {
console.log('a');
resolve(true);
}, 100);
}).then(async()=>{
await streamHandle('aaaaa', 'a');
});
// 第二个请求
const b = new Promise((resolve)=>{
setTimeout(() => {
resolve(true);
}, 300);
}).then(async()=>{
await streamHandle('bbbbb', 'b');
});
// 页面进入时打印 hello world 标识一下
await writerWrite('Hello World!');
return new Response(readable, { status: 200 })
}
示例中,[TransformStream(https://developer.mozilla.org/en-US/docs/Web/API/TransformStream) 是一个流数据的集合,它的输出数据和输入数据是有映射关系的,当对writer执行一串写入逻辑时,reader就能读取一段写入的数据流。这个就是我们流式渲染的最核心的点。
上面的demo你能看到,页面先输出 "Hello World!",再输出 "aaaa",最后输出 "bbbbb" 结束。而利用这个特性,我们可以做到将页面的N个模块分片输出。
在达人主页流式返回示意如下:
看到上面那个图,有人应该就能发现,我们其实是无法控制是「头部」先返回还是「作品」先返回,所以这里需要先给页面挖坑。
👆示意图中第一个节点返回时,页面的document应该是这样的(这里body和html都没有闭合,会在最后一个流完全返回后追加,下面会讲到):
<!doctype html>
<html>
<head></head>
<body>
<div id="root">
<div>骨架图</div>
<div id="a"></div>
<div id="b"></div>
</div>
第二个流会返回一段script脚本,这个脚本会针对指定的ID进行替换:
<!doctype html>
<html>
<head></head>
<body>
<div id="root">
<div>骨架图</div>
<div id="header"></div>
<div id="feeds"></div>
</div>
<script type="text/javascript" name="header">
const erHtml_header = '<div>这里是html内容</div>';
const erData_header = {"icon":"xxx","fans":"1234","title":"达人名称"};
erReplace('header', erHtml_header);
erMergeData('header', erData_header);
</script>
直到所有的坑位被替换完,补充js挂载脚本 & 闭合html标签
<!doctype html>
<html>
<head></head>
<body>
<div id="root">
<div>骨架图</div>
<div id="header"></div>
<div id="feeds"></div>
</div>
<script type="text/javascript" name="header">
const erHtml_header = '<div>这里是esr生成的dom内容</div>';
const erData_header = {"icon":"xxx","fans":"1234","title":"达人名称"};
erReplace('header', erHtml_header);
erMergeData('header', erData_header);
</script>
<script type="text/javascript" name="feeds">
const erHtml_feeds = '<div>这里是esr生成的dom内容</div>';
const erData_feeds = {"data":[]};
erReplace('feeds', erHtml_feeds);
erMergeData('feeds', erData_feeds);
</script>
<script src="//xxxx/index.js"></script>
</body>
</html>
缓存管理
既然要提升首屏性能,那缓存肯定也是必不可少的一环。相比端上的预渲染预请求只能服务于一台手机的用户,那CDN上的数据存储,则可以让访问统一个CDN节点的用户都共享这份数据。
举个例子:如果A用户访问了李佳琦的页面,同区域(同CDN节点)的B用户也同时访问了李佳琦的页面,那么A访问时所存储的数据是可以被B共享的,这样就无需去服务端再请求一次数据。达人的页面访问量越高,缓存命中率也就越高,也意味着更多共享了数据的用户能更快的看到页面内容。
那什么样的内容需要缓存什么样的内容不适合缓存呢?
还是以页面为例:
可以看出不同时刻、不同用户在访问达人主页时还是有些微的差异的(红色区域部分)。这些差异往往都是时效性要求较高,或千人千面的个性化数据。这些数据是不适合放到缓存中的,如果存储这些数据,那缓存命中率将会极低。我们在CDN节点上共享的,希望是那些无差异的数据,这里称之为静态数据。
当这些非红色区域的静态数据被存储后,用户命中缓存的时候,CDN直出的就是去掉红色区域的内容。这个时候页面首屏内容已经出来了。我们只需要在js脚本挂载之后,执行一次动态数据的请求来补全红色信息即可。
最终完整示意图如下:
当然现在ER提供的缓存能力还比较弱,目前只能存储在节点的内存中。这意味同CDN节点的用户才能共享缓存数据。但未来,阿里云ER团队已经在研发新的存储能力,提供Swift 的 Open API ,实现数据的 KV 存储。这就意味着,这个存储空间可以比现在大的多。
由于目前的缓存能力,我们只能缓存1万个达人的数据。不过得益于当前淘宝直播的业务特点,几个大V主播的流量占了很大比例,我们再通过LRU缓存算法,保证高频数据的存储,整体的缓存数据还是很不错的。
从双十期间的数据来看,由于各大主播占据大部分流量,直播时段也几乎集中在7点到12点之间。整体缓存命中率直播高峰能维持在 40%-60%;白天非直播高峰期,也基本在 25% 上下。
关于缓存数据更新问题,这里结合历史数据看,缓存的静态内容变动的频率还是比较低的,为了保证命中率,会存在短时间的缓存数据更新慢的问题。这个问题未来CDN团队会提供全国(全球)节点数据同步能力,这样缓存时间能进一步加长。也许在这一切能力都满足后,非直播高峰期的缓存命中率也能达到 40%。
其他
除了常规流程以外,为了保障双十一的稳定性,当然我们也加入了很多容灾逻辑、安全拦截逻辑、日志监控逻辑,这里就不拓展开了。
NO.4
四、性能结果
低端机对比数据
PS:以下所有数据均为低端机手淘冷启动时的测试数据:其中安卓低端机为vivoY67,iOS低端机为iPhone6
(冷启动为卸载APP后重装第一次访问H5,此时webview均处于未初始化状态。)
从整体数据上看,首次有效绘制时长 (FMP) 的提升是非常明显的,能提升60%左右。对于冷启动的极端机数据尚且如此,而事实上大部分用户是不会处于冷启动状态来访问的,所以页面秒开率还是相当可观的。
改造后各机型数据
直观看一下改造前后录屏对比:
NO.5
总结&展望
总的来说这次ESR流式渲染是前端在新领域的一次突破性尝试。这套方案对于 数据刷新率不高、访问量大 的页面,是非常适合用的。
目前达人主页是我们ESR的第一个落地业务,我们会将一整套的流式渲染逻辑包装成ESR框架,其他业务在使用时只需要拆分好模块即可快速实现 BSR 到 ESR 的升级。当然前文所提到的CDN缓存能力较弱、缓存同步更新的问题也在同步升级中。未来当CDN缓存能做到全国 or 全球节点同步,swift缓存能力上线时,我们的缓存命中率还能近一步提升,缓存同步更新也能得到保证。
当然现在ER的技术才刚刚起步,还有很多不足和成长空间,我更看重的是这个新技术的给前端拓开新的视野,前端的生命周期能延长到CDN边缘节点上。也许读者看到这里,已经想到其他可以在ER上尝试的新玩儿法了。
欢迎关注东半球最大的前端团队
喜欢就点这里