万字长文:深度解析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的工作流程如下:

  1. 客户端请求网页,服务器首先发送一个最基本的HTML文件到浏览器。这个HTML文件通常只包含必要的结构和一个指向JavaScript文件的链接。这个时候用户看到的是无内容的页面。

  2. 浏览器下载并执行这个JavaScript文件。这个文件通常包含了React库和你的应用程序代码。

  3. React在浏览器中运行,动态创建 DOM 元素并将它们插入到页面中。这个过程通常发生在一个名为"root"的DOM节点内。此时真正将 React组件 渲染到页面上。

  4. 如果需要额外的数据,JavaScript代码会发起API请求,获取数据后再次更新DOM。基本上依赖后台数据的组件,在这个时候才会真正渲染完成。

CSR前端最早渲染模式

说CSR是前端最早的渲染模式,其实不太严谨,因为CSR已经可以认为是前后端分离那时候的事情了,我们严谨地捋一下这个演变过程:

  1. 静态HTML阶段:最初的Web页面就是纯静态的HTML文件,所有内容都是预先编写好的。没有JavaScript,这是最初始互联网超链接的形态。

  2. 动态HTML阶段:这个阶段前端有了一些基本的动画交互,并且数据也不仅仅是静态的超链接,可能是服务器端返回的一些数据,大部分是使用PHP 、JSP、 ASP这些技术在服务端生成HTML内容。其实这才是最早的服务器端渲染(Server-Side Rendering),我们姑且称之为传统 SSR,和现代SSR区分开来。

  3. AJAX( Asynchronous JavaScript and XML )阶段: 约在2006年左右,AJAX技术在谷歌的带领开始流行,允许在不刷新整个页面的情况下更新部分内容。这可以被视为向CSR过渡的一个重要步骤。

  4. 客户端渲染 ( CSR ): 随着JavaScript框架(如AngularJS、React、Vue等)的出现和普及,CSR成为主流的前端渲染模式。这标志着前后端分离的时代真正到来。

因此,更准确地说,CSR 是在前后端分离范式下,前端主导的渲染模式中最早被广泛采用的方式。 它代表了Web开发思路的一个重大转变,即将大部分渲染工作从服务器转移到了客户端。

当然前后端分离还有更复杂的因素,比如前后端的专业化分工需要,部署与 CDN 分发的需要, SPA 单页应用 的发展等等,这些不是本篇文章讨论的重点,先不展开。

这些过于较真的历史对本文也不太重要,因此为了简单起见,我们姑且就认为CSR是一个最初的起点。

那CSR有什么优势呢?

  1. 更好的用户交互体验,特别是对于单页应用(SPA),路由的切换没有白屏。
  2. 减少服务器负载,因为大部分渲染工作都在客户端完成,服务端只需要提供API就好。
  3. 前后端分离,专业化分工,极大地提高了开发效率和代码的可维护性。

然而,CSR也并非完美无缺。它也存在一些局限性,比如:

  1. 初始加载时间可能较长,特别是对于大型应用。
  2. 搜索引擎优化(SEO)可能会受到影响,因为搜索引擎爬虫可能无法看到动态生成的内容。
  3. 在低性能设备上可能表现不佳(需要执行大量JavaScript)。

因为这些限制,又促使了后续其他渲染模式继续迭代(SSR、SSG)等。

SSR (Server-Side Rendering)——历史的倒车?

由于CSR的一些局限,后续前端的优化又回到了服务端渲染的模式——SSR(Server-Side Rendering)。

乍一看,SSR似乎是一种回归到早期动态HTML时代的做法,但实际上不是,我们先来看看现代SSR的工作流程:

  1. 当用户请求页面时,服务器会执行JavaScript代码,生成完整的HTML内容。
  2. 服务器将这个预渲染的HTML发送给浏览器,用户可以立即看到页面内容,而不是空白页面。
  3. 同时,服务器还会发送必要的JavaScript代码。
  4. 浏览器加载这些JavaScript后,应用会在客户端hydration,接管页面的交互和后续的动态渲染。

SSR渲染模式

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;
  1. 服务器使用ReactDOMServer.renderToString()渲染App组件,生成静态HTML。
  2. 客户端接收并显示这个HTML。
  3. 客户端加载React代码。
  4. ReactDOM.hydrate()被调用,React开始hydrate过程:
  • React遍历DOM,将事件监听器(如按钮的onClick)附加到相应的元素上。
  • React重建虚拟DOM树,确保它与服务器渲染的内容匹配。
  • React初始化组件的状态(如count状态)。
  1. 完成后,应用变为完全可交互状态

我们可以看到,虽然都是服务端返回HTML,但和最初始的动态HTML阶段有显著的不同:

  • hydration过程:现代SSR的一个核心特性是hydration。在客户端接收到服务器渲染的HTML后,JavaScript会"水合"这些静态内容,使其变为可交互的动态应用。

  • 组件化架构:和传统的服务端返回HTML不同的是,无论页面是不是在服务端渲染,都是采用同样的基于组件的开发范式,服务端渲染的是组件树,这是和模板时代有本质的区别。

  • 状态管理:现代SSR框架通常提供了复杂的状态管理解决方案,可以在服务器端预填充状态,然后在客户端无缝接管。这是之前服务端拼接HTML所无法做到的。

所以,看似是回到了服务端渲染的模式,但是和之前已经有本质的区别了,前端完全掌控了整个渲染的生命周期,同构化的JavaScript,可以说是结合了前后端分离的优势以及传统服务端渲染的优势。

但是,SSR并不是银弹,它也有它的弊端:

  1. 增加服务器负载, 最明显的就是每个请求都要服务器来执行完整的渲染流程,增加了服务端成本 。
  2. 开发复杂性提高, 由于同一套代码保证在客户端和服务端的运行,有些生命周期、浏览器API、调试都变得更加的困难,且容易出错。
  3. 状态同步问题多,服务端和客户端在hydrate状态不一致的情况,容易导致很多莫名其妙的Bug产生。

SSG(Static Site Generation)——静态生成页面

如果我们想同时兼具CSR和SSR的优势呢?比如既不需要服务器负载和维护,又可以做到友好的 SEO,于是SSG就诞生了。

SSG是一种在构建时(而不是在请求时)生成完整的静态HTML页面的技术。

  1. 在构建过程中,SSG工具会遍历所有的页面和路由。
  2. 对每个页面,它会获取必要的数据(从API、数据库、 文件系统等)。
  3. 使用这些数据生成完整的HTML页面。
  4. 生成的静态HTML文件被保存并可以直接部署到 CDN或静态文件服务器。

SSG静态页面生成

这种方式显然平衡了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架构1

Fiber过程2

Fiber 允许 React 执行增量渲染:能够将渲染工作分割成小的单元,然后分批执行

Fiber 的渲染过程分为两个主要阶段:

  • Reconciliation 阶段(也称为 "render" 或 "差异化" 阶段):
  1. 可以被中断。
  2. 执行一些比如调用生命周期方法、计算差异等工作。
  • Commit 阶段:
  1. 不可被中断。
  2. 执行实际的 DOM 更新。

Fiber 架构的优势:

  1. 更好的性能:通过将工作分解成小单元,避免长时间阻塞主线程。
  2. 更高的灵活性:可以根据优先级调度和执行渲染工作。
  3. 改善用户体验:通过优先处理重要更新,提高应用的响应性。
  4. 更强大的错误处理:提供了更好的错误边界功能。

为接下来讲的一些高级特性带来了可能。

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>
  );
}

在这个例子中:

  1. LazyComponent是通过React.lazy()动态导入的。
  2. Suspense包裹了LazyComponent,提供了一个fallback UI(在这里是一个"Loading..."的div)。
  3. 当LazyComponent正在加载时,会显示fallback UI。
  4. 一旦LazyComponent加载完成,fallback UI会被替换为实际的组件内容。
  5. 实际上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 Server Components (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先不渲染,其他元素先进行流式渲染,所以:

  1. 首先渲染的是我的博客(h1)+ ArticleList部分(这也是个RSC,没有Suspense意味着会阻塞返回)
  2. 其次是一个fallback - 加载天气信息
  3. 当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的心智模型是这样的:
RSC之前

在服务端,实际上是调用了类似renderToString的方法,将Component转化为HTML,并且通过构建的方式,将组件的代码打包到main.js中,供浏览器加载,执行JS逻辑。实际上,一个组件有两个输出:

  1. 输出HTML给浏览器
  2. 输出main.js(通过构建)被浏览器加载执行

RSC出现后,实际上并未改变这个心智模型,只是在上面又加了一层:

RSC之后

多个RSC实际上会形成一个Server Tree,区分于非RSC组件,这个Server Tree没有任何JS需要在客户端执行(当然微观来看还是有一个updateDOM的过程,可以忽略),然后RSC的子组件可以是一个客户端组件,这个时候它可以将Props传给客户端组件(当然这个props是需要可序列化的)。

那么,我们可以理解为,老的SSR心智,就是标记为"use client"的这些组件,它在Nextjs这样服务器优先的框架上,依旧是服务端渲染的,它组成了一颗Client Tree进行hydrate,它的代码会被打包到main.js,由浏览器加载执行:

新的RSC之后

所以我们可以这样理解,"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。

只需要记住两点:

  1. "use client"标记客户端组件,但不代表它不会在服务器执行。
  2. "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.",
];

它的树形结构如下:

RSC客户端和服务渲染综合例子

在这个case中:

  1. App作为根组件是一个服务器组件,它包含了服务器组件(FancyText)和客户端组件(InspirationGenerator)。

  2. 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的缓存策略决定的:

Nextjs的缓存策略

通过这个图你就可以看到Next的缓存策略有多么疯狂,至少有Router CacheRequest MemorizeData 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整个流程可以这样理解:

ISR缓存更新方法

  1. 构建时:
  • Web服务器从数据源或API获取初始数据。
  • 使用这些数据生成静态页面。
  • 将生成的静态页面部署到CDN或边缘服务器。
  1. 首次请求(页面未过期):
  • 用户向CDN请求页面。
  • CDN检查页面是否过期。
  • 如果未过期,CDN直接返回静态页面给用户。
  1. 页面过期后的请求:
  • 用户再次请求页面。
  • CDN检查并发现页面已过期。
  • CDN仍然返回旧版本的静态页面给用户,确保快速响应。
  • 同时,CDN触发Web服务器重新生成页面。
  • Web服务器从数据源获取最新数据。
  • Web服务器使用最新数据重新生成页面。
  • 更新后的页面被发送回CDN。
  1. 更新后的请求:
  • 下一个用户请求页面。
  • CDN检查页面,发现是最新的。
  • CDN返回更新后的静态页面给用户。

Nextjs的PPR策略

PPR是Partial Prerendering,如果把ISR比作旧时期的SSR,那可以把PPR比作新时代的RSC。

怎么理解呢?

我们可以看到,ISR策略虽然看上去是混合了SSG+SSR的模式,但是实际上它的粒度是整个页面为维度的,意味着整个HTML都会在revalidate时效到期之后重新生成替换,意味着其实页面的动态性还是比较弱,如果这个页面包含一些用户相关的组件,那么整个页面就无法使用ISR的模式,全都降级成SSR(这里指的是有 RSC +StreamingSSR) 了。

PPR相当于是支持局部渲染静态组件和动态组件,它的简单原理如下:

  1. 在构建时,生成页面的静态部分(shell)。
  2. 为动态内容预留位置(holes)。
  3. 当用户请求页面时:
  • 立即发送静态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的流程可以总结如下:

整体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应用。

关于我
loading