在了解流式渲染之前,我们要对了解要了解浏览器怎么处理 html 的。浏览器的渲染过程我就不介绍了,不清楚的可以看下这篇文章。
浏览器容错机制
首先,浏览器的容错机制是很强的,你能想得到的问题浏览器几乎都能处理,例如下面这个 html,我们就算没写闭合标签,浏览器也能正常解析。
<div>
hello
<div>world
浏览器边加载边渲染
浏览器不会等 html 加载完才会渲染,它会尽快将 html 渲染上去,甚至在加载完之前就会渲染到页面上,如果我们将 html 分块传输,浏览器是可以先把加载的分块渲染到页面上的。
我们用 node 做个 demo,可以看到loading会先显示出来,然后done才会显示。
import http from "http";
const server = http.createServer(async (req, res) => {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html");
res.write("<div>loading...</div>");
await new Promise((resolve) => setTimeout(resolve, 1000));
res.write("<div>done</div>");
res.end();
});
console.log("Server running at http://localhost:8080/");
server.listen(8080);
优点
一个方案总是要解决一些问题的,从上面例子中你也能一窥一二,简单来说,流式渲染的优点有:
- 更快的初始加载时间,不需要等待整个页面加载完成
- 改善搜索引擎优化,虽然 Google 等一些搜索引擎已经可以处理 js 渲染的页面,但是流式 SSR 有助于提高搜索引擎对网站的可见性和排名
- 更好的用户体验,用户可以更早地开始与页面进行交互
- 更容易实现首屏渲染,网站可以更快地展示重要的内容给用户
实现方案
我们先介绍下目前大部分框架的流式渲染方案,然后再介绍下一个 slot 的方案
主流方案
现在主流的方案其实很简单,先把处理完的 html 传给浏览器,对于一些要覆盖的地方,可以在后续返回 script 标签并执行替换。
这里以 react 的renderToPipeableStream为例,我们可以搭个 ssr 的 demo 看一下,具体代码就不在这里描述了,唯一值得注意的是为了更长时间触发Suspense
,我在Post.jsx
抛出了一个 Promise 错误,详情可以查看这里。
最后生成的的 html 如下:
<!doctype html>
<html>
<body id="app">
<!--$?-->
<template id="B:0"></template>
<div>Loading...</div>
<!--/$-->
</body>
</html>
<div hidden id="S:0">
<div>this is a post</div>
</div>
<script>
function $RC(a, b) {
a = document.getElementById(a);
b = document.getElementById(b);
b.parentNode.removeChild(b);
if (a) {
a = a.previousSibling;
var f = a.parentNode,
c = a.nextSibling,
e = 0;
do {
if (c && 8 === c.nodeType) {
var d = c.data;
if ("/$" === d)
if (0 === e) break;
else e--;
else ("$" !== d && "$?" !== d && "$!" !== d) || e++;
}
d = c.nextSibling;
f.removeChild(c);
c = d;
} while (c);
for (; b.firstChild; ) f.insertBefore(b.firstChild, c);
a.data = "$";
a._reactRetry && a._reactRetry();
}
}
$RC("B:0", "S:0");
</script>
整体逻辑不难理解,react 用<!--$?-->
和<!--/$-->
注释节点(nodeType==8)标记要替换的节点,然后$RC
方法将注释内的节点完全移除,并将S:0
的内容做替换。这里也可以看到浏览器的容错机制,即使在 html 标签外面也能正常解析。
我们改造下前面的 demo,让它支持替换 loading 元素,这里用原生replaceWith
方法代替
import http from "http";
const server = http.createServer(async (req, res) => {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html");
res.write(`<div id="loading">loading</div>`);
await new Promise((resolve) => setTimeout(resolve, 1000));
res.write(`<div id="done">done</div>`);
res.write(`<script>document.getElementById("loading").replaceWith(document.getElementById("done"));</script>`);
res.end();
});
console.log("Server running at http://localhost:8080/");
server.listen(8080);
slot 方案
虽然上面方案在体验上能满足需求,但是还是有些问题的,比如:seo 不友好,虽然流式渲染比传统的单页应用更容易被搜索引擎爬取,但不稳定的结构还是会影响 seo,另外借助 js 的替换也可能有性能问题。
哪有没有原生的方式做到同样的效果呢?答案是有的,我们可以使用slot来实现,slot 是 html5 的新特性,可以让我们在 template 中定义一些占位符,然后在后续的 html 中替换这些占位符。不过 slot 也有一些限制,它只能在 template 中使用。
<template shadowrootmode="open">
<slot name="content">loading</slot>
</template>
<div slot="content">done</div>
上面这段代码会渲染成done
。
继续改造下前面的 demo,让它支持 slot 替换 loading 元素
import http from "http";
const server = http.createServer(async (req, res) => {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html");
res.write(`<body>
<template shadowrootmode="open">
<slot name="content">loading</slot>
</template>
</body>`);
await new Promise((resolve) => setTimeout(resolve, 1000));
res.write(`<div slot="content">done</div>`);
res.end();
});
console.log("Server running at http://localhost:8080/");
server.listen(8080);
不过这个方案依赖于Declarative Shadow DOM,支持非常有限。