万字长文:深度解析React渲染技术演进之路
前言
还记得你第一次接触React的时候吗?
对我来说,那是2015年的夏天。那个时代仿佛还是Angular的天下,Vue还是个默默无闻的新秀,而React则刚刚开始崭露头角,虽然距离它第一个版本已经过去了2年。那个时代是前端繁荣发展的时代,React推广并创新了声明式范式、 虚拟DOM 、组件化编程、 JSX 语法、 单向数据流 、 SSR 渲染 等概念,引起了业界极大的关注和讨论。
然而,在过去的这么多年里,React始终保持着创新的思路引领前端发展,React一开始面临的痛点就是在Facebook广告业务的性能问题,所以它在渲染模式上一直在演进,从最初的客户端渲染 ( CSR ) 开始,到服务器端渲染( SSR ) 成为热议的话题,接着是静态站点生成 ( SSG ) 的兴起,随后增量静态再生( ISR ) 带来了新的可能性,而现在,部分预渲染(PPR) 又为我们开辟了新的前沿。
最近几年经济下行,AI兴起,大家对React关注度可能也不如之前那么高了。值得注意的是,React生态系统正在经历一些有趣的变化。我们看到一些React核心团队的成员选择加入Vercel等公司,在新的平台上继续推动前端技术的发展。同时,随着React 19的开发,我们也观察到React与Next.js之间的联系越来越紧密。
在这样的背景下,我决定详细回顾和梳理一下 React 渲染技术的演变历程。在这篇文章中,我将尽量用通俗易懂的语言,分享我对这些技术的理解和实践经验。无论你是刚入门的新手,还是经验丰富的老兵,我都希望这篇文章能为你提供一些新的见解和启发。
单一渲染阶段
CSR(Client Side Rendering)——前端最早的渲染模式
回顾最原始的Web渲染,就是 CSR(Client Side Rendering),它是指在浏览器端使用JavaScript来渲染页面内容的技术。具体来说,CSR的工作流程如下:
-
客户端请求网页,服务器首先发送一个最基本的HTML文件到浏览器。这个HTML文件通常只包含必要的结构和一个指向JavaScript文件的链接。这个时候用户看到的是无内容的页面。
-
浏览器下载并执行这个JavaScript文件。这个文件通常包含了React库和你的应用程序代码。
-
React在浏览器中运行,动态创建 DOM 元素并将它们插入到页面中。这个过程通常发生在一个名为"root"的DOM节点内。此时真正将 React组件 渲染到页面上。
-
如果需要额外的数据,JavaScript代码会发起API请求,获取数据后再次更新DOM。基本上依赖后台数据的组件,在这个时候才会真正渲染完成。
说CSR是前端最早的渲染模式,其实不太严谨,因为CSR已经可以认为是前后端分离那时候的事情了,我们严谨地捋一下这个演变过程:
-
静态HTML阶段:最初的Web页面就是纯静态的HTML文件,所有内容都是预先编写好的。没有JavaScript,这是最初始互联网超链接的形态。
-
动态HTML阶段:这个阶段前端有了一些基本的动画交互,并且数据也不仅仅是静态的超链接,可能是服务器端返回的一些数据,大部分是使用PHP 、JSP、 ASP这些技术在服务端生成HTML内容。其实这才是最早的服务器端渲染(Server-Side Rendering),我们姑且称之为传统 SSR,和现代SSR区分开来。
-
AJAX( Asynchronous JavaScript and XML )阶段: 约在2006年左右,AJAX技术在谷歌的带领开始流行,允许在不刷新整个页面的情况下更新部分内容。这可以被视为向CSR过渡的一个重要步骤。
-
客户端渲染 ( CSR ): 随着JavaScript框架(如AngularJS、React、Vue等)的出现和普及,CSR成为主流的前端渲染模式。这标志着前后端分离的时代真正到来。
因此,更准确地说,CSR 是在前后端分离范式下,前端主导的渲染模式中最早被广泛采用的方式。 它代表了Web开发思路的一个重大转变,即将大部分渲染工作从服务器转移到了客户端。
当然前后端分离还有更复杂的因素,比如前后端的专业化分工需要,部署与 CDN 分发的需要, SPA 单页应用 的发展等等,这些不是本篇文章讨论的重点,先不展开。
这些过于较真的历史对本文也不太重要,因此为了简单起见,我们姑且就认为CSR是一个最初的起点。
那CSR有什么优势呢?
- 更好的用户交互体验,特别是对于单页应用(SPA),路由的切换没有白屏。
- 减少服务器负载,因为大部分渲染工作都在客户端完成,服务端只需要提供API就好。
- 前后端分离,专业化分工,极大地提高了开发效率和代码的可维护性。
然而,CSR也并非完美无缺。它也存在一些局限性,比如:
- 初始加载时间可能较长,特别是对于大型应用。
- 搜索引擎优化(SEO)可能会受到影响,因为搜索引擎爬虫可能无法看到动态生成的内容。
- 在低性能设备上可能表现不佳(需要执行大量JavaScript)。
因为这些限制,又促使了后续其他渲染模式继续迭代(SSR、SSG)等。
SSR (Server-Side Rendering)——历史的倒车?
由于CSR的一些局限,后续前端的优化又回到了服务端渲染的模式——SSR(Server-Side Rendering)。
乍一看,SSR似乎是一种回归到早期动态HTML时代的做法,但实际上不是,我们先来看看现代SSR的工作流程:
- 当用户请求页面时,服务器会执行JavaScript代码,生成完整的HTML内容。
- 服务器将这个预渲染的HTML发送给浏览器,用户可以立即看到页面内容,而不是空白页面。
- 同时,服务器还会发送必要的JavaScript代码。
- 浏览器加载这些JavaScript后,应用会在客户端hydration,接管页面的交互和后续的动态渲染。
React SSR示例
// server.js
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import App from './App';
const app = express();
app.get('/', (req, res) => {
const html = ReactDOMServer.renderToString(<App />);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>React SSR</title>
</head>
<body>
<div id="root">${html}</div>
<script src="/client.js"></script>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log('Server is running on http://localhost:3000');
});
// client.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.hydrate(
<App />,
document.getElementById('root')
);
// App.js
import React from 'react';
const App = () => {
return <div>Hello, React SSR!</div>;
};
export default App;
- 服务器使用
ReactDOMServer.renderToString()
渲染App组件,生成静态HTML。 - 客户端接收并显示这个HTML。
- 客户端加载React代码。
ReactDOM.hydrate()
被调用,React开始hydrate过程:
- React遍历DOM,将事件监听器(如按钮的onClick)附加到相应的元素上。
- React重建虚拟DOM树,确保它与服务器渲染的内容匹配。
- React初始化组件的状态(如
count
状态)。
- 完成后,应用变为完全可交互状态
我们可以看到,虽然都是服务端返回HTML,但和最初始的动态HTML阶段有显著的不同:
-
hydration过程:现代SSR的一个核心特性是hydration。在客户端接收到服务器渲染的HTML后,JavaScript会"水合"这些静态内容,使其变为可交互的动态应用。
-
组件化架构:和传统的服务端返回HTML不同的是,无论页面是不是在服务端渲染,都是采用同样的基于组件的开发范式,服务端渲染的是组件树,这是和模板时代有本质的区别。
-
状态管理:现代SSR框架通常提供了复杂的状态管理解决方案,可以在服务器端预填充状态,然后在客户端无缝接管。这是之前服务端拼接HTML所无法做到的。
所以,看似是回到了服务端渲染的模式,但是和之前已经有本质的区别了,前端完全掌控了整个渲染的生命周期,同构化的JavaScript,可以说是结合了前后端分离的优势以及传统服务端渲染的优势。
但是,SSR并不是银弹,它也有它的弊端:
- 增加服务器负载, 最明显的就是每个请求都要服务器来执行完整的渲染流程,增加了服务端成本 。
- 开发复杂性提高, 由于同一套代码保证在客户端和服务端的运行,有些生命周期、浏览器API、调试都变得更加的困难,且容易出错。
- 状态同步问题多,服务端和客户端在hydrate状态不一致的情况,容易导致很多莫名其妙的Bug产生。
SSG(Static Site Generation)——静态生成页面
如果我们想同时兼具CSR和SSR的优势呢?比如既不需要服务器负载和维护,又可以做到友好的 SEO,于是SSG就诞生了。
SSG是一种在构建时(而不是在请求时)生成完整的静态HTML页面的技术。
- 在构建过程中,SSG工具会遍历所有的页面和路由。
- 对每个页面,它会获取必要的数据(从API、数据库、 文件系统等)。
- 使用这些数据生成完整的HTML页面。
- 生成的静态HTML文件被保存并可以直接部署到 CDN或静态文件服务器。
这种方式显然平衡了CSR和SSR的劣势,兼顾解决了SEO和服务器的问题,但是它的场景是十分有限的:
- 不适合频繁更新的动态内容。
- 构建时间可能较长,特别是对于大型站点。
- 无法处理用户特定的 动态内容。
Next.js SSG示例
// pages/posts/[id].js
import React from 'react';
export default function Post({ post }) {
return (
<div><h1>{post.title}</h1><p>{post.content}</p></div>
);
}
export async function getStaticPaths() {
// 这里通常会从API或数据库获取所有可能的路径
const paths = [
{ params: { id: '1' } },
{ params: { id: '2' } },
{ params: { id: '3' } },
];
return { paths, fallback: false };
}
export async function getStaticProps({ params }) {
// 这里通常会根据id从API或数据库获取具体的文章数据
const post = {
id: params.id,
title: `Post ${params.id}`,
content: `This is the content of post ${params.id}`,
};
return { props: { post } };
}
npm run build
将在 .next/static
目录下生成静态 HTML 文件。
单一阶段总结
特性 | CSR ( 客户端渲染 ) | SSR (服务器端渲染) | SSG ( 静态站点生成 ) |
---|---|---|---|
渲染位置 | 浏览器 | 服务器 | 构建时 |
初始加载速度 | 较慢 | 快 | 快 |
SEO 友好度 | 较差 | 好 | 好 |
服务器负载 | 低 | 高 | 低 |
开发复杂度 | 低 | 高 | 低 |
动态内容 | 支持 | 支持 | 支持 |
适用场景 | 高交互性的Web应用 | 需要SEO的动态内容网站 | 内容较少变动的网站 |
主要优点 | 富客户端体验,开发简单 | 更快的首次内容呈现,更好的SEO | 极快的加载速度,最佳SEO,低服务器成本 |
主要缺点 | 首次加载慢,SEO不友好 | 服务器负载高,复杂度增加 不适合频繁更新的动态内容 |
混合渲染阶段
前面讲到的CSR、SSR、SSG其实都是以整个HTML渲染的角度去说的,也就是说,对于一个页面,我们只能采取一种模式渲染,比如客户端渲染、服务端渲染或是提前静态生成。
但是实际场景中,我们面临更复杂的页面形式,往往单一渲染方式并不是最优方案。可能在组件或内容功能维度有不同的渲染诉求:
- 静态内容(如页面标题、导航栏等固定不变的内容)适合SSG
- 动态且和用户相关的内容(如推荐列表)适合SSR
- 高度交互和个性化的内容(如用户仪表板)适合CSR
有没有一种方式是可以进行自由组合进行渲染呢?于是前端来到了混合渲染的探索阶段。
然而,混合渲染并没有那么简单,以React为例,我们看看它在这方面做了什么样的努力。
基石:React的发展
React Fiber架构的演进
Fiber架构大家应该都听说过,实际上,在Fiber架构之前,React的渲染就是一个递归 的过程,当一个组件需要更新时,React会从该组件开始,递归地遍历整个组件树,处理每一个树的节点。
这个递归会丧失掉很多灵活性,首先是性能问题,可能会占用主线程过多的时间,导致事件循环被阻塞;其次是没法中断,一旦开始就必须完成整个组件树的渲染。
于是React在16版本中开始引入新的Fiber架构,支持更好的增量渲染、优先级管理、错误处理和并发模式。
Fiber 允许 React 执行增量渲染:能够将渲染工作分割成小的单元,然后分批执行。
Fiber 的渲染过程分为两个主要阶段:
- Reconciliation 阶段(也称为 "render" 或 "差异化" 阶段):
- 可以被中断。
- 执行一些比如调用生命周期方法、计算差异等工作。
- Commit 阶段:
- 不可被中断。
- 执行实际的 DOM 更新。
Fiber 架构的优势:
- 更好的性能:通过将工作分解成小单元,避免长时间阻塞主线程。
- 更高的灵活性:可以根据优先级调度和执行渲染工作。
- 改善用户体验:通过优先处理重要更新,提高应用的响应性。
- 更强大的错误处理:提供了更好的错误边界功能。
为接下来讲的一些高级特性带来了可能。
React Supense
Suspense是React 16.6引入的特性,它允许组件在渲染之前"等待"某些操作。最早的实现是配合lazy一起使用的,主要是为了解决代码分割(code splitting)的问题:
import React, { Suspense } from 'react';
const LazyComponent = React.lazy(() => import('./LazyComponent'));
function MyComponent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
);
}
在这个例子中:
- LazyComponent是通过React.lazy()动态导入的。
- Suspense包裹了LazyComponent,提供了一个fallback UI(在这里是一个"Loading..."的div)。
- 当LazyComponent正在加载时,会显示fallback UI。
- 一旦LazyComponent加载完成,fallback UI会被替换为实际的组件内容。
- 实际上LazyComponent代码并不会打包进主包,因为已经是异步加载了的。
它背后的原理可以简单理解为:
-
抛出 Promise:
当一个组件需要等待某些数据时,它会抛出一个 Promise。这个 Promise 代表了正在进行的异步操作。 -
捕获 Promise:
Suspense 组件会捕获这个 Promise,并渲染指定的 fallback 内容。 -
恢复渲染:
当 Promise resolve 后,React 会重新尝试渲染,这时数据已经可用,组件可以正常渲染。
再详细一点:
- 当遇到 Suspense 边界时,React 会创建一个 特殊的Fiber 节点。
- 如果子树抛出 Promise,React 会将这个 Promise 附加到 Suspense Fiber 节点上。
- React 然后会渲染 fallback 内容,并设置一个回调来在 Promise resolve 时重新渲染。
我们可以将Suspense理解为一个**Boundary,只要代码是异步的,就可以实现 延迟加载 **。
在React 18版本中,Suspense 的功能得到了进一步的扩展和增强,允许在服务器端使用 Suspense 进行组件的异步渲染。这意味着,我们不仅仅可以利用Suspense做到异步组件,还可以利用Suspense获取异步数据(这个case我们在Server Component里面给到) 。
流式渲染(Streaming Rendering)
React 16.0实际上就已经支持Streaming SSR了,引入了ReactDOMServer.renderToNodeStream()
方法。这个方法其实就是将 React 元素树转换为一个 Node.js Readable Stream。 这个流包含了渲染后的 HTML 字符串。
我们可以简单通过伪代码理解一下流式渲染背后的原理:
function renderToNodeStream(element) {
const stream = new Readable();
function process() {
// 渲染下一个组件块
const chunk = renderNextChunk(element);
if (chunk) {
// 如果还有内容,写入流
stream.push(chunk);
// 安排下一个块的处理
setImmediate(process);
} else {
// 渲染完成,结束流
stream.push(null);
}
}
// 开始处理
process();
return stream;
}
- 使用 Node.js 的 Readable 流。
- 渲染过程被分解成多个小任务(通过 setImmediate)。
- 逐步将渲染的内容推送到流中。
实际上的chunk拆分和React Fiber的Schedule策略有关,这里不展开,主要理解就是Streaming API让HTML可以局部地传输到客户端中。
上面只是流式渲染最初始的版本,React 18也特地增强了流式API:
renderToPipeableStream()
:成熟的streaming SSR机制以及Suspense的彻底支持。
客户端:
- 使用
<Suspense>
组件包裹可能需要异步加载的内容 - 在内容加载完成之前显示 fallback UI
举个例子:
import React, { Suspense } from 'react';
import { renderToPipeableStream } from 'react-dom/server';
// 模拟一个异步加载的组件
const AsyncComponent = React.lazy(() => new Promise(resolve => {
setTimeout(() => {
resolve({
default: () => <div>This content was loaded asynchronously!</div>
});
}, 2000);
}));
function App() {
return (
<html><head><title>React Streaming SSR Demo</title></head>
<body><h1>Welcome to React Streaming SSR</h1>
<Suspense fallback={<div>Loading...</div>}>
<AsyncComponent />
</Suspense>
</body></html>
);
}
// 服务器端渲染函数
function serverRender(res) {
const stream = renderToPipeableStream(<App />, {
onShellReady() {
res.statusCode = 200;
res.setHeader('Content-type', 'text/html');
stream.pipe(res);
},
onShellError(error) {
console.error(error);
res.statusCode = 500;
res.send('<!DOCTYPE html><html><body><h1>Something went wrong</h1></body></html>');
},
onAllReady() {
console.log('All content loaded');
}
});
}
export default serverRender;
这个例子展示了如何使用React 18的renderToPipeableStream()
和<Suspense>
组件来实现流式SSR。(这个例子的suspense其实还是异步组件,不是异步数据,在下面我们演示Server Component的场景,它真正地在服务端加载组件数据)
React Server Components (RSC)——混合渲染模式的基座
为什么说RSC是混合渲染模式的基座呢?因为RSC真正让SSR可以不以整个HTML为粒度,而是根据Component维度进行服务端渲染,RSC只在服务端上运行,数据获取和渲染都在服务端,意味着它不需要打包到客户端,从而带来了另一个优势,可以大幅度减小客户端代码的体积。
React怎么做到组件粒度的服务端渲染呢?在服务层面的逻辑是:
服务器渲染 RSC 树,将结果序列化为一种特殊格式(React Server Component Payload)。这个payload是二进制的,当然我们可以简单理解为以下数据结构:
{
"node": "...", // 渲染树
"chunks": [...], // 代码分割的 chunk
"moduleMap": {...}, // 模块映射
"errorMap": {...}, // 错误信息
"metadata": {...} // 元数据
}
客户端在拿到这个payload时候,动态的hydrate到当前的渲染树当中,从而完成客户端的动态更新。
一个RSC看起来是这样的:
// Page.server.js
import db from './db';
async function BlogPost({ id }) {
const post = await db.posts.get(id);
return (
<article><h1>{post.title}</h1><p>{post.content}</p></article>
);
}
export default BlogPost;
这个组件在服务端执行完,实际上已经是拿到了post数据,客户端直接hydrate即可。
注意RSC是无状态的,不能使用 hooks 或保持内部状态。也不能直接添加事件处理程序。
对于RSC,React官网有个很形象的例子:
// bundle.js
import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)
function Page({page}) {
const [content, setContent] = useState('');
// NOTE: loads *after* first page render.
useEffect(() => {
fetch(`/api/content/${page}`).then((data) => {
setContent(data.content);
});
}, [page]);
return <div>{sanitizeHtml(marked(content))}</div>;
}
// api.js
app.get(`/api/content/:page`, async (req, res) => {
const page = req.params.page;
const content = await file.readFile(`${page}.md`);
res.send({content});
});
上面是一个传统的client component的例子,在没有server component的时候,需要在客户端引入将近75K(Gzip) 的包,调用API接口,处理自己的状态,完成渲染。
有了server component后,就不需要在client端加载如此厚重的JS,直接由Server处理完成:
import marked from 'marked'; // Not included in bundle
import sanitizeHtml from 'sanitize-html'; // Not included in bundle
async function Page({page}) {
// NOTE: loads *during* render, when the app is built.
const content = await file.readFile(`${page}.md`);
return <div>{sanitizeHtml(marked(content))}</div>;
}
当然RSC最大的价值还是在于能开启CSR和SSR的混合渲染,借助Suspense,做到CSR和SSR的局部渲染方式:
import { Suspense } from 'react';
import ArticleList from './components/ArticleList';
async function getWeatherData() {
// 模拟从外部 API 获取天气数据
await new Promise(resolve => setTimeout(resolve, 1000));
return { temp: 22, condition: "晴朗" };
}
export default async function WeatherWidget() {
const weather = await getWeatherData();
return (
<div>
<h3>当前天气</h3>
<p>温度: {weather.temp}°C</p>
<p>状况: {weather.condition}</p>
</div>
);
}
export default function Home() {
return (
<div>
<h1>我的博客</h1>
<ArticleList />
<Suspense fallback={<div>加载天气信息...</div>}>
<WeatherWidget />
</Suspense>
</div>
);
}
如上面所示,天气组件是一个RSC(其实Home也是一个RSC),当我们使用Supsense包裹时,意味着通过Streaming SSR的模式,Suspense这个Boundary先不渲染,其他元素先进行流式渲染,所以:
- 首先渲染的是我的博客(h1)+ ArticleList部分(这也是个RSC,没有Suspense意味着会阻塞返回)
- 其次是一个fallback - 加载天气信息
- 当WeatherWidget在服务端渲染完成后,流式推送给客户端进行hydrate
但是,这个例子本质上还是RSC渲染,只不过用Suspense做了个异步,那么CSR渲染还存在吗?答案是存在,需要标记为“use client”,然而这个标记并不简单,接下来详细聊聊。
令人迷惑的"use client"
当我们使用RSC时,它是不允许浏览器的运行API的(比如click事件、hook、state等),如果要用这些特性,需要在文件首行标记“use client”
,这样在构建的过程中,就会bundle到js里面交给浏览器去执行。
但实际上,一个令人惊讶的事实是,标记为"use client"
的组件依然会在服务端运行。这是这个指令最令人迷惑的地方,这个问题Dan本人专门发过一篇解释,可以参见:github.com/reactwg/ser…
简单来说,在RSC出现之前,我们理解的SSR的心智模型是这样的:
在服务端,实际上是调用了类似renderToString的方法,将Component转化为HTML,并且通过构建的方式,将组件的代码打包到main.js中,供浏览器加载,执行JS逻辑。实际上,一个组件有两个输出:
- 输出HTML给浏览器
- 输出main.js(通过构建)被浏览器加载执行
RSC出现后,实际上并未改变这个心智模型,只是在上面又加了一层:
多个RSC实际上会形成一个Server Tree,区分于非RSC组件,这个Server Tree没有任何JS需要在客户端执行(当然微观来看还是有一个updateDOM
的过程,可以忽略),然后RSC的子组件可以是一个客户端组件,这个时候它可以将Props
传给客户端组件(当然这个props
是需要可序列化的)。
那么,我们可以理解为,老的SSR心智,就是标记为"use client"
的这些组件,它在Nextjs这样服务器优先的框架上,依旧是服务端渲染的,它组成了一颗Client Tree进行hydrate,它的代码会被打包到main.js
,由浏览器加载执行:
所以我们可以这样理解,"use client"
会标记一个组件为"客户端组件",这个客户端组件不是"物理意义"上的客户端组件,它不代表是采用CSR渲染,它代表的是这个组件会有一些客户端行为,需要在浏览器执行。
我们也可以理解为,"use client"
这些组件,和我们之前用SSR渲染的组件,它们行为是一致的,而RSC是一种新的模式,它不允许写客户端代码,仅在服务端进行执行。
那么CSR呢?如果从微观角度来说,CSR在Nextjs这种框架里已经不存在了,从宏观角度来说,我们认为标记"use client"
,在useEffect中加载数据并渲染的组件,可以称之为CSR的组件。
由于RSC的各种优势,新的React范式推荐自顶向下应该尽可能采用RSC,只有在叶子结点,必要时才用客户端组件(标记为"use client"
)。
另外,一个组件一旦被标记为client,那么它的子组件也会自动变成client。
更令人迷惑的"use server"
React的另一个指令"use server"
,我们本以为是声明这个组件是服务端组件,然而它是声明Server Action的。Server Action在React中又是另一个概念了,为避免本文过于发散,先不展开Server Action。
只需要记住两点:
"use client"
标记客户端组件,但不代表它不会在服务器执行。"use server"
不是用来标记 服务端组件 , RSC 不用任何标注,它是用来标注Server Action的。
一个综合的例子
这是一个结合RSC和客户端组件综合的例子(来自React官方):
// app.js
import FancyText from './FancyText';
import InspirationGenerator from './InspirationGenerator';
import Copyright from './Copyright';
export default function App() {
return (
<>
<FancyText title text="Get Inspired App" />
<InspirationGenerator>
<Copyright year={2004} />
</InspirationGenerator>
</>
);
}
// FancyText.js
export default function FancyText({title, text}) {
return title
? <h1 className='fancy title'>{text}</h1>
: <h3 className='fancy cursive'>{text}</h3>
}
// InspirationGenerator.js
'use client';
import { useState } from 'react';
import inspirations from './inspirations';
import FancyText from './FancyText';
export default function InspirationGenerator({children}) {
const [index, setIndex] = useState(0);
const quote = inspirations[index];
const next = () => setIndex((index + 1) % inspirations.length);
return (
<>
<p>Your inspirational quote is:</p>
<FancyText text={quote} />
<button onClick={next}>Inspire me again</button>
{children}
</>
);
}
// Copyright.js
export default function Copyright({year}) {
return <p className='small'>©️ {year}</p>;
}
// inspirations.js
export default [
"Don’t let yesterday take up too much of today.” — Will Rogers",
"Ambition is putting a ladder against the sky.",
"A joy that's shared is a joy made double.",
];
它的树形结构如下:
在这个case中:
-
App作为根组件是一个服务器组件,它包含了服务器组件(FancyText)和客户端组件(
InspirationGenerator
)。 -
InspirationGenerator
是一个客户端组件,但它的子组件Copyright是一个服务器组件。一个客户端组件要包含服务端组件,只能通过Children的方式,因为客户端组件无法直接引入服务端组件。
Nextjs的渲染策略是什么?
前面提到过,Nextjs是一个以服务端渲染优先的框架,所以它应该是服务端渲染优先?
错了,准确的说,它是RSC 优先的框架。那么,RSC不就是服务器组件吗?为什么不是服务端渲染优先?
实际上,RSC的设计理念里面,RSC不仅仅可以在服务器上运行,也可以在构建上运行。
严格来说,Nextjs是 构建渲染优先 的框架(也可以理解为SSG优先)。
我们不妨用nextjs写一个hello world,然后执行一下next build,就会看到:
(Static) prerendered as static content
实际上,在构建时,HTML就已经先生成好了,只要开启output,就可以编译出纯静态的HTML。
现在,我们把代码改一改,改成一个RSC的样子:
import { promises as fs } from "fs";
import path from "path";
export default async function Home() {
const data = await fs.readFile(path.join(process.cwd(), "data.json"), "utf8");
return <div>{data}</div>;
}
这已经是一个RSC了,我们再执行一下next build:
(Static) prerendered as static content
你会惊讶地发现,就算我有异步的数据访问,但是它的渲染方式还是Static! 意味着它还是构建时生成的,就算你把它部署到服务器上,也压根就不会执行数据访问。
不信?我们看一下.next/server/app/index.html
看到了吧,数据早就已经打进去了。
你可能会觉得这是用fs.readFile
的效果,实际上,你换成fetch
也是同样的结果。这是由于Nextjs
的缓存策略决定的:
通过这个图你就可以看到Next的缓存策略有多么疯狂,至少有Router Cache
、Request Memorize
和Data Cache
三层,这是根据Next团队认为最佳的性能策略决定的,但本文不详细展开这里的cache策略(实际上,Next15已经默认把这些cache策略基本取消了🐶)。
那怎样才能让它走到服务端渲染逻辑呢?需要在RSC中调用Dynamic Function,next认为两个方法属于Dynamic:
- cookies
- headers
只要在RSC中调用两个函数之一,就会改变渲染策略。听起来也合理,一般只有cookies或headers决定了数据可能是和用户相关,每个用户千人千面,这时候不适合缓存。
我们改一下调用:
import { promises as fs } from "fs";
import { cookies } from "next/headers";
import path from "path";
export default async function Home() {
const cookie = cookies().get("token")?.value;
const data = await fs.readFile(path.join(process.cwd(), "data.json"), "utf8");
return <div>{data}{cookie}</div>;
}
这时候再执行next build:
(Static) prerendered as static content
(Dynamic) server-rendered on damand
可以看到渲染模式终于发生了改变,变成了Dynamic。我们再看.next/server/app
,发现index.html
消失了,这意味着我们部署到服务器上,服务端将真正运行动态的代码。
Nextjs的ISR策略
ISR 是 Incremental Static Regeneration,实际上就是混合了SSG和SSR的模式,我们用Vercel官方的例子理解一下:
interface Post {
title: string;
id: number;
}
export default async function Page() {
const res = await fetch('https://api.vercel.app/blog', {
next: { revalidate: 10 },
});
const posts = (await res.json()) as Post[];
return (
<ul>
{posts.map((post: Post) => {
return <li key={post.id}>{post.title}</li>;
})}
</ul>
);
}
这里在fetch函数加入了revalidate参数,意味着这个fetch在10s中缓存失效,会重新请求。
我们执行一下next build:
(Static) prerendered as static content
发现这里还是static的,意味着构建时,整个html页面已经生成好了,我们进入.next/server/app/index.html
看一下:
数据已经在HTML文件当中了,那和之前有什么不同呢?
我们跑起来这个服务看看
在我访问这个页面之前,我们先看一下app目录的文件时间:
我们访问页面,过10s后再访问一次,然后发现这里文件时间变更了:
这意味着在10s过后访问,缓存失效,next自动进行了重新生成(注意这时我并没有手动执行next build)。
实际上,在10s后的那一次访问,还是返回的旧文件,再之后访问就是新的了,ISR整个流程可以这样理解:
- 构建时:
- Web服务器从数据源或API获取初始数据。
- 使用这些数据生成静态页面。
- 将生成的静态页面部署到CDN或边缘服务器。
- 首次请求(页面未过期):
- 用户向CDN请求页面。
- CDN检查页面是否过期。
- 如果未过期,CDN直接返回静态页面给用户。
- 页面过期后的请求:
- 用户再次请求页面。
- CDN检查并发现页面已过期。
- CDN仍然返回旧版本的静态页面给用户,确保快速响应。
- 同时,CDN触发Web服务器重新生成页面。
- Web服务器从数据源获取最新数据。
- Web服务器使用最新数据重新生成页面。
- 更新后的页面被发送回CDN。
- 更新后的请求:
- 下一个用户请求页面。
- CDN检查页面,发现是最新的。
- CDN返回更新后的静态页面给用户。
Nextjs的PPR策略
PPR是Partial Prerendering,如果把ISR比作旧时期的SSR,那可以把PPR比作新时代的RSC。
怎么理解呢?
我们可以看到,ISR策略虽然看上去是混合了SSG+SSR
的模式,但是实际上它的粒度是整个页面为维度的,意味着整个HTML都会在revalidate
时效到期之后重新生成替换,意味着其实页面的动态性还是比较弱,如果这个页面包含一些用户相关的组件,那么整个页面就无法使用ISR的模式,全都降级成SSR(这里指的是有 RSC +Streaming
的SSR
) 了。
PPR相当于是支持局部渲染静态组件和动态组件,它的简单原理如下:
- 在构建时,生成页面的静态部分(shell)。
- 为动态内容预留位置(holes)。
- 当用户请求页面时:
- 立即发送静态shell。
- 同时在服务器端开始准备动态内容。
- 动态内容准备好后,通过流式传输填充到客户端的holes中。
我们通过一个实际的例子看一下,由于目前ppr仍然是实验特性,需要安装实验版本的Next:
npm install next@canary
在next配置文件中开启ppr:
/** @type { import('next').NextConfig } */
const nextConfig = {
experimental: {
ppr: true,
}
};
export default nextConfig;
接下来我们把博客的例子稍微改造一下:
import { Suspense } from 'react';
import { unstable_noStore as noStore } from 'next/cache';
interface Post {
title: string;
id: number;
}
async function PostList() {
noStore();
const res = await fetch('https://api.vercel.app/blog', {
next: { revalidate: 10 },
});
const posts = (await res.json()) as Post[];
return (
<ul>
{posts.map((post: Post) => {
return <li key={post.id}>{post.title}</li>;
})}
</ul>
);
}
export default function Page() {
return (
<div>
<h1>我的博客</h1>
<Suspense fallback={<p>正在加载文章...</p>}>
<PostList />
</Suspense>
</div>
);
}
引入了Suspense和unstable_noStore。Suspense用于包裹动态内容,而unstable_noStore用于标记动态内容。这样,现在这个页面里面包含了一个静态组件Page,和一个动态组件PostList。
执行以下next build:
(Partial prerender) prerendered as static HTML with dynamic server-streamed content
可以看到ppr已经生效了,意味着我们页面的静态组件将会被构建成静态HTML,而动态组件则通过异步插槽渲染,让我们看一下.next/server/app
里的文件:
可以看到有HTML生成,且“我的博客”是直接静态写在HTML里的,Suspense的fallback
也是包含在内,有一个template
插槽,意味着这里将被动态替换,当动态组件Ready后,会流式传输给客户端进行局部渲染。
整体PPR的流程可以总结如下:
混合渲染总结
特性 | RSC ( React Server Components ) | ISR (Incremental Static Regeneration) | PPR (Partial Prerendering) |
---|---|---|---|
定义 | 在服务器上渲染的React组件 | 允许在构建后更新静态内容的策略 | 在单个路由中结合静态和动态渲染的技术 |
主要目的 | 减少客户端JavaScript bundle大小,提高性能 | 结合静态生成的性能和动态内容的新鲜度 | 提供更快的初始加载和 动态内容 更新 |
渲染位置 | 服务器 | 服务器(构建时和重新验证时) | 服务器(静态部分) 和客户端(动态部分) |
更新频率 | 每次请求时(如果需要) | 基于配置的时间间隔或按需 | 静态部分在构建时,动态部分在请求时 |
数据获取 | 直接在服务器上,无需API层 | 在构建时和重新验证时 | 静态数据在构建时,动态数据在请求时 |
缓存策略 | 可以利用服务器端缓存 | 在CDN或边缘服务器上缓存页面 | 静态部分缓存,动态部分实时生成 |
适用场景 | 需要服务器资源的组件,如数据库查询 | 内容相对稳定但需要定期更新的页面动态性支持有限 | 页面结构固定但部分内容需要实时更新 |
首次内容呈现速度 | 快(服务器直接渲染) | 非常快(预渲染内容) | 非常快(静态shell立即显示) |
对服务器负载的影响 | 可能较高(取决于请求量) | 低(大部分请求直接由CDN处理) | 中等(静态部分由CDN处理,动态部分需要服务器) |
总结
回顾一下,本文详细梳理了 React 渲染技术的演变历程,从最初的 CSR 模式到后续的 SSR 、 SSG 以及 RSC 、 ISR 、 PPR的演进。
在单一渲染阶段,CSR 提供了良好的用户交互体验和前后端分离的优势,但存在初始加载时间长、SEO 不友好等缺点。SSR 解决了部分 SEO 问题,但增加了服务器负载和开发复杂性。SSG 在平衡 CSR 和 SSR 劣势方面有一定优势,但适用场景有限。
混合渲染阶段,React 通过 Fiber 架构的演进、Suspense、流式渲染、React Server Components 等技术和特性,为更灵活的渲染方式提供了支持。RSC 作为混合渲染模式的基座,能够根据组件维度进行服务端渲染,减少客户端代码体积,并开启 CSR 和 SSR 的混合渲染。同时,"use client"和"use server"指令也有其特定的作用和令人迷惑的地方。
Nextjs 作为一个服务端渲染框架,其渲染策略并非简单的服务端渲染优先,而是构建渲染优先(SSG 优先)。通过缓存策略和动态函数的调用,可以改变渲染模式。ISR 策略混合了 SSG 和 SSR,PPR 策略则支持局部渲染静态和动态组件。
纵观一下前端发展的历程,React团队可以说在创新性和追求性能极致方面做出了非常突出的贡献,不断追求更好用户体验、向更高性能和更灵活开发方式的努力。
希望本文的梳理能够对大家有所启发,在前端领域保持学习和适应新技术的能力是开发者的关键素质,在做实际项目时,顺应时代发展,做好更优的技术选型,构建更高质量、更高性能的Web应用。