React 为两台计算机而生
April 9, 2025
我已经尝试写这篇文章至少有十几次了。我不是在打比方;有段时间,我的桌面文件夹里真的有十几个被遗弃的草稿。这些草稿风格迥异——从严谨到混乱晦涩、甚至让人难以忍受的元叙事;它们总是突兀地开始,反复自我咀嚼,最终无疾而终。最后,我把它们一个个都扔掉了,因为全都写得很烂。
事实证明,我其实并不是在写一篇文章;实际上,我是在准备一场演讲。等我意识到这一点时,我已经在写这篇文章的过程中走得很远了。哎呀!幸运的是,React Conf 的组织者允许我临时换了一个新主题,于是八个月前我做了这场演讲。你可以在下方观看 React for Two Computers。
这场演讲讲的是大家最“喜欢”的话题——React Server Components。(也许并不是。)
我已经放弃了把这场演讲转成文章的想法,也不认为这是可能的。但我还是想记下一些补充演讲内容的笔记。我假设你已经看过了演讲。这里只是一些没能成型、没法串联起来的零散思考。
第一幕
配方与蓝图
标签和函数调用有什么区别?
这是一个标签:
<p>Hello</p>
这是一个函数调用:
alert('Hello');
一个区别在于 <
和 >
看起来尖锐而有棱角,而 (
和 )
则圆润柔和。但我不是指这个,这只是视觉上的差别。那么它们在工作方式、意义和我们对它们的期望上有什么不同呢?
当然,如果不指定语言,标签或函数调用本身并没有特定含义。例如,JavaScript 的函数调用和 Haskell 的函数调用可能行为不同;HTML 的标签和 ColdFusion 的标签也不一样。尽管如此,正因为我们熟悉主流语言中它们的用法,我们对标签和函数调用有一些预期。尖括号 <
>
和圆括号 (
)
都承载着一套联想和直觉。我想深入探讨这些直觉。
让我们先看看 alert('Hello')
和 <p>Hello</p>
有什么共同点:
- 我们通过名字引用函数或标签。 按惯例,函数调用通常以动词开头(如
createElement
、printMoney
、querySelectorAll
),而标签通常用名词命名(比如p
代表段落)。这不是硬性规则(alert
既像动词也像名词;b
代表加粗),但大多数时候如此。(为什么会这样?) - 我们可以向函数或标签传递信息。 前面我们传递了一段文本(
'Hello'
)给标签和函数。但我们并不限于传递一个字符串。在 JavaScript 的函数调用中,我们可以传递多个参数,包括字符串、数字、布尔值、对象等。在 HTML 标签中,我们可以传递多个属性,但属性值不能是对象或其他复杂数据结构——这很受限。幸运的是,JSX 中的标签(以及许多基于 HTML 的模板语言)允许我们传递对象和其他丰富的值。 - 函数调用和标签都可以深度嵌套。 例如,我们可以写
alert('Hello, ' + prompt('Who are you?'))
来表达两个函数调用的关系:内部的prompt
调用结果与字符串拼接后传给外部的alert
调用。(如果不确定,可以在控制台试试。)虽然函数调用中嵌套很常见,但标签的嵌套几乎是“游戏的本质”。你几乎不会看到一个标签孤零零地存在,不被其他标签包围。(为什么会这样?)
显然,函数调用和标签非常相似。它们都允许我们向一个有名字的东西传递信息,并且如果需要,还可以通过进一步嵌套来扩展(向有名字的东西传递更多信息(无限嵌套(太棒了!)))。
我们也开始看到它们之间的根本区别。首先,函数调用往往是动词,而标签往往是名词。此外,你会更常遇到深度嵌套的标签,而不是深度嵌套的函数调用。
这是怎么回事?
先说后者。为什么标签总是喜欢“抱团”?标签天生就喜欢靠近其他标签,直到——啪——组合在一起?也许这些尖角家伙真的渴望连接?也许如此,但请考虑:也许我们喜欢用标签来描述深度嵌套的结构,因为我们能看到每个标签的 </end>
,不用猜测哪个 )
是在闭合。
标签并不会导致深度嵌套——而是我们选择用标签来描述深度嵌套的结构。(回想一下,尽管 JSX 刚出来时几乎被全社区否定,JavaScript 社区最终还是广泛采用了它。标签嵌套的便利实在难以割舍!)
好吧,假设我们更喜欢用标签来描述嵌套。但为什么标签更常用名词而不是动词?这只是巧合,还是有更深层的原因?
这更像是我的一个猜想:我认为是因为名词比动词更容易分解。名词描述的是事物,而事物往往可以完全通过其他事物的组合来描述。例如,一栋建筑由楼层组成,楼层由房间组成,房间由人组成,人由水组成。注意,这种描述是无时间性的——不是说它是经典,而是说它描述的是某一时刻的快照,就像电影中的一帧,或者像一张蓝图。你可以把时间因素省略掉,这种描述依然很有用。
而动词往往描述的是过程,是随时间发生的——它们是有时间性的!想想做菜的配方:“加热锅,放黄油,等黄油融化,然后倒入鸡蛋。”虽然这里也有组合的机会(比如“如何打蛋”?),但步骤的顺序至关重要!你必须时刻关注先做哪一步、再做哪一步、以及每一步之间要做什么决策。和蓝图不同,配方有明确的顺序,也有某种紧迫感。
那么,这和标签与函数调用有什么关系?
配方规定了一系列按顺序执行的步骤。它由动词组成,但很少有大量嵌套。(实际上,嵌套反而会让顺序变得模糊。)每一步可能会改变某些东西,或依赖于前面的步骤,因此必须严格按照书写顺序自上而下执行。这些配方,也就是命令式程序,通常用函数调用来写:
const eggs = crackEggs();
heat(fryingPan);
put(fryingPan, butter);
await delay(30000);
put(fryingPan, eggs);
而蓝图描述的是一个事物由哪些名词组成。它并不规定具体的操作顺序——只描述整体如何被拆分为各个部分。这就是为什么这些蓝图,也就是声明式程序,天然会变得很深的嵌套,因此用标签来写更方便:
<Building>
<Roof />
<Floor>
<Room />
<Room>
<Person name="Alice" />
<Person name="Bob" />
</Room>
</Floor>
<Basement />
</Building>
许多现实世界的程序其实是两者结合的。例如,一个典型的 React 组件中,事件处理器里的函数调用是命令式配方,而返回的 JSX 标签则是声明式蓝图。
但归根结底,我们的程序必须做点什么。配方是可以立即执行的——不会留下下一步该做什么的歧义。一步接一步,做完就结束。而蓝图只是蓝图——一份详细的构建计划。只有当某个配方决定去实现蓝图时,它才会“活”起来。(比如 React 会根据你的 JSX 蓝图构建 DOM。)
某种意义上,蓝图几乎就是配方,只不过它更被动、惰性、等待未来的解释。它是配方,但把时间因素剥离了。剥离了时间,剩下的只有结构——事物、名词、标签。
蓝图是潜在的配方。它是一份计划——是否会被执行,取决于是否有配方去实现它。
现在,蓝图由标签组成,配方由函数调用组成。如果说蓝图是潜在的配方,那么……标签就是潜在的函数调用。
等等,这是什么意思?
Await 与 RPC
假设你想调用一个函数。这很容易:
alert('Hello');
你可以相当确定,只要这个函数执行完,下一行代码就会立刻运行。尤其是,你可以很方便地获取一个函数调用的结果,并立即用于下一个函数调用:
const name = prompt('Who are you?');
alert('Hello, ' + name);
console.log('Done.');
但假设你想调用的函数在另一台计算机上。这就麻烦了,但现实中确实会遇到。
标准做法是发起某种网络请求。我们在这方面已经有很多成熟方案,比如 HTTP,甚至更底层的协议。大多数人一辈子都没搞清楚数据包是怎么通过海底光缆传输的。真是神奇。
问题在于,程序必须等到网络请求结束才能继续。如果不和另一台计算机通信,就无法获得 name
,所以在调用 alert
之前,代码必须“暂停”执行。
假设你是第一个遇到这个问题的人。
你可能会想到发明一个 callNetwork
API,接受一个函数参数:
callNetwork('https://another-computer/?fn=prompt&args=Who+are+you?', (response) => {
const name = response;
alert('Hello, ' + name);
console.log('Done.');
});
一旦响应到达,你的 callNetwork
API 就会用 response
调用传入的函数,剩下的代码也就能照常运行了。
这作为第一个想法其实还不错。但也有明显问题:
- 网络调用把代码缠绕在一起了。 原本代码是自上而下顺序执行的,现在却拐了个弯。概念上
alert('Hello' + name)
是“下一步”,但我们不得不把它嵌套在callNetwork
里,告诉计算机“等一等”。 - 我们割裂了代码之间的联系。 通常你想调用一个函数,直接调用就行。如果在另一个文件,就
export
再import
。但现在我们面对的不是函数调用,而是 HTTP 调用。经过几十年 REST API 的洗礼,这种割裂可能不易察觉,但我们确实丢失了某些本质。比如,类型检查没了!接口可能根本不存在。你也不能 command-click 跳转到定义处看实现。原本函数调用和被调用处有直接的、可感知的联系,现在没了——不是因为你想要概念隔离,而是没有别的办法保留这种联系。
如果我们假设 alert
也在另一台计算机上(甚至是不同的机器),问题会更明显:
callNetwork('https://another-computer/?fn=prompt&args=Who+are+you?', (response) => {
const name = response;
callNetwork('https://yet-another-computer/?fn=alert&args=Hello,+' + name, () => {
console.log('Done.');
});
});
那怎么解决这两个问题?
你会想到两个主意。
为了解决第一个问题(代码“缠绕”),你引入了 async
函数的概念。async
函数不保证一步执行完——它可能会“暂停”执行(比如遇到网络请求)。既然可能暂停,调用方就需要用 await
来显式表示“我知道这里会暂停”。这样,调用链上的每一层都需要标记为 async
,以保证没人会对代码“暂停”感到意外。至少你是这么想的。
这个想法很不错——事实上,几乎所有新语言都把它当作标配。你也不用再说服别人它有多好。
这样代码就变成了:
const name = await callNetwork('https://another-computer/fn=prompt&args=Who+are+you?');
await callNetwork('https://yet-another-computer/fn=alert&args=Hello,+' + name);
console.log('Done.');
接下来要解决第二个问题。你其实是想在一台计算机上调用 prompt
,在另一台计算机上调用 alert
。假设这些函数都在你的代码库里定义。
如果你能直接import它们呢?
import { prompt } from 'another-computer';
import { alert } from 'yet-another-computer';
const name = await prompt('Who are you?');
await alert('Hello, ' + name);
console.log('Done.');
但这样其实没解决问题。比如 TypeScript 不认识 'another-computer'
。那如果你直接从本地文件 import:
import { prompt, alert } from './stuff';
const name = await prompt('Who are you?');
await alert('Hello, ' + name);
console.log('Done.');
但这只是普通的 import。它会把函数带到本地,而你想要的是让它们部署在另一台计算机上。你想通过 HTTP 跨网络边界调用,需要在代码里表达出来。
那我们发明一个特殊语法,比如 import rpc
,因为这种方式几十年来都叫 RPC,即“远程过程调用”:
import rpc { prompt, alert } from './stuff';
const name = await prompt('Who are you?');
await alert('Hello, ' + name);
console.log('Done.');
假设 TypeScript 不仅能跳转到定义,还能知道这些函数在远程,需要声明为 async
,并确保参数和返回值都是可序列化的(能在网络上传输)。
好吧,async
/ await
和 import rpc
,今天的发明就到这。
除非……
Call Me Maybe
有同事来找你:“那个 async
/ await
和 import rpc
很棒,真的很棒。但只有在对方计算机会回信时才行。想象一下有台计算机不会回信,你怎么调用它的函数?”
这问题听起来很荒谬,但你还是琢磨了一下。
如果对方计算机不会回信……那你当然无法知道什么时候执行完,所以 await
就没用了。你不能这样写:
await alert('Hello, ' + name);
更糟糕的是,如果函数所在的计算机不会回信,你也拿不到任何返回值,所以这也不行:
const name = await prompt('Who are you?');
你本想放弃,但又想再想想。虽然你不能把信息传回来……但你还是可以传过去。
比如,这里只是单向传递信息:
alert('Hello');
即使对方计算机不会回信,这里你只是让它调用 alert
,传递 'Hello'
字符串。你并不需要任何返回值。
所以这个调用是可以做的!不过……它不像普通函数调用,所以用同样的语法不太对。通常大家期望函数调用结束后,后面的代码才会执行,但这里无法保证。事实上,调用可能根本不会成功——如果网络中断,你也不会知道。和 RPC 不同,这里不会收到网络错误通知。
这不是函数调用。这是……潜在的函数调用。也许未来会发生,也许不会。你可以说它是一份函数调用的蓝图。
我们为这种“潜在调用”发明个语法:
alert⧼'Hello'⧽;
语法以后可以再改。现在先想想语义,即它到底要做什么。设计时,最好从限制出发(“对方计算机不会回信”),看看这些“潜在调用”在语义上会受到哪些约束。
显然,这种“潜在调用”不会阻塞执行,也不会影响后续代码。它们不需要“等待”任何东西,因为没什么可等的:
alert⧼'Hello'⧽; // 无法知道是否/何时成功或失败
console.log('Done.') // 立刻执行
那这些“潜在调用”应该返回什么?
const name = prompt⧼'Who are you?'⧽;
console.log(name); // ???
很明显,prompt⧼'Who are you?'⧽
不可能返回实际的 prompt 结果,因为对方计算机不会回信。我们可以规定这种语法总是返回 undefined
,但这太受限了。我们就无法协调 prompt
和 alert
的“潜在调用”!
我们想实现的是这样:
const name = prompt⧼'Who are you?'⧽;
alert⧼'Hello, ' + name⧽;
但问题是,代码没法这样写,因为拿不到 prompt
的返回值。既不能声明 name
变量,也不能在本地拼接字符串。不过,我们可以把这两行完全用“潜在调用”表达:
alert⧼
concat⧼
'Hello, ',
prompt⧼'Who are you?'⧽
⧽
⧽;
(假设 concat
是全局函数 (a, b) => a + b
。)
这样做有两个好处。第一,避免了声明毫无意义的 name
变量(因为我们还不在对方计算机上)。第二,我们可以把这些嵌套的“潜在调用”当作一个表达式,方便用 JSON 编码:
{
fn: 'alert',
args: [{
fn: 'concat',
args: ['Hello, ', {
fn: 'prompt',
args: ['Who are you?']
}]
}]
}
然后我们可以把这个 JSON 发送给对方计算机(它不会回信!),让它用类似这样的函数解释:
function interpret(json) {
if (json && json.fn) {
// 根据名字找到全局函数
let fn = window[json.fn];
// 递归解释嵌套的潜在调用
let args = json.args.map(arg => interpret(arg));
// 实际执行调用
let result = fn(...args);
// 如果返回值还有潜在调用,继续解释
return interpret(result);
} else {
return json;
}
}
你可以在控制台验证,把上面的 JSON 传给 interpret()
,效果和原始代码一样。(别忘了定义全局 concat
!)
换句话说,这种方式可行!
再看一遍这种语法:
alert⧼
concat⧼
'Hello, ',
prompt⧼'Who are you?'⧽
⧽
⧽;
我们已经看到,“潜在调用”之间的依赖,比如 prompt
和 alert
,应该通过嵌套表达。我们不能在它们之间插入代码,除非代码也在对方计算机上。在本地,它们更像……标记。
既然没有别的组合方式,嵌套层级自然会很深。所以最好让语法更易读:
<alert>
<concat>
Hello,
<prompt>Who are you?</prompt>
</concat>
</alert>
注意一个奇特的现象。
普通函数调用时,返回值由被调用函数决定:
const result = prompt('Who are you?');
console.log(result); // 'Dan'
但“潜在函数调用”时,返回值是调用本身的数据结构:
const inner = <prompt>Who are you?</prompt>;
// { fn: 'prompt', args: ['Who are you?'] }
const outer = <concat>Hello, {inner}</concat>;
// {
// fn: 'concat',
// args: ['Hello', { fn: 'prompt', args: ['Who are you?'] }]
// }
const outest = <alert>{outer}</alert>;
// {
// fn: 'alert',
// args: [{
// fn: 'concat',
// args: ['Hello', { fn: 'prompt', args: ['Who are you?'] }]
// }]
// }
这些调用还没有被执行——我们只是在构建这些调用的蓝图:
<alert>
<concat>
Hello,
<prompt>Who are you?</prompt>
</concat>
</alert>
这种“潜在调用”的蓝图看起来像代码,但本质上是数据。它在结构上类似函数调用,但更被动、惰性、等待解释。我们还没发送这份蓝图给对方计算机去解释。
总之,老写“潜在函数调用”有点烦。
我们就叫它“标签”吧。
拆分函数
来看一个函数:
function greeting() {
const name = prompt('Who are you?');
alert('Hello, ' + name);
}
运行它会一次性执行完。函数通常如此。
假设你想把它拆成两部分。第一部分立即执行,第二部分由调用者决定何时执行。
可以这样做:
function greeting() {
const name = prompt('Who are you?');
return function resume() {
alert('Hello, ' + name);
};
}
现在你可以分两步运行:
const resume = greeting(); // 执行第一部分
resume(); // 执行第二部分
如果你想让第二部分在另一台计算机上运行。你还是把它当作一个整体的计算,只是物理上分布了。
“很简单!”你说:
function greeting() {
const name = prompt('Who are你?');
return `function resume() {
alert('Hello, ' + name);
}`;
}
等等,这么做其实是把剩下的代码作为字符串返回,这样可以传到另一台计算机继续执行。但这样不行——在对方计算机上,name
是未定义的:
function resume() {
alert('Hello, ' + name); // 🔴 ReferenceError: name is not defined
}
“没关系,”你说:
function greeting() {
const name = prompt('Who are you?');
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
啊,你把第一台计算机拿到的 name
值直接嵌入到要发送的代码里。对方看来,name
是预先计算好的:
function resume() {
alert('Hello, ' + "Dan");
}
实际上,这个函数完全不知道自己只是更大流程的一部分。对它来说,世界从第二台计算机开始。如果函数更复杂,它可能会以为自己就是全部。这没问题。
但你见过全部:
function greeting() {
const name = prompt('Who are you?');
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
这是个有趣的结构——程序返回自身剩余部分,可以通过网络转移到另一台机器继续执行。你可以称之为跨网络的闭包。注意几点:
- 数据流严格单向——从第一台到第二台计算机。 第二部分可以看到第一部分的值(只要能转成文本)。但第一部分对第二部分一无所知。第一部分写剧本,第二部分上台表演。
- 两部分完全隔离。 虽然它们是一个概念上的程序,但运行环境完全独立。运行时无法协调,各自有自己的模块系统、全局变量,甚至可能是不同的 JS 引擎。
- 边界既坚固又灵活。 坚固是因为确实是两个环境——只有闭包的数据能共享。灵活是因为你可以选择把哪些代码放在哪一边,何时在第二台机器上多运行点代码,何时直接传递预计算的数据。
最后一点值得展开。假设你要写 FizzBuzz,对 1 到 n 每个数弹窗,能被 3 整除弹 ‘Fizz’,能被 5 整除弹 ‘Buzz’,都能整除弹 ‘FizzBuzz’:
function fizzBuzz() {
const n = Number(prompt('How many?'));
for (let i = 1; i <= n; i++) {
if (i % 3 === 0 && i % 5 === 0) {
alert('FizzBuzz');
} else if (i % 3 === 0) {
alert('Fizz');
} else if (i % 5 === 0) {
alert('Buzz');
} else {
alert(i);
}
}
}
假设这是给两台计算机写的程序。你可以有多种拆分方式。比如,所有工作都在第二台计算机做:
function fizzBuzz() {
return `function resume() {
const n = Number(prompt('How many?'));
for (let i = 1; i <= n; i++) {
if (i % 3 === 0 && i % 5 === 0) {
alert('FizzBuzz');
} else if (i % 3 === 0) {
alert('Fizz');
} else if (i % 5 === 0) {
alert('Buzz');
} else {
alert(i);
}
}
}`;
}
但你也可以把 prompt
放在第一台计算机,n
作为数据传给第二部分:
function fizzBuzz() {
const n = Number(prompt('How many?'));
return `function resume() {
const n = ${JSON.stringify(n)};
for (let i = 1; i <= n; i++) {
if (i % 3 === 0 && i % 5 === 0) {
alert('FizzBuzz');
} else if (i % 3 === 0) {
alert('Fizz');
} else if (i % 5 === 0) {
alert('Buzz');
} else {
alert(i);
}
}
}`;
}
对第二台计算机来说,n
是硬编码的。
你甚至可以在第一台计算机预先计算所有消息:
function fizzBuzz() {
const n = Number(prompt('How many?'));
const messages = [];
for (let i = 1; i <= n; i++) {
if (i % 3 === 0 && i % 5 === 0) {
messages.push('FizzBuzz');
} else if (i % 3 === 0) {
messages.push('Fizz');
} else if (i % 5 === 0) {
messages.push('Buzz');
} else {
messages.push(i);
}
}
return `function resume() {
const messages = ${JSON.stringify(messages)};
messages.forEach(alert);
}`;
}
这样第二台计算机只需遍历消息。例如,n=16 时,第二台计算机看到的就是:
function resume() {
const messages = [1,2,"Fizz",4,"Buzz","Fizz",7,8,"Fizz","Buzz",11,"Fizz",13,14,"FizzBuzz",16];
messages.forEach(alert);
}
预计算 messages
的缺点是数据量随 n 增大而增大。由于 FizzBuzz 算法很简单,传递 n 让第二台计算机自己算更明智。关键在于你可以选择传递数据还是传递代码。
回到最初的例子。
我们已经说明,把程序拆分到两台计算机,可以灵活地移动计算。但实际上,你大概不想把一半代码写在字符串里:
function greeting() {
const name = prompt('Who are you?');
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
更理想的是把 resume
写在另一个文件,然后import:
import { resume } from './stuff';
function greeting() {
const name = prompt('Who are you?');
return resume(name);
}
但等等,resume
不能是普通 import——你想让它的代码被发送到另一台计算机!你不想导入函数本身或在本地运行它;你只是想引用它。这让你想起了 RPC 的 import rpc
,那我们再发明一个类似的注解,表示函数要被发送到另一台计算机:
import tag { resume } from './stuff';
function greeting() {
const name = prompt('Who are you?');
return resume(name);
}
为什么叫 import tag
?因为这个函数在不会“回信”的计算机上,你无法直接调用它。你最多只能做“潜在调用”——也就是标签!
import tag { resume } from './stuff';
function greeting() {
const name = prompt('Who are you?');
return <resume name={name} />;
}
(后面我们还会再讨论 import rpc
和 import tag
语法。)
像这样拆分的程序通常被称为客户端-服务器应用。
import tag { Client } from './stuff';
function Server() {
const data = precomputeData();
return <Client data={data} />;
}
你可能会把客户端和服务器看作两个独立的程序相互通信。但现在你知道,这其实是一个函数,通过把自身剩余部分跨越时空发送出去,实现了“网络闭包”。
祝你以后再也无法“看不见”这一点。
两边的标签
前面我们发明了标签:
function greeting() {
return (
<alert>
<concat>
Hello,
<prompt>Who are you?</prompt>
</concat>
</alert>
);
}
提醒一下,标签很像函数调用,但它们并不真正调用任何东西——只是反映调用的结构。因此,它们非常适合表示你希望发生的计算——但也许不是现在,也许不是在这里。标签代表一份计划,一份蓝图:
function greeting() {
return {
fn: 'alert',
args: [{
fn: 'concat',
args: ['Hello, ', {
fn: 'prompt',
args: ['Who are you?']
}]
}]
};
}
标签本身什么都不做。需要有代码去解释它们。我们之前已经见过一种解释方法:
function interpret(json) {
if (json && json.fn) {
let fn = window[json.fn];
let args = json.args.map(arg => interpret(arg));
let result = fn(...args);
return interpret(result);
} else {
return json;
}
}
运行代码 可以看到 interpret(greeting())
得到预期结果。
不过,解释本身是主观的。对同一个东西有不止一种解释方式。这正是解释的意义所在,允许灵活性。
在前面的例子里,interpret
直接在全局 window
作用域查找实现每个标签的函数,比如 window.alert
、window.prompt
等。现在我们来做一个稍有不同的版本。这个版本会接收一个显式的 knownTags
字典,包含这些函数。未知标签会被跳过。
看这里:
function interpret(json, knownTags) {
if (json && json.fn) {
if (knownTags[json.fn]) {
let fn = knownTags[json.fn];
let args = json.args.map(arg => interpret(arg, knownTags));
let result = fn(...args);
return interpret(result, knownTags);
} else {
let args = json.args.map(arg => interpret(arg, knownTags));
return { fn: json.fn, args };
}
} else {
return json;
}
}
现在,如果你传入空的 knownTags
,会得到原始的调用树:
interpret(greeting(), {});
// {
// fn: 'alert',
// args: [{
// fn: 'concat',
// args: ['Hello, ', {
// fn: 'prompt',
// args: ['Who are you?']
// }]
// }]
// };
但是,注意如果你传入 { prompt: window.prompt }
:
interpret(greeting(), {
prompt: window.prompt
});
现在会先弹出输入框(prompt
确实执行了),然后得到这个树:
// {
// fn: 'alert',
// args: [{
// fn: 'concat',
// args: ['Hello, ', 'Dan' /* (或你输入的内容) */]
// }]
// };
你还是得到调用树,但这次 prompt
已经“溶解”了!
再试试同时“溶解” prompt
和 concat
(但不溶解 alert
):
interpret(greeting(), {
prompt: window.prompt,
concat: (a, b) => a + b,
});
这次会先弹出输入框,但 alert
的消息已经拼接好了——没有 concat
了:
// {
// fn: 'alert',
// args: ['Hello, Dan']
// };
也就是说,除了 alert
以外,其他都被预先计算了。
再试试同时“溶解” alert
、prompt
和 concat
:
interpret(greeting(), {
alert: window.alert,
prompt: window.prompt,
concat: (a, b) => a + b,
});
// undefined
这次所有步骤都执行了,什么都不剩了。
因为标签蓝图是无时间性的——它不规定操作顺序,只规定结构——你可以自由操控执行顺序。例如,你可以把一个计算拆成多步:
const step1 = greeting();
// {
// fn: 'alert',
// args: [{
// fn: 'concat',
// args: ['Hello, ', {
// fn: 'prompt',
// args: ['Who are you?']
// }]
// }]
// };
const step2 = interpret(step1, {
prompt: window.prompt,
concat: (a, b) => a + b,
});
// {
// fn: 'alert',
// args: ['Hello, Dan']
// };
interpret(step2, {
alert: window.alert,
});
// undefined
这也许会给你启发。
如果你把 step1
和 step2
放在不同的计算机上运行呢?也就是说,在第一台计算机上“溶解”部分标签,然后把剩下的标签通过网络发送到另一台计算机,在那里继续“溶解”。如果某些标签天然更适合在某一端解释,这会很有用——比如两台机器有不同能力。
想想水的三态变化:先在山顶融化成水,然后河流流下,最后水蒸发。同理,标签也可以。部分标签可以在第一台计算机提前“融化”,剩下的标签流经网络,到另一台计算机“终结”。
两台计算机
你的理论还很模糊,有时你觉得它很荒谬,但它的轮廓开始浮现。如果让你总结到目前为止的内容,你会这样说:
有些程序是分布式计算,跨越多台机器。特别是,有些程序可以表示为跨越两台机器的函数(当然原则上可以更多)。这些函数有个特殊形态——第一台机器做一部分计算,然后把剩下的计算“交给”第二台机器,通过发送剩余代码实现。你的理论正关注这些函数。
我们给两台机器的环境起个名字。你的程序始于早期世界(Early)——第一台机器。有一部分工作会在那里完成。然后剩下的工作会被传递到晚期世界(Late)——第二台机器。
早期世界和晚期世界是完全隔离的运行环境,隔着时空,没有共享状态或全局变量。早期世界可以为晚期世界留下残余信息——比如剩余要运行的代码和所需数据——但仅此而已。
早期世界和晚期世界不会直接 import
彼此的代码,因为那样会把代码带入本地世界。它们做的,是引用彼此的代码。import tag
和 import rpc
都是引用另一台计算机代码的例子(而且是类型安全的!),并能在不实际加载的情况下做有用的事。
由于隔离坚固,早期世界的函数无法调用晚期世界的函数。毕竟函数调用是为了把信息传回调用者,但调用者早已不在。
但把信息传递到未来,从早期到晚期世界,依然有意义。为此,你发明了比函数调用更弱的概念——标签。标签像函数调用,但被动、惰性、等待解释。它是潜在的函数调用,随时可以被执行,也可以等更合适的时机,甚至永远不执行。标签是原型调用。
你得意地看着理论的线索终于汇聚在一起。突然,Boss 音乐响起。
有人说“时间”吗?
时间的反击
你的第一个 Boss 是时间本身。要打败时间,你必须证明你的无时间性蓝图真的无时间性——即改变计算顺序不会破坏程序。你最好是对的!
这是你之前的 greeting
函数:
function greeting() {
return (
<alert>
<concat>
Hello,
<prompt>Who are you?</prompt>
</concat>
</alert>
);
}
我稍微加强一下,让 Boss 战更有趣(也更吓人)。
你可能经常想把 alert
和 concat
组合,所以我把它们提取成一个函数,叫 p
,代表“段落”。
function p(...children) {
return (
<alert>
<concat>
{children}
</concat>
</alert>
);
}
现在 greeting
可以只返回 p
标签:
function greeting() {
return (
<p>
Hello,
<prompt>Who are you?</prompt>
</p>
);
}
我还加了一个 clock
函数,返回当前时间:
function clock() {
return new Date().toString();
}
最后,加一个 app
函数,把 greeting
和 clock
组合在 p
里:
function app() {
return [
<greeting />,
<p>The time is: <clock /></p>
];
}
现在是时候让 interpret
支持数组了——很简单:
function interpret(json, knownTags) {
if (json && json.fn) {
if (knownTags[json.fn]) {
let fn = knownTags[json.fn];
let args = json.args.map(arg => interpret(arg, knownTags));
let result = fn(...args);
return interpret(result, knownTags);
} else {
let args = json.args.map(arg => interpret(arg, knownTags));
return { fn: json.fn, args };
}
} else if (Array.isArray(json)) {
return json.map(item => interpret(item, knownTags));
} else {
return json;
}
}
好,看看 interpret
是否能胜任。
先试试全部一起解释:
interpret(app(), {
alert: window.alert,
prompt: window.prompt,
concat: (a, b) => a + b,
p: p,
greeting: greeting,
clock: clock,
});
// [undefined, undefined]
运行代码 得到预期结果:
- 弹出输入框问名字
- 弹窗显示
Hello, Dan
- 再弹窗显示当前时间
一切正常!
现在,你要证明的是,既然这些只是蓝图——标签还没变成调用——你可以随意改变执行顺序。
来试试。
先只“溶解”一半标签(p
、greeting
、clock
):
const step2 = interpret(app(), {
// alert: window.alert,
// concat: (a, b) => a + b,
// prompt: window.prompt,
p: p,
greeting: greeting,
clock: clock,
});
// [
// { fn: 'alert', args: [{ fn: 'concat', args: ['Hello', { fn: 'prompt', args: ['Who are you?'] }] }] },
// { fn: 'alert', args: [{ fn: 'concat', args: ['The time is ', 'Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)'] }] }
// ]
啪。
果然,什么都没发生……现在你可以把中间结果传给 interpret
,溶解剩下的标签(alert
、concat
、prompt
):
interpret(step2, {
alert: window.alert,
concat: (a, b) => a + b,
prompt: window.prompt,
// p: p,
// greeting: greeting,
// clock: clock,
});
// [undefined, undefined]
一切正常:
- 弹出输入框问名字
- 弹窗显示
Hello, Dan
- 再弹窗显示当前时间
恭喜!
你证明了用标签(而非函数调用)描述的计算可以拆分成多步、任意顺序执行——成功打败了时间。
除非……
你试试只把 concat
留到最后,其余标签都一起溶解:
interpret(app(), {
alert: window.alert,
prompt: window.prompt,
// concat: (a, b) => a + b,
p: p,
greeting: greeting,
clock: clock,
});
在无时间性蓝图里,晚点执行 concat
应该没问题吧?
你运行代码:
- 弹出输入框问名字
- 弹窗显示
[object Object]
- 再弹窗显示
[object Object]
你死了。
致命缺陷
发生了什么?
原来你的理论有个漏洞。即使用标签描述程序,时间其实很重要!至少对某些函数来说如此。
来看这个例子:
<concat>
Hello,
<prompt>Who are you?</prompt>
</concat>
当两个标签嵌套时,应该按什么顺序解释?是先解释 <prompt>
,把结果传给 concat
?还是 concat
得到 <prompt>
本身作为标签?
先看普通函数调用的行为:
concat(
'Hello, ',
prompt('Who are you?') // 先执行这个
)
在 JS 里,调用函数时参数会先计算,然后函数才被调用:
function concat(a, b) {
// a 是 'Hello, '
// b 是 'Dan'
return a + b;
}
我们的 interpret
处理标签时也是这样。遇到 <concat>
,会先递归解释参数(比如 <prompt>
),然后再调用 concat()
:
function interpret(json, knownTags) {
if (json && json.fn) {
if (knownTags[json.fn]) {
let fn = knownTags[json.fn];
let args = json.args.map(arg => interpret(arg, knownTags));
let result = fn(...args);
return interpret(result, knownTags);
} else {
// ...
}
} else {
// ...
}
}
所以这段代码:
<concat>
Hello,
<prompt>Who are you?</prompt>
</concat>
等价于:
concat(
'Hello, ',
prompt('Who are you?') // 先执行
)
但这样似乎有点问题。
标签不是应该是无时间性的蓝图吗?为什么还要受限于 JS 的参数先算顺序?如果标签最终和函数调用行为一样,那“标签”还有什么意义?
那还能怎么做?
如果这样:
<concat>
Hello,
<prompt>Who are you?</prompt>
</concat>
等价于:
concat( // 先执行这个
'Hello, ',
<prompt>Who are you?</prompt>
)
假设标签是外层先解释,而不是内层先。这样 <concat>
里 <prompt />
还只是个标签:
function concat(a, b) {
// a 是 'Hello, '
// b 是 { fn: 'prompt', args: ['Who are you?'] }
return a + b;
}
当然,这会彻底搞坏 concat
,因为它只能拼接字符串,不能拼接还没执行的标签。
这个问题不仅仅是 concat
。比如 alert
也需要字符串,它无法处理标签对象:
alert({ fn: 'concat', args: [/* ... */] });
或者说,它会处理——把对象转成字符串 [object Object]
。
这就解释了 Boss 战发生的事!
虽然我们的 interpret
通常会先处理参数,但我们特意延迟了 <concat>
的解释,想证明顺序无关紧要。结果发现,顺序其实很重要——concat
和 alert
都需要参数是字符串。
看来你的无时间性蓝图并不那么无时间。函数需要参数先算好。时间就藏在这里。
你的理论有个致命缺陷。
新的希望
你的理论有致命缺陷。你可以有三种选择。
你可以假装它不存在。但这无法修正理论。
你可以放弃理论。但你明明快要成功了,不是吗?
最后,你可以让这个缺陷引导你。就像一次精心设计的失败实验,它告诉你重要的信息。你犯了错,但到底错在哪里?
有个好办法可以找出来。
目前,我们总是先递归解释嵌套标签,再调用父标签的函数,保证标签函数内层先执行:
function interpret(json, knownTags) {
if (json && json.fn) {
if (knownTags[json.fn]) {
let fn = knownTags[json.fn];
let args = json.args.map(arg => interpret(arg, knownTags));
let result = fn(...args);
return interpret(result, knownTags);
} else {
let args = json.args.map(arg => interpret(arg, knownTags));
return { fn: json.fn, args };
}
} else if (Array.isArray(json)) {
return json.map(item => interpret(item, knownTags));
} else {
return json;
}
}
如果我们改成直接传递原始参数(即使参数里有标签)呢?
function interpret(json, knownTags) {
if (json && json.fn) {
if (knownTags[json.fn]) {
let fn = knownTags[json.fn];
let args = json.args;
let result = fn(...args);
return interpret(result, knownTags);
} else {
let args = json.args.map(arg => interpret(arg, knownTags));
return { fn: json.fn, args };
}
} else if (Array.isArray(json)) {
return json.map(item => interpret(item, knownTags));
} else {
return json;
}
}
当然,这会完全搞坏之前的例子。记住,alert()
不能处理对象参数 <concat>
,concat()
也无法处理对象参数 <prompt>
,它需要字符串:
const tags = (
<concat>
Hello, <prompt>Who are you?</prompt>
</concat>
);
interpret(tags, {
concat: (a, b) => a + b,
prompt: window.prompt,
});
// 'Hello, [object Object]'
但如果彻底接受这个“缺陷”,也许能发现什么是可行的。
比如,把 <concat>
换成 <p>
,就不会坏掉:
function p(...children) {
return (
<alert>
<concat>
{children}
</concat>
</alert>
);
}
// ...
const tags = (
<p>
Hello, <prompt>Who are you?</prompt>
</p>
);
interpret(tags, {
p: p,
prompt: window.prompt,
});
// { fn: 'alert', args: [{ fn: 'concat', args: ['Hello, ', 'Dan'] }] }
这看起来微不足道(我们还是要晚点执行 concat
)。但其实很重要!concat
和 p
本质上有区别。外层先调用会搞坏 concat
,但不会搞坏 p
。
为什么正是这样?
嵌入与内省
来看两个函数:
function concat(a, b) {
return a + b;
}
function pair(a, b) {
return [a, b];
}
它们有何不同?
显然,功能不同。一个拼接字符串,一个创建数组。但还有更微妙的区别:它们对参数的处理方式不同。
打个比方。
假设你的工作是把两根绳子绑在一起。这不难。你拿两根绳子,打个结,搞定。现在有人递给你一根绳子和一个南瓜。你就没法工作了。你需要抓住绳子的末端,但南瓜没有末端。
你可能会得出结论:随便换成南瓜会出事,确实有时如此。但并非总是如此。
假设你在玩具店包礼物。你每天包各种礼物,娃娃、小汽车、甚至整个玩具屋。有人递给你一个南瓜。虽然你可能会拒绝,但技术上你完全可以包南瓜。你包东西时,不关心它是什么(不像绳子那样需要末端)。你只是把它放进盒子。你是在嵌入,不是内省。
concat
和 pair
的区别就在于,concat
关心参数是什么。它内省。你给它南瓜就不行。但 pair
无所谓。它嵌入,所以什么都能接受。
这和执行顺序有什么关系?
因为 concat
内省参数 a
和 b
(+
会把它们转成字符串),如果参数是未解释的标签就会坏掉:
concat('Hello ', <prompt>Who are you?</prompt>);
// 'Hello, [object Object]'
而 pair
嵌入参数。它只创建 [a, b]
数组,无论 a
或 b
是什么都行。可以直接嵌入标签:
const todo = pair('Hello ', <prompt>Who are you?</prompt>);
// ['Hello, ', { fn: 'prompt', args: ['Who are you?'] }]
这样你可以在之后再解释标签:
const result = interpret(todo, { prompt: window.prompt });
// ['Hello, ', 'Dan']
总结一下。
通常,函数希望参数先算好再调用。但如果函数只是嵌入参数而不内省,你可以延迟计算。你可以把未计算的标签作为参数传给函数,等需要时再计算。
也许你真的找到了打败时间的方法。
思考与行动
你的程序还是这样:
function app() {
return [
<greeting />,
<p>The time is: <clock /></p>
];
}
function clock() {
return new Date().toString();
}
function greeting() {
return (
<p>
Hello,
<prompt>Who are you?</prompt>
</p>
);
}
function p(...children) {
return (
<alert>
<concat>
{children}
</concat>
</alert>
);
}
function alert(message) {
window.alert(message);
}
function prompt(message) {
return window.prompt(message);
}
function concat(a, b) {
return a + b;
}
但你的 interpret
更简单了——它外层先解释标签,不会先解释参数,而是把标签传给标签。
function interpret(json, knownTags) {
if (json && json.fn) {
if (knownTags[json.fn]) {
let fn = knownTags[json.fn];
let args = json.args;
let result = fn(...args);
return interpret(result, knownTags);
} else {
let args = json.args.map(arg => interpret(arg, knownTags));
return { fn: json.fn, args };
}
} else if (Array.isArray(json)) {
return json.map(item => interpret(item, knownTags));
} else {
return json;
}
}
时间对你冷笑。
“这不会有用吧?函数需要知道参数。”
“有些函数需要。”
你检查所有函数,看它们是内省嵌套标签还是只是嵌入:
- 显然,
alert
和concat
会内省嵌套内容。 - 有些函数(
app
、clock
、greeting
)根本不接收参数。 - 虽然你确实给
p
传了东西,但它只是嵌入。 prompt
情况有点特殊。技术上它会内省message
(传给内建window.prompt
)。但目前你没想嵌套别的标签进<prompt>
。如果保证不这么做(比如类型约束),就没问题。
为了区分,你引入个新约定。
不会因为参数是标签而坏掉的函数(即嵌入而非内省),名字首字母大写:
function App() {
return [
<Greeting />,
<P>The time is: <Clock /></P>
];
}
function Clock() {
return new Date().toString();
}
function Greeting() {
return (
<P>
Hello,
<prompt>Who are you?</prompt>
</P>
);
}
function P(...children) {
return (
<alert>
<concat>
{children}
</concat>
</alert>
);
}
function alert(message) {
window.alert(message);
}
function prompt(message) {
return window.prompt(message);
}
function concat(a, b) {
return a + b;
}
把这类首字母大写的函数叫做:组件(Component)。组件是程序的“大脑”——负责思考要做什么。因为它们不会内省嵌套内容,所以可以任意顺序、任意步数、一起或分开执行。换句话说,组件真的无时间性。它们向未来返回标签,向过去接受标签。
剩下的函数,比如 alert
、prompt
、concat
,叫做原语(Primitive)。原语也能用作标签,但它们不只是嵌入——它们会内省。原语必须知道所有参数。原语是程序的“肌肉”——在组件思考完后执行。原语最后执行:“先思考,后行动”。
这样自然把程序分成两阶段。
首先是思考——运行组件。用 interpret
实现:
const primitives = interpret(<App />, {
App,
Greeting,
Clock,
P
});
// [
// { fn: 'alert', args: [{ fn: 'concat', args: ['Hello', { fn: 'prompt', args: ['Who are you?'] }] }] },
// { fn: 'alert', args: [{ fn: 'concat', args: ['The time is: ', 'Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)'] }] }
// ]
思考之后是行动。思考阶段的结果只包含原语。我们写个新的 perform
,类似于 interpret
,但只处理原语。因为原语需要知道参数,perform
保证内层先执行:
function perform(json, knownTags) {
if (json && json.fn) {
let fn = knownTags[json.fn];
let args = perform(json.args, knownTags);
let result = fn(...args);
return perform(result, knownTags);
} else if (Array.isArray(json)) {
return json.map(item => perform(item, knownTags));
} else {
return json;
}
}
注意 perform
不跳过未知标签——假设 knownTags
包含所有原语。因为 perform
是最终步骤,不能再拆分了。
现在可以用 perform
完成计算:
perform(primitives, {
alert,
concat,
prompt
});
// undefined
会弹出输入框和两个弹窗。
那么,你打败时间了吗?
算是吧。
以前,interpret
很脆弱,因为跳过某些标签(如 concat
)会破坏顺序,导致其他标签(如 alert
)出错。现在 interpret
只处理组件,组件不在乎执行顺序(因为只是嵌入)。
原语由 perform
处理,总是一次性完成。所以问题不会再出现。
如果以后你的程序要跨两台计算机,拆分的应该是组件(不是原语)。因为组件不在乎执行顺序。原语必须最后一起执行——它们属于晚期世界。
如果你能控制晚期世界的计算机,有个有趣的优化。你可以预装所有常用原语和 JS 运行时一起。这样的原语集合要精心挑选,尽可能通用。比如你的 P
函数其实可以做成原语:
function p(...children) {
return (
<alert>
<concat>
{children}
</concat>
</alert>
);
}
“段落”是很多程序都需要的!
如果再进一步,可以搞一整套原语——有的负责图形(如 <b>
加粗、<i>
斜体),有的负责行为(如 <details>
展开、<a />
链接)。
如果大家都用同一套原语,而且都用它们构建复杂程序,也许可以把它们的实现从 JS 移到更底层的语言(如 Rust 或 C++),再通过高层 API 暴露给 JS。这样 perform
可以用这些 API 协调计算:
function perform(json) {
if (json && json.fn) {
let tagName = json.fn;
let children = perform(json.args);
let node = document.createElement(tagName);
for (let child of [children].flat().filter(Boolean))) {
node.appendChild(child);
}
return node;
} else if (typeof json === 'string') {
return document.createTextNode(json);
} else if (Array.isArray(json)) {
return json.map(perform);
} else {
return json;
}
}
const tree = perform(json);
document.body.appendChild(tree);
甚至可以专门设计一种声明式语言,只用来描述这种原语树。它可以比现在的方案更宽容,方便手写。
但关于原语的话题就说到这。以后我们假设有很多原语,它们用小写标签(如 <p>
)表示,perform
知道如何处理。
时间让路。
你学会了驾驭时间的力量——也学会了尊重它的法则。现在,如果你想继续深造,是时候学习空间的课程了。
第二幕
读者与作者
读者: 这篇文章真长!
作者: 你说得对。
读者: 我们才到一半?
作者: 应该是吧。
读者: 什么叫“应该”?你不知道要写到哪吗?
作者: 我大致有个方向,但说实话,基本上是边写边想。
读者: 这可不太负责。我花了很多时间读这篇文章。如果最后没个满意的结局怎么办?要是你烂尾了怎么办?
作者: 这也是我担心的。但我只有写完才能知道。你这边,只能继续读下去了。
读者: 好吧,我只能这样了。
作者: 谢谢你的理解。
读者: 反正我也没得选。
作者: 为什么?
读者: 因为我只是你笔下的角色。你让我说什么我就说什么。我没什么……“选择权”。
作者: 啊,对。选择权。
作者短暂地看了看观众,表情难以捉摸。
读者: …
作者: …
读者: 你没给我准备多少台词吧?
作者: 抱歉,差不多就这些了。
读者: …
作者: …
读者: 这段对话为什么要写进来?对故事有帮助吗?
作者: 我不知道。你觉得呢?
读者: 我以为你才是作者。
作者: 是啊,但不是你在读吗?
代码与数据
在本文的前半部分,我们学会了如何在时间上拆分计算。
事实证明,计算的某些部分——那些真正做事情的原语——不喜欢被拆开,必须一起执行。其他部分——负责思考的组件——可以在不同时间、不同顺序、甚至不同地点执行。
现在我们暂时不谈组件和原语。
让我们探究一下在时间和空间上拆分函数的区别。前面我们看到,想在时间上拆分函数,只需增加嵌套:
function greeting() {
const name = prompt('Who are you?');
return function resume() {
alert('Hello, ' + name);
};
}
这样你可以分步执行:
const resume = greeting(); // 第一步
resume(); // 第二步
greeting
的返回值是个函数——但这不是全部。关键在于这个函数嵌套在 greeting
里,否则无法读取 name
变量。也就是说,greeting
返回的不只是代码(alert
调用),还有它需要的数据(name
变量)。
如果你把 resume
提取到顶层,就必须显式传递 name
:
function resume(name) {
alert('Hello, ' + name);
}
那 greeting
怎么改?可以返回一个嵌套函数,自动传递 name
:
function greeting() {
const name = prompt('Who are you?');
return () => resume(name);
}
function resume(name) {
alert('Hello, ' + name);
}
const resume = greeting(); // 第一步
resume(); // 第二步
还可以更进一步。实际上,() => resume(name)
组合了两部分信息:代码(resume
)和数据(name
)。我们可以更明确地返回 [resume, name]
——代码和数据的配对:
function greeting() {
const name = prompt('Who are你?');
return [resume, name];
}
function resume(name) {
alert('Hello, ' + name);
}
const [code, data] = greeting(); // 第一步
code(data); // 第二步
其实这和我们现在用的标签对象很像,只不过 fn
是函数而不是字符串:
function greeting() {
const name = prompt('Who are you?');
return { fn: resume, args: [name] };
}
function resume(name) {
alert('Hello, ' + name);
}
const { fn, args } = greeting(); // 第一步
fn(...args); // 第二步
几乎就像 greeting
返回的是标签而不是函数调用。它表达了接下来要运行的代码,但还没真正执行。
这给了我们一个新视角:标签不仅是潜在的函数调用,也可以看作是代码和数据的配对。
时间与空间
现在回顾一下如何在空间上拆分计算。我们之前发现了一种模式——返回代码字符串:
function greeting() {
const name = prompt('Who are you?');
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
const code = greeting();
你可以调用 greeting()
,保存返回的 code
,然后在另一台计算机上运行。第二台计算机会认为这就是全部程序:
function resume() {
alert('Hello, ' + "Dan");
}
但你知道,完整程序包括两部分。
现在 greeting
返回的是代码字符串。但其实它返回的是代码和数据。我们只是把数据(name
变量)直接插入代码字符串里。
如果把 resume
移到外部,这一点更明显:
const RESUME_CODE = `
function resume(name) {
alert('Hello, ' + name);
}
`;
function greeting() {
const name = prompt('Who are you?');
return [RESUME_CODE, name];
}
const [code, data] = greeting();
const jsonString = JSON.stringify([code, data]);
现在 resume
接收 name
作为参数,greeting
返回代码和数据。然后可以把 [code, data]
转成 JSON,传到另一台计算机,再调用 code(data)
完成程序。
当然,写程序时你不想把 resume
当字符串写。你想像正常代码一样写在顶层,有语法高亮、类型检查等等:
function resume(name) {
alert('Hello, ' + name);
}
但怎么把这段代码和 greeting
关联起来?
function greeting() {
const name = prompt('Who are you?');
return [RESUME_CODE, name];
}
这就像这两个函数存在于两个世界——一个在即将被发送的代码字符串“外部”,一个在“内部”。greeting
像是在写故事,resume
是故事里的角色。
它们之间有清晰的逻辑联系,但比起分文件,隔阂更大。当 greeting
运行时,resume
只是字符串——更像计划或想法而不是实际函数。而当 resume
运行时,它完全不知道 greeting
存在——只接收传下来的 name
。
function greeting() {
const name = prompt('Who are you?');
return [RESUME_CODE, name];
}
function resume(name) {
alert('Hello, ' + name);
}
如果你眯着眼,还能看出程序的“真”结构:
function greeting() {
const name = prompt('Who are you?');
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
但这种“拆分”视角对两个世界都更公平。没有谁优先。它们都是我们的程序,只是被时间和空间分开:
function greeting() {
const name = prompt('Who are you?');
return [RESUME_CODE, name];
}
function resume(name) {
alert('Hello, ' + name);
}
问题是,怎么把它们串起来?
两个世界
最简单的办法是给晚期世界的每个函数一个唯一名字,让早期世界可以引用。
比如,只需要一个叫 resume
的函数:
function greeting() {
const name = prompt('Who are you?');
return ['resume', name];
}
window['resume'] = function resume(name) {
alert('Hello, ' + name);
}
虽然有点笨拙,但确实建立了显式(虽然脆弱)的联系。如果以后要重命名 resume
,你可能会搜全代码库,找到早期世界的 greeting
。甚至可以给 window['resume']
加类型。
其实这和你用浏览器内置原语时很像。你不是直接 import,而是用全局名字,比如 p
:
function Greeting() {
const name = prompt('Who are you?');
return <p>Hello, {name}</p>;
}
document.createElement = function(tagName) {
switch (tagName) {
case 'p':
// ...
}
}
从这个意义上说,浏览器内部其实就是某种“晚期世界”。很多逻辑(如 <p>
的样式、布局、绘制等)都在你调用 document.createElement('p')
之后才运行。从这个角度看,<p>
真的是标签——还需要未来某个时刻“实现”。
但别跑题。浏览器原语可以用全局名字,因为数量有限,需要查找,而且项目间都一样。但你自己定义的函数,还是希望有更显式的联系。
回到你要连接的这两部分:
function greeting() {
const name = prompt('Who are you?');
return [RESUME_CODE, name];
}
function resume(name) {
alert('Hello, ' + name);
}
第一步当然是给 resume
加上 export
。你希望其他文件能引用它。它不是可以随意删除的实现细节。你不希望它被当成死代码!
function greeting() {
const name = prompt('Who are you?');
return [RESUME_CODE, name];
}
export function resume(name) {
alert('Hello, ' + name);
}
既然 export
了,下一步当然是 import
:
import { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [RESUME_CODE, name];
}
export function resume(name) {
alert('Hello, ' + name);
}
但等等。
这没帮上忙!
你想要的是 RESUME_CODE
,也就是:
const RESUME_CODE = `
function resume(name) {
alert('Hello, ' + name);
}
`;
但 import
得到的是:
function resume(name) {
alert('Hello, ' + name);
}
你丢了反引号!
注意鸿沟
让我们彻底确认,import
不行。
归根结底,你想模块化这种模式:
function greeting() {
const name = prompt('Who are you?');
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
为此,你把 greeting
和 resume
拆到两个世界——结果丢失了语法上的联系。
假设你想用 import
跨越“鸿沟”:
import { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
export function resume(name) {
alert('Hello, ' + name);
}
但除非你改变 import
的行为,否则这只是把 resume
本身带到 greeting
的世界:
function resume(name) {
alert('Hello ', + name);
}
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
export function resume(name) {
alert('Hello, ' + name);
}
也就是说,程序结构变成这样:
function greeting() {
const name = prompt('Who are you?');
return function resume() {
alert('Hello, ' + name);
};
}
但你需要的结构是:
function greeting() {
const name = prompt('Who are you?');
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
关键就在反引号!
import
会把代码带到本地。但你想要的只是引用那段代码,不执行。你希望 greeting
返回的是关于南瓜的故事,而不是南瓜本身。
如果 resume
里还 import 了第三方库(比如弹 toast),问题会更明显:
import { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
import { showToast } from 'toast-library';
export function resume(name) {
showToast('Hello, ' + name);
}
用普通 import
,整个程序结构等价于:
// 来自 toast-library
function initializeToastLibrary() { /* ... */ }
function showToast(message) { /* ... */ }
initializeToastLibrary();
function greeting() {
const name = prompt('Who are you?');
return function resume() {
showToast('Hello, ' + name);
};
}
但你想要的结构更像:
function greeting() {
const name = prompt('Who are you?');
return `
// 来自 toast-library
function initializeToastLibrary() { /* ... */ }
function showToast(message) { /* ... */ }
initializeToastLibrary();
function resume() {
showToast('Hello, ' + name);
}
`;
}
两个世界的边界必须坚固。每个世界都要像独立程序一样运行。
我们不想打破这种一致性。
我们只需要一扇门。
一扇门
我们需要一种方式表达:“我想引用另一个文件里的东西,但不想执行或加载它的代码。只给我一个能让我以后找到那段代码的标识。”幸运的是,这一切都是虚构的,我们可以随便发明语法。
登场!
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
import { showToast } from 'toast-library';
export function resume(name) {
showToast('Hello, ' + name);
}
就这样?
当然,为什么不呢。
那这种语法做了什么?
首先,假设它返回函数源码。这样我们就能把代码发给另一台计算机:
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
const [code, data] = greeting();
// [
// 'function resume(name) { showToast("Hello, " + name); }',
// 'Dan'
// ]
但这其实没啥用——showToast
并没有包含在内。我们不想要单独的 resume
源码,而是能让另一台计算机加载并运行 resume
的一切。
那我们让它返回一个唯一标识符,比如文件名加导出名:
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
const [code, data] = greeting();
// [
// '/src/stuff/resume.js#resume',
// 'Dan'
// ]
这样就要考虑对方怎么加载代码了。比如 Node.js 可以用文件系统 import()
,只要部署过去。浏览器可以用 HTTP import()
,但可能效率不高,最好用打包工具合并代码,再用特定标识符:
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
const [code, data] = greeting();
// [
// 'chunk123#module456#resume',
// 'Dan'
// ]
最简单的情况,如果所有晚期世界的代码都打包成一个大文件,直接用全局名也行:
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
const [code, data] = greeting();
// [
// 'window.resume',
// 'Dan'
// ]
关键是我们现在有语法让早期世界的代码引用晚期世界的代码。这是一扇门。
这样我们就能实现:
function greeting() {
const name = prompt('Who are you?');
return `
import { showToast } from 'toast-library';
function resume() {
showToast('Hello, ' + name);
}
`;
}
只需这样写:
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
import { showToast } from 'toast-library';
export function resume(name) {
showToast('Hello, ' + name);
}
这样我们就能写出跨越两个编程环境的单一程序。
春季大扫除
我们找到了早期世界和晚期世界之间的一扇门。这扇门 import tag
让我们可以在时间和空间上拆分计算。
但在用这扇门之前,先把房子打扫一下。我们对标签语法做些调整,让写组件更方便。(如果你熟悉 React,会发现这让我们更接近 JSX。)
比如:
function App() {
return (
<div>
<Greeting />
<p>The time is: <Clock /></p>
</div>
);
}
目前我们假设这种语法生成的对象树是:
function App() {
return {
fn: 'div',
args: [
{ fn: 'Greeting', args: [] },
{
fn: 'p',
args: ['The time is: ', { fn: 'Clock', args: [] }]
}
]
};
}
但这样没法传递命名属性,比如 <p className="text-purple-500">
。我们要调整约定,让组件和原语都接收一个包含命名参数的对象,叫 props
(属性)。嵌套标签作为 children
属性。
function App() {
return {
type: 'div',
props: {
children: [
{ type: 'Greeting', props: {} },
{
type: 'p',
props: {
className: 'text-purple-500',
children: ['The time is: ', { type: 'Clock', props: {} }]
}
}
]
}
};
}
我顺便把 fn
改成 type
。现在像 <p>
这样的原语由 document.createElement('p')
处理,不再是我们自己的函数 p()
,叫“函数”不合适。
interpret
也要调整:
function interpret(json, knownTags) {
if (json && json.type) {
if (knownTags[json.type]) {
let Component = knownTags[json.type];
let props = json.props;
let result = Component(props);
return interpret(result, knownTags);
} else {
let children = json.props.children?.map(arg => interpret(arg, knownTags));
let props = { ...json.props, children };
return { type: json.type, props };
}
} else if (Array.isArray(json)) {
return json.map(item => interpret(item, knownTags));
} else {
return json;
}
}
perform
也要调整,支持属性如 className
:
function perform(json) {
if (json && json.type) {
let tagName = json.type;
let node = document.createElement(tagName);
for (let [propKey, propValue] of Object.entries(json.props)) {
if (propKey === 'children') {
let children = perform(propValue);
for (let child of [children].flat().filter(Boolean)) {
node.appendChild(child);
}
} else {
node[propKey] = propValue;
}
}
return node;
} else if (typeof json === 'string') {
return document.createTextNode(json);
} else if (Array.isArray(json)) {
return json.map(perform);
} else {
return json;
}
}
现在 <p className="text-purple-500">
就能用了!
再次大扫除
现在可以做另一个提升体验的改动。
目前,要把组件树转成原语树,必须把所有组件作为字典传给 interpret
:
function App() {
return (
<div>
<Greeting />
<p>The time is: <Clock /></p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
const primitives = interpret(<App />, {
App,
Greeting,
Clock
});
但这很傻。写 <Greeting />
时,Greeting
已经在作用域里了。即使不在,也应该 import 进来。既然已经在作用域,为什么 <Greeting />
不能“记住”它指的是哪个函数?
我们可以约定:小写标签如 <div>
,type
是字符串 'div'
;大写标签如 <Greeting />
,type
是函数本身:
function App() {
return {
type: 'div', // 原语(字符串)
props: {
children: [
{ type: Greeting, props: {} }, // 组件(函数)
{
type: 'p', // 原语(字符串)
props: {
children: [
'The time is: ',
{ type: Clock, props: {} } // 组件(函数)
],
}
}
]
}
};
}
我们已经约定组件名首字母大写,无需重命名。
这样 interpret
就能简化。只需判断 typeof json.type
。如果是函数,就是组件;否则是原语:
function interpret(json) {
if (json && json.type) {
if (typeof json.type === 'function') {
let Component = json.type;
let props = json.props;
let result = Component(props);
return interpret(result);
} else {
let children = json.props.children?.map(interpret);
let props = { ...json.props, children };
return { type: json.type, props };
}
} else if (Array.isArray(json)) {
return json.map(interpret);
} else {
return json;
}
}
现在直接调用 interpret
就行:
const primitives = interpret(<App />);
// {
// type: 'div',
// props: {
// children: [{
// type: 'p',
// props: {
// children: [
// 'Hello, ',
// { type: 'input', props: { placeholder: 'Who are you?' } }
// ]
// }
// }, {
// type: 'p',
// props: {
// children: ['The time is ', 'Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)']
// }
// }]
// }
// }
interpret
会“溶解”所有组件,只剩原语。然后 perform
再“溶解”原语,生成最终的 DOM:
const tree = perform(primitives);
// [HTMLDivElement]
document.body.appendChild(tree);
Boss 音乐响起。
空间登场。
早期组件与晚期组件
这是你的完整组件树:
export function App() {
return (
<div>
<Greeting />
<p>The time is: <Clock /></p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
要打败空间,你必须把计算拆分到两台计算机。
具体来说,App
和 Greeting
在第一台机器上运行,Clock
组件在第二台机器上运行。两边的计算要无缝组合,最终在第二台机器上生成 DOM。你不能修改任何组件内部代码。
一步步来。
首先,把 Clock
移到另一个文件并 export
:
export function Clock() {
return new Date().toString();
}
现在可以在主文件 import
:
import { Clock } from './Clock';
export function App() {
return (
<div>
<Greeting />
<p>The time is: <Clock /></p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
但这样并没有拆分到两台计算机。你需要开一扇门,把 import
改成 import tag
。你在早期世界开门,晚期世界立刻现身:
import tag { Clock } from './Clock';
export function App() {
return (
<div>
<Greeting />
<p>The time is: <Clock /></p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
export function Clock() {
return new Date().toString();
}
如果你查看 App
返回的标签,会发现 <Clock />
变成了奇怪的东西:
{
type: 'div', // 原语(字符串)
props: {
children: [{
type: Greeting, // 组件(函数)
props: {}
}, {
type: 'p',
props: {
children: [
'The time is ',
{
type: '/src/Clock.js#Clock', // 这是啥?
props: {}
}
]
}
}]
}
}
按我们的约定,大写标签用作用域里的函数作为 type
,比如 <Greeting />
变成 { type: Greeting, props: {} }
。
<Clock />
也是如此。但 Clock
是 import tag
,不是普通函数,而是引用——一个以后能加载 Clock
源码的标识。这就是 '/src/Clock.js#Clock'
。
现在引入新术语:
- 早期组件:在早期世界执行的组件。本例中
App
和Greeting
。 - 晚期组件:被发送到晚期世界执行的组件。本例中只有
Clock
。
你要先溶解早期组件,得到晚期世界要用的代码和数据。然后在晚期世界加载代码,溶解晚期组件,最终得到原语。
计划很清晰。
在早期世界运行 interpret(<App />)
,看结果。所有早期组件(App
和 Greeting
)都被溶解了:
{
type: 'div',
props: {
children: [{
type: 'p',
props: {
children: [
'Hello, ',
{ type: 'input', props: { placeholder: 'Who are you?' } }
]
}
}, {
type: 'p',
props: {
children: [
'The time is ',
{
type: '/src/Clock.js#Clock',
props: {}
}
]
}
}]
}
}
只剩原语('div'
、'p'
、'input'
)和晚期组件('/src/Clock.js#Clock'
)。我们没对晚期组件做特殊处理——它们不是函数,interpret
不会执行,直接保留,就像原语一样:
function interpret(json) {
if (json && json.type) {
if (typeof json.type === 'function') {
let Component = json.type;
let props = json.props;
let result = Component(props);
return interpret(result);
} else {
let children = json.props.children?.map(interpret);
let props = { ...json.props, children };
return { type: json.type, props };
}
} else if (Array.isArray(json)) {
return json.map(interpret);
} else {
return json;
}
}
interpret
的结果没有函数,可以直接转成字符串,通过网络发送:
const lateComponents = intepret(<App />);
const jsonString = JSON.stringify(lateComponents);
在另一台计算机上,可以把字符串转回对象。你可能想直接传给 perform
生成 DOM:
const lateComponents = JSON.parse(jsonString);
const tree = perform(lateComponents);
但这样会报错:
function perform(json) {
if (json && json.type) {
let tagName = json.type;
// 🔴 执行 'createElement' 失败:
// 提供的标签名 ('/src/Clock.js#Clock') 非法。
let node = document.createElement(tagName);
// ...
return node;
} else {
// ...
}
}
对,perform
只处理原语,但 Clock
是晚期组件。你已经在早期世界溶解了早期组件(App
、Greeting
)。现在在晚期世界,应该溶解晚期组件(Clock
)。
你想用 interpret
溶解剩下的组件:
const lateComponents = JSON.parse(jsonString);
const primitives = interpret(lateComponents);
但什么都没发生。'/src/Clock.js#Clock'
还在。
空间冷笑。
对,interpret
只会执行函数:
function interpret(json) {
if (json && json.type) {
if (typeof json.type === 'function') {
let Component = json.type;
let props = json.props;
let result = Component(props);
return interpret(result);
} else {
let children = json.props.children?.map(interpret);
let props = { ...json.props, children };
return { type: json.type, props };
}
} else if (Array.isArray(json)) {
return json.map(interpret);
} else {
return json;
}
}
你手头只有引用,需要加载 Clock
函数。
空间递给你:
async function loadReference(lateReference) {
// 假装通过网络或缓存加载
await new Promise(resolve => setTimeout(resolve, 3000));
if (lateReference === '/src/Clock.js#Clock') {
return Clock;
} else {
throw Error('Module not found.');
}
}
假设有这样一个加载函数,也许由环境或打包工具提供。你可以传入 '/src/Clock.js#Clock'
,异步加载 Clock
:
await loadReference('/src/Clock.js#Clock');
// function Clock(){}
这就是拼图的最后一块。
当 JSON.parse
遇到引用时,传给 loadReference()
,并收集所有 Promise:
const pendingPromises = [];
const lateComponents = JSON.parse(jsonString, (key, value) => {
if (typeof value?.type === 'string' && value.type.includes('#')) {
// `value.type` 是引用,需要函数
// 开始加载
const promise = loadReference(value.type).then(fn => {
// 加载完成后替换为函数
value.type = fn;
});
// 记录 Promise
pendingPromises.push(promise);
}
return value;
});
// 等待所有引用加载完
await Promise.all(pendingPromises);
这样 lateComponents
变成:
{
type: 'div',
props: {
children: [{
type: 'p',
props: {
children: [
'Hello, ',
{ type: 'input', props: { placeholder: 'Who are你?' } }
]
}
}, {
type: 'p',
props: {
children: [
'The time is ',
{
type: Clock, // 已加载的函数!
props: {}
}
]
}
}]
}
}
现在只剩晚期组件和原语——引用都已加载。
终于可以传给 interpret
,执行 Clock
,得到原语树,再用 perform
生成 DOM:
const primitives = interpret(lateComponents);
const tree = perform(primitives);
document.body.appendChild(tree);
大功告成!
再看一遍全流程:
早期世界用 interpret
溶解所有早期组件,得到晚期世界要用的字符串:
const lateComponents = intepret(<App />);
const jsonString = JSON.stringify(lateComponents);
晚期世界解析字符串,加载引用,再用 interpret
溶解晚期组件,得到原语树:
const pendingPromises = [];
const lateComponents = JSON.parse(jsonString, (key, value) => {
if (typeof value?.type === 'string' && value.type.includes('#')) {
const promise = loadReference(value.type).then(fn => {
value.type = fn;
});
pendingPromises.push(promise);
}
return value;
});
await Promise.all(pendingPromises);
const primitives = interpret(lateComponents);
最后,原语树可以转成 DOM 或其他格式:
const tree = perform(json);
document.body.appendChild(tree);
恭喜!
你已经在时间和空间上都拆分了计算。
甜甜圈
空间向你低头,终于承认你是平等的对手。
“你做得很好。”
但它并没有让路。反而继续扭曲,自己折叠成奇异的形状——向前、再翻转,中心形成虫洞。
看起来像个甜甜圈。
一个包罗万象、美丽又可怕的甜甜圈。
“但你还没结束。”
等等……你听过这个声音。
难道是……
“时间?”
第二条血条出现。
组合
你的程序如下:
import tag { Clock } from './Clock';
export function App() {
return (
<div>
<Greeting />
<p>
The time is: <Clock />
</p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
export function Clock() {
return new Date().toString();
}
要打败时空,把 Clock
显示的时间改为早期世界的时间,但 <p>
的颜色由晚期世界决定。
第一步很简单。
要让 Clock
显示早期世界时间,只需把它提升回去:
export function App() {
return (
<div>
<Greeting />
<p>
The time is: <Clock />
</p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
现在要指定 <p>
的颜色。假设 perform
能处理 style
属性,你可以这样写:
<p style={{
color: prompt('Pick a color:')
}}>
<Clock />
</p>
很好,但时空说 prompt
只在晚期世界有。现在 App
组件定义在早期世界,prompt
不存在:
export function App() {
return (
<div>
<Greeting />
<p style={{
// 🔴 ReferenceError: prompt is not defined.
color: prompt('Pick a color:')
}}>
<Clock />
</p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
也许你可以把 App
组件本身移到晚期世界?这样 prompt
就有了,但 Greeting
和 Clock
在晚期世界不可用:
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
export function App() {
// 🔴 ReferenceError: Greeting is not defined
// 🔴 ReferenceError: Clock is not defined
return (
<div>
<Greeting />
<p style={{
color: prompt('Pick a color:')
}}>
<Clock />
</p>
</div>
);
}
也许你可以把 Greeting
和 Clock
也下移?
export function App() {
return (
<div>
<Greeting />
<p style={{
color: prompt('Pick a color:')
}}>
<Clock />
</p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
但你想让 Clock
显示早期世界时间,不能下移。越来越棘手……
也许你可以让 App
在晚期世界,但用 import tag
引用早期世界的 Greeting
和 Clock
?试试:
export function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
export function Clock() {
return new Date().toString();
}
// 🔴 不能从晚期模块 import 早期标签。
import tag { Clock, Greeting } from './early';
export function App() {
return (
<div>
<Greeting />
<p style={{
color: prompt('Pick a color:')
}}>
<Clock />
</p>
</div>
);
}
不行。这和在反引号里的函数不能调用外部函数一样:
function greeting() {
function showToast() {
/* ... */
}
return `function resume() {
const name = prompt('Who are you?');
// 🔴 ReferenceError: showToast is not defined
showToast('Hello, ' + name);
}`;
}
import tag
只能向下引用,不能向上。
时空甜甜圈开始收紧。你没多少时间了。你想起一个半梦半醒的主意。
import tag
只能引用下层世界。但你不是还发明了 import rpc
,可以跨网络边界引用函数吗?如果早期世界还在,也许可以响应你的请求,返回 Greeting
和 Clock
的结果?
export function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
export function Clock() {
return new Date().toString();
}
import rpc { Clock, Greeting } from './early';
export function App() {
return (
<div>
<Greeting />
<p style={{
color: prompt('Pick a color:')
}}>
<Clock />
</p>
</div>
);
}
甜甜圈停了一下。
这就是解法吗?
看起来可行——
“不能多次网络调用。必须一次性搞定。”
甜甜圈又开始旋转,快把你吞没了。你不再害怕,甚至有点期待。
你脑中浮现一个念头。
不是念头——是画面。
一个形状。
import tag { Donut } from './Donut';
export function App() {
return (
<div>
<Greeting />
<Donut>
The time is: <Clock />
</Donut>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
export function Donut({ children }) {
return (
<p style={{
color: prompt('Pick a color:')
}}>
{children}
</p>
);
}
你不能调用过去,但可以包裹过去。你不明白这意味着什么,但你知道现在没破坏任何规则。
所以,它一定能行。
你闭上眼睛。
梦境片段
最初是标签,
标签在早期世界,
标签是 <App />
。
<App />
什么是 App
?
它是 <div>
有 <Greeting>
,
有 <Donut>
,
里面有 <Clock />
。
<div>
<Greeting />
<Donut>
The time is: <Clock />
</Donut>
</div>
什么是 <div>
?
<div>
<Greeting />
<Donut>
The time is: <Clock />
</Donut>
</div>
我们还不知道。
什么是 <Greeting />
?
<div>
<Greeting />
<Donut>
The time is: <Clock />
</Donut>
</div>
它是 <p>
里面有 <input>
。
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: <Clock />
</Donut>
</div>
什么是 <p>
?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: <Clock />
</Donut>
</div>
我们还不知道。
什么是 <input>
?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: <Clock />
</Donut>
</div>
我们还不知道。
什么是 Donut
?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: <Clock />
</Donut>
</div>
我们还不知道。
什么是 Clock
?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: <Clock />
</Donut>
</div>
它是这个世界的时间,
这个世界是早期世界,
它的时间已到终点。
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)
</Donut>
</div>
再见 App
,
再见 Greeting
,
再见 Clock
。
***
(调制解调器声)
***
什么是 <div>
?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)
</Donut>
</div>
我们还不关心。
什么是 <p>
?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)
</Donut>
</div>
我们还不关心。
什么是 <input>
?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)
</Donut>
</div>
我们还不关心。
什么是 <Donut>
?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<Donut>
The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)
</Donut>
</div>
让我们加载它。
<script src="chunk123.js"></script>
哦,Donut
是一个用户自选颜色的 <p>
。
请选颜色!
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<p style={{ color: 'purple' }}>
The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)
</p>
</div>
你已选择。
什么是 <p>
?
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<p style={{ color: 'purple' }}>
The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)
</p>
</div>
我们还不关心——
这不是我们的职责。
再见 Donut
;
让我们把它交给
某段 C++ 代码。
<div>
<p>
Hello, <input placeholder="Who are you?" />
</p>
<p style={{ color: 'purple' }}>
The time is: Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)
</p>
</div>
尾声
还有很多内容没讲,但我快写不下去了。以下是有心人可以继续探索的方向:
- 毒丸(Poison Pills): 随着代码库变大,你会越来越不想关心自己处于哪个世界——你只想声明依赖了哪些能力。例如,如果你要读数据库,而数据库只在早期世界有,你希望“毒丸”机制让晚期世界 import 数据库模块时报错(而不是试图打包数据库代码)。在 Node.js 里,自定义用户条件可以方便地实现这一点。
- 指令(Directives): 事实上,
import tag
和import rpc
虽然理论优雅,但实际用起来不太友好。技术上的世界隔离必须坚固,但心理上你会逐渐习惯把代码写得像“世界”无关紧要。用毒丸机制保证不会在错误世界执行后,你可以随意移动边界,遇到构建错误再“开门”。当你想开门时,你会发现把标记写在export旁边比写在 import 处更自然。这样可以快速切换边界,import 的世界成了实现细节。一种做法是利用指令语法。如果你把 Early 和 Late 改名(比如 Early 改成 Server,Late 改成 Client),那么import tag
可以被'use client'
替代,import rpc
可以变成'use server'
。 - 数据获取: 早期世界(或称服务器世界)非常适合数据获取,因为可以部署到低延迟环境。你可以把“思考”阶段改成异步,作为练习试试看。
- 流式执行: 在我们的例子里,每个阶段都是串行的:前一步没结束,后一步不开始。但实际上,组件可以外层先执行,可以把所有阶段混合起来流式执行。比如,不用等整个 JSON 树的晚期组件(或称客户端组件)都准备好,可以用专门的线协议,留下“洞”,后续再填补。
- 有状态的晚期世界: 如果引入 State,晚期组件会更有用。这再次说明标签是潜在的函数调用——可能发生、可能不发生,也可能多次发生。每次晚期组件状态变化,都可以重新执行,不影响早期组件。这样 State 变化就能保证即时。
- 重新定义早期和晚期世界: 早期和晚期世界不一定要和“服务器”和“客户端”一一对应。例如,如果晚期组件有 State,你有服务器,可能会在服务器上同时运行早期和晚期世界。服务器上用初始 State 执行晚期世界,生成原语树,再转成 HTML。这样可以很早就显示内容——在客户端组件加载前。
- 缓存: 早期世界不一定要按需运行。你可以提前运行,缓存中间结果(即所谓静态站点生成)。如果你有兴趣,还可以引入第三个世界——缓存世界——复用部分计算结果。
如果你想玩最终例子,欢迎:
如果你想体验真正的 React Server Components,又不想用框架,Parcel 最近发布了对 RSC 的支持,值得一试。
感谢阅读!