假设我想在你的屏幕上展示一些东西。不管我想展示的是像这篇博客文章这样的网页、一个交互式的网络应用程序,还是你可能从某个应用商店下载的原生应用,至少需要两个设备参与进来:你的设备和我的设备。
这一过程始于我的设备上的一些代码和数据。例如,我正在编辑这篇博客文章作为我笔记本电脑上的一个文件。如果你在屏幕上看到它,它必须已经从我的设备传输到了你的设备。在某个地方、某个时刻,我的代码和数据转变成了指挥你的设备显示这些内容的HTML和JavaScript。
那么,这和React有什么关系呢?React是一个UI编程范式,它让我可以将要显示的东西(一个博客文章、一个注册表单,甚至是整个应用)分解成独立的部分称为组件,并像乐高积木一样组合它们。我假设你已经知道并喜爱组件了;请查看react.dev了解介绍。
组件是代码,而这些代码必须在某处运行。但等等——它们应该在谁的计算机上运行呢?应该在你的电脑上运行吗?还是在我的电脑上?
首先,我将论证组件应该在你的电脑上运行。
这里有一个小计数器按钮来展示交互性。点击它几次看看!
你点击了我0次
假设这个组件的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框架出现之前,网站是如何工作的吗?
当你的电脑向服务器询问新鲜界面时,这种方式在用户期待一点延迟的时候运行得很好——例如,当点击一个链接时。当用户知道他们正在导航到你的应用中的某个不同位置时,他们会等待。然而,任何直接操纵(如拖动滑块、切换标签页、在帖子编辑器中输入、点击喜欢按钮、滑动卡片、悬停菜单、拖动图表等)如果不能可靠地至少提供一些即时反馈,就会感觉到中断。
这个原则并不严格是技术性的——它源于日常生活中的直觉。例如,你不会期望按电梯按钮能瞬间带你到下一层。但当你推门时,你确实期望它能直接跟随你手的移动,否则会感觉卡住了。事实上,即使是电梯按钮,你也会期望至少有一些即时的反馈:它应该在你手的压力下回弹。然后它应该亮起来,以确认你的按压。
当你构建用户界面时,你需要能够以保证低延迟并且零网络往返的方式至少对一些交互作出响应。
你可能已经看到React的心智模型被描述为一种方程式:UI是状态的函数,或者说UI = f(state)。这并不意味着你的UI代码必须真的是一个单一函数,带有状态作为参数;它只意味着当前状态决定了UI。当状态变化时,UI需要重新计算。由于状态“存在”于你的电脑上,计算UI的代码(你的组件)也必须在你的电脑上运行。
或者说,这就是这个论点。
接下来,我将提出相反的论点——组件应该在我的电脑上运行。
这是这个博客中另一篇文章的预览卡片:
<PostPreview slug="a-chain-reaction" />
"A Chain Reaction" 2452个单词
这个页面上的组件如何知道那一页的单词数量?
如果你检查网络标签页,你会看到没有额外的请求。我没有从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解析它。当我想计算单词数时,我切分文本并计算它们。没有额外的我需要做的,因为我的代码就在数据所在的地方运行。
假设我想要列出我的博客上所有的帖子及其字数。
很简单:
"A Chain Reaction" 2452个单词
"A Complete Guide to useEffect" 9913个单词
...(以此类推列出更多帖子)...
我所需要做的就是为每一个帖子文件夹渲染一个<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>
);
}
这段代码无需在你的电脑上运行——事实上,它也无法在你的电脑上运行,因为你的电脑上没有我的文件。让我们来看看这段代码是什么时候运行的:
Fri Jan 05 2024 00:50:25 GMT+0000 (Coordinated Universal Time)
啊哈,那正是我最后一次将我的博客部署到我的静态网站托管服务时的时间!我的组件是在构建过程中运行的,因此它们可以完全访问我的帖子。
将我的组件靠近它们的数据来源运行使得它们可以读取自己的数据并在将任何信息发送到你的设备之前预处理它。
当你加载这个页面时,不存在更多的<PostList>和<PostPreview>了,没有fileContent和dirs,没有fs和gray-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绝对在服务器上运行。构建时被视为“服务器”。)
如果我们抛开熟悉性偏见,这两种方法都在它们所擅长的领域非常引人注目。不幸的是,这些愿景似乎彼此不兼容。
完.
文章的结尾部分提出了一个开放性的问题,思考如何在两个非常不同的编程环境中分担组件的运行——这里的关键挑战在于如何保持React优秀的特性,同时在客户端和服务器端之间共享组件的责任。作者挑战读者思考是否有可能设计一种方式,使得可以将一些组件在服务器上运行以处理数据,同时又能让其他组件在客户端上运行以实现即时交互。
实际上,这引出了一个更广泛的问题:在保留React的响应式UI模型的同时,我们如何最有效地利用服务器端和客户端的各自优势?作者提供了一个思考方向:即UI = f(data, state)的模型,这个公式试图将两种看似对立的方法(UI作为状态的函数vs UI作为数据的函数)结合起来。这意味着,如果没有数据或状态,这个公式能归化到那些情况;但理想情况下,作者希望他的编程范式能同时处理这两种情况,而不是需要选择另一种抽象。
这篇文章概述的问题核心在于如何将我们的“f”(代表全部组件)跨两种非常不同的编程环境分担。请记住,这里讨论的不是某个具体的函数f——f在这里代表了所有的组件。如果我们可以在你的计算机和我的计算机之间以某种方式分担组件,同时保留React的优点,那会怎样?我们能否结合和嵌套来自两个不同环境的组件?这将如何工作?又应该如何工作?
作者邀请读者给予这个问题一些思考,并表示在下次讨论时将比较各自的见解。
文章的结尾提出了一个未解的挑战:如何在不牺牲用户体验和开发效率的前提下,在服务端和客户端之间优雅地共享React组件的执行责任。这不仅是对技术的挑战,也是对设计思维的挑战,要求开发者在构建应用时更加灵活和创新。
这篇博客挑战了传统的开发思维,提出了将React应用中的UI组件跨客户端和服务器端运行的概念,旨在激发读者对于现代Web应用开发模式的新思考。