overreactedby Dan Abramov

“use client” 是做什么的?

April 25, 2025

React Server Components(RSC)以其“无 API 表面”而(恶名?)远扬。这个全新的编程范式,基本上只源自两个指令:

  • 'use client'
  • 'use server'

我想大胆断言,这两者的发明,应该和结构化编程(if / while)、一等函数、以及 async/await 并列为划时代的进步。换句话说,我预期它们会超越 React 本身,成为业界的常识。

服务器必须向客户端发送代码(通过 <script> 传递)。客户端必须回传数据到服务器(通过 fetch 请求)。'use client''use server' 这两个指令对这些行为进行了抽象,提供了一种一等的、类型安全的、可静态分析的方式,让你可以把代码库中的某一部分控制权交给另一台计算机上的代码:

  • 'use client' 就是带类型的 <script>
  • 'use server' 就是带类型的 fetch()

这两个指令让你可以在模块系统内部表达客户端/服务器的边界。它们让你可以把客户端/服务器应用建模为一整个跨两台机器的程序,同时又不会忽视网络与序列化的现实差异。这样一来,就能实现跨网络的无缝组合

即使你从未打算使用 React Server Components,我也建议你了解这些指令及其工作原理。它们甚至不仅仅关乎 React。

它们关乎模块系统。


'use server'

我们先来看 'use server'

假设你正在写一个后端服务器,里面有一些 API 路由:

async function likePost(postId) {
  const userId = getCurrentUser();
  await db.likes.create({ postId, userId });
  const count = await db.likes.count({ where: { postId } });
  return { likes: count };
}
 
async function unlikePost(postId) {
  const userId = getCurrentUser();
  await db.likes.destroy({ where: { postId, userId } });
  const count = await db.likes.count({ where: { postId } });
  return { likes: count };
}
 
app.post('/api/like', async (req, res) => {
  const { postId } = req.body;
  const json = await likePost(postId);
  res.json(json);
});
 
app.post('/api/unlike', async (req, res) => {
  const { postId } = req.body;
  const json = await unlikePost(postId);
  res.json(json);
});

然后你有一些前端代码,会调用这些 API 路由:

document.getElementById('likeButton').onclick = async function() {
  const postId = this.dataset.postId;
  if (this.classList.contains('liked')) {
    const response = await fetch('/api/unlike', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ postId })
    });
    const { likes } = await response.json();
    this.classList.remove('liked');
    this.textContent = likes + ' Likes';
  } else {
    const response = await fetch('/api/like', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ postId, userId })
    });
    const { likes } = await response.json();
    this.classList.add('liked');
    this.textContent = likes + ' Likes';
  }
});

(为了简化,这个例子没有处理竞态条件和错误。)

这段代码没什么问题,但它是“字符串类型”的。我们真正想做的,其实是在另一台计算机上调用一个函数。但由于后端和前端是两个独立的程序,我们只能通过 fetch 这种方式来表达。

现在,假设我们把前端和后端看作一整个被拆分到两台机器上的程序。那我们该如何表达“某段代码想要调用另一段代码”这个事实?最直接的表达方式是什么?

如果暂时放下我们对“前后端应该怎么构建”的成见,其实我们真正想说的就是:我们希望从前端代码里调用 likePostunlikePost

import { likePost, unlikePost } from './backend'; // 这样写其实不行 :(
 
document.getElementById('likeButton').onclick = async function() {
  const postId = this.dataset.postId;
  if (this.classList.contains('liked')) {
    const { likes } = await unlikePost(postId);
    this.classList.remove('liked');
    this.textContent = likes + ' Likes';
  } else {
    const { likes } = await likePost(postId);
    this.classList.add('liked');
    this.textContent = likes + ' Likes';
  }
};

问题在于,likePostunlikePost 实际上不能在前端执行。我们不能真的把它们的实现导入到前端。直接从前端导入后端本身就是没有意义的。

但假如我们可以在模块级别,把 likePostunlikePost 标记为从服务器导出的函数呢?

'use server'; // 标记所有导出为“可被前端调用”
 
export async function likePost(postId) {
  const userId = getCurrentUser();
  await db.likes.create({ postId, userId });
  const count = await db.likes.count({ where: { postId } });
  return { likes: count };
}
 
export async function unlikePost(postId) {
  const userId = getCurrentUser();
  await db.likes.destroy({ where: { postId, userId } });
  const count = await db.likes.count({ where: { postId } });
  return { likes: count };
}

这样我们就可以自动化地在背后设置 HTTP 接口。有了这种导出函数到网络上的语法,我们就可以赋予从前端导入它们以实际意义——import 这些函数实际上会得到执行 HTTP 请求的 async 函数:

import { likePost, unlikePost } from './backend';
 
document.getElementById('likeButton').onclick = async function() {
  const postId = this.dataset.postId;
  if (this.classList.contains('liked')) {
    const { likes } = await unlikePost(postId); // 实际上是 HTTP 调用
    this.classList.remove('liked');
    this.textContent = likes + ' Likes';
  } else {
    const { likes } = await likePost(postId); // 实际上是 HTTP 调用
    this.classList.add('liked');
    this.textContent = likes + ' Likes';
  }
};

这正是 'use server' 指令的作用。

其实这并不是新想法——RPC(远程过程调用)已经有几十年历史了。这只是为客户端-服务器应用定制的一种 RPC 变体,服务器代码可以用 'use server' 指定某些函数为“服务器导出”。从服务器代码导入 likePost 和普通 import 一样,但从客户端代码导入时,得到的是一个会发起 HTTP 调用的 async 函数。

再来看看这两个文件:

'use server'; // 标记所有导出为“可被前端调用”
 
export async function likePost(postId) {
  const userId = getCurrentUser();
  await db.likes.create({ postId, userId });
  const count = await db.likes.count({ where: { postId } });
  return { likes: count };
}
 
export async function unlikePost(postId) {
  const userId = getCurrentUser();
  await db.likes.destroy({ where: { postId, userId } });
  const count = await db.likes.count({ where: { postId } });
  return { likes: count };
}
import { likePost, unlikePost } from './backend';
 
document.getElementById('likeButton').onclick = async function() {
  const postId = this.dataset.postId;
  if (this.classList.contains('liked')) {
    const { likes } = await unlikePost(postId); // 实际上是 HTTP 调用
    this.classList.remove('liked');
    this.textContent = likes + ' Likes';
  } else {
    const { likes } = await likePost(postId); // 实际上是 HTTP 调用
    this.classList.add('liked');
    this.textContent = likes + ' Likes';
  }
};

你可能会有一些疑问——比如它不适合多个 API 消费者(除非都在同一个代码库里);需要考虑版本和部署问题;比直接写 fetch 更隐式。

但如果你把后端和前端看作一整个被拆分到两台计算机上的程序,你就再也无法“看不见”这种联系。现在,两个模块之间有了直接而具体的连接。你可以为它们加上类型约束(并确保类型可序列化)。你可以用“查找所有引用”来追踪哪些服务器函数被客户端用到了。未使用的接口可以自动被标记,甚至被死代码分析移除。

更重要的是,你现在可以创建完全自包含的抽象,把前后端的逻辑都封装在一起——一个“前端”组件直接对应它的“后端”实现。你不必担心 API 路由数量爆炸——服务器/客户端的拆分可以像你的抽象一样模块化。没有全局命名空间;你可以用 exportimport 随处组织代码。

'use server' 指令让服务器和客户端的连接成为语法层面的事实。这不再只是约定——它写进了你的模块系统。

它为服务器打开了一扇


'use client'

现在假设你想把一些信息从后端传递到前端代码。例如,你可能会渲染一段带 <script> 的 HTML:

app.get('/posts/:postId', async (req, res) => {
  const { postId } = req.params;
  const userId = getCurrentUser();
  const likeCount = await db.likes.count({ where: { postId } });
  const isLiked = await db.likes.count({ where: { postId, userId } }) > 0;
  const html = `<html>
    <body>
      <button
        id="likeButton"
        className="${isLiked ? 'liked' : ''}"
        data-postid="${Number(postId)}">
        ${likeCount} Likes
      </button>
      <script src="./frontend.js></script>
    </body>
  </html>`;
  res.text(html);
});

浏览器会加载那个 <script>,并为按钮绑定交互逻辑:

document.getElementById('likeButton').onclick = async function() {
  const postId = this.dataset.postId;
  if (this.classList.contains('liked')) {
    // ...
  } else {
    // ...
  }
};

这样做可以,但有一些不足。

首先,你可能不希望前端逻辑是“全局”的——理想情况下,应该可以渲染多个 Like 按钮,每个按钮都能接收自己的数据并维护自己的本地状态。你也希望 HTML 模板和 JS 事件处理的展示逻辑能统一。

我们知道如何解决这些问题。这正是组件库的用武之地!让我们用声明式的 LikeButton 组件重写前端逻辑:

function LikeButton({ postId, likeCount, isLiked }) {
  function handleClick() {
    // ...
  }
 
  return (
    <button className={isLiked ? 'liked' : ''}>
      {likeCount} Likes
    </button>
  );
}

为简单起见,我们暂时用纯客户端渲染。此时服务器代码的职责只是传递初始 props:

app.get('/posts/:postId', async (req, res) => {
  const { postId } = req.params;
  const userId = getCurrentUser();
  const likeCount = await db.likes.count({ where: { postId } });
  const isLiked = await db.likes.count({ where: { postId, userId } }) > 0;
  const html = `<html>
    <body>
      <script src="./frontend.js></script>
      <script>
        const output = LikeButton(${JSON.stringify({
          postId,
          likeCount,
          isLiked
        })});
        render(document.body, output);
      </script>
    </body>
  </html>`;
  res.text(html);
});

这样 LikeButton 就能带着这些 props 出现在页面上:

function LikeButton({ postId, likeCount, isLiked }) {
  function handleClick() {
    // ...
  }
 
  return (
    <button className={isLiked ? 'liked' : ''}>
      {likeCount} Likes
    </button>
  );
}

这很合理,事实上这正是 React 在客户端路由出现前,集成到服务端渲染应用中的常见方式。你会把客户端代码写进 <script>,再用另一个 <script> 把初始数据(props)内联传递给它。

让我们再多琢磨一下这种代码结构。这里有个有趣的现象:后端代码显然想要把信息传递给前端代码。然而,这个传递过程依然是字符串类型的!

发生了什么?

app.get('/posts/:postId', async (req, res) => {
  // ...
  const html = `<html>
    <body>
      <script src="./frontend.js></script>
      <script>
        const output = LikeButton(${JSON.stringify({
          postId,
          likeCount,
          isLiked
        })});
        render(document.body, output);
      </script>
    </body>
  </html>`;
  res.text(html);
});

我们实际上是在说:让浏览器加载 frontend.js,然后找到文件里的 LikeButton 函数,再把这个 JSON 传给它。

那如果我们就直接这么写呢?

import { LikeButton } from './frontend';
 
app.get('/posts/:postId', async (req, res) => {
  // ...
  const jsx = (
    <html>
      <body>
        <LikeButton
          postId={postId}
          likeCount={likeCount}
          isLiked={isLiked}
        />
      </body>
    </html>
  );
  // ...
});
'use client'; // 标记所有导出为“可被后端渲染”
 
export function LikeButton({ postId, likeCount, isLiked }) {
  function handleClick() {
    // ...
  }
 
  return (
    <button className={isLiked ? 'liked' : ''}>
      {likeCount} Likes
    </button>
  );
}

我们这里做了一个概念上的飞跃,但请继续跟着思路走。我们说的是,这依然是两个独立的运行环境——后端和前端——但我们把它们看作一个整体程序

这就是为什么我们要在“传递信息的地方”(后端)和“需要接收信息的地方”(前端函数)之间建立语法上的连接。而最自然的表达方式,还是 import

注意,这里从带 'use client' 的文件导入,在后端并不会直接拿到 LikeButton 函数本身。而是得到一个客户端引用——底层可以转化为 <script> 标签。

来看下具体过程。

这段 JSX:

import { LikeButton } from './frontend'; // "/src/frontend.js#LikeButton"
 
// ...
<html>
  <body>
    <LikeButton
      postId={42}
      likeCount={8}
      isLiked={true}
    />
  </body>
</html>

会生成如下 JSON:

{
  type: "html",
  props: {
    children: {
      type: "body",
      props: {
        children: {
          type: "/src/frontend.js#LikeButton", // 一个客户端引用!
          props: {
            postId: 42
            likeCount: 8
            isLiked: true
          }
        }
      }
    }
  }
}

而这些信息——这个客户端引用——让我们可以生成 <script> 标签,加载正确的文件并在底层调用正确的函数:

<script src="./frontend.js"></script>
<script>
  const output = LikeButton({
    postId: 42,
    likeCount: 8,
    isLiked: true
  });
  // ...
</script>

实际上,我们还可以用这些信息在服务器上预生成初始 HTML,实现客户端渲染丢失的 SSR 效果:

<!-- 可选:初始 HTML -->
<button class="liked">
  8 Likes
</button>
 
<!-- 交互逻辑 -->
<script src="./frontend.js"></script>
<script>
  const output = LikeButton({
    postId: 42,
    likeCount: 8,
    isLiked: true
  });
  // ...
</script>

预渲染初始 HTML 是可选的,但原理完全一样。

现在你知道它怎么运作了,再回头看看这段代码:

import { LikeButton } from './frontend'; // "/src/frontend.js#LikeButton"
 
app.get('/posts/:postId', async (req, res) => {
  // ...
  const jsx = (
    <html>
      <body>
        <LikeButton
          postId={postId}
          likeCount={likeCount}
          isLiked={isLiked}
        />
      </body>
    </html>
  );
  // ...
});
'use client'; // 标记所有导出为“可被后端渲染”
 
export function LikeButton({ postId, likeCount, isLiked }) {
  function handleClick() {
    // ...
  }
 
  return (
    <button className={isLiked ? 'liked' : ''}>
      {likeCount} Likes
    </button>
  );
}

如果你抛开对前后端如何交互的既有认知,你会发现这里发生了很特别的事情。

后端代码通过带 'use client'import 引用了前端代码。换句话说,它在模块系统内部表达了“发送 <script> 的地方”和“活在 <script> 里的代码”之间的直接联系。由于有了这种直接联系,可以做类型检查,可以“查找所有引用”,所有工具链都能感知到。

就像 'use server' 一样,'use client' 让服务器和客户端的连接成为语法层面的事实。如果说 'use server' 打开了从客户端到服务器的门,'use client' 则打开了从服务器到客户端的门。

就像两个世界之间的两扇门。


两个世界,两扇门

这就是为什么 'use client''use server' 不应该被看作是“标记代码运行在哪一端”的方式。它们的作用不是这个。

它们真正做的是打开一扇门,让一个环境可以访问另一个环境:

  • 'use client' 将客户端函数导出给服务器。 底层,后端代码看到的是类似 '/src/frontend.js#LikeButton' 的引用。它们可以被渲染为 JSX 标签,最终会转化为 <script> 标签。(你也可以选择在服务器上预运行这些脚本,生成初始 HTML。)
  • 'use server' 将服务器函数导出给客户端。 底层,前端看到的是会通过 HTTP 调用后端的 async 函数。

这些指令让你在模块系统内部表达网络隔阂。你可以把客户端/服务器应用描述为一个跨越两个环境的整体程序

它们承认并充分利用了这两个环境无法共享执行上下文的事实——所以 import 并不会直接执行对方的代码。它们只是让一方引用另一方的代码,并向其传递信息。

两者结合,让你可以“编织”你的程序两端,创建和组合同时包含前后端逻辑的可复用抽象 但我认为,这种模式会超越 React,甚至超越 JavaScript。归根结底,这就是模块系统层面的 RPC,只不过多了一个“向客户端发送更多代码”的镜像机制。

服务器和客户端是同一个程序的两面。它们被时间和空间隔开,无法共享执行上下文,也无法直接 import 彼此。指令“打开了跨越时空的门”:服务器可以渲染客户端为 <script>,客户端可以回传数据给服务器(通过 fetch())。而 import 是最直接的表达方式,所以这些指令让你可以这样用。

是不是很有道理?


P.S.

这里有一张架构图,可以用在你的幻灯片里:

黑白太极图
Pay what you like

Discuss on Bluesky  ·  Watch on YouTube  ·  Edit on GitHub