Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

从 新的 Suspense SSR 聊到 HTTP 分块传输 #7

Open
Janlaywss opened this issue Apr 2, 2022 · 1 comment
Open

从 新的 Suspense SSR 聊到 HTTP 分块传输 #7

Janlaywss opened this issue Apr 2, 2022 · 1 comment

Comments

@Janlaywss
Copy link
Owner

Janlaywss commented Apr 2, 2022

前言

前几天,React 18的正式版发布了,一瞬间我的朋友圈动态都被刷屏了。

其中,有一个新特性很有意思:Suspense SSR。看起来这是在SSR场景下,针对以往 Suspense 实现的适配。今天我们来聊一聊这个 feature

旧的实现

在开始了解Suspense SSR之前,我们需要了解Suspense是个啥。

在React 16.6 中新增了Suspense组件,其用处是更加优雅地加载一个异步需求的组件。如异步拆包的路由,需要异步请求结果的组件等。

下图是异步组件在Suspense中的应用。当异步组件还未加载过来时,Suspense会首先加载fallback中的loading组件,等异步组件加载后渲染其加载结果。

const ProfilePage = React.lazy(() => import('./ProfilePage')); // Lazy-loaded

// Show a spinner while the profile is loading
<Suspense fallback={<Spinner />}>
  <ProfilePage />
</Suspense>

这样的方式优雅且易用。但是,在SSR场景下,现有的renderToString都是同步的。服务端场景下也不支持类似浏览器端 requestIdleCallback 的调度。服务端应用也无法主动让出调度任务,以换取更高的效率。

同步的最大问题,就是渲染时间长会拖慢性能。由于服务端渲染是CPU密集型计算,所以服务端渲染服务的tps,往往不尽人意。如果还要等待 Suspense 的异步渲染,则处理时间更无法保证。

于是,React 为了在服务端适配 Suspense,做了个一举两得的事情。既适配了功能,还做了优化。

新的实现

在新的服务端实现中,我们依然选择了递归的方式renderString。但遇到Suspense时,其逻辑发生了变化。

Suspense 到来时,首屏会先将 fallback 中的 loading 渲染。此时子组件的 promise 会被推进异步队列,等待 promise 完毕时,再通过HTTP分块技术推送到浏览器进行替换。

其逻辑大致的代码如下。在promise结束后,服务端推送一个 replace("1", "2") 函数,将异步结果的dom替换之前的loading``dom

<div>
  <!--$?-->
  <div id="1">Loading...</div>
  <!--/$-->
</div>
<div hidden id="2">
  <div>Actual Content</div>
</div>
<script>
  replace("1", "2");
</script>

其中,异步结果的推送并没有新开一个http请求,而是基于之前的http请求基础上,通过分块传输的特性推送过去的。造成了一种长连接的假象。 接下来,我们分析一下分块传输的玩法。

分块传输

要先讲分块传输,首先先要从HTTP协议的结束符开始讲起。在一个HTTP报文中,如何判断一个请求/响应体结束呢?通常有2个方案:

  • content-length:这种方案是在请求/响应体报文上增加一个content-length请求头,值是响应体的字节数。如果浏览器在接收到 content-Length 单位个字节后,就会视为本次请求完毕。
  • Transfer-Encoding: chunked:这个方案代表开启了不定长度的分块传输。我们需要通过发送终结符来告诉浏览器请求体结束。而Suspense SSR正是利用这个实现,将Suspense渲染后的结果一点点推送过去。

其中Transfer-Encoding: chunked的优先级要大于content-length。ps:不过判断数据块结束最严谨的方式,是提前计算好长度,而不是使用终结符。不过在绝大多数场景,长度还是能够被确定计算的。

接下来我们做个实验,来看一下分块传输在抓包工具下的现象。

实验

我们用express启动一个服务,来模拟一下分块传输的逻辑。代码逻辑大致为:

  1. 先推送html整体骨架,包括html标签,编码格式,body标签。
  2. 每隔一秒钟推送一个p标签,推送10次后不再推送。
  3. 关闭推送,声明请求结束

在这个情况下,我们可以模拟Suspense SSR在异步情况下推送结果的原理实现。

const express = require('express')
const app = express()
const port = 3677;

const renderToStream = (res) => {
  return new Promise((resolve, reject) => {
    let count = 0;
    let timeId = setInterval(() => {
      if (count === 10) {
        res.write("<p>已经推送了" + count + "次</p>");
        clearInterval(timeId);
        resolve();
        return;
      }
      res.write("<p>每1秒钟推送1次</p>");
      count++;
    }, 1000)
    })
}

app.get('/stream', (req, res) => {
  res.write("<!DOCTYPE html>");
  res.write("<head><meta charset='UTF-8'/><title>test stream</title></head>");
  res.write("<body></body>");
  renderToStream(res).then(() => {
    res.end();
  })
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

我们打开Wireshark,启动对服务的监控,然后刷新页面开始请求

可以看到,当我们开始请求时,首先服务端先给我们推送了请求头、body、html标签等信息。其中,Transfer-Encoding: chunked 在此刻就已经被确定。

image

随后的每一秒,都会捕获到一条TCP数据包和其ack确认消息。TCP数据包是响应内容,ack是浏览器发回去的确认报文,刚好10对。

image.png

TCP数据包中,包含着我们每次推送的信息:

  • 在数据包体的开头,声明这我们本次的包大小,格式是十六进制。0x1b换算后,就是27字节。
  • 紧接着就是两个十六进制字符:0x0d,0x0a,这两个字符分别代表着回车和换行符号。
  • 在数据包体的结尾,也存放着0x0d,0x0a,声明数据包的结束。

image.png

我们也可以在维基百科中验证到这个逻辑,毕竟这就是规范定义的 🤗

每一个非空的块都以该块包含数据的字节数(字节数以十六进制表示)开始,跟随一个CRLF (回车换行),然后是数据本身,最后块CRLF结束。在一些实现中,块大小和CRLF之间填充有白空格(0x20)。

https://zh.wikipedia.org/wiki/%E5%88%86%E5%9D%97%E4%BC%A0%E8%BE%93%E7%BC%96%E7%A0%81

在经历10次的推送后,最终会产生一个HTTP响应报文。这个报文里面综合了前面的TCP数据包块。在所有数据包的末尾,会填充一个0、空白单行与0x0d,0x0a,代表响应体的结束:

这里为啥是0?很简单,空白单行的字节数就是0

image.png

这里说明一点:我们第一次推送的html骨架,第二次推送的p标签都会被拼接到请求尾部。浏览器自动帮我们做了矫正,拼到了body里面。

Suspense SSR就通过这样的方式,将每次的渲染结果和替换函数推送到前端。

最后

React 费这么大力气,就为了适配 Suspense 吗?显然不是。如果我们再一次站在当年fiber架构出现的时刻看这个问题,就再也不奇怪了。

原有的fiber架构,花大力气把组件树从树改为链表,其目的就是为了链表能够在遍历时可以打断。而树的递归遍历,只能一次性从根节点遍历到叶子结点,中间无法暂停。

如果遍历可以中断,中断后我们就可以借助浏览器的调度能力,看看我们的遍历时间会不会影响浏览器的渲染。如果影响,那就等下一次调度的时候再继续渲染。

但服务端是没有浏览器的调度API的。而服务端渲染又是CPU密集计算型应用,每次渲染一次非常耗时且占资源。

Suspense SSR 则借助适配 Suspense 这个理由,将Suspense的异步渲染推进异步队列,在等待异步渲染结果之前,此时我们的主线程还可以让出时间来处理其他的渲染请求,提升可处理的渲染任务数量。 这个做法让体验和性能一举两得。

快去升级尝试吧!

参考资料

@xiaoxiaojx
Copy link

赞 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants