函数式 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 扩展了几个新颖的原语:
- 服务器标签:序列化时在服务器端运行。
- 对象:作为一等值(以及承载它们的 JSON 格式)。
- 客户端引用:让服务器引用客户端(一等
<script>
)。 - 服务器引用:让客户端引用服务器(一等
fetch
)。
这种设计自然带来了一个有趣的结果。
假设你 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>
把后续内容插入到正确位置。这样用户就能看到一系列有意设计的加载状态:
- 初始 HTML 外壳加载时是空白页面。
- 外壳、头部、底部,以及正文一起出现。
- 评论区加载完成。
关键在于,我们要保留让某些内容(比如正文)阻塞屏幕的能力。对于博客文章页面,正文内容是核心,显示骨架屏反而不如什么都不显示。但如果想为它显示骨架屏,只需把 <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>
服务器标签:
连锁反应
836 wordsuseEffect 完全指南
3,313 words给我们这些普通人的代数效应
825 words在你使用 memo() 之前
289 words应对反馈
25 words像没人看见一样修复
4 words函数式 HTML
1,301 words再见,整洁代码
271 words函数组件与类组件有何不同?
678 wordsReact 如何区分类和函数?
1,021 wordssetState 是如何知道该做什么的?
477 words开发模式是如何工作的?
375 wordsRSC 中的导入机制详解
1,278 words我正在做一些咨询工作
41 words不可能的组件
1,819 wordsJSX 跨网络传输
4,052 words用 React Hooks 声明式地使用 setInterval
1,095 words我的十年回顾
38 words我对热重载的愿望清单
264 words给它命名,他们就会到来
26 wordsnpm audit:天生有缺陷
558 words关于 let 与 const
106 words每次导航只需一次往返
968 words为变化而优化
15 words技术演讲准备指南(第一部分):动机
27 words技术演讲准备指南(二):核心内容、动机与方法
38 words技术演讲准备指南(三):内容
199 words渐进式 JSON
890 wordsReact 作为 UI 运行时
1,955 wordsReact 为两台计算机而生
5,332 words面向 Astro 开发者的 RSC 指南
385 words面向 LISP 开发者的 RSC
77 words静态即服务器
90 words抑制的抑制
27 words“Bug-O” 记法
363 wordsUI 工程的要素
97 words数学被鬼附身了
571 words两种 React
302 wordsWET 代码库
26 words截至 2018 年我不知道的那些事
143 wordsReact 团队的原则是什么?
79 words“use client” 是做什么的?
1,090 wordsJavaScript 是由什么组成的?
423 words为什么 React Hooks 依赖调用顺序?
1,216 words为什么 React 元素有一个 $$typeof 属性?
247 words为什么我们要写 super(props)?
339 words为什么 RSC 要与打包工具集成?
263 words为什么 X 不是 Hook?
456 words编写高健壮性的组件
1,367 words
这是所有服务器标签计算完毕后的 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 不仅描述了初始静态输出——它描述了整个交互流程。
它说明了需要下载哪些客户端交互代码(如 SortableList
和 ExpandingSection
),以及从哪里下载。
它可以转化为“真实”HTML——无论是纯 SPA 的 <script>
,还是完整的初始 HTML 渲染。但它比“真实”HTML 更加结构化。
这让这种格式特别适合服务端缓存。早期服务端渲染常常缓存 HTML “片段”,以便在多次请求间复用页面的某些部分。特别是,如果能缓存带有空洞的片段——静态外壳加动态内容——就更好了。不幸的是,带 <script>
和数据的动态 HTML 让这种缓存方式很难实现,因为你无法把数据和代码“拆开”。
但上面的结构清晰地分离了数据和代码。它表达了——“这里是标签,这里是需要传递给它们的丰富信息”。静态和动态内容用完全相同的方式表达。这意味着这些 JSON 片段可以独立缓存,并且可以有“空洞”,以便后续插入更频繁变化的内容。
总结
在这篇文章中,我们再次从零发明了 React Server Components。我们已经看到,它们可以被理解为:
而在本文中,我试图展示它们还可以被看作是函数式、可编程、可组合的 HTML——两端皆可自定义标签。