“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
这种方式来表达。
现在,假设我们把前端和后端看作一整个被拆分到两台机器上的程序。那我们该如何表达“某段代码想要调用另一段代码”这个事实?最直接的表达方式是什么?
如果暂时放下我们对“前后端应该怎么构建”的成见,其实我们真正想说的就是:我们希望从前端代码里调用 likePost
和 unlikePost
:
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';
}
};
问题在于,likePost
和 unlikePost
实际上不能在前端执行。我们不能真的把它们的实现导入到前端。直接从前端导入后端本身就是没有意义的。
但假如我们可以在模块级别,把 likePost
和 unlikePost
标记为从服务器导出的函数呢?
'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 路由数量爆炸——服务器/客户端的拆分可以像你的抽象一样模块化。没有全局命名空间;你可以用 export
和 import
随处组织代码。
'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.
这里有一张架构图,可以用在你的幻灯片里:
