You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
constProfilePage=React.lazy(()=>import('./ProfilePage'));// Lazy-loaded// Show a spinner while the profile is loading<Suspensefallback={<Spinner/>}><ProfilePage/></Suspense>
constexpress=require('express')constapp=express()constport=3677;constrenderToStream=(res)=>{returnnewPromise((resolve,reject)=>{letcount=0;lettimeId=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}`)})
前言
前几天,
React 18
的正式版发布了,一瞬间我的朋友圈动态都被刷屏了。其中,有一个新特性很有意思:
Suspense SSR
。看起来这是在SSR
场景下,针对以往Suspense
实现的适配。今天我们来聊一聊这个 feature旧的实现
在开始了解
Suspense SSR
之前,我们需要了解Suspense
是个啥。在React 16.6 中新增了
Suspense
组件,其用处是更加优雅地加载一个异步需求的组件。如异步拆包的路由,需要异步请求结果的组件等。下图是异步组件在
Suspense
中的应用。当异步组件还未加载过来时,Suspense
会首先加载fallback
中的loading
组件,等异步组件加载后渲染其加载结果。这样的方式优雅且易用。但是,在
SSR
场景下,现有的renderToString
都是同步的。服务端场景下也不支持类似浏览器端requestIdleCallback
的调度。服务端应用也无法主动让出调度任务,以换取更高的效率。同步的最大问题,就是渲染时间长会拖慢性能。由于服务端渲染是CPU密集型计算,所以服务端渲染服务的tps,往往不尽人意。如果还要等待
Suspense
的异步渲染,则处理时间更无法保证。于是,
React
为了在服务端适配Suspense
,做了个一举两得的事情。既适配了功能,还做了优化。新的实现
在新的服务端实现中,我们依然选择了递归的方式
renderString
。但遇到Suspense
时,其逻辑发生了变化。在
Suspense
到来时,首屏会先将fallback
中的loading
渲染。此时子组件的promise
会被推进异步队列,等待promise
完毕时,再通过HTTP分块技术推送到浏览器进行替换。其逻辑大致的代码如下。在
promise
结束后,服务端推送一个replace("1", "2")
函数,将异步结果的dom替换之前的loading``dom
其中,异步结果的推送并没有新开一个
http
请求,而是基于之前的http
请求基础上,通过分块传输的特性推送过去的。造成了一种长连接
的假象。 接下来,我们分析一下分块传输的玩法。分块传输
要先讲分块传输,首先先要从HTTP协议的结束符开始讲起。在一个HTTP报文中,如何判断一个请求/响应体结束呢?通常有2个方案:
content-length
:这种方案是在请求/响应体报文上增加一个content-length
请求头,值是响应体的字节数。如果浏览器在接收到content-Length
单位个字节后,就会视为本次请求完毕。Transfer-Encoding: chunked
:这个方案代表开启了不定长度的分块传输。我们需要通过发送终结符来告诉浏览器请求体结束。而Suspense SSR
正是利用这个实现,将Suspense
渲染后的结果一点点推送过去。其中
Transfer-Encoding: chunked
的优先级要大于content-length
。ps:不过判断数据块结束最严谨的方式,是提前计算好长度,而不是使用终结符。不过在绝大多数场景,长度还是能够被确定计算的。接下来我们做个实验,来看一下分块传输在抓包工具下的现象。
实验
我们用
express
启动一个服务,来模拟一下分块传输的逻辑。代码逻辑大致为:html
整体骨架,包括html
标签,编码格式,body
标签。在这个情况下,我们可以模拟
Suspense SSR
在异步情况下推送结果的原理实现。我们打开
Wireshark
,启动对服务的监控,然后刷新页面开始请求可以看到,当我们开始请求时,首先服务端先给我们推送了请求头、body、html标签等信息。其中,
Transfer-Encoding: chunked
在此刻就已经被确定。随后的每一秒,都会捕获到一条TCP数据包和其ack确认消息。TCP数据包是响应内容,ack是浏览器发回去的确认报文,刚好10对。
TCP数据包中,包含着我们每次推送的信息:
我们也可以在维基百科中验证到这个逻辑,毕竟这就是规范定义的 🤗
在经历10次的推送后,最终会产生一个HTTP响应报文。这个报文里面综合了前面的TCP数据包块。在所有数据包的末尾,会填充一个0、空白单行与0x0d,0x0a,代表响应体的结束:
Suspense SSR
就通过这样的方式,将每次的渲染结果和替换函数推送到前端。最后
React
费这么大力气,就为了适配Suspense
吗?显然不是。如果我们再一次站在当年fiber架构出现的时刻看这个问题,就再也不奇怪了。原有的
fiber
架构,花大力气把组件树从树改为链表,其目的就是为了链表能够在遍历时可以打断。而树的递归遍历,只能一次性从根节点遍历到叶子结点,中间无法暂停。如果遍历可以中断,中断后我们就可以借助浏览器的调度能力,看看我们的遍历时间会不会影响浏览器的渲染。如果影响,那就等下一次调度的时候再继续渲染。
但服务端是没有浏览器的调度API的。而服务端渲染又是CPU密集计算型应用,每次渲染一次非常耗时且占资源。
Suspense SSR
则借助适配Suspense
这个理由,将Suspense
的异步渲染推进异步队列,在等待异步渲染结果之前,此时我们的主线程还可以让出时间来处理其他的渲染请求,提升可处理的渲染任务数量。 这个做法让体验和性能一举两得。快去升级尝试吧!
参考资料
The text was updated successfully, but these errors were encountered: