前言

随着互联网技术的突飞猛进,前端代码变得日益简单。然而前端代码的简单带来了客户端体积增大,用户须要下载更多的内容能力将页面渲染进去。为了升高首屏渲染工夫,进步用户体验,前端工程师们推出了很多无效强力的技术,服务端渲染就是其中之一。

服务端渲染

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

能够看到,CSR 的链路十分长,须要通过:

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

等至多 4 步能力实现首次渲染(First Paint)
为了升高 FP 工夫,前端工程师引入了服务端渲染(Server Side Render):

然而,SSR 尽管升高了 FP 工夫,然而在 FP 与 可交互(Time To Interactive) 中有大量的不可交互工夫,在极其状况下,用户会一脸懵逼:“咦,页面上不是曾经有内容了吗,怎么点不了滚不动?”

总结一下:

CSR与SSR的共同点是,先返回了 HTML,因为 HTML 是所有的根底。

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

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

然而,先返回 js 还是先返回 data,这两者并不抵触,不应该是阻塞串行的,而应该是并行的。

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

根本思维

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

同时为了最大水平进步加载速度,所以须要升高首字节工夫(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,请读者刮目相待。

原文链接:https://bytedance.feishu.cn/w...

参考资料

  • https://github.com/facebook/r...
  • https://github.com/facebook/r...
  • https://hackernoon.com/whats-...
  • https://reactjs.org/docs/reac...
  • https://zhuanlan.zhihu.com/p/...
  • https://zhuanlan.zhihu.com/p/...
  • https://github.com/overlookmo...

The End