【译】两个 React

假设我想在你的屏幕上显示一些内容。无论我想要显示类似于这篇博客文章的网页、一个交互式的网络应用,甚至是你可能从某个应用商店下载的本地应用,至少需要涉及两个设备。

你的设备和我的设备。

这始于我设备上的一些代码和数据。例如,我正在将这篇博客文章作为文件编辑在我的笔记本电脑上。如果你在你的屏幕上看到它,它必须已经从我的设备传输到了你的设备。在某个时刻,我的代码和数据转变成了指示你的设备显示这个内容的HTMLJavaScript

那么这与React有什么关系呢?React是一种UI编程范式,它让我将要显示的内容(博客文章、注册表单,甚至是整个应用)分解为独立的部件,称为组件,并像乐高积木一样组合它们。我假设你已经了解并喜欢组件;可以在react.dev上查看介绍。

组件是代码,而这些代码必须在某个地方运行。但等等——它们应该在谁的计算机上运行?它们应该在你的计算机上运行?还是在我的计算机上运行?

让我们为每一方提出理由。

首先,我会主张组件应该在你的计算机上运行。

这里有一个小的计数器按钮来演示交互性。点击它几次!

1
2
<Counter />
You clicked me 0 times

假设这个组件的JavaScript代码已经加载完毕,这个数字会增加。请注意,它在按下按钮时会立即增加。没有延迟。不需要等待服务器。也不需要下载任何额外的数据。

这是因为这个组件的代码正在你的计算机上运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useState } from "react";

export function Counter() {
const [count, setCount] = useState(0);
return (
<button
className="dark:color-white rounded-lg bg-purple-700 px-2 py-1 font-sans font-semibold text-white focus:ring active:bg-purple-600"
onClick={() => setCount(count + 1)}
>
You clicked me {count} times
</button>
);
}

在这里,count是客户端状态的一部分——存在于你计算机内存中的一点信息,每次按下按钮时都会更新。我不知道你会按按钮多少次,所以我无法预测并在我的计算机上准备所有可能的输出。我敢于在我的计算机上准备的最多只是初始渲染输出(“You clicked me 0 times”),并将其作为HTML发送。但从那时起,你的计算机必须接管运行这段代码。

你可以争辩说,仍然不需要在你的计算机上运行这段代码。也许我可以让它在我的服务器上运行?每当你按下按钮时,你的计算机可以向我的服务器请求下一个渲染输出。这难道不是网站在所有这些客户端JavaScript框架之前的工作方式吗?
要求服务器提供新的用户界面在用户期望有一点延迟时效果很好——例如,当点击链接时。当用户知道他们正在导航到应用程序中的某个不同位置时,他们会等待。然而,任何直接的操作(例如拖动滑块、切换标签、在帖子编辑器中输入文字、点击喜欢按钮、刷卡、悬停菜单、拖动图表等等)如果不能可靠地提供至少一些即时反馈,那就会感觉到不连贯。

这个原则并不是严格的技术性原则——它是日常生活中的直觉。例如,你不会期望电梯按钮立即带你到下一层。但当你推门把手时,你确实期望它直接跟随你手的移动,否则会感觉卡住。事实上,即使是对于电梯按钮,你也期望至少有一些即时反馈:它应该顺应你手的压力。然后它应该亮起来以确认你的按下。

当你构建用户界面时,你需要能够对至少一些交互作出有保证的低延迟响应,并且不需要进行任何网络往返。

你可能已经见过React的思维模型被描述为一种方程:UI是状态的函数,或者UI = f(state)。这并不意味着你的UI代码必须字面上是一个以状态为参数的单个函数;它只意味着当前状态决定了UI。当状态发生变化时,UI需要重新计算。由于状态“存储”在你的计算机上,计算UI的代码(你的组件)也必须在你的计算机上运行。

或者说是这个论点。

接下来,我会提出相反的论点——即组件应该在我的计算机上运行。

这里是来自这个博客的另一篇文章的预览卡片:

1
2
3
<PostPreview slug="a-chain-reaction" />
A Chain Reaction
2452 words

这个页面的组件如何知道那个页面的单词数?

如果你检查网络标签,你会看到没有额外的请求。我并没有从GitHub下载整篇博客文章,只是为了计算其中的单词数。我也没有将那篇博客文章的内容嵌入到这个页面中。我也没有调用任何API来计算单词数。我确实没有自己数所有这些单词。

那么这个组件是如何工作的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { readFile } from "fs/promises";
import matter from "gray-matter";

export async function PostPreview({ slug }) {
const fileContent = await readFile("./public/" + slug + "/index.md", "utf8");
const { data, content } = matter(fileContent);
const wordCount = content.split(" ").filter(Boolean).length;

return (
<section className="rounded-md bg-black/5 p-2">
<h5 className="font-bold">
<a href={"/" + slug} target="_blank">
{data.title}
</a>
</h5>
<i>{wordCount} words</i>
</section>
);
}

这个组件在我的计算机上运行。当我想读取一个文件时,我使用fs.readFile读取文件。当我想解析其Markdown头时,我用gray-matter解析它。当我想计算单词数时,我将其文本拆分并计数。因为我的代码直接运行在数据所在的地方,所以我不需要额外做任何事情。

假设我想列出我的博客上的所有帖子以及它们的单词数。

很容易:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<PostList />
A Chain Reaction
2452 words
A Complete Guide to useEffect
9913 words
Algebraic Effects for the Rest of Us
3062 words
Before You memo()
856 words
Coping with Feedback
669 words
Fix Like No One’s Watching
251 words
Goodbye, Clean Code
1196 words
...

我只需要为每个帖子文件夹渲染一个<PostPreview />

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { readdir } from "fs/promises";
import { PostPreview } from "./post-preview";

export async function PostList() {
const entries = await readdir("./public/", { withFileTypes: true });
const dirs = entries.filter(entry => entry.isDirectory());
return (
<div className="mb-4 flex h-72 flex-col gap-2 overflow-scroll font-sans">
{dirs.map(dir => (
<PostPreview key={dir.name} slug={dir.name} />
))}
</div>
);
}

这段代码中的任何部分都不需要在你的计算机上运行——事实上也无法在你的计算机上运行,因为你的计算机没有我的文件。让我们来看看这段代码运行的时间:

1
2
3
<p className="text-purple-500 font-bold">
{new Date().toString()}
</p>

Fri Jan 05 2024 00:50:25 GMT+0000 (Coordinated Universal Time)

嗯——那正是我上次将我的博客部署到静态Web托管时的时间!我的组件在构建过程中运行,因此它们可以完全访问我的帖子。

在数据源附近运行我的组件使它们能够读取它们自己的数据并在将任何信息发送到你的设备之前对其进行预处理。

当你加载这个页面时,就没有了<PostList><PostPreview>,也没有了fileContentdirs,没有了fsgray-matter。相反,只有一个包含几个<section>,每个<section>中包含<a><i><div>。你的设备只接收到它实际需要显示的UI(已渲染的帖子标题、链接URL和帖子单词数),而不是你的组件用来计算UI的完整原始数据(实际帖子)。

按照这种思维模式,UI是服务器数据的函数,或者UI = f(data)。这些数据只存在于我的设备上,所以组件应该在那里运行。

或者这就是这个论点。

UI由组件构成,但我们提出了两种非常不同的愿景:

  • UI = f(state),其中状态是客户端的,f在客户端运行。这种方法允许编写像<Counter />这样立即交互的组件。(在这里,f也可以在服务器上运行,使用初始状态生成HTML。)
  • UI = f(data),其中数据是服务器端的,f仅在服务器上运行。这种方法允许编写像<PostPreview />这样的数据处理组件。(在这里,f仅在服务器上运行。构建时间视为“服务器”。)

如果我们忽略熟悉性偏见,这两种方法在它们擅长的领域都是令人信服的。不幸的是,这些愿景似乎是彼此不兼容的。

如果我们想允许像<Counter />所需的即时交互,我们必须在客户端上运行组件。但是像<PostPreview />这样的组件原则上不能在客户端上运行,因为它们使用的是只能在服务器上使用的API,比如readFile。(这就是它们的全部意义!否则我们可能会将它们运行在客户端上。)

好的,如果我们将所有组件都放在服务器上运行怎么样?但是在服务器上,像<Counter />这样的组件只能呈现它们的初始状态。服务器不知道它们的当前状态,并且在服务器和客户端之间传递这个状态太慢了(除非它很小,比如一个URL),而且甚至并不总是可能的(例如,我的博客的服务器代码只在部署时运行,所以你不能“传递”东西给它)。

再次,似乎我们必须在两种不同的React之间做出选择:

  • “客户端”UI = f(state)范式,让我们编写<Counter />
  • “服务器”UI = f(data)范式,让我们编写<PostPreview />

但是在实践中,真正的“公式”更接近于UI = f(data, state)。如果没有数据或状态,它会推广到这些情况。但理想情况下,我更喜欢我的编程范式能够处理这两种情况,而无需选择另一个抽象,我知道至少你们中的一些人也会喜欢这样做。

那么,要解决的问题就是如何在两个非常不同的编程环境中拆分我们的“f”。这可能吗?请记住,我们不是在谈论某个实际的名为f的函数——在这里,f代表所有我们的组件。

有没有办法我们可以将组件在你的计算机和我的计算机之间进行分割,以保留React的优点?我们是否可以将来自两个不同环境的组件进行组合和嵌套?那会怎么样?