什么是流式渲染

在了解流式渲染之前,我们要对了解要了解浏览器怎么处理 html 的。浏览器的渲染过程我就不介绍了,不清楚的可以看下这篇文章

浏览器容错机制

首先,浏览器的容错机制是很强的,你能想得到的问题浏览器几乎都能处理,例如下面这个 html,我们就算没写闭合标签,浏览器也能正常解析。

<div> hello <div>world

浏览器边加载边渲染

浏览器不会等 html 加载完才会渲染,它会尽快将 html 渲染上去,甚至在加载完之前就会渲染到页面上,如果我们将 html 分块传输,浏览器是可以先把加载的分块渲染到页面上的。

我们用 node 做个 demo,可以看到loading会先显示出来,然后done才会显示。

javascript
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);

stream capture

优点

一个方案总是要解决一些问题的,从上面例子中你也能一窥一二,简单来说,流式渲染的优点有:

  1. 更快的初始加载时间,不需要等待整个页面加载完成
  2. 改善搜索引擎优化,虽然 Google 等一些搜索引擎已经可以处理 js 渲染的页面,但是流式 SSR 有助于提高搜索引擎对网站的可见性和排名
  3. 更好的用户体验,用户可以更早地开始与页面进行交互
  4. 更容易实现首屏渲染,网站可以更快地展示重要的内容给用户

实现方案

我们先介绍下目前大部分框架的流式渲染方案,然后再介绍下一个 slot 的方案

主流方案

现在主流的方案其实很简单,先把处理完的 html 传给浏览器,对于一些要覆盖的地方,可以在后续返回 script 标签并执行替换。

这里以 react 的renderToPipeableStream为例,我们可以搭个 ssr 的 demo 看一下,具体代码就不在这里描述了,唯一值得注意的是为了更长时间触发Suspense,我在Post.jsx抛出了一个 Promise 错误,详情可以查看这里

最后生成的的 html 如下:

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方法代替

javascript
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);

stream replace capture

slot 方案

虽然上面方案在体验上能满足需求,但是还是有些问题的,比如:seo 不友好,虽然流式渲染比传统的单页应用更容易被搜索引擎爬取,但不稳定的结构还是会影响 seo,另外借助 js 的替换也可能有性能问题。

哪有没有原生的方式做到同样的效果呢?答案是有的,我们可以使用slot来实现,slot 是 html5 的新特性,可以让我们在 template 中定义一些占位符,然后在后续的 html 中替换这些占位符。不过 slot 也有一些限制,它只能在 template 中使用。

html
<template shadowrootmode="open">
  <slot name="content">loading</slot>
</template>
<div slot="content">done</div>

上面这段代码会渲染成done

继续改造下前面的 demo,让它支持 slot 替换 loading 元素

javascript
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,支持非常有限。