大势所趋:流式服务端渲染

avatar
前端工程师 @字节跳动

豆皮粉儿们,大家好,又见面了。

5c6e7105-97d1-46b5-a71f-5958494f9332.gif

前言

随着互联网技术的日新月异,前端代码变得日益复杂。然而前端代码的复杂带来了客户端体积增大,用户需要下载更多的内容才能将页面渲染出来。为了降低首屏渲染时间,提高用户体验,前端工程师们推出了很多有效强力的技术,服务端渲染就是其中之一。

服务端渲染

为了解释什么是服务端渲染,让我们先画出传统客户端渲染(Client Side Render) 的流程图:

image.png

可以看到,CSR 的链路非常长,需要经过:

  1. 请求 html
  2. 请求 js
  3. 请求 数据
  4. 执行 js

等至少 4 步才能完成首次渲染(First Paint) 为了降低 FP 时间,前端工程师引入了服务端渲染(Server Side Render):

image.png

然而,SSR 虽然降低了 FP 时间,但是在 FP 与 可交互(Time To Interactive) 中有大量的不可交互时间,在极端情况下,用户会一脸懵逼:“咦,页面上不是已经有内容了吗,怎么点不了滚不动?”

总结一下:

CSR与SSR的共同点是,先返回了 HTML,因为 HTML 是一切的基础。

之后 CSR 先返回了 js,后返回了 data,在首次渲染之前页面就已经可交互了。

而 SSR 先返回了 data,后返回 js,页面在可交互前就完成了首次渲染,使用户可以更快的看到数据。

但是,先返回 js 还是先返回 data,这两者并不冲突,不应该是阻塞串行的,而应该是并行的。

它们的阻塞导致了在 FP 与 TTI 之间总有一段时间效果不如人意。为了使它们并行,来进一步提高渲染速度,我们需要引入流式服务端渲染(Steaming Server Side Render)渲染 的概念。

基本思想

综上,理想中的流式服务端渲染流程如下:

image.png

同时为了最大程度提高加载速度,所以需要降低首字节时间(Time To First Byte),最好的方法就是复用请求,因此,仅需发送两个请求:

  1. 请求 html,server 会先返回骨架屏的 html,之后再返回所需数据,或者带有数据的 html,最后关闭请求。
  2. 请求 js,js 返回并执行后就可以交互了。

为什么要叫“流式服务端渲染”?是因为返回html的那个请求的相应体是流(stream),流中会先返回如骨架屏/fallback的同步HTML代码,再等待数据请求成功,返回对应的异步HTML代码,都返回后,才会关闭此HTTP连接。

优势在于:

  • 请求 data 与 请求 js 是并行的,而以前的大多解决方案都是串行的。
  • 在最优情况下,仅发送两个请求,大幅度 降低了 TTFB 总时长

但是,ssr 框架通常只执行render函数一次,为了让其知道何为加载状态,何为数据状态,我们需要对其进行升级改造,首先就是lazySuspense

lazySuspense

然后我们来通过简单的讨论实现原理来进一步研究它们是如何为流式服务端渲染服务的。 一个最简单的 lazy 如下:

function lazy(loader) {
  let p
  let Comp
  let err
  return function Lazy(props) {
    if (!p) {
      p = loader()
      p.then(
        exports => (Comp = exports.default || exports),
        e => (err = e)
      )
    }
    if (err) throw err
    if (!Comp) throw p
    return <Comp {...props} />
  }
}

其主要逻辑为,加载目标组件,如目标组件正在加载,则抛出对应的Promise,否则正常渲染目标组件。

为什么这里选择的是throw这样的设计呢?是因为在语法层面,只有throw能跳出多层函数的逻辑,找到最近的catch继续执行,而其他流程控制关键字,如breakcontinuereturn等,都是调度单个函数内的逻辑,影响的是语句块block。

经常把throwError结合使用的读者可能会感到意外,但是有时候就需要跳出常理看待问题的能力。

lazy 通常和 Suspense 配套使用,一个简单的Suspense如下所示:

function Suspense({ children, fallback }) {
  const forceUpdate = useForceUpdate()
  const addedRef = useRef(false)
  try {
    // 先尝试渲染 children,为方便理解就简单编写了
    return children
  } catch (e) {
    if(e instanceof Promise) {
      if(!addedRef.current) {
        e.then(forceUpdate)
        addedRef.current = true
      }      
      return fallback
    } else {
      throw e
    }
  }
}

主要逻辑为:尝试渲染children,如果children抛出了Promise,则渲染fallback,当Promise resolve,则 rerender。

至于这个Promise是来自lazy的,还是来自fetch的,其实不是很在乎。 然而,框架内部的 Suspense 通常不会这么写,其最简实现为:

function Suspense({ children }) {
  return children
}

没错,就这么简单,和Fragment代码相同,仅仅是为调度提供一个标志位而已。

为了提高可扩展性与鲁棒性,React 内部使用Symbol作为标志位,但原理相同。

在调度此组件时,如果被throw打断,就会回退至fallback:

try {
  updateComponent(WIP) // 被 throw 打断
} catch(e) {
  WIP = WIP.parent // 回退到 Suspense 组件
  WIP.child = WIP.props.fallback // 更换 child 指针
}

部分框架,如 vue/preact,它们的底层数据结构不是 fiber 或者链表,原理则为设置两个占位符,根据调度时的具体 state 来决定渲染哪个占位

<Suspense> 
  <template #default> 
    <article-info/> 
  </template> 
  <template #fallback> 
    <div>Loading…</div> 
  </template> 
</Suspense>

由于不是此次的重点,这里就不展开了,感兴趣的同学可以去阅读有关源码。

最后一块积木

在完成lazySuspense的原理探究后,让我们来为流式服务端渲染放上最后一块积木:ssr 框架。

app.get("/", (req, res) => {
  res.write("<!DOCTYPE html><html><head><title>My Page</title></head><body>");
  res.write("<div id='root'>"); 
const stream = ReactServerDom.renderToNodeStream (<App />);
stream.pipe(res, { end: false });
stream.on('end', () => {
    res.write("</div></body></html>");
    res.end();
  });
});

renderToNodeStream 的过程中,每完成一个组件的渲染则直接放入 stream 中。在浏览器看来,可能收到的 html 字符串如下所示:

<html>
  <body>
    <div id="root">
      <input />
      <div>some content</

看起来只有一半,这要怎么展示呢?

别担心,现代浏览器对于 html 有着优异的容错能力,哪怕只有一半,它也能把这一半完好无损的渲染出来,这就是流式服务端渲染的基础所在。

在调度时,当遇见Suspense从而需要WIP回退时,会往流中放入fallback并执行Promise,当Promise resolve ,放入对应的替换代码,一个简单的例子如下所示: 先渲染fallback:

<html>
  <body>
    <div id="root">
      <div className="loading" data-react-id="123" />

当Promise resolve 后,返回:

<div data-react-id="456">{content}</div>
<script>
  // 举个例子,并不是真有这个API
  React.replace("123", "456")
</script>

使用 inline 的 js 脚本来替换 dom,以此实现流式加载。

整体看起来如下所示:

<html>
  <body>
    <div id="root">
      <div className="loading" data-react-id="123" />
      <!-- 同步 HTML 渲染完成后返回客户端 js -->
      <script src="./index.js" />
      <!-- 客户端使用“部分水合”算法对服务端 HTML 与客户端虚拟 dom 进行 merge,跳过由 Suspense 管理的节点 -->

      <!-- 过了一段时间 -->
      <div data-react-id="456">{content}</div>
      <script>
        // 举个例子,并不是真有这个API
        React.replace("123", "456")
      </script>
    </div>
  </body>
</html>

结语

流式服务端渲染为降低渲染时间、提高用户体验开启了一扇全新的大门,美中不足的是,仍在理论当中,各大框架均在研发,暂无可用 demo,请读者拭目以待。

原文链接:bytedance.feishu.cn/wiki/wikcn5…

参考资料


数据平台前端团队,在公司内负责风神、TEA、Libra、Dorado等大数据相关产品的研发。我们在前端技术上保持着非常强的热情,除了数据产品相关的研发外,在数据可视化、海量数据处理优化、web excel、WebIDE、私有化部署、工程工具都方面都有很多的探索和积累。 ~ 欢迎进入团队主页的招聘页面给我们投递简历。