- 原文地址(本人博客):www.waylon.online/blog?id=b30...
前言
假设我们要做一个商品列表,并且这个列表的数据计算比较复杂(需要混入许多复杂的商品数据),耗时较大,最简单实现流程大致如下:
- 调用接口获取列表数据,等待接口返回;
- 接口处理完成,返回数据(假设耗时3s);
- 拿到数据,开始渲染列表;
那么在这接口返回的3s内,用户将无法操作页面,只能干看着loading发呆!并且如果这个场景发生在首屏,那么将会给用户带来极度糟糕的体验。
如何避免长时响应让用户等待焦虑的方法,下面我们将引入我们的主角 - 流式渲染
Web性能指标
在开始介绍流逝渲染之前,我们需要更加精准细致的理解、描述我们所遇到的问题,这里我们引入3个Web性能指标,附上链接,不做过多介绍。
FCP(First Contentful Paint)
首次内容绘制 (FCP) 指标测量页面从开始加载到页面内容的任何部分在屏幕上完成渲染的时间。对于该指标,"内容"指的是文本、图像(包括背景图像)、<svg>
元素或非白色的<canvas>
元素。
LCP(Largest Contentful Paint)
最大内容绘制 (LCP) 指标会根据页面首次开始加载的时间点来报告可视区域内可见的最大图像或者文本块完成渲染的相对时间。
TTI(Time to Interactive)
可交互时间(TTI) 指标测量页面从开始加载到主要子资源完成渲染,并能够快速、可靠地响应用户输入所需的时间。
通过这三个指标,我们可以很明显的预测出,我们假设的场景中,如果不使用流式渲染,它们的值是较大的,后面我也会给出实验截图做最直观的对比。
服务端开启流式传输
流式传输可以依赖http, rtmp, rtcp, udp...等等网络协议,http中,流式传输可以在开启Transfer-Encoding:chunked的前提下,设置Transfer-Encoding为chunked实现,报文如下:
js
Connection: keep-alive
Transfer-Encoding: chunked
根据我们所假设的场景,我们再增加一下细节,分两步:
- 第一步,我们拿到了主数据,此时商品信息只包含了商品id,这里是30条,每条的速度大约是100ms,合计处理时长大约3s;
- 第二步,我们需要根据商品id去各个服务查询相关的商品信息,比如商品颜色,商品尺码,等等等等。这里精简为只有颜色,尺码;
下面列上node实现的代码,为了方便一会的实验对比,我同时提供了一个支持流式处理的接口/getGoodsByStream
以及一次性处理的接口/getGoods
。
js
const express = require('express'); // 我这里采用了express框架 其他都大同小异
const app = express();
const path = require('path');
// 前端页面
app.use(express.static(path.resolve(__dirname, './public')))
/**
* @type {Array<{goodsId: number, color?: string, size?: string}>}
* @description 模拟30条商品主数据(只含有goodsId)
*/
const goods = Array.from({ length: 30 }, (_, i) => ({ goodsId: i }));
// 模拟混入数据
const getData = ({ goodsId }) => new Promise((resolve) => {
// 随机生成颜色 模拟需要混入的数据
const color = '#' + Math.floor(Math.random() * 16777215).toString(16);
// 拿到随机的尺码 模拟需要混入的数据
const size = Math.random() > 0.5 ? 'L' : 'S';
setTimeout(() => resolve({ goodsId, color, size }), 100);
});
// 流式处理
app.get('/getGoodsByStream', async (req, res) => {
res.setHeader('Transfer-Encoding', 'chunked');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'text/plain');
const startTime = Date.now();
const run = async (i = 0) => {
const { goodsId } = goods[i];
return await getData(goodsId);
};
for (let i = 0; i < goods.length; i++) {
const { color, size } = await run(i);
goods[i].color = color;
goods[i].size = size;
res.write(JSON.stringify(goods[i]));
}
const endTime = Date.now();
res.end();
console.log('流式处理用时:', endTime - startTime);
});
// 一次性处理
app.get('/getGoods', async (req, res) => {
const startTime = Date.now();
const run = async (i = 0) => {
const { goodsId } = goods[i];
return await getData(goodsId);
};
for (let i = 0; i < goods.length; i++) {
const { color, size } = await run(i);
goods[i].color = color;
goods[i].size = size;
}
const endTime = Date.now();
res.json(goods);
console.log('一次性处理用时:', endTime - startTime);
});
app.listen(3000);
客户端流式接收渲染
在客户端,我们可以通过Streams API访问从接口接收的数据流,并操作它们,目前大部分现代浏览器都能够支持Stream API。
我们使用express内置的static模块创建一个html文件作为我们的页面,页面结构很简单,就是一个列表,渲染上面我们写的接口所返回的数据。
同样的,这里我会根据页面参数判断,当前的页面是否走流式渲染,将普通渲染与流式渲染进行对比。 当访问http://localhost:3000/
时页面将一次性渲染30条数据,当访问http://localhost:3000/?isStreaming=1
时页面将进行流式渲染。
代码如下:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>streaming-rendering-demo</title>
</head>
<body>
<style>
.item {
border: 1px solid #ccc;
border-radius: 5px;
margin-top: 4px;
}
</style>
<div id="root"></div>
<script>
const root = document.getElementById('root');
// 通过isStreaming标识判断是否走流式渲染
const isStreaming = new URLSearchParams(location.search).get('isStreaming') === '1'
const startTime = Date.now();
// 绘制商品
const renderGoods = (item) => {
const el = document.createElement('div');
el.innerHTML = `id: ${item.goodsId}, color: ${item.color}, size: ${item.size}`;
el.className = 'item';
root.appendChild(el);
}
// 结束计时
const renderTimeText = (time) => {
const el = document.createElement('div');
el.innerHTML = `用时: ${time}ms`;
root.appendChild(el);
}
// 清理Loading
const clearLoading = () => {
root.innerHTML = '';
}
if (isStreaming) {
const utf8Decoder = new TextDecoder("utf-8");
fetch('/getGoodsByStream').then(async ({ body }) => {
const reader = body.getReader();
const processor = async () => {
const { done, value } = await reader.read();
if (done) {
return;
}
const chunk = utf8Decoder.decode(value, { stream: true });
const item = JSON.parse(chunk);
renderGoods(item);
window.scrollTo(0, document.body.scrollHeight);
// 递归直至读取完毕
await processor();
}
clearLoading();
await processor();
const endTime = Date.now();
const time = endTime - startTime;
renderTimeText(time);
window.scrollTo(0, document.body.scrollHeight);
})
} else {
fetch('/getGoods').then(res => res.json()).then(data => {
const endTime = Date.now();
const time = endTime - startTime;
clearLoading();
data.forEach((item) => {
renderGoods(item);
});
renderTimeText(time)
window.scrollTo(0, document.body.scrollHeight);
})
}
</script>
</body>
</html>
实验比较
完成了代码以后,我们得出了下面的实验结果:
对于一次性渲染30条数据,最终的效果是这样的:
对于流式渲染30条数据,最终效果则是:
很明显,流式渲染在观感或者是交互的体验上都要强于普通渲染,从数据层的角度,借助于Chrome自带的performance,我们可以得出下面两份实验报告(为了强化对比,我把loading去掉了):
通过数据可以得出,普通渲染的FCP与LCP均位于3.89s,而流式渲染的FCP则在179.49ms(即第一个chunk返回的时间),LCP则在2.41s,由于仅仅只是demo,没有任何交互,这里暂时没有TTI的对比,但是结论可以预见。
还有一个更直观的对比,就是通过Chrome提供的Lighthouse,我们可以得出下面两个实验结果:
由此可见,流式渲染可以大大的增强网站性能,增强用户的交互体验,这里笔者只是提供了一个简单易懂的demo,实际应用中还有更多的考量。