以NextJS为背景谈谈渲染模式的演变

234 阅读16分钟

经典的CSR

对于老Web开发来说CSR可能也绝不是一开始Web开发的写法,而是相对新的写法,不过在2024年这个时间节点认为CSR模式是Web前端开发的经典模式应该不算错。

CSR就是客户端渲染模式,在这种模式下,我们请求一个路由的URL,服务端返回给我们的是一个静态的HTML文件,HTML里有有一个根节点,里面是没有任何用户希望看到的后端数据的,所有后端的数据都是JS中请求到然后更新到根节点下面的DOM中的。

我们以React的CSR应用为例来具体分析一下CSR下整个渲染的流程:

  1. 用户从浏览器访问一个URL

  2. 服务端接收请求,返回一个HTML,HTML里有有一个根节点,还有一堆Script标签

  3. 浏览器把HTML渲染成DOM树,同时标签中的标签的链接是也就是JS资源开始加载,一段时候后浏览器得到结果后浏览器开始解析JS文件

  4. JS中的React代码被执行,状态被初始化

  5. 首轮渲染逻辑被执行,在首轮渲染的结束阶段把含有状态的初始值的HTML插入到2中HTML的根节点上。

  6. effect也就是useEffect中的代码开始执行,这里我们只关注最重要的网路请求部分

  7. 网络请求的结果收到后,开始执行setState开启再一轮的渲染

  8. 这轮渲染会生成一个新的带有后端状态的Fiber树,React会把新的Fiber树和上一轮的进行比对,也就是diff,找到哪里需要DOM删除,增加或者更新,然后把这些DOM操作押入到任务列表中

  9. 渲染结束阶段,会实际执行列表中的所有DOM操作,实现了把后端数据渲染到页面上

CSR最为最经典的模式,实现了前后端分离,让职责更加清晰,甚至前端工程师这个工种可能都是随着CSR成为主流才发展壮大的。

不过CSR被诟病的点就是首屏慢,前端资源加载好后才发起网络请求获取数据,假设JS资源体积大网络慢的话,首屏时间也会很慢。 另外这样也必须先渲染一遍默认值然后才能切换为后端的数据值,这个过程如果从全局的视角来看是不必要的并且还可能会有闪烁。

水合方案下的SSR

基于CSR的上述缺点,在一些对性能敏感的场景里SSR成了非常有效的解决方案。

SSR具体实现有很多,不过核心思路很简单,就是不会像CSR那样返回一个不带后端数据的HTML,而是在请求一个URL的时候,服务端并不会直接返回HTML,而是先会去读取数据,然后把数据放到HTML上之后再把这个HTML返回。

这显然比CSR快了非常多,我们来盘点一下到底都省去了哪些内容:

1 加载JS资源

2 执行首次渲染的代码

3 完成状态的初始化,把默认状态渲染到DOM上

4 执行effect也就是useEffect中的代码,发起网络请求

5 等待网络请求返回结果

6 重渲染,把重要数据渲染到页面上

以上内容SSR通通是不用的,其中加载JS资源,网络请求和DOM操作都是可能比较耗时的操作,这些内容都没有,速度肯定是快得多了,但这样就OK了吗?当然不是,当前阶段还远远不是完整的SSR解决方案。

注意我们的HTML是服务端生成的,服务端是没有DOM一说的,所以你应该明确一点,服务端返回的HTML就是一个文本,浏览器接受到之后会给这个文本转化为DOM,数据就能被用户看见了,不过这里的DOM上显然是不会有任何事件的,因为这个DOM是根据一段文本转化而来的,而事件必须要浏览器中的JS才能绑定。

对于现代的网页,绝大部份内容都需要交互,所以我们必须有一个机制,给干瘪的只能看不能用的HTML上赋予状态和事件,这就是水合作用。

水合作用具体是如何实现的呢?我们以NextJS为例进行讲解。

首先我们需要知道NextJS中要如何开启SSR,实际上非常简单,在根组件文件里导出getServerSideProps就可以开启了,只不过要注意一点就是NextJS 13版本之后,只能在旧的page route模式下开启。另外你只能在根组件里导出getServerSideProps函数。

getServerSideProps这个函数的作用就是服务端在接收到访问请求之后就会执行这个函数来获取后端数据,这个数据获取之后服务端会执行组件函数,得到整个组件树,并且把jsx转化为html文本返回。

这里因为组件是会在服务端被执行的,所以我们不能在函数体里的任何浏览器环境的代码,否则都会导致报错。

然后我们就说到水合作用了,这里我必须承认我其实并不清楚水合的细节,但大概的流程是这样应该是一个非常合理的推测:

1 HTML返回,浏览器完成解析构建出DOM树,用户可以看见,同时JS也开始加载

2 浏览器端执行JS文件中的首次渲染的代码

3 渲染结束阶段,会比对现在的DOM(也就是服务端返回的HTML生成的DOM)和本次渲染的结果,找到对应的DOM,在原来的DOM上完成事件绑定,这样就从只能看不能用的DOM变成了可以交互的DOM了

注意这里很关键的一点是,水合是基于服务端返回的HTML渲染出来的DOM树的。读者可能觉得是废话,不然为什么叫水合?其实如果只是想能交互的话,我们完全可以抛弃服务端的DOM树,在首次渲染后把新的树取代旧的就好,这样其实更简单。

之所以没有这样做可能一方面是删除和重新插入整个树可能有性能问题,另一方面可能会有闪烁造成用户体验问题。

不过选择更好的比对方案后我们要明白的一点是,找到对应DOM然后赋予状态和事件的路线的话实际上是有一点难度的,我们必须一一找好对应关系,保证对应关系不出错,这也引出了水合作用的一个缺点:要求服务端生成的DOM和初次渲染出来的结果必须是完全一致的。

比如如果我在jsx中写Date.now()的话,因为服务端跑的那个时间点和浏览器跑的那个时间点是不一样的,这样显然就会造成结果不一致,导致经典的水合错误。

这个要求完全可以理解,因为如果不一致的话可能会导致找不到新DOM和旧DOM的对应关系,那还怎么水合?虽然这个时间的例子中我不认为会导致找不到对应关系,但是从框架的开发者的角度来说,他很难判断什么情况下会导致找不到对应关系,或者说通过编程判断一个差别是否可能有会问题是危险的,所以他只能选择让用户保证结果一致。

不过这样就加重用户的心智负担和开发成本,处理这一点一般需要我们额外声明一个state然后在effect里执行浏览器限定的代码然后更新state,因为effect中的代码一定只是在浏览器中执行的,并且不会影响首次渲染的结果。但是这样也会导致代码变脏,流程也会变得不一样:多了一个仅仅是为了SSR才强行创造出来的初始状态。

即使除去了上面的缺点,水合作用还有其他问题,比如首个响应时间慢,因为首屏虽然很快,但是接下来要水合之后才能响应。

好了,我们总结一下水合方案下SSR的特点:

  1. 首屏快,注意首屏是指后端数据渲染出来的时间而不是前端状态的默认值渲染出来的时间

  2. 不能在函数体里任何浏览器API等服务端没有的代码,

  3. 即使代码服务端可以执行,还要考虑是否会造成水合错误

  4. 首个响应时间慢

  5. 只有请求一个页面的时候才是SSR,也就是说getServerSideProps一次页面请求只会触发一次,想要更新这里的数据只能在effect里自己写另一个请求数据的代码,这导致如果需要更新的话,我们需要对同一个请求客户端和服务端搞两套。

  6. 只有通过页面访问,页面跳转和页面刷新才能触发SSR,通过路由跳转的话并不会走SSR加水合的流程

如果你仔细看,可以看到我总结的是水合方案下SSR的特点,这说明了服务端渲染和水合并不是强绑定的(虽然通常提到SSR确实是在说水合方案下的),但我们回想一下,水合其实不就是为了让服务端渲染的HTML可以交互吗?我们是有其他方案可以实现这一点的,这就是最新的RSC范式。

RSC范式

为什么说服务端渲染和客户端渲染的时候直接说SSR和CSR,而RSC时说RSC范式呢?因为RSC的本意是React Sever Component,严谨来说指的是一个组件。不过其实真正应用RSC的时候,它并不是说其他不变,某个组件替换成RSC这种粒度的东西,实际上RSC是和SSR和CSR一样,是一种范式上的改变,需要用不同的思维模型来思考。

那么RSC是什么范式呢?就是可以实现几乎和SSR一样的效果,但是却不需要水合的范式。

在新版本NextJS的app route模式下会自动开启,并且废弃了SSR模式。 那么RSC的原理是怎样的呢?它是怎么不需要水合就能实现类似的效果的呢?

实际上RSC和SSR非常类似,SRR获取数据的部分是getServerSideProps写在一个页面的最顶层组件的文件里,而RSC也同样是写在一个页面组件树的顶部。如果你希望用RSC,那么最顶层一定是一个RSC,但RSC不一定仅仅在最顶层,因为RSC既可以以RSC做子组件又可以以客户端组件为子组件。

RSC本身就是一个异步函数,它不像SSR那样通过getServerSideProps函数获取数据,而是在RSC内部调用fetch或者直接连接数据等方式来获取数据并返回一个带有数据的jsx。 这个带有服务端获取到的数据的jsx最终会在访问这个路由的时候以HTML的形态直接返回。并且这部分HTML并不会像SSR模式下那样最终被水合变得可以交互。 读到这里可能就要问了,既然不能交互怎么能说和SSR效果几乎一样呢?交互在现在前端页面里必须的,只有极少情况比如文本可能不需要交互,仅仅是为了极少数情况就发明RSC吗?

显然RSC并非如此不便之物,RSC的一个很常见的用法是获取完数据之后,对于不需要交互的内容直接在RSC里返回,需要交互的数据会作为props传入客户端渲染的子组件,而客户端渲染的子组件天然就是可交互的,根本不需要水合也可以可以交互。

RSC的思路其实是非常简单的,我们知道本来传统的CSR,我们访问一个路由就是返回一个空的HTML,上面有一个根节点,然后我们的JS会遍历整个React组件树,然后把jsx对应的DOM插入到根节点就好了。 有了RSC之后情况会变成这样:

  1. 访问路由的时候,服务端并不是直接返回一个空的HTML,而是要执行RSC等待这里的操作和计算,得到包含了部分数据的HTML

  2. 第一步中的部分数据可能会直接写在RSC的jsx直接体现到HTML上,另一些数据会传给客户端渲染的子组件

  3. 子组件用2中的数据作为初始值进行首次渲染,并且渲染结果会渲染1中的HTML的对应的节点上。

后续就和普通的客户端渲染没什么区别了。 我们看到,RSC的优点相当明显:

  1. 避免水合,把服务端渲染的部分限制在了组件树的顶层,这大大减少了开发的心智负担,避免了无意义的避免水合错误的代码修改。

  2. 缩短可响应时间,SSR架构下首屏到首个可响应时间大概是首次渲染时间+水合时间,而RSC模式下首屏到首个可响应时间也可以说是0

RSC的首屏的分析其实比较复杂,因为后端数据取到后可能会被分为两部分,一部分是不需要交互的就直接在RSC中渲染了,而需要交互的会传给客户端渲染的组件进行渲染。而通常来说之后需要交互的才是重要数据,更应该作为首屏的指标,那这部分因为是在客户端渲染的,所以从可以看到开始就是可响应的。所以RSC范式下可以认为和CSR一样没有首屏和首个可响应的时间差。

但当然RSC不可能是完全没有缺点的,可以看到真正重要的数据为了可以交互,通常是不在首屏的HTML里,而是从RSC传入到客户端渲染的组件中,在首次渲染后才加入到DOM树中的,这样至少有两个缺点:

  1. 重要内容的展示上,不如首屏就展示出来的SSR模式快,多了一个首次渲染的时间

  2. 爬虫只看首屏HTML,因此重要内容都是客户端渲染的RSC模式在SEO上肯定是劣于SSR的

不过这两个缺点并不是很重要,RSC模式展示重要内容虽然比SSR要慢一点,不过和CSR比较一下我们就能明白是即比CSR快又比CSR效果好的,因为CSR必须要渲染一次甚至多次网络请求到结果前的初始值,而RSC模式是在首次渲染就有数据了,没有这个渲染不必要的初始值的过程,速度快了而且不会有闪烁。

而SEO的部分,虽然通常确实不如水合方案下的SSR的范围大,不过一方面我们是可以控制多大范围开启SSR的,另一方面SEO可以通过一些其他手段提升,不局限于所有首页数据都直出。

不过RSC模式和纯CSR相比也并非完胜,有一个还蛮重要的问题是我们可能需要刷新RSC中获取的数据的,但是如果你想直接这么做的话除了刷新页面也就是location.reload之外是没有办法的,但这样我们CSR的状态也就都丢失了,这大概率是不符合需求的。

所以很有可能我们需要在接收RSC数据的客户端组件里,额外再写一个网络请求来刷新这个CSR组件的状态。这样同一组数据,初始值是通过node的写法(可能是直接读数据库也可能调接口)来获取进行初始化,更新时却是在客户端以浏览器环境的写法(一定是调接口)获取并进行更新,这个缺点水合方案下的SSR也有,而CSR中我们用一个网络请求库就完事了,数据一定是在前端处理和获取,因此与CSR相比还是有一定的思维负担和开发负担的。

好了,我们现在对RSC的了解已经基本足够了,可以总结一下关键点:

  1. NextJS 13之后再app route模式下默认开启,反而是客户端组件需要在组件文件里声明use client才能开启,这可以认为是NextJS在推崇RSC范式取代SSR和CSR

  2. RSC永不水合,通过把数据传给客户端渲染的组件来实现可交互,不需要为了避免水合作用让代码变脏

  3. RSC范式下首屏只有RSC自己返回的jsx对应的HTML,重要数据一般为了可交互是会传给客户端渲染的子组件的,因此重要数据其实不是直出的,而是在第一次渲染结束后可见同时可交互的

  4. 重要数据渲染出来的时间通常来说是慢于SSR,快于CSR

  5. RSC可以嵌套RSC和客户端渲染的组件,但是客户端渲染的组件不能嵌套RSC,也就是说RSC一定在组件树的顶层

  6. RSC无法在不刷新页面的前提下重新执行,如果RSC中请求的数据要更新,只能在子组件里自己发送网络请求完成

  7. 区分了服务端组件和客户端组件,开发者知道一个组件内的代码在哪个环境执行,相比水合SSR心智负担降低

总结

RSC范式作为最新推出的渲染方案,有显著的优点也有一些不完美的地方,不过总体而言暂时没有发现很大的缺点,个人认为产出是要高于代价的。如果有首屏的性能需要,或者已经使用NextJS的话,尤其是在使用水合方案下的SSR的话是应该考虑升级到RSC范式的。