overreactedby Dan Abramov

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> 有什么共同点:

  1. 我们通过名字引用函数或标签。 按惯例,函数调用通常以动词开头(如 createElementprintMoneyquerySelectorAll),而标签通常用名词命名(比如 p 代表段落)。这不是硬性规则(alert 既像动词也像名词;b 代表加粗),但大多数时候如此。(为什么会这样?)
  2. 我们可以向函数或标签传递信息。 前面我们传递了一段文本('Hello')给标签和函数。但我们并不限于传递一个字符串。在 JavaScript 的函数调用中,我们可以传递多个参数,包括字符串、数字、布尔值、对象等。在 HTML 标签中,我们可以传递多个属性,但属性值不能是对象或其他复杂数据结构——这很受限。幸运的是,JSX 中的标签(以及许多基于 HTML 的模板语言)允许我们传递对象和其他丰富的值。
  3. 函数调用和标签都可以深度嵌套。 例如,我们可以写 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 调用传入的函数,剩下的代码也就能照常运行了。

这作为第一个想法其实还不错。但也有明显问题:

  1. 网络调用把代码缠绕在一起了。 原本代码是自上而下顺序执行的,现在却拐了个弯。概念上 alert('Hello' + name) 是“下一步”,但我们不得不把它嵌套callNetwork 里,告诉计算机“等一等”。
  2. 我们割裂了代码之间的联系。 通常你想调用一个函数,直接调用就行。如果在另一个文件,就 exportimport。但现在我们面对的不是函数调用,而是 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 / awaitimport rpc,今天的发明就到这。

除非……


Call Me Maybe

有同事来找你:“那个 async / awaitimport 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,但这太受限了。我们就无法协调 promptalert 的“潜在调用”!

我们想实现的是这样:

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?'

;

我们已经看到,“潜在调用”之间的依赖,比如 promptalert,应该通过嵌套表达。我们不能在它们之间插入代码,除非代码也在对方计算机上。在本地,它们更像……标记。

既然没有别的组合方式,嵌套层级自然会很深。所以最好让语法更易读:

<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 rpcimport 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.alertwindow.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 已经“溶解”了!

再试试同时“溶解” promptconcat(但不溶解 alert):

interpret(greeting(), {
  prompt: window.prompt,
  concat: (a, b) => a + b,
});

这次会先弹出输入框,但 alert 的消息已经拼接好了——没有 concat 了:

// {
//   fn: 'alert',
//   args: ['Hello, Dan']
// };

也就是说,除了 alert 以外,其他都被预先计算了。

再试试同时“溶解” alertpromptconcat

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

运行代码。

这也许会给你启发。

如果你把 step1step2 放在不同的计算机上运行呢?也就是说,在第一台计算机上“溶解”部分标签,然后把剩下的标签通过网络发送到另一台计算机,在那里继续“溶解”。如果某些标签天然更适合在某一端解释,这会很有用——比如两台机器有不同能力。

想想水的三态变化:先在山顶融化成水,然后河流流下,最后水蒸发。同理,标签也可以。部分标签可以在第一台计算机提前“融化”,剩下的标签流经网络,到另一台计算机“终结”。


两台计算机

你的理论还很模糊,有时你觉得它很荒谬,但它的轮廓开始浮现。如果让你总结到目前为止的内容,你会这样说:

有些程序是分布式计算,跨越多台机器。特别是,有些程序可以表示为跨越两台机器的函数(当然原则上可以更多)。这些函数有个特殊形态——第一台机器做一部分计算,然后把剩下的计算“交给”第二台机器,通过发送剩余代码实现。你的理论正关注这些函数。

我们给两台机器的环境起个名字。你的程序始于早期世界(Early)——第一台机器。有一部分工作会在那里完成。然后剩下的工作会被传递到晚期世界(Late)——第二台机器。

早期世界和晚期世界是完全隔离的运行环境,隔着时空,没有共享状态或全局变量。早期世界可以为晚期世界留下残余信息——比如剩余要运行的代码和所需数据——但仅此而已。

早期世界和晚期世界不会直接 import 彼此的代码,因为那样会把代码带入本地世界。它们的,是引用彼此的代码。import tagimport rpc 都是引用另一台计算机代码的例子(而且是类型安全的!),并能在不实际加载的情况下做有用的事。

由于隔离坚固,早期世界的函数无法调用晚期世界的函数。毕竟函数调用是为了把信息传回调用者,但调用者早已不在。

但把信息传递到未来,从早期到晚期世界,依然有意义。为此,你发明了比函数调用更弱的概念——标签。标签像函数调用,但被动、惰性、等待解释。它是潜在的函数调用,随时可以被执行,也可以等更合适的时机,甚至永远不执行。标签是原型调用。

你得意地看着理论的线索终于汇聚在一起。突然,Boss 音乐响起。

有人说“时间”吗?


时间的反击

你的第一个 Boss 是时间本身。要打败时间,你必须证明你的无时间性蓝图真的无时间性——即改变计算顺序不会破坏程序。你最好是对的!

这是你之前的 greeting 函数:

function greeting() {
  return (
    <alert>
      <concat>
        Hello,
        <prompt>Who are you?</prompt>
      </concat>
    </alert>
  );
}

我稍微加强一下,让 Boss 战更有趣(也更吓人)。

你可能经常想把 alertconcat 组合,所以我把它们提取成一个函数,叫 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 函数,把 greetingclock 组合在 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]

运行代码 得到预期结果:

  1. 弹出输入框问名字
  2. 弹窗显示 Hello, Dan
  3. 再弹窗显示当前时间

一切正常!

现在,你要证明的是,既然这些只是蓝图——标签还没变成调用——你可以随意改变执行顺序。

来试试。

先只“溶解”一半标签(pgreetingclock):

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,溶解剩下的标签(alertconcatprompt):

interpret(step2, {
  alert: window.alert,
  concat: (a, b) => a + b,
  prompt: window.prompt,
  // p: p,
  // greeting: greeting,
  // clock: clock,
});
// [undefined, undefined]

一切正常

  1. 弹出输入框问名字
  2. 弹窗显示 Hello, Dan
  3. 再弹窗显示当前时间

恭喜!

你证明了用标签(而非函数调用)描述的计算可以拆分成多步、任意顺序执行——成功打败了时间。

除非……

你试试只把 concat 留到最后,其余标签都一起溶解:

interpret(app(), {
  alert: window.alert,
  prompt: window.prompt,
  // concat: (a, b) => a + b,
  p: p,
  greeting: greeting,
  clock: clock,
});

无时间性蓝图里,晚点执行 concat 应该没问题吧?

运行代码

  1. 弹出输入框问名字
  2. 弹窗显示 [object Object]
  3. 再弹窗显示 [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> 的解释,想证明顺序无关紧要。结果发现,顺序其实很重要——concatalert 都需要参数是字符串。

看来你的无时间性蓝图并不那么无时间。函数需要参数先算好。时间就藏在这里。

你的理论有个致命缺陷。


新的希望

你的理论有致命缺陷。你可以有三种选择。

你可以假装它不存在。但这无法修正理论。

你可以放弃理论。但你明明快要成功了,不是吗?

最后,你可以让这个缺陷引导你。就像一次精心设计的失败实验,它告诉你重要的信息。你犯了错,但到底错在哪里

有个好办法可以找出来。

目前,我们总是先递归解释嵌套标签,再调用父标签的函数,保证标签函数内层先执行:

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)。但其实很重要!concatp 本质上有区别。外层先调用会搞坏 concat,但不会搞坏 p

为什么正是这样?


嵌入与内省

来看两个函数:

function concat(a, b) {
  return a + b;
}
 
function pair(a, b) {
  return [a, b];
}

它们有何不同?

显然,功能不同。一个拼接字符串,一个创建数组。但还有更微妙的区别:它们对参数的处理方式不同。

打个比方。

假设你的工作是把两根绳子绑在一起。这不难。你拿两根绳子,打个结,搞定。现在有人递给你一根绳子和一个南瓜。你就没法工作了。你需要抓住绳子的末端,但南瓜没有末端。

你可能会得出结论:随便换成南瓜会出事,确实有时如此。但并非总是如此。

假设你在玩具店包礼物。你每天包各种礼物,娃娃、小汽车、甚至整个玩具屋。有人递给你一个南瓜。虽然你可能会拒绝,但技术上你完全可以包南瓜。你包东西时,不关心它是什么(不像绳子那样需要末端)。你只是把它放进盒子。你是在嵌入,不是内省

concatpair 的区别就在于,concat 关心参数是什么。它内省。你给它南瓜就不行。但 pair 无所谓。它嵌入,所以什么都能接受。

这和执行顺序有什么关系?

因为 concat 内省参数 ab+ 会把它们转成字符串),如果参数是未解释的标签就会坏掉:

concat('Hello ', <prompt>Who are you?</prompt>);
// 'Hello, [object Object]'

pair 嵌入参数。它只创建 [a, b] 数组,无论 ab 是什么都行。可以直接嵌入标签:

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;
  }
}

时间对你冷笑。

“这不会有用吧?函数需要知道参数。”

有些函数需要。”

你检查所有函数,看它们是内省嵌套标签还是只是嵌入

  • 显然,alertconcat 会内省嵌套内容。
  • 有些函数(appclockgreeting)根本不接收参数。
  • 虽然你确实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)。组件是程序的“大脑”——负责思考要做什么。因为它们不会内省嵌套内容,所以可以任意顺序、任意步数、一起或分开执行。换句话说,组件真的无时间性。它们向未来返回标签,向过去接受标签。

剩下的函数,比如 alertpromptconcat,叫做原语(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)});
  }`;
}

为此,你把 greetingresume 拆到两个世界——结果丢失了语法上的联系。

假设你想用 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();
}

运行代码。

要打败空间,你必须把计算拆分到两台计算机。

具体来说,AppGreeting 在第一台机器上运行,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 /> 也是如此。但 Clockimport tag,不是普通函数,而是引用——一个以后能加载 Clock 源码的标识。这就是 '/src/Clock.js#Clock'

现在引入新术语:

  • 早期组件:在早期世界执行的组件。本例中 AppGreeting
  • 晚期组件:被发送到晚期世界执行的组件。本例中只有 Clock

你要先溶解早期组件,得到晚期世界要用的代码和数据。然后在晚期世界加载代码,溶解晚期组件,最终得到原语。

计划很清晰。

在早期世界运行 interpret(<App />),看结果。所有早期组件(AppGreeting)都被溶解了:

{
  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 是晚期组件。你已经在早期世界溶解了早期组件(AppGreeting)。现在在晚期世界,应该溶解晚期组件(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 就有了,但 GreetingClock 在晚期世界不可用:

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>
  );
}

也许你可以把 GreetingClock 也下移?

 
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 引用早期世界的 GreetingClock?试试:

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,可以跨网络边界引用函数吗?如果早期世界还在,也许可以响应你的请求,返回 GreetingClock 的结果?

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 tagimport 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 的支持,值得一试。

感谢阅读!

Pay what you like

Discuss on Bluesky  ·  Edit on GitHub