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

豆皮粉儿们,你们好,又见面了。html

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

前言

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

服务端渲染

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

image.png

能够看到,CSR 的链路很是长,须要通过:node

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

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

image.png

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

总结一下:github

CSR与SSR的共同点是,先返回了 HTML,由于 HTML 是一切的基础。web

以后 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、私有化部署、工程工具都方面都有不少的探索和积累。 ~ 欢迎进入团队主页的招聘页面给咱们投递简历。

相关文章
相关标签/搜索