overreactedby Dan Abramov

函数式 HTML

May 2, 2025

这里有一段 HTML 代码:

<html>
  <body>
    <p>Hello, world</p>
  </body>
</html>

假设这是你人生中见过的唯一一段 HTML。如果你拥有完全的自由,你会为 HTML 添加哪些特性?你会按什么顺序添加?

你会从哪里开始?


服务器标签

就我个人而言,我想从添加一种自定义 HTML 标签的方式开始。

其实不需要太复杂。我们可以直接用 JavaScript 函数来实现:

<html>
  <body>
    <Greeting />
  </body>
</html>
 
function Greeting() {
  return <p>Hello, world</p>
}

为了让它工作,我们可以规定:当 HTML 通过网络发送(即被序列化)时,服务器必须将这些自定义标签替换为它们返回的内容:

<html>
  <body>
    <p>Hello, world</p>
  </body>
</html>

当所有的标签函数都被调用完毕后,HTML 就可以发送了。

很酷的特性,对吧?

还好我们一开始就加上了它。

它可能会影响我们后续的所有设计思路。


属性

接下来,我们要支持向标签传递属性,并在标记中插入变量。

<html>
  <body>
    <Greeting name="Alice" />
    <Greeting name="Bob" />
  </body>
</html>
 
function Greeting({ name }) {
  return <p>Hello, {name}</p>
}

当然,这些参数也不一定非得是字符串

我们甚至可以直接传递一个对象给 Greeting

<html>
  <body>
    <Greeting person={{ name: 'Alice', favoriteColor: 'purple' }} />
    <Greeting person={{ name: 'Bob', favoriteColor: 'pink' }} />
  </body>
</html>
 
function Greeting({ person }) {
  return (
    <p style={{ color: person.favoriteColor }}>
      Hello, {person.name}
    </p>
  );
}

对象让我们可以把相关的数据组织在一起。

根据我们目前的规范序列化上面的 HTML 会得到:

<html>
  <body>
    <p style={{ color: 'purple' }}>Hello, Alice</p>
    <p style={{ color: 'pink' }}>Hello, Bob</p>
  </body>
</html>

不过,对象其实还没有完全消失。

{ color: '...' } 这样的对象直接发送出去可以吗?

我们到底应该如何处理这些对象?


对象

我们熟悉的“真实”HTML 对象并不是一等公民。如果我们想输出“真实”的 HTML,就必须把 style 格式化成字符串:

<html>
  <body>
    <p style="color: purple">Hello, Alice</p>
    <p style="color: pink">Hello, Bob</p>
  </body>
</html>

但如果我们在重新想象 HTML,就不必受这些限制。实际上,我们可以规定服务器必须把我们的想象中 HTML 序列化为一棵 JSON 树:

["html", {
  children: ["body", {
    children: [
      ["p", {
        children: "Hello, Alice",
        style: { color: "purple" }
      }],
      ["p", {
        children: "Hello, Bob",
        style: { color: "pink" }
      }]
    ]
  }]
}]

等等,这是什么?

这种奇怪的 JSON 表达方式目前看起来没什么特别有趣或有用。但从现在开始,我们会把这种表达方式作为主要的输出格式(而不是“真实”的 HTML)。这种格式有趣的地方在于它比真实 HTML 更加丰富和有表现力。它既能表达 HTML,也能表达对象。这让我们可以保留 style 对象——或者任何我们想要传递的对象!

再读一遍,你会发现我们以后完全可以很容易地把它转成“真实”的 HTML。

如果我们想的话。


异步服务器标签

之前我们是把对象硬编码到 HTML 里的:

<html>
  <body>
    <Greeting person={{ name: 'Alice', favoriteColor: 'purple' }} />
    <Greeting person={{ name: 'Bob', favoriteColor: 'pink' }} />
  </body>
</html>
 
function Greeting({ person }) {
  // ...
}

但我们也可以从别的地方获取这些数据。

比如我们可以从文件系统读取数据:

<html>
  <body>
    <Greeting person={JSON.parse(await readFile('./alice123.json', 'utf8'))} />
    <Greeting person={JSON.parse(await readFile('./bob456.json', 'utf8'))} />
  </body>
</html>
 
function Greeting({ person }) {
  // ...
}

注意这里用了 await。读取数据通常是异步的!

其实,这样写有点重复——我们可以把 await 移到 Greeting 组件内部:

<html>
  <body>
    <Greeting username="alice123" />
    <Greeting username="bob456" />
  </body>
</html>
 
async function Greeting({ username }) {
  const filename = `./${username.replace(/\W/g, '')}.json`;
  const person = JSON.parse(await readFile(filename, 'utf8'));
  return (
    <p style={{ color: person.favoriteColor }}>
      Hello, {person.name}
    </p>
  );
}

我们需要稍微修改一下规范。现在,每当服务器序列化我们想象中的 HTML 时,遇到 async 的标签函数都必须 await 它们。

最终结果仍然是同样的 JSON:

["html", {
  children: ["body", {
    children: [
      ["p", {
        children: "Hello, Alice",
        style: { color: "purple" }
      }],
      ["p", {
        children: "Hello, Bob",
        style: { color: "pink" }
      }]
    ]
  }]
}]

我们依然可以把这个 JSON 转成“真实”的 HTML,如果我们想的话:

<html>
  <body>
    <p style="color: purple">Hello, Alice</p>
    <p style="color: pink">Hello, Bob</p>
  </body>
</html>

注意,我们的“想象 HTML”让我们能够用用户的语言表达

<html>
  <body>
    <Greeting username="alice123" />
    <Greeting username="bob456" />
  </body>
</html>
 
async function Greeting({ username }) {
  // ...
}

这是针对特定用户名的问候——而不仅仅是一个“段落”。

数据如何加载、输出什么内容,都是 Greeting 的实现细节。

很酷!


事件

当我们遇到一个函数标签时会发生什么?

<html>
  <body>
    <Greeting />
  </body>
</html>
 
function Greeting() {
  return <p>Hello, world</p>
}

我们会调用这个函数,并用它的输出替换掉标签:

<html>
  <body>
    <p>Hello, world</p>
  </body>
</html>

这样我们可以在发送 HTML 时消除所有函数,不用考虑如何通过网络传递它们。

但如果我们不想让函数现在执行呢?

如果我们想让它以后执行呢?

比如,点击时执行?

<button>
  Like
</button>

我们需要以某种方式把函数传递到网络另一端

我们可以把代码作为字符串传递:

<button onClick="addLike()">
  Like
</button>

但这样并不太好维护,对吧?

假设我们想把 onLike 写成一个真正的函数:

<button onClick={onLike}>
  Like
</button>
 
function onLike() {
  addLike();
}

然而,没有字符串引号包裹代码就变得模棱两可了。onLike 是在和 HTML 相同的环境下执行吗——也就是在服务器上?我能在这里调用 writeFile 吗?还是说它会在浏览器里执行?我能调用 alert 吗?

这种模糊性还体现在 JSON.stringify 会直接忽略它:

["button", {
  children: "Like"
  // 这里没有 onClick :(
}]

默认情况下,JSON.stringify 不知道如何处理函数。(实际上,我们可以修订规范:如果服务器在除标签外的任何位置遇到函数就抛出错误。这样就能强制我们做出明确的选择。)

那么我们希望它怎么做呢?


客户端引用

假设我们想把 onClick 的代码通过 <script> 标签发送到客户端。

为此,我们需要知道要发送哪个 <script> 文件,以及文件中的哪个函数是点击处理器。我们可以这样编码:

["button", {
  children: "Like",
  onClick: "/src/bundle.js#onLike"
}]

我们把 '/src/bundle.js#onLike' 称为客户端引用——一种让服务器可以引用客户端代码的方式;即客户端函数的“地址”,能够唯一定位它。

如果浏览器能直接理解这种格式,它们就会加载相应的 <script> 并挂载 onClick 事件。但即使不能,上面的 JSON 也足以转换成“真实”的 HTML:

<button id="btn">Like</button>
<script src="/src/bundle.js"></script>
<script>btn.onclick = onLike;</script>

但我们希望如何编写这段代码呢?

显然,我们希望像这样把 onLike 写成一个真正的函数:

<button onClick={onLike}>
  Like
</button>
 
function onLike() {
  addLike();
}

但这样其实说不通;我们不能在同一个文件里混合服务器和客户端代码。这样太容易混淆。如果我们想 import 某些只在某个环境下可用的东西怎么办?我们怎么知道哪些依赖会在哪里执行?

我们可以把 HTML 一分为二。第一部分在服务器端:

<button onClick={onLike}>
  Like
</button>

第二部分是我们要发送到客户端的代码:

function onLike() {
  addLike();
}

现在我们需要一种语法,明确地“切分”这两部分。我们需要一种语法来表达:“当你 import 这个模块时,拿到的不是函数本身——而是它的客户端引用”。幸运的是,我们已经发明了它:

import { onLike } from './onLike';
 
<button onClick={onLike}>
  Like
</button>
'use client'; // 作为客户端引用序列化
 
export function onLike() {
  alert('You liked this.');
}

'use client' 指令的意思是:“当你 import 我时,拿到的不是函数本身,而是它的地址,可以引用它。”

["button", {
  children: "Like",
  onClick: "/src/bundle.js#onLike"
}]

这种 JSON 可以有多种解释方式。我们可以把它转成 <button> 和对应的 <script>,也可以完全跳过 HTML 生成,直接在客户端用 document.createElement('button') 并挂载事件。

关键在于,我们现在有了一等语法来把代码发送到客户端。在我们想象的 HTML 演化中,<script> 标签变得不再必要'use client'<script> 成为模块系统的一部分。


服务器引用

通过 <script> 标签序列化函数是一种方式。

但并不是唯一的方式。

另一种常见模式是把 onLike 保留在服务器上,让客户端可以调用它——比如通过 POST fetch() 请求。我们可以这样编码:

["button", {
  children: "Like",
  onClick: "/api?fn=onLike"
}]

我们把 '/api?fn=onLike' 称为服务器引用——一种让客户端可以引用服务器代码的方式;即服务器函数的“地址”,能够唯一定位它。

如果我们要把它转成“真实”的 HTML,有几种做法。我们可以给 <button> 挂上 onclick,让它执行 fetch('/api?fn=onLike')。如果服务器引用被传递给“真实”HTML 的 <form action><button formAction>,我们甚至可以进一步加上 <form action="/api?fn=onLike">,这样即使 JavaScript 没加载也能工作。我们也可以完全不生成 HTML,全部交给客户端处理。

那么,我们希望如何写这段代码呢?

在这种情况下,其实没必要把代码分成多个文件。

两部分代码都在服务器上运行——没有歧义:

<button onClick={onLike}>
  Like
</button>
 
async function onLike() {
  const likes = Number(await readFile('./likes.txt', 'utf8'));
  await writeFile('./likes.txt', likes + 1, 'utf8');
}

不过,我们还是希望能主动把某个函数暴露为 API 接口。

幸运的是,我们已经发明了一种方式:

<button onClick={onLike}>
  Like
</button>
 
async function onLike() {
  'use server'; // 作为服务器引用序列化
  const likes = Number(await readFile('./likes.txt', 'utf8'));
  await writeFile('./likes.txt', likes + 1, 'utf8');
}

'use server' 指令的意思是:“当你序列化这个函数时,把它转成服务器引用——客户端可以用这个地址来调用它。”

["button", {
  children: "Like",
  onClick: "/api?fn=onLike"
}]

然后可以转成 HTML(有时还能渐进增强),也可以完全由客户端 JavaScript 解释。

关键在于,我们现在有了一等方式把服务器函数传递给客户端。换句话说,我们让 API 调用本身成为模块系统的一部分


客户端标签

到目前为止,我们已经为 HTML 扩展了几个新颖的原语:

这种设计自然带来了一个有趣的结果。

假设你 import 了一个客户端引用,并把它作为标签使用:

import { LikeButton } from './LikeButton';
 
<LikeButton color="purple" />
'use client'; // 作为客户端引用序列化
 
export function LikeButton({ color }) {
  function onLike() {
    alert('You liked this.');
  }
 
  return (
    <button onClick={onLike} style={{ color }}>
      Like
    </button>
  );
}

根据我们之前的规则,序列化时服务器必须运行所有遇到的标签函数。然而,客户端引用不是函数:

["/src/bundle.js#LikeButton", {
  color: "purple"
}]

因此,序列化 JSON 的服务器无需对它做任何处理。换句话说,这让我们可以把某些标签的执行延迟到后续阶段。

我们把这样的标签称为客户端标签(Client Tags)。

这种 JSON 可以有多种方式转成“真实”HTML。我们可以选择只在客户端渲染应用,这时只需要输出 <script> 和一段数据:

<script src="bundle.js"></script>
<script>
  const output = LikeButton({ color: 'purple' });
  render(document.body, output);
</script>

或者我们可以预渲染客户端标签为“真实”HTML,以获得更快的首屏渲染:

<!-- 可选:初始 HTML -->
<button>
  Like
</button>
 
<!-- 交互逻辑 -->
<script src="bundle.js"></script>
<script>
  const output = LikeButton({ color: 'purple' });
  render(document.body, output);
</script>

我们还可以只在客户端加载代码,完全不生成 HTML。

无论选择哪种策略,所有必要信息都在 JSON 里:

["/src/bundle.js#LikeButton", {
  color: "purple"
}]

全栈标签

这意味着我们现在可以在两端自由组合自定义标签:

import { LikeButton } from './LikeButton';
 
<>
  <PersonalizedLikeButton username="alice123" />
  <PersonalizedLikeButton username="bob456" />
</>
 
async function PersonalizedLikeButton({ username }) {
  const filename = `./${username.replace(/\W/g, '')}.txt`;
  const color = await readFile(filename);
  return <LikeButton color={color} />;
}
'use client';
 
export function LikeButton({ color }) {
  function onLike() {
    alert('You liked this.');
  }
 
  return (
    <button onClick={onLike} style={{ color }}>
      Like
    </button>
  );
}

序列化时,服务器标签如 PersonalizedLikeButton 会被执行,只留下它们的输出。反序列化时,客户端标签如 LikeButton 会被执行,最终生成 HTML、DOM 或其它你想要的内容。

这样我们就可以创建不可能的组件——跨越前后端的全栈抽象,完全封装自己的状态和数据加载逻辑。

同时我们还能自由组合客户端和服务器端的行为。例如,我们可以把 onLike 的一部分逻辑移到服务器作为 addLike

'use server';
 
import { readFile, writeFile } from 'fs/promises';
 
export async function addLike() {
  const likes = Number(await readFile('./likes.txt', 'utf8'));
  await writeFile('./likes.txt', likes + 1, 'utf8');
}
'use client';
 
import { addLike } from './actions';
 
export function LikeButton({ color }) {
  async function onLike() {
    await addLike();
    alert('You liked this.');
  }
 
  return (
    <button onClick={onLike} style={{ color }}>
      Like
    </button>
  );
}

现在 LikeButton 直接“挂载”了一段后端逻辑。

我们从不在同一个文件中混合客户端和服务器代码,但它们可以通过 'use client''use server' 指令相互引用。这样我们就能用类型化、模块化的方式表达前后端的天然耦合,而不是依赖像 <script> 标签和 API 路由那样的字符串式约定。


刷新

初次渲染时,生成完整 HTML 是有益的(虽然不是必须的)。这样可以保证在页面上的 <script> 客户端引用加载期间,用户能看到内容。事实上,预渲染 HTML 还能让我们提前发送提示,尽早加载这些 <script>

但既然我们的主要输出格式是 JSON 而不是 HTML,我们也可以写一个 <Router> 客户端标签,拦截导航、获取下一个页面的 JSON 输出,并优雅地应用到现有 DOM 上。所有实际的数据获取都只需一次往返,因为所有服务器标签在序列化时就已执行。客户端可以自由地“应用”这些 JSON,不会破坏任何客户端状态。JSON 里包含所有客户端标签的新属性——这些属性可以是任意对象,而不仅仅是字符串。

我们还可以通过嵌套 <Router> 客户端标签实现更细粒度的局部刷新,每个标签负责一个路由段。底层,Router 客户端标签可以用服务器引用来获取新的 JSON 树。


流式渲染

这种方案的一个显著缺点是,只有等所有异步服务器标签都解析完,页面才能渲染。我们来设想一种改进方法。

假设我们要渲染多个异步标签:

function Page() {
  return (
    <Layout>
      <PostContent />
      <PostComments />
    </Layout>
  );
}
 
function Layout({ children }) {
  return (
    <article>
      <header>Welcome to Overreacted</header>
      {children}
      <footer>Thanks for reading</footer>
    </article>
  );
}
 
async function PostContent() {
  // ...
}
 
async function PostComments() {
  // ...
}

我们可以规定,服务器应当自外而内地序列化标签为 JSON,不必阻塞——但遇到内容尚未就绪时留出“空洞”:

["article", {
  children: [
    ["header", {
      children: 'Welcome to Overreacted'
    }],
    /* HOLE_1 */,
    /* HOLE_2 */,
    ["footer", {
      children: 'Thanks for reading'
    }]
  ]
}]

等这些空洞内容在服务器上准备好后,我们可以流式填充进去:

["article", {
  children: [
    ["header", {
      children: 'Welcome to Overreacted'
    }],
    /* HOLE_1 */,
    /* HOLE_2 */,
    ["footer", {
      children: 'Thanks for reading'
    }]
  ]
}]
/* HOLE_1: */["article", { children: [["p", "Here is a piece of HTML:", ...]]}]
/* HOLE_2: */["ul", { className: "comments", children: [["li", { children: "Server rendering sucks, you should only do things on the client" }], ["li", { children: "Client rendering sucks, you should only do things on the server" }]] }]

我们甚至可以用同样的机制来序列化 Promise。(等等,真的可以吗?)

我们希望避免“跳动”的用户体验。虽然让计算“尽可能流式”是好事,但每个用户感知到的加载状态都应该是有意为之。事实上,这正是“真实”HTML 所缺失的特性——因为没有声明式加载状态的原语,页面加载时会“跳动”。

我们可以设想,自己的 HTML 版本有个这样的原语。

我们叫它 <Placeholder>

function Page() {
  return (
    <Layout>
      <PostContent />
      <Placeholder fallback={<CommentsGlimmer />}>
        <PostComments />
      </Placeholder>
    </Layout>
  );
}
 
function CommentsGlimmer() {
  return <div className="glimmer" />;
}

它不会直接影响序列化——你会在 JSON 里看到它作为客户端标签:

["article", {
  children: [
    ["header", {
      children: 'Welcome to Overreacted'
    }],
    /* HOLE_1 */,
    ["Placeholder", {
      fallback: ["div", { className: "glimmer" }],
      children: /* HOLE_2 */
    }],
    ["footer", {
      children: 'Thanks for reading'
    }]
  ]
}]
/* HOLE_1: */["article", { children: [["p", "Here is a piece of HTML:", ...]]}]
/* HOLE_2: */["ul", { className: "comments", children: [["li", { children: "Server rendering sucks, you should only do things on the client" }], ["li", { children: "Client rendering sucks, you should only do things on the server" }]] }]

但这意味着反序列化的人可以决定是等待“空洞”加载完成,还是先显示 fallback

比如,如果我们为博客生成 100% 静态 HTML,完全可以等所有“空洞”加载完再显示。显示 fallback 没什么意义。

但如果我们是实时生成页面,可以选择立即输出 fallback 的 HTML,然后用隐藏标签和内联 <script> 把后续内容插入到正确位置。这样用户就能看到一系列有意设计的加载状态:

  1. 初始 HTML 外壳加载时是空白页面。
  2. 外壳、头部、底部,以及正文一起出现。
  3. 评论区加载完成。

关键在于,我们要保留让某些内容(比如正文)阻塞屏幕的能力。对于博客文章页面,正文内容是核心,显示骨架屏反而不如什么都不显示。但如果为它显示骨架屏,只需把 <Placeholder> 移到它外面:

function Page() {
  return (
    <Layout>
      <Placeholder fallback={<PostGlimmer />}>
        <PostContent />
        <PostComments />
      </Placeholder>
    </Layout>
  );
}

也可以嵌套占位符:

function Page() {
  return (
    <Layout>
      <Placeholder fallback={<PostGlimmer />}>
        <PostContent />
        <Placeholder fallback={<CommentsGlimmer />}>
          <PostComments />
        </Placeholder>
      </Placeholder>
    </Layout>
  );
}

我们可以把 <Placeholder> 看作是加载状态的 try / catch。关键是,它对序列化没有强加任何语义,客户端可以按需解释——比如可以节流嵌套 fallback 的显示,减少跳动,或等全部内容缓冲好再显示。

(即使物理上运行在服务器上,生成 HTML 其实也是“客户端”。在这种架构下,“客户端”指的是任何解释我们 JSON 输出流的东西。)


缓存

与“真实”HTML 不同,上述 JSON 结构是 100% 可组合的。例如,如果你想渲染三个 <Counter /> 客户端标签,并传递不同的数据给每个标签,在 JSON 中表达起来非常简单:

["div", {
  children: [
    ["/src/chunk123.js#Counter", { initialCount: 0, color: "pink" }],
    ["/src/chunk123.js#Counter", { initialCount: 10, color: "purple" }],
    ["/src/chunk123.js#Counter", { initialCount: 100, color: "blue" }],
  ]
}]

如果你天真地把 <script> 标签重复三次,结果就会很乱——脚本会重复、变量会冲突:

<button id="counter">0</button>
<script src="/src/chunk123.js"></script>
<script>Counter('#counter', { initialCount: 0, color: "pink" })</script>
<button id="counter">10</button>
<script src="/src/chunk123.js"></script>
<script>Counter('#counter', { initialCount: 10, color: "purple" })</script>
<button id="counter">100</button>
<script src="/src/chunk123.js"></script>
<script>Counter('#counter', { initialCount: 100, color: "blue" })</script>

当然可以解决,但最好在流程的最后一步再处理。

我们再看一个需要更复杂数据的例子——比如我的博客的可排序文章列表。下面是 <SortablePostList> 服务器标签:

这是所有服务器标签计算完毕后的 JSON:

["div", {
  className: "mb-8 flex h-72 flex-col gap-2 overflow-scroll font-sans",
  children: ["/chunk123.js#SortableList", {
    items: [
      ["/chunk456.js#ExpandingSection", {
        extraContent: ["p", {children: "I wrote a bit of JSX in my editor: [...]"}],
        children: [
          ["a", { href: "/a-chain-reaction", children: "A Chain Reaction" }],
          ["i", { children: "2,452 words" }]
        ]
      }],
      ["/chunk456.js#ExpandingSection", {
        extraContent: ["p", {children: "You wrote a few components with Hooks [...]"}],
        children: [
          ["a", { href: "/a-complete-guide-to-useeffect", children: "A Complete Guide to useEfffct" }],
          ["i", { children: "9,913 words" }]
        ]
      }],
      /* ... */
    ]
  }]
}]

注意,这个 JSON 不仅描述了初始静态输出——它描述了整个交互流程。 它说明了需要下载哪些客户端交互代码(如 SortableListExpandingSection),以及从哪里下载。

它可以转化为“真实”HTML——无论是纯 SPA 的 <script>,还是完整的初始 HTML 渲染。但它比“真实”HTML 更加结构化。

这让这种格式特别适合服务端缓存。早期服务端渲染常常缓存 HTML “片段”,以便在多次请求间复用页面的某些部分。特别是,如果能缓存带有空洞的片段——静态外壳加动态内容——就更好了。不幸的是,带 <script> 和数据的动态 HTML 让这种缓存方式很难实现,因为你无法把数据和代码“拆开”。

但上面的结构清晰地分离了数据和代码。它表达了——“这里是标签,这里是需要传递给它们的丰富信息”。静态和动态内容用完全相同的方式表达。这意味着这些 JSON 片段可以独立缓存,并且可以有“空洞”,以便后续插入更频繁变化的内容。


总结

在这篇文章中,我们再次从零发明了 React Server Components。我们已经看到,它们可以被理解为:

而在本文中,我试图展示它们还可以被看作是函数式、可编程、可组合的 HTML——两端皆可自定义标签。

Pay what you like

Discuss on Bluesky  ·  Edit on GitHub