overreactedby Dan Abramov

两种 React

January 4, 2024

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

你的设备和我的设备。

一切都始于我的设备上的一些代码和数据。例如,我正在我的笔记本电脑上以文件的形式编辑这篇博客文章。如果你能在你的屏幕上看到它,那它一定已经从我的设备传输到了你的设备。在某个时刻、某个地方,我的代码和数据被转换成了 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 是一段客户端状态——存储在你电脑内存中的一小段信息,每次你点击按钮时都会更新。我无法预知你会点击多少次按钮,所以无法在我的电脑上预先准备好所有可能的输出。我最多只能在我的电脑上准备初始渲染输出(“You clicked me 0 times”),然后以 HTML 的形式发送出去。但从那一刻起,你的电脑必须接管这段代码的运行。

你可能会认为,其实也不一定非要在你的电脑上运行。也许我可以让它在我的服务器上运行?每当你点击按钮时,你的电脑可以向我的服务器请求下一次的渲染输出。这不就是在所有客户端 JavaScript 框架出现之前,网站的工作方式吗?

当用户预期会有一点延迟时(比如点击链接),向服务器请求新的 UI 是可行的。当用户知道他们正在跳转到应用的另一个地方时,他们会等待。但任何直接操作(比如拖动滑块、切换标签、在编辑框中输入、点击点赞按钮、滑动卡片、悬停菜单、拖动图表等),如果不能可靠地提供至少某种即时反馈,就会让人觉得体验很差。

这个原则并不完全是技术层面的——它其实来自于日常生活的直觉。例如,你不会指望电梯按钮一按下就立刻带你到下一层。但当你推门把手时,你确实期望门会跟随你的手移动,否则就会觉得门卡住了。事实上,即使是电梯按钮,你也会期望至少有某种即时反馈:比如按钮会随着你的按压力度下陷,然后点亮以确认你的操作。

当你构建用户界面时,至少要能对某些交互以保证低延迟且无需网络往返的方式做出响应。

你可能见过 React 的思维模型被描述为一种方程式:UI 是状态的函数,即 UI = f(state)。这并不意味着你的 UI 代码必须真的是一个以 state 为参数的函数;它只是说当前状态决定了 UI。当状态变化时,UI 需要重新计算。由于状态“存在”于你的电脑上,所以用于计算 UI 的代码(你的组件)也必须运行在你的电脑上。

——这就是这种观点的论据。


接下来,我要论证相反的观点——组件应该在我的电脑上运行。

这里有一个博客中其他文章的预览卡片:

<PostPreview slug="a-chain-reaction" />
连锁反应
836 words

这个页面上的组件是如何知道那一篇文章的字数的?

如果你查看网络请求(Network)面板,会发现没有额外的请求。我并没有从 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.toLocaleString()} words</i>
    </section>
  );
}

这个组件运行在我的电脑上。当我需要读取文件时,就用 fs.readFile 读取。当我需要解析 Markdown 头部时,就用 gray-matter 解析。当我想统计字数时,就把文本拆分后计数。因为我的代码运行在数据所在的位置**,所以我无需做任何额外的事情。**

假设我想列出博客中所有文章及其字数。

很简单:

<PostList />
连锁反应
836 words
useEffect 完全指南
3,313 words
给我们这些普通人的代数效应
825 words
在你使用 memo() 之前
289 words
应对反馈
25 words
像没人看见一样修复
4 words
函数式 HTML
1,301 words
再见,整洁代码
271 words
函数组件与类组件有何不同?
678 words
React 如何区分类和函数?
1,021 words
setState 是如何知道该做什么的?
477 words
开发模式是如何工作的?
375 words
RSC 中的导入机制详解
1,278 words
我正在做一些咨询工作
41 words
不可能的组件
1,819 words
JSX 跨网络传输
4,052 words
用 React Hooks 声明式地使用 setInterval
1,095 words
我的十年回顾
38 words
我对热重载的愿望清单
264 words
给它命名,他们就会到来
26 words
npm audit:天生有缺陷
558 words
关于 let 与 const
106 words
每次导航只需一次往返
968 words
为变化而优化
15 words
技术演讲准备指南(第一部分):动机
27 words
技术演讲准备指南(二):核心内容、动机与方法
38 words
技术演讲准备指南(三):内容
199 words
渐进式 JSON
890 words
React 作为 UI 运行时
1,955 words
React 为两台计算机而生
5,332 words
面向 Astro 开发者的 RSC 指南
385 words
面向 LISP 开发者的 RSC
77 words
静态即服务器
90 words
抑制的抑制
27 words
“Bug-O” 记法
363 words
UI 工程的要素
97 words
数学被鬼附身了
571 words
两种 React
302 words
WET 代码库
26 words
截至 2018 年我不知道的那些事
143 words
React 团队的原则是什么?
79 words
“use client” 是做什么的?
1,090 words
JavaScript 是由什么组成的?
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

我只需要为每个文章文件夹渲染一个 <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>

Thu Jul 31 2025 09:17:24 GMT+0000 (Coordinated Universal Time)

哈——这正是我上一次将博客部署到静态网站托管时的时间!我的组件在构建过程中运行,因此可以完全访问我的所有文章。

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

等你加载这个页面时,已经没有 <PostList>、没有 <PostPreview>,没有 fileContent、没有 dirs,也没有 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”并不是某个实际的函数——它代表我们所有的组件。

有没有办法可以在你的电脑和我的电脑之间拆分组件,同时保留 React 的优点?我们能否把来自两个不同环境的组件组合、嵌套在一起?这会如何实现?

理想的做法又是什么?

不妨思考一下,下次我们再来交流彼此的想法。

Pay what you like

Discuss on Bluesky  ·  Edit on GitHub