RSC 中的导入机制详解
June 5, 2025
React Server Components(RSC)是一种编程范式,它允许你将客户端/服务器应用程序作为一个跨越两个环境的单一程序来表达。具体来说,RSC 扩展了模块系统(即 import
和 export
关键字),通过全新的语义让开发者能够灵活控制前端与后端的分界。
我之前写过一篇关于 'use client'
和 'use server'
指令的文章,这些指令用于标记两个环境之间的“分界点”。本文将重点讨论这些指令与 import
和 export
关键字之间的交互方式。
这是一篇面向希望建立 RSC 精确心智模型的开发者,以及对模块系统感兴趣的朋友的深度解析。你可能会发现 RSC 的方法既出人意料,又比你想象的更简单。
一如既往,本文 90% 的内容并不直接讨论 RSC,而是讲述导入机制的通用原理,以及当我们尝试在前后端共享代码时会发生什么。我的目标是展示 RSC 如何为我们在跨越前后端写代码时遇到的最后 10% 难题提供自然的解决方案。
让我们从基础讲起。
什么是模块系统?
当计算机执行程序时,其实并不需要“模块”。计算机只需要程序的代码和数据完整加载到内存中,才能运行和处理它们。实际上,我们人类才需要将代码拆分成模块:
- 模块让我们能把复杂程序拆解成易于理解的小部分。
- 模块让我们能够约束哪些代码行可以被其他部分“看到”(或导出),哪些应当作为实现细节隐藏起来。
- 模块让我们能够复用他人(或自己)写的代码。
我们希望编写的程序是拆分成多个部分的——但执行程序时,这些部分会被“展开”到内存中。模块系统的职责,就是桥接人类编写代码的方式与计算机执行代码的方式之间的差距。
具体来说,模块系统是一套规则,规定了程序如何拆分成文件,开发者如何控制哪些部分可以“看到”其他部分,以及这些部分如何被链接成一个可以加载到内存中的完整程序。
在 JavaScript 中,模块系统通过 import
和 export
关键字对外暴露。
导入就像复制粘贴……
来看下面两个文件,分别叫做 a.js
和 b.js
:
export function a() {
return 2;
}
export function b() {
return 2;
}
单独来看,它们只是定义了两个函数,什么都没做。
现在再看这个叫 index.js
的文件:
import { a } from './a.js';
import { b } from './b.js';
const result = a() + b(); // 4
console.log(result);
这就是一个把它们组合成单一程序的模块!
JavaScript 模块系统的规则很复杂,内部有很多细节。但我们可以用一个简单的直觉来理解。JavaScript 的模块系统设计目标是确保在上面这个程序运行时,它的行为应该与下面这个完全不使用模块的单文件程序一致:
function a() {
return 2;
}
function b() {
return 2;
}
const result = a() + b(); // 4
console.log(result);
换句话说,import
和 export
关键字本质上就是一种类似于复制粘贴的机制——因为归根结底,程序最终确实需要被 JS 引擎“展开”到进程内存中。
……但又不完全是
前面说导入像复制粘贴,但其实并不完全如此。要理解原因,我们可以回顾一下 C 语言中的 #include
指令。
#include
指令比 JavaScript 的 import
早了大约 40 年,它的行为几乎就是字面意义上的复制粘贴!比如,下面是一个 C 程序:
#include "a.h"
#include "b.h"
int main() {
return a() + b();
}
在 C 语言中,#include
指令会直接把 a.h
和 b.h
的全部内容嵌入到上面的文件中。这种做法很简单,但有两个大问题:
- 一个问题是,不同文件中无关的函数如果同名会冲突。而在现代模块系统中,我们理所当然地认为所有标识符都是文件私有的。
- 另一个问题是,同一个文件可能会被多个地方“include”——这样在输出程序中就会重复多次!为了解决这个问题,最佳实践是用编译时的“只 include 一次”保护包裹每个可被 include 的文件。现代的模块系统,比如
import
,会自动处理类似问题。
我们来详细说说第二点,因为它很重要。
JavaScript 模块是单例的
假设我们新增了一个叫 c.js
的模块,内容如下:
export function c() {
return 2;
}
现在假设我们把 a.js
和 b.js
都改成了各自导入 c.js
的 c
函数,并做一些处理:
import { c } from './c.js';
export function a() {
return c() * 2;
}
import { c } from './c.js';
export function b() {
return c() * 3;
}
如果 import
真的是复制粘贴(像 #include
),那我们的程序里就会有两份 c
函数。但幸运的是,事实并非如此!
JavaScript 的模块系统保证,上述代码连同前面的 index.js
文件,其语义等价于下面这个单文件程序。注意,c
函数只定义了一次,尽管被导入了两次:
function c() {
return 2;
}
function a() {
return c() * 2;
}
function b() {
return c() * 3;
}
const result = a() + b(); // (2 * 2) + (2 * 3) = 10
console.log(result);
换句话说,现代模块系统(如 JavaScript 模块系统)保证**每个模块内部的代码最多只会执行一次,**无论这个模块被多少地方、多少次导入。
这是一个至关重要的设计选择,带来了很多好处:
- 当代码被打包成单一程序(无论是可执行文件、bundle 还是内存中的模块),输出体积不会因为重复而膨胀。
- 每个模块可以在顶层变量中保存“私有状态”,并且无论被导入多少次都能保证状态不会被重建。
- 心智模型极大简化:每个模块就是一个“单例”。如果你希望某段代码只执行一次,只需写在模块顶层。
底层实现上,模块系统通常会维护一个 Map
,记录哪些模块(以文件名为 key)已经加载过,以及它们的导出值。任何 JS import
实现都会有类似逻辑,比如:Node.js 源码、webpack 源码、Metro (RN) 源码。
再强调一遍:每个 JavaScript 模块都是单例。多次导入同一个模块不会重复执行其代码。每个模块最多只会运行一次。
我们刚才讨论的是多个模块,那多台计算机呢?
一个程序,一台计算机
大多数 JavaScript 程序都是为单台计算机编写的。
可能是浏览器,也可能是 Node.js 服务器,或者其他 JS 运行时。总之,**绝大多数 JS 程序都是为单机执行而写的。**程序被加载,运行,结束。
如前所述,JavaScript 模块系统正是为这种最常见的场景设计的。再来回顾一下它的工作方式:
- 有一个文件作为程序的入口点。在前面的例子中是
index.js
。JS 引擎从这里开始执行。 - 这个文件可能会导入其他模块,比如
a.js
或b.js
,它们又可以导入更多模块。JS 引擎会执行这些模块的代码,并把每个模块的导出结果缓存到内存中。 - 如果 JS 引擎遇到已经加载过的模块(比如第二次导入
c.js
),不会再执行该模块。模块是单例!而是直接从内存缓存中读取其导出值。
最终,可以把结果想象成把所有模块复制粘贴到一个文件里,变量名冲突时做适当重命名,并确保每个模块内容只包含一次:
/* c.js */ function c() { return 2; }
/* a.js */ function a() { return c() * 2; }
/* b.js */ function b() { return c() * 3; }
const result = a() + b(); // (2 * 2) + (2 * 3) = 10
console.log(result);
从这个意义上说,当你 import
某段代码时,就是把它带进你的程序。
但如果我们希望前后端都用 JavaScript 编写呢?(或者说,我们意识到加一个 JS BFF 能让应用更好?)
两个程序,两台计算机
传统上,JS 的前端和后端意味着我们在开发两个不同的程序,分别运行在两台不同的计算机上。很多情况下,甚至可能由两个几乎不交流的团队分别维护。
我们来仔细看看这两个程序。后端负责提供 HTML 页面(以及可能的 API),前端负责页面上的交互逻辑。
后端代码可能在 backend/index.js
:
function server() {
return (
`<html>
<body>
<button onClick="sayHello()">
Press me
</button>
<script src="/frontend/index.js type="module"></script>
</body>
</html>`
);
}
前端代码可能在 frontend/index.js
:
function sayHello() {
alert('Hi!');
}
window['sayHello'] = sayHello;
我们把它们放在一起,强调它们是两个不同但相关的程序:
function server() {
return (
`<html>
<body>
<button onClick="sayHello()">
Press me
</button>
<script src="/frontend/index.js type="module"></script>
</body>
</html>`
);
}
function sayHello() {
alert('Hi!');
}
window['sayHello'] = sayHello;
现在来看下,如果我们从任一端导入代码会发生什么。
假设我们在 backend/index.js
中导入了 a.js
和 b.js
:
import { a } from '../a.js';
import { b } from '../b.js';
function server() {
return (
`<html>
<body>
<button onClick="sayHello()">
Press me
</button>
<script src="/frontend/index.js type="module"></script>
</body>
</html>`
);
}
function sayHello() {
alert('Hi!');
}
window['sayHello'] = sayHello;
从后端代码导入它们,会把它们带进后端代码:
/* c.js */ function c() { return 2; }
/* a.js */ function a() { return c() * 2; }
/* b.js */ function b() { return c() * 3; }
function server() {
return (
`<html>
<body>
<button onClick="sayHello()">
Press me
</button>
<script src="/frontend/index.js type="module"></script>
</body>
</html>`
);
}
function sayHello() {
alert('Hi!');
}
window['sayHello'] = sayHello;
现在假设我们也在 frontend/index.js
导入了它们:
import { a } from '../a.js';
import { b } from '../b.js';
function server() {
return (
`<html>
<body>
<button onClick="sayHello()">
Press me
</button>
<script src="/frontend/index.js type="module"></script>
</body>
</html>`
);
}
import { a } from '../a.js';
import { b } from '../b.js';
function sayHello() {
alert('Hi!');
}
window['sayHello'] = sayHello;
从前端代码导入它们,会把它们带进前端代码:
/* c.js */ function c() { return 2; }
/* a.js */ function a() { return c() * 2; }
/* b.js */ function b() { return c() * 3; }
function server() {
return (
`<html>
<body>
<button onClick="sayHello()">
Press me
</button>
<script src="/frontend/index.js type="module"></script>
</body>
</html>`
);
}
/* c.js */ function c() { return 2; }
/* a.js */ function a() { return c() * 2; }
/* b.js */ function b() { return c() * 3; }
function sayHello() {
alert('Hi!');
}
window['sayHello'] = sayHello;
注意,前端和后端并不共享模块系统!
这是一个重要的认识。无论从哪一端导入代码,都是把代码带进那一端,仅此而已。两端有各自独立的模块系统。模块依然是单例——但仅限于每个环境。
虽然我们在前后端复用了 a.js
、b.js
和 c.js
的实现,但更准确地说,前端和后端各自拥有“自己的版本”的这些模块。
目前为止,这一切都很常规。这正是全栈应用中前后端共享代码的常见做法。然而,随着我们在两端复用的代码越来越多,就有可能不小心复用了不该被另一端用到的内容。
我们如何约束和控制代码复用?
构建失败其实是好事
假设有人把 c.js
改成只适用于后端的代码,比如用 fs
读取服务器文件:
import { readFileSync } from 'fs';
export function c() {
return Number(readFileSync('./number.txt', 'utf8'));
}
这对后端代码来说没问题:
/* fs.js */ function readFileSync() { /* ... */}
/* c.js */ function c() { return Number(readFileSync('./number.txt', 'utf8')); }
/* a.js */ function a() { return c() * 2; }
/* b.js */ function b() { return c() * 3; }
function server() {
return (
`<html>
<body>
<button onClick="sayHello()">
Press me
</button>
<script src="/frontend/index.js type="module"></script>
</body>
</html>`
);
}
但在前端构建时会失败,因为前端没有 fs
:
import { readFileSync } from 'fs'; // 🔴 构建错误:无法导入 'fs'
/* c.js */ function c() { return Number(readFileSync('./number.txt', 'utf8')); }
/* a.js */ function a() { return c() * 2; }
/* b.js */ function b() { return c() * 3; }
function sayHello() {
alert('Hi!');
}
window['sayHello'] = sayHello;
这其实是好事!
当我们在两端复用代码时,我们希望有信心被复用的代码确实能在两端正常工作。
如果某些 API 只适用于一端(比如 fs
只适用于后端),我们希望构建能早早失败,这样我们就能及时修复:
- 可以选择把
fs
调用移出c.js
。 - 可以重构
a.js
和b.js
,让它们不依赖c.js
。 - 可以让
frontend/index.js
不再依赖a.js
和b.js
。
**上述每种方案都是合理的。**你选择哪种,取决于实际需求。没有自动化工具能帮你选出“最佳”方案——这更像是解决一次真实的 Git 冲突。虽然不太有趣,但最终行为由你(或 LLM)决定。
这就是复用代码的代价。好处是逻辑可以灵活迁移到需要的那一端。坏处是出问题时,你得看构建报错,决定该修哪个模块。
在这个例子里,我们很幸运地因为导入了“错误端”的内容而导致构建失败,让我们能立刻发现问题。但如果没报错呢?
仅限服务器端的代码
假设有人把 c.js
改成导入服务器端的密钥:
import { secret } from './secrets.js';
export function c() {
return secret;
}
这比前面的例子更糟糕!不会有构建错误,secret
会同时出现在后端和前端代码中:
/* secrets.js */ const secret = 12345;
/* c.js */ function c() { return secret; }
/* a.js */ function a() { return c() * 2; }
/* b.js */ function b() { return c() * 3; }
function server() {
return (
`<html>
<body>
<button onClick="sayHello()">
Press me
</button>
<script src="/frontend/index.js type="module"></script>
</body>
</html>`
);
}
/* secrets.js */ const secret = 12345;
/* c.js */ function c() { return secret; }
/* a.js */ function a() { return c() * 2; }
/* b.js */ function b() { return c() * 3; }
function sayHello() {
alert('Hi!');
}
window['sayHello'] = sayHello;
这是灾难性的,但很多全栈应用都没有防止开发者不小心把密钥带到前端的机制!
我们如何改进?
前面我们看到,从前端导入 fs
会导致构建失败,迫使我们修复问题。
这正是我们希望在这里也能发生的!
**假设我们创建一个特殊包,叫做 server-only
,用来标记那些绝不能被带到前端的代码。**这个包本身不包含任何实际代码,只是一个“毒丸”。我们会让前端打包工具在遇到这个模块时构建失败。
假设我们已经实现了这一机制,现在可以在 secrets.js
中标记为 server-only:
import 'server-only';
export const secret = 12345;
这样一来,把 secrets.js
带进 bundle 会导致前端构建失败。具体来说,a.js
和 b.js
会带入 c.js
,c.js
会带入 secrets.js
,secrets.js
会带入 server-only
——而这个毒丸会让构建失败:
/* server-only */ /* (在后端无影响) */
/* secrets.js */ const secret = 12345;
/* c.js */ function c() { return secret; }
/* a.js */ function a() { return c() * 2; }
/* b.js */ function b() { return c() * 3; }
function server() {
return (
`<html>
<body>
<button onClick="sayHello()">
Press me
</button>
<script src="/frontend/index.js type="module"></script>
</body>
</html>`
);
}
/* server-only */ /* 🔴(在前端构建时报错) */
/* secrets.js */ const secret = 12345;
/* c.js */ function c() { return secret; }
/* a.js */ function a() { return c() * 2; }
/* b.js */ function b() { return c() * 3; }
function sayHello() {
alert('Hi!');
}
window['sayHello'] = sayHello;
现在我们可以控制哪些代码不能被带出后端!(具体实现可参考 Next.js 打包器中的相关代码、逻辑)
和前面导入 fs
一样,我们有多种修复方式:
- 可以把
secrets.js
的导入移出c.js
。 - 可以重构
a.js
和b.js
,让它们不依赖c.js
。 - 可以让
frontend/index.js
不再依赖a.js
和b.js
。
但这个方案的关键在于毒丸会自动沿着导入链向上传播。你无需为 a.js
、b.js
、c.js
等每个文件都加 server-only,除非有特殊原因。只需在绝对不能被带出的文件(如 secrets.js
)加标记,剩下的交给“毒丸”沿导入链传播即可。
仅限客户端的代码
类似于 server-only
毒丸,我们可以创建一个镜像的 client-only
毒丸,让它在服务端构建时报错。(如果服务器不打包,也可以像 TypeScript 一样单独做检查。)
假设我们在 c.js
里用了浏览器专有 API。这可能是我们决定绝不允许它被带到后端的理由:
import 'client-only';
export function c() {
return Number(prompt('How old are you?'));
}
虽然这不像密钥那样关键,但能更快发现错误。我们的目标是把那些因导入不适合另一端的代码导致的运行时错误,变成构建时错误,强制我们修复:
/* client-only */ /* 🔴(在后端构建时报错) */
/* c.js */ function c() { return Number(prompt('How old are you?')); }
/* a.js */ function a() { return c() * 2; }
/* b.js */ function b() { return c() * 3; }
function server() {
return (
`<html>
<body>
<button onClick="sayHello()">
Press me
</button>
<script src="/frontend/index.js type="module"></script>
</body>
</html>`
);
}
/* client-only */ /* (在前端无影响) */
/* c.js */ function c() { return Number(prompt('How old are you?')); }
/* a.js */ function a() { return c() * 2; }
/* b.js */ function b() { return c() * 3; }
function sayHello() {
alert('Hi!');
}
window['sayHello'] = sayHello;
同样,这会让我们做出选择:
- 可以重构
c.js
让其适用于后端(并移除毒丸)。 - 可以重构
a.js
和b.js
,让它们不依赖c.js
。 - 可以让
backend/index.js
不再依赖a.js
和b.js
。
我们还可以设想更细粒度的 client-only
和 server-only
,应用于单个包的导入。例如,React 包可以声明像 useState
和 useEffect
这样的 API 为 client-only
,这样一旦被带到后端代码就会立即构建失败。(提示:React 实际上确实通过 package.json
的条件导出机制实现了这一点。)
你可能已经发现了一个规律。随着我们在前后端代码库间共享和复用的代码越来越多——甚至这两个代码库逐渐融合成一个——这些构建时断言为我们带来了安心。
并非每个模块都必须只属于某一端。实际上,大多数模块并不关心自己属于哪一端,因为它们本身不会引发兼容性问题。例如,a.js
和 b.js
并不会规定自己只能存在于某一端,因为它们不了解 c.js
的实现细节。但如果某个模块确实需要只属于一端,现在可以用 server-only
或 client-only
在本地表达。声明的不兼容性会递归地“感染”所有导入它的模块。
还要理解的是,server-only
和 client-only
毒丸并不控制代码最终会去哪一端。它们不会“把”代码“放到后端”或“放到前端”。它们唯一的作用是阻止代码被带到不支持的环境。它们只是毒丸,仅此而已。
到这里,我们已经几乎发明了 RSC。
还差最后一个细节。
一个程序,两台计算机
让我们再看一遍前后端作为两个独立程序的情形:
function server() {
return (
`<html>
<body>
<button onClick="sayHello()">
Press me
</button>
<script src="/frontend/index.js type="module"></script>
</body>
</html>`
);
}
function sayHello() {
alert('Hi!');
}
window['sayHello'] = sayHello;
此时,我们对两端如何共享代码已经有了清晰的心智模型:
- 从任一端导入代码,都是把它带进那一端。
- 两端的模块系统完全独立。如果你从两端都导入了某个共享代码,它会被分别带进两端。
- 默认情况下,任何代码都假定可复用。但我们可以用
server-only
和client-only
毒丸标记那些绝不能被带进某一端的模块(因为这些模块内部直接包含了不兼容的代码)。这不会改变代码实际运行的位置,只是让我们能及早发现问题。
说实话,到这里我们就已经有了一个比很多流行方案更安全的全栈开发模式。
不过,这种方式还有一个弱点。当前,后端和前端代码依赖约定来保持同步。后端想要引用前端的 sayHello
函数,但没有语法上的方式,只能假定它会在另一端存在:
function server() {
return (
`<html>
<body>
<button onClick="sayHello()">
Press me
</button>
<script src="/frontend/index.js type="module"></script>
</body>
</html>`
);
}
function sayHello() {
alert('Hi!');
}
window['sayHello'] = sayHello;
这其实很脆弱。
当然,后端不能直接导入 sayHello
,因为——细心的读者可能已经意识到——那样只会把它带进后端代码。
如果有办法让后端代码引用 sayHello
,而不是把它带进后端就好了。幸运的是,这正是 'use client'
的作用:
import { sayHello } from '../frontend/index.js';
function Server() {
return (
<html>
<body>
<button onClick={sayHello}>
Press me
</button>
</body>
</html>
);
}
'use client';
export function sayHello() {
alert('Hi.');
}
这就是 RSC 增加的“最后 10%”。
在 RSC 中,两端的导入通常就像普通导入一样工作——但 'use client'
会改变这种行为,相当于“打开通往前端环境的大门”。
当你加上 'use client'
,你是在说:“如果你从后端世界导入我,不要真的把我的代码带进后端——而是提供一个引用,让 React 最终能把它转成 <script>
标签并在前端激活。”
同理,'use server'
让前端代码“打开通往后端的大门”,引用后端模块,而不是把它带进前端世界。
这些指令不是用来逐个模块指定“代码运行在哪”的。你不应该把 'use client'
放在所有前端模块,或把 'use server'
放在所有后端模块——那毫无意义! 它们的唯一作用是让你在两个模块系统之间开“门”,让你引用另一个世界。
如果你想把数据从后端传到前端(作为 <script>
标签),你需要 'use client'
。如果你想把数据从前端传到后端(作为 API 调用),你需要 'use server'
。除此之外,正常用 import
就可以,始终停留在当前世界。
总结
RSC 并不回避前后端各自拥有独立模块系统的事实。它的工作方式与传统 JS 代码库复用前后端代码完全一样,被复用的代码实际上会存在于两端。RSC 在此基础上只增加了两种机制:
import 'client-only'
和import 'server-only'
毒丸,让某些模块声明自己绝不能被带进另一个世界。'use client'
和'use server'
指令,让你引用另一个世界的模块并传递数据,而不是带进本地。
有了这两种机制,你可以把 RSC 应用看作一个跨越两台计算机的单一程序——拥有两个独立的模块系统、两颗毒丸、两扇传递信息的门。
当你习惯了这种“分层”方式后,你会发现 frontend/
和 backend/
目录变得多余甚至具有误导性,因为模块本身已经包含了所有信息。而且这种信息是局部的,边界会随着代码演进自动调整。
毒丸确保没有东西被带进错误的世界,指令让你在两个世界间传递信息,普通导入则一切照旧。
剩下的,就是修复构建错误了。
听说 LLM 现在已经很擅长这件事了。