背景
先让我们来回顾一下浏览器渲染的流程之一:服务器返回浏览器客户端一个html格式的response时,客户端会做什么事情?这个问题答案看似简单,但如果细问,可能会难倒大片同学。
简单的回答就是:浏览器收到html开始解析,遇到css时,并行构建cssom树、dom树,遇到js脚本时,看是否带有defer、async属性,如果没有,则执行js脚本,阻塞解析,如果有,则继续解析.....
现在我们引出一个新的问题,此时服务端响应或者浏览器接收的html是完整的嘛,如果一个html请求content-length很长,我们是不是可以做出一些优化呢?即接收到一小块html,就开始解析渲染,然后重复此步骤,直至完整的html渲染完成。
答案:我们可以做到,并且可以因此来实现服务端渲染的性能优化,让我们来看看为什么?
当用户打开网页时,收到的HTML的第一个TCP块都是14kb。这是由于TCP慢启动算法为平衡传输速度所导致的,小的HTML文件可帮助浏览器在接收到第一个块时就能开始解析。确切地说,在前14kb中包含足够的数据将使页面的渲染速度更快,这就是我们所说的首页14KB原则。
在 TCP 慢启动 中,在收到初始包之后,服务器会将下一个数据包的大小加倍到大约 28KB。后续的数据包依次是前一个包大小的二倍直到达到预定的阈值,或者遇到拥塞。而一旦浏览器在收到数据的第一块,便可以开始解析收到的信息了。
目标
在做ssr渲染时,服务器同构处理组件,将处理结果以流的形式pipe给服务器的输出,而不是等全部组件处理完毕后将字符串作为结果输出,这样我们就实现了分块(流失)传输。
实现
这边我们搭建最简单的http服务,来模拟一个html请求,同时sleep模拟一些数据异步请求,阻塞传输。
const http = require("http");
const { Readable } = require("stream");
const url = require("url");
const sleep = (ms) => {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
};
const server = http.createServer(async (req, res) => {
const { pathname } = url.parse(req.url);
if (pathname === "/") {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html");
// 设置分块传输(基于http1.1协议)
res.setHeader("Transfer-Encoding", "chunked");
const readable = new Readable.from([
"<html><body><div>First segment</div>",
"<div>Second segment</div>",
sleep(2000).then(() => {
return "<div>third segment</div>";
}),
"<div>fourth segment</div></body></html>",
]);
readable.pipe(res);
return;
}
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("okay");
});
server.listen(8080);
效果
拓展
React 18 向开发者提供了 renderToPipeableStream API。通过 流传输 以及 Suspense + Lazyload ,大幅度解决了 服务端、客户端同构模式下的ssr 的问题 —— TTI 时间过慢。
特点如下:
-
服务端分块传输,浏览器对于 HTML 的解析、渲染更快开始,FP/FCP 更快。
-
浏览器 hydration 阶段无需等待所有组件的 JS bundle 加载完成,再进行 hydration。这意味着对于已渲染模块可以进行相关的事件绑定,提升 TTI 时间,其余区域可以等bundle加载完后,再进行渲染、激活事件。