overreactedby Dan Abramov

RSC 中的导入机制详解

June 5, 2025

React Server Components(RSC)是一种编程范式,它允许你将客户端/服务器应用程序作为一个跨越两个环境的单一程序来表达。具体来说,RSC 扩展了模块系统(即 importexport 关键字),通过全新的语义让开发者能够灵活控制前端与后端的分界。

我之前写过一篇关于 'use client''use server' 指令的文章,这些指令用于标记两个环境之间的“分界点”。本文将重点讨论这些指令与 importexport 关键字之间的交互方式。

这是一篇面向希望建立 RSC 精确心智模型的开发者,以及对模块系统感兴趣的朋友的深度解析。你可能会发现 RSC 的方法既出人意料,又比你想象的更简单。

一如既往,本文 90% 的内容并不直接讨论 RSC,而是讲述导入机制的通用原理,以及当我们尝试在前后端共享代码时会发生什么。我的目标是展示 RSC 如何为我们在跨越前后端写代码时遇到的最后 10% 难题提供自然的解决方案。

让我们从基础讲起。


什么是模块系统?

当计算机执行程序时,其实并不需要“模块”。计算机只需要程序的代码和数据完整加载到内存中,才能运行和处理它们。实际上,我们人类才需要将代码拆分成模块:

  • 模块让我们能把复杂程序拆解成易于理解的小部分。
  • 模块让我们能够约束哪些代码行可以被其他部分“看到”(或导出),哪些应当作为实现细节隐藏起来。
  • 模块让我们能够复用他人(或自己)写的代码。

我们希望编写的程序是拆分成多个部分的——但执行程序时,这些部分会被“展开”到内存中。模块系统的职责,就是桥接人类编写代码的方式与计算机执行代码的方式之间的差距。

具体来说,模块系统是一套规则,规定了程序如何拆分成文件,开发者如何控制哪些部分可以“看到”其他部分,以及这些部分如何被链接成一个可以加载到内存中的完整程序。

在 JavaScript 中,模块系统通过 importexport 关键字对外暴露。


导入就像复制粘贴……

来看下面两个文件,分别叫做 a.jsb.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);

换句话说,importexport 关键字本质上就是一种类似于复制粘贴的机制——因为归根结底,程序最终确实需要被 JS 引擎“展开”到进程内存中。


……但又不完全是

前面说导入像复制粘贴,但其实并不完全如此。要理解原因,我们可以回顾一下 C 语言中的 #include 指令。

#include 指令比 JavaScript 的 import 早了大约 40 年,它的行为几乎就是字面意义上的复制粘贴!比如,下面是一个 C 程序:

#include "a.h"
#include "b.h"
 
int main() {
  return a() + b();
}

在 C 语言中,#include 指令会直接把 a.hb.h 的全部内容嵌入到上面的文件中。这种做法很简单,但有两个大问题:

  1. 一个问题是,不同文件中无关的函数如果同名会冲突。而在现代模块系统中,我们理所当然地认为所有标识符都是文件私有的。
  2. 另一个问题是,同一个文件可能会被多个地方“include”——这样在输出程序中就会重复多次!为了解决这个问题,最佳实践是用编译时的“只 include 一次”保护包裹每个可被 include 的文件。现代的模块系统,比如 import,会自动处理类似问题。

我们来详细说说第二点,因为它很重要。


JavaScript 模块是单例的

假设我们新增了一个叫 c.js 的模块,内容如下:

export function c() {
  return 2;
}

现在假设我们把 a.jsb.js 都改成了各自导入 c.jsc 函数,并做一些处理:

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 模块系统正是为这种最常见的场景设计的。再来回顾一下它的工作方式:

  1. 有一个文件作为程序的入口点。在前面的例子中是 index.js。JS 引擎从这里开始执行。
  2. 这个文件可能会导入其他模块,比如 a.jsb.js,它们又可以导入更多模块。JS 引擎会执行这些模块的代码,并把每个模块的导出结果缓存到内存中。
  3. 如果 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.jsb.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.jsb.jsc.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 只适用于后端),我们希望构建能早早失败,这样我们就能及时修复:

  1. 可以选择把 fs 调用移出 c.js
  2. 可以重构 a.jsb.js,让它们不依赖 c.js
  3. 可以让 frontend/index.js 不再依赖 a.jsb.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.jsb.js 会带入 c.jsc.js 会带入 secrets.jssecrets.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 一样,我们有多种修复方式:

  1. 可以把 secrets.js 的导入移出 c.js
  2. 可以重构 a.jsb.js,让它们不依赖 c.js
  3. 可以让 frontend/index.js 不再依赖 a.jsb.js

但这个方案的关键在于毒丸会自动沿着导入链向上传播。你无需为 a.jsb.jsc.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;

同样,这会让我们做出选择:

  1. 可以重构 c.js 让其适用于后端(并移除毒丸)。
  2. 可以重构 a.jsb.js,让它们不依赖 c.js
  3. 可以让 backend/index.js 不再依赖 a.jsb.js

我们还可以设想更细粒度的 client-onlyserver-only,应用于单个包的导入。例如,React 包可以声明像 useStateuseEffect 这样的 API 为 client-only,这样一旦被带到后端代码就会立即构建失败。(提示:React 实际上确实通过 package.json 的条件导出机制实现了这一点。)

你可能已经发现了一个规律。随着我们在前后端代码库间共享和复用的代码越来越多——甚至这两个代码库逐渐融合成一个——这些构建时断言为我们带来了安心。

并非每个模块都必须只属于某一端。实际上,大多数模块并不关心自己属于哪一端,因为它们本身不会引发兼容性问题。例如,a.jsb.js 并不会规定自己只能存在于某一端,因为它们不了解 c.js 的实现细节。但如果某个模块确实需要只属于一端,现在可以用 server-onlyclient-only 在本地表达。声明的不兼容性会递归地“感染”所有导入它的模块。

还要理解的是,server-onlyclient-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-onlyclient-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 现在已经很擅长这件事了。

Pay what you like

Discuss on Bluesky  ·  Edit on GitHub