译:两个 React

题图:possessedphotography @ unsplash.com

原文:https://overreacted.io/the-two-reacts/
作者:dan abramov
译者:ChatGPT 4 Turbo

编者注:几个收获,1)这是一篇很好的关于 RSC 的科普文,dan 写文章的功力真好,2)代码可能运行在哪?扩展下,可能是你的电脑、我的电脑、CD 的机器、服务器,3)传统 React 的范式是 UI = f(state),PHP 的范式是 UI = f(data),而 RSC 的范式是 UI = f(data, state),既支持客户端状态,也支持服务端数据。

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

你的设备和我的。

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

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

组件是代码,而这些代码必须在某处运行。但等等——它们应该运行在谁的电脑上?应该运行在你的电脑上吗?还是在我的电脑上?

让我们为双方各自陈述一下理由。


首先,我会主张组件应该在你的电脑上运行。

这是一个小计数按钮,用来演示交互性。点击它几次吧!

<Counter />

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

这是可能的,因为这个组件的代码正在你的电脑上运行:

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 是客户端状态——你的计算机内存中的一小块信息,每次你按下那个按钮时都会更新。我不知道你会按多少次按钮,所以我无法预测并准备它在我的计算机上所有可能的输出。我敢在我的计算机上准备的最多的就是初始渲染输出(“你点击了我 0 次”)并将其作为 HTML 发送。但从那一刻起,你的计算机必须接管运行这段代码。

你可以说在你的电脑上运行这段代码仍然没有必要。也许我可以让它在我的服务器上运行?每当你按下按钮时,你的电脑可以向我的服务器请求下一个渲染输出。在所有这些客户端 JavaScript 框架出现之前,网站不就是这样工作的吗?

当用户期待稍微延迟时,向服务器请求新的 UI 工作得很好——例如,当点击一个链接时。当用户知道他们正在导航到你的应用程序中的不同位置时,他们会等待。然而,任何直接操作(例如拖动滑块、切换标签页、在帖子编辑器中输入、点击喜欢按钮、滑动卡片、悬停菜单、拖动图表等)如果不能可靠地至少提供一些即时反馈,就会感觉到中断。

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

在构建用户界面时,你需要能够至少对一些交互做出响应,确保低延迟并且零网络往返。

你可能已经看到 React 思维模型被描述为一种等式:UI 是状态的函数,或者 UI = f(state) 。这并不意味着你的 UI 代码必须真的是一个接受状态作为参数的单一函数;它只是意味着当前状态决定了 UI。当状态改变时,UI 需要重新计算。由于状态“存在”于你的电脑上,计算 UI(你的组件)的代码也必须在你的电脑上运行。

或者这个论点是这样的。


接下来,我将提出相反的观点——组件应该在我的电脑上运行。

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

<PostPreview slug="a-chain-reaction" />

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

如果你检查网络标签页,你会看到没有额外的请求。我没有从 GitHub 下载整个博客文章只是为了计算它的字数。我也没有在这个页面嵌入那篇博客文章的内容。我没有调用任何 API 来计算字数。而且我当然没有自己数那些所有的字。

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

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 解析。当我想统计单词时,我分割文本并计数。因为我的代码就在数据所在的地方运行,我不需要做任何额外的事情。

假设我想列出我的博客上的所有文章及其字数统计。

简单:

<PostList />

我需要做的就是为每个帖子文件夹渲染一个 <PostPreview />

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

这些代码不需要在你的电脑上运行——事实上它也不能运行,因为你的电脑没有我的文件。让我们检查一下这些代码是什么时候运行的:

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

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

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

将我的组件运行在靠近它们数据源的位置,可以让它们读取自己的数据,并在将任何信息发送到你的设备之前进行预处理。

在你加载这个页面的时候,已经没有 <PostList><PostPreview> ,没有 fileContentdirs ,没有 fsgray-matter 。取而代之的是只有一个 <div> ,里面有几个 <section> ,每个 <section> 里都有 <a><i> 。你的设备只接收到它实际需要显示的 UI(渲染后的帖子标题、链接 URL 和帖子字数),而不是你的组件曾经用来计算那个 UI 的完整原始数据(实际的帖子)。

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

或者这个论点是这么说的。


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

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

如果我们抛开熟悉性偏见,这两种方法在各自擅长的领域都很有说服力。不幸的是,这些观点似乎相互不兼容。

如果我们想要实现像 <Counter /> 那样的即时交互,我们必须在客户端运行组件。但是像 <PostPreview /> 这样的组件原则上不能在客户端运行,因为它们使用了像 readFile 这样的仅限服务器的 API。 (这就是它们的全部意义!否则我们也可以在客户端运行它们。)

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

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

  • 客户端 UI = f(state) 范式让我们可以编写 <Counter />
  • 服务器 UI = f(data) 范式允许我们编写 <PostPreview />

==但实际上,真正的“公式”更接近于 UI = f(data, state) 。==如果你没有 data 或没有 state ,它将泛化到这些情况。但理想情况下,我希望我的编程范式能够同时处理这两种情况,而不必选择另一个抽象,我知道至少你们中的一些人也希望如此。

那么,要解决的问题是如何将我们的 “f” 分割到两个非常不同的编程环境中。这甚至可能吗?回想一下,我们并不是在讨论某个实际的名为 f 的函数 —— 这里, f 代表我们所有的组件。

我们有没有什么方法可以在你的电脑和我的电脑之间分割组件,同时保留 React 的优点?我们能否将来自两个不同环境的组件结合并嵌套?这将如何工作?

这应该怎么运作?

好好想想,下次我们再对比一下笔记。