React渲染方式对比
前段时间对 React 的几种渲染方式(CSR/SSR/SSG/RSC)非常感兴趣。尽管从工程角度来看,使用 Next.js 等框架可以避免重复造轮子,但框架的实现复杂且不透明——为了真正理解这些渲染方式背后的原理,我用 webpack 从零搭建了一个 React CSR/SSR/SSG/RSC 最小演示项目。本文将介绍其中的一些关键点,具体代码详见:https://github.com/tomzhu1024/react-playground。
CSR (Client-Side Rendering)
CSR(客户端渲染)是最早出现、也是最常用的渲染方式。
构建时:诸如 webpack 的打包器会将 React 组件、样式、逻辑以及外部依赖、React 运行时全部塞进 .js 文件中(有时 .css 文件也会外置)。同时,它会生成一个 .html 文件,其中只有一个空的 <div> 挂载点和对 .js 文件(有时也会有 .css 文件)的引用。
运行时:诸如 Nginx 的 HTTP 服务器负责向浏览器提供这些静态文件。浏览器会先渲染 .html 文件,得到一个空白的页面;再运行 .js 文件,使用 react-dom/client 的 createRoot() 动态修改 DOM、注册事件监听器,将应用呈现出来。
如果存在 <Suspense> 边界,浏览器将先渲染 fallback 内容,待数据或懒加载组件就绪后替换为实际内容。
SSR (Server-Side Rendering)
CSR 需要在浏览器上动态地修改 DOM。这是一笔不小的性能开销。SSR(服务端渲染)的出现便是为了解决 CSR 的这个痛点。
构建时:SSR 也需要用到打包器。不同的是,相比于 CSR 只有一份客户端,SSR 有服务端和客户端。
运行时:服务端在收到请求时,使用 react-dom/server 的 renderToPipeableStream() 渲染出包含 DOM 结构的 HTML 流,并在其中引用客户端的 .js 文件(以静态文件形式提供),再传输给浏览器。由于 HTML 流中已经包含了 DOM 结构,浏览器不再需要像 CSR 那样从零开始构建,只需要运行客户端的 .js 文件,使用 react-dom/client 的 hydrateRoot() 注册事件监听器即可。由于页面的布局和样式在一开始就已经就绪,客户端只是让应用变得“可交互”了,这个过程被称为“水合(hydration)”。
虽然客户端不再需要从零开始构建 DOM 结构了,它还是需要在水合时对服务端返回的 DOM 结构进行验证的。如果有误,客户端会修改 DOM 结构。这就意味着服务端和客户端都需要包含完整的 React 组件代码。
另外,在服务端返回的 HTML 流中,会包含 <!-- --> 这样的文本分隔注释。它们的作用是防止文本节点合并、影响水合时的验证。举个例子,如果在 React 组件代码中写:
1 | |
<p> 包含三个子节点:纯文本 Hello 、表达式 {name} 和纯文本 !。如果不加注释,服务端输出的是:
1 | |
客户端期望看到三个节点,但却只看到了一个,这就会产生一个水合错误。而加上注释后:
1 | |
客户端就能在水合时将三个节点逐一对应。
如果存在 <Suspense> 边界,不同 API 的行为各不相同:
renderToString():不解析,只渲染并发送 fallbackrenderToStaticMarkup():不解析,只渲染并发送 fallback;不生成水合标记,不支持客户端水合renderToPipeableStream()和renderToReadableStream():流式传输,先发送 fallback,待数据或懒加载组件就绪后发送实际内容
流式传输
流式传输的实现很有意思。举个例子,如果在 React 组件代码中写:
1 | |
服务端会先发送类似这样的 HTML:
1 | |
<!--$?--> 和 <!--/$--> 标记了一个尚未解析完毕的 Suspense 边界。注意此时还没有 </body>,</html> 等闭合标签。不过,浏览器都支持渐进式解析 HTML,收到一部分就会渲染一部分,并不会受此影响。此时,显示的是 fallback 内容。等到实际内容就绪了,服务端会在流里继续添加类似这样的 HTML:
1 | |
<div hidden id="S:0"> 包含实际内容,但 hidden 属性使其不显示。$RC 是 React 运行时中的一个函数,它会:
- 通过 ID 找到
<template id="B:0">占位符和<div hidden id="S:0"> - 通过
<!--$?-->和<!--/$-->注释定位并删除占位符和 fallback 内容 - 将
<div hidden>中的实际内容插入到正确位置 - 删除
<div hidden> - 将
<!--$?-->改为<!--$-->,表示该 Suspense 边界已经解析完毕
整个过程是自包含的,服务端每流式传输一点,浏览器就替换一点——唯一需要的就是 React 运行时中的 $RC() 函数,而它是客户端 .js 文件的一部分,已经在最开始就被发送并加载了。
SSG (Static Site Generation)
SSG(静态站点生成)和 SSR 非常类似。唯一的区别就是 HTML 的生成从运行时提前到了构建时。
构建时:打包器除了生成客户端 .js 文件之外,还会使用 react-dom/static 的 prerenderToNodeStream()(与其相似的一个 API 是 prerender())生成 HTML 并保存在 .html 文件中。HTML 中会包含对客户端 .js 文件的引用。如果存在 <Suspense> 边界,在构建时就会被解析。
运行时:诸如 Nginx 的 HTTP 服务器负责向浏览器提供这些静态文件。浏览器会先渲染 .html 文件,再运行 .js 文件,使用 react-dom/client 的 hydrateRoot() 验证 DOM、注册事件监听器,也就是进行水合。
ISG (Incremental Static Generation)
在 SSR 中,服务端会为每个请求重新渲染 DOM,虽然保证了数据的实时性,却也带来了不必要的重复性能开销;而 SSG 在构建时一次性预渲染所有页面,性能虽好,却无法反映数据的实时变化。ISG(增量静态生成)正是两者的折中:它让服务端定期重新生成页面的 HTML,在数据实时性和性能之间取得了一个平衡。
CSR、SSG 理论上可以只有客户端、没有服务端,而 SSR、ISG 需要同时有客户端和服务端。
其实 SSG 和 ISG 不一定非要用 react-dom/static 的 API,也可以用 react-dom/server 的 API。Next.js 就是用 react-dom/server 来实现 SSG 和 ISR 的。
RSC (React Server Component)
无论是 SSR、SSG 还是 ISG,客户端始终需要一份完整的 React 组件代码来完成水合。随着应用规模增长,这个体积问题会越来越严重。
RSC(React 服务端组件)的出现便是为了解决这个痛点。RSC 同样有服务端和客户端。RSC 将组件分成了三个类别:Client Components、Server Components 和 Server Actions。构建时,打包器会用到诸如 react-server-dom-webpack/plugin 的插件,来确保 Client Components 只存在于客户端中、Server Components 和 Server Actions 只存在于服务端中。运行时,服务端不再向浏览器传输 HTML 流,而是通过私有的 Flight 协议流传输组件的序列化结果——服务端使用 react-server-dom-webpack/server.node 的 renderToPipeableStream() 渲染并传输 Flight 流;客户端使用 react-server-dom-webpack/client 的 createFromFetch() 和 react-dom/client 的 createRoot() 消费 Flight 流并重建组件树。
Client Components
那些通过 CSR、SSR、SSG、ISG 方式渲染的组件,它们的 DOM 可以在服务端或是客户端生成,但是它们一定有一些 .js 文件跑在浏览器上。在 RSC 体系中,这些组件被称为 Client Components。
在一个 JSX 文件的开头加入 "use client" 来表明这个文件中的所有组件均为 Client Components。这些组件的具体代码只存在于客户端;在服务端和 Flight 流中,它们都只是一个模块引用,没有具体的实现。客户端根据模块引用加载对应的代码。
Server Components
Server Components 和 Client Components 相反,它们在浏览器中不执行任何逻辑,是完全静态的。
它们不需要任何特殊声明,一个组件默认就是 Server Component。Server Components 的代码只存在于服务端,永远不会被发送到客户端。它们在服务端被渲染并序列化在 Flight 流中,客户端反序列化并展示。
Server Components 可以是 async 函数,用 await 来获取数据。
值得一提的是,Server Components 中可以包含 Client Components;而 Client Components 中不能包含 Server Components,Server Components 只能作为其 children。
Server Actions
Client Components 可以通过 Server Components 来获取数据,但它要如何修改数据呢?Server Actions 便是为了解决这个问题而出现的。Server Actions 本质是一些只在服务端存在、执行的函数。在开发时,Client Components 直接调用 Server Actions 函数;构建后,这些函数引用会被替换为向服务端发送的 HTTP 请求——本质上是一种 RPC 机制。在一个函数的开头加入 "use server" 即可将其声明为 Server Action。