不可能的组件
April 22, 2025
假设我想用我最喜欢的颜色来向你问好。
这需要结合来自两台不同计算机的信息。你的名字来自你的电脑,而颜色则在我的电脑上。
你可以想象有这样一个组件:
import { useState } from 'react';
import { readFile } from 'fs/promises';
async function ImpossibleGreeting() {
const [yourName, setYourName] = useState('Alice');
const myColor = await readFile('./color.txt', 'utf8');
return (
<>
<input placeholder="What's your name?"
value={yourName}
onChange={e => setYourName(e.target.value)}
/>
<p style={{ color: myColor }}>
Hello, {yourName}!
</p>
</>
);
}
但这个组件实际上是无法实现的。readFile
只能在我的电脑上执行,而 useState
只有在你的电脑上才有意义。我们无法同时做到这两件事,而不破坏可预测的自顶向下的执行流程。
或者说,真的不行吗?
拆分组件
让我们把这个组件拆成两部分。
第一部分负责读取文件,这只能在我的电脑上进行。它负责加载数据,所以我们称之为 GreetingBackend
:
import { readFile } from 'fs/promises';
import { GreetingFrontend } from './client';
async function GreetingBackend() {
const myColor = await readFile('./color.txt', 'utf8');
return <GreetingFrontend color={myColor} />;
}
它会读取我选择的颜色,并作为 color
属性传递给第二部分,负责交互的部分。我们称之为 GreetingFrontend
:
'use client';
import { useState } from 'react';
export function GreetingFrontend({ color }) {
const [yourName, setYourName] = useState('Alice');
return (
<>
<input placeholder="What's your name?"
value={yourName}
onChange={e => setYourName(e.target.value)}
/>
<p style={{ color }}>
Hello, {yourName}!
</p>
</>
);
}
第二部分接收 color
,并返回一个可交互的表单。你可以把 “Alice” 改成你的名字,注意当你输入时问候语会实时更新:
Hello, Alice!
(如果你就叫 Alice,也可以不用改。)
注意,后端总是先运行。我们的思维模型不是“前端从后端加载数据”,而是“后端传递数据给前端”。
这就是 React 的自顶向下数据流,只不过现在把后端也纳入了这个流。后端是数据的真实来源——所以它必须是前端的父级。
再看一遍这两部分,体会数据是如何向下流动的:
import { readFile } from 'fs/promises';
import { GreetingFrontend } from './client';
async function GreetingBackend() {
const myColor = await readFile('./color.txt', 'utf8');
return <GreetingFrontend color={myColor} />;
}
'use client';
import { useState } from 'react';
export function GreetingFrontend({ color }) {
const [yourName, setYourName] = useState('Alice');
return (
<>
<input placeholder="What's your name?"
value={yourName}
onChange={e => setYourName(e.target.value)}
/>
<p style={{ color }}>
Hello, {yourName}!
</p>
</>
);
}
从后端到前端。从我的电脑到你的电脑。
它们共同组成了一个封装良好、跨越两个世界的抽象体:
<GreetingBackend />
Hello, Alice!
它们共同组成了一个“不可能的组件”。
(这里以及下文中,'use client'
语法暗示我们即将学习 React Server Components。你可以在 Next 试用它——或者用 Parcel,无需框架即可体验。)
本地状态,本地数据
这种模式的美妙之处在于,我可以用一个 JSX 标签来引用整个功能——两边的逻辑——只需要写“后端”部分的标签。因为后端会渲染前端,所以渲染后端就等于渲染了两者。
举个例子,我们多次渲染 <GreetingBackend>
:
<>
<GreetingBackend />
<GreetingBackend />
<GreetingBackend />
</>
Hello, Alice!
Hello, Alice!
Hello, Alice!
你会发现每个输入框都可以独立编辑。
很自然地,每个 GreetingBackend
里的 GreetingFrontend
状态都是隔离的。同时,每个 GreetingBackend
加载数据的方式也是隔离的。
为演示这一点,我们把 GreetingBackend
改为接收 colorFile
属性:
import { readFile } from 'fs/promises';
import { GreetingFrontend } from './client';
async function GreetingBackend({ colorFile }) {
const myColor = await readFile(colorFile, 'utf8');
return <GreetingFrontend color={myColor} />;
}
接下来,新增 Welcome
组件,分别渲染不同颜色文件的 GreetingBackend
:
import { readFile } from 'fs/promises';
import { GreetingFrontend } from './client';
function Welcome() {
return (
<>
<GreetingBackend colorFile="./color1.txt" />
<GreetingBackend colorFile="./color2.txt" />
<GreetingBackend colorFile="./color3.txt" />
</>
);
}
async function GreetingBackend({ colorFile }) {
const myColor = await readFile(colorFile, 'utf8');
return <GreetingFrontend color={myColor} />;
}
来看下效果:
<Welcome />
每个问候语都会读取自己的文件,每个输入框也能独立编辑。
Hello, Alice!
Hello, Alice!
Hello, Alice!
这可能让你联想到 Rails 或 Django 里的“服务器局部模板”,但这里渲染的不是 HTML,而是完整的可交互 React 组件树。
现在你可以看到整个模式的优势:
- 每个
GreetingBackend
知道如何加载自己的数据。 这部分逻辑被封装在GreetingBackend
内部——你无需协调它们。 - 每个
GreetingFrontend
知道如何管理自己的状态。 这部分逻辑也被封装在GreetingFrontend
内部——同样无需手动协调。 - 每个
GreetingBackend
渲染一个GreetingFrontend
。 这让你可以把GreetingBackend
视为一个自包含单元,既能加载数据又能管理状态——一个“不可能的组件”。它是后端的一部分,带着自己的前端部分。
当然,你可以把“读取文件”替换为“查询 ORM”、“调用带密钥的 LLM 接口”、“请求内部微服务”或任何只在后端可用的资源。同理,输入框代表任何交互行为。关键在于,你可以把两边逻辑组合成自包含的组件。
我们再渲染一次 Welcome
:
<Welcome />
Hello, Alice!
Hello, Alice!
Hello, Alice!
注意我们并没有传递任何数据或状态。
<Welcome />
标签完全自包含!
而且因为后端部分总是先运行,当你加载这个页面时,对前端来说数据已经“就绪”。不会出现“从后端加载数据”的闪烁——后端已经把数据传给前端了。
本地状态。
本地数据。
单次往返。
自包含。
这不仅仅是 HTML
好吧,但这和渲染一堆 HTML 有什么不同?
让我们稍微修改一下 GreetingFrontend
组件:
import { readFile } from 'fs/promises';
import { GreetingFrontend } from './client';
async function GreetingBackend() {
const myColor = await readFile('./color.txt', 'utf8');
return <GreetingFrontend color={myColor} />;
}
'use client';
import { useState } from 'react';
export function GreetingFrontend({ color }) {
const [yourName, setYourName] = useState('Alice');
return (
<>
<input placeholder="What's your name?"
value={yourName}
onChange={e => setYourName(e.target.value)}
onFocus={() => {
document.body.style.backgroundColor = color;
}}
onBlur={() => {
document.body.style.backgroundColor = '';
}}
/>
<p>
Hello, {yourName}!
</p>
</>
);
}
我们不再给 <p>
加样式,而是在输入框聚焦时,把 document.body.style.backgroundColor
设置为后端传来的 color
,失焦时还原。
试着在输入框里输入:
Hello, Alice!
从某种角度看,这种“直接可用”可能让你觉得理所当然,也可能让你很惊讶。后端把 props 传给前端,但不是为了生成初始 HTML 标记。
这些 props 会在后续——比如事件处理器里——被用到。
'use client';
import { useState } from 'react';
export function GreetingFrontend({ color }) {
// ...
return (
<>
<input placeholder="What's your name?"
// ...
onFocus={() => {
document.body.style.backgroundColor = color;
}}
// ...
/>
...
</>
);
}
当然,我们传递的不只是颜色。可以是字符串、数字、布尔值、对象、JSX 片段——任何能跨网络传递的内容。
现在我们再渲染一次 <Welcome />
,组合我们的组件:
import { readFile } from 'fs/promises';
import { GreetingFrontend } from './client';
function Welcome() {
return (
<>
<GreetingBackend colorFile="./color1.txt" />
<GreetingBackend colorFile="./color2.txt" />
<GreetingBackend colorFile="./color3.txt" />
</>
);
}
async function GreetingBackend({ colorFile }) {
const myColor = await readFile(colorFile, 'utf8');
return <GreetingFrontend color={myColor} />;
}
注意每个问候语现在都有新行为,但依然互不影响:
Hello, Alice!
Hello, Alice!
Hello, Alice!
本地数据、本地状态。
互不干扰。没有全局标识符,没有命名冲突。任何组件都可以在树中任意复用,依然自包含。
本地化,因此可组合。
理解了这个思路后,让我们玩点更有趣的。
可排序列表
想象另一个不可能的组件——一个可排序的文件列表。
import { useState } from 'react';
import { readdir } from 'fs/promises';
async function SortableFileList({ directory }) {
const [isReversed, setIsReversed] = useState(false);
const files = await readdir(directory);
const sortedFiles = isReversed ? files.toReversed() : files;
return (
<>
<button onClick={() => setIsReversed(!isReversed)}>
Flip order
</button>
<ul>
{sortedFiles.map(file =>
<li key={file}>
{file}
</li>
)}
</ul>
</>
);
}
当然,这样写没意义。readdir
只在我的电脑上有用,而你用 useState
选择的排序顺序只存在于你的电脑上。(我最多只能生成初始状态的 HTML。)
怎么修正这个组件?
你已经知道套路了:
import { SortableList } from './client';
import { readdir } from 'fs/promises';
async function SortableFileList({ directory }) {
const files = await readdir(directory);
return <SortableList items={files} />;
}
'use client';
import { useState } from 'react';
export function SortableList({ items }) {
const [isReversed, setIsReversed] = useState(false);
const sortedItems = isReversed ? items.toReversed() : items;
return (
<>
<button onClick={() => setIsReversed(!isReversed)}>
Flip order
</button>
<ul>
{sortedItems.map(item => (
<li key={item}>
{item}
</li>
))}
</ul>
</>
);
}
试试看:
<SortableFileList directory="." />
- client.js
- color.txt
- color1.txt
- color2.txt
- color3.txt
- components.js
- index.md
- server.js
目前为止一切正常。注意 items
其实是个数组,我们已经用它来切换顺序了。还能对数组做什么?
可筛选列表
如果能用输入框筛选文件列表就更好了。筛选必须在你的电脑上发生(我最多只能生成初始 HTML)。因此,筛选逻辑应加在前端部分:
import { SortableList } from './client';
import { readdir } from 'fs/promises';
async function SortableFileList({ directory }) {
const files = await readdir(directory);
return <SortableList items={files} />;
}
'use client';
import { useState } from 'react';
export function SortableList({ items }) {
const [isReversed, setIsReversed] = useState(false);
const [filterText, setFilterText] = useState('');
let filteredItems = items;
if (filterText !== '') {
filteredItems = items.filter(item =>
item.toLowerCase().startsWith(filterText.toLowerCase())
);
}
const sortedItems = isReversed ? filteredItems.toReversed() : filteredItems;
return (
<>
<button onClick={() => setIsReversed(!isReversed)}>
Flip order
</button>
<input
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
placeholder="Search..."
/>
<ul>
{sortedItems.map(item => (
<li key={item}>{item}</li>
))}
</ul>
</>
);
}
注意后端部分只执行一次——我的博客是静态的,构建时运行。前端逻辑则对你的每次输入都能实时响应:
- client.js
- color.txt
- color1.txt
- color2.txt
- color3.txt
- components.js
- index.md
- server.js
而且这个组件可复用,可以指向其他数据源:
<SortableFileList directory="./node_modules/react/" />
- LICENSE
- README.md
- cjs
- compiler-runtime.js
- index.js
- jsx-dev-runtime.js
- jsx-dev-runtime.react-server.js
- jsx-runtime.js
- jsx-runtime.react-server.js
- package.json
- react.react-server.js
我们再次拥有了一个自包含组件,既能在后端加载数据,又能在前端实现交互。
让我们看看还能走多远。
可展开预览
这是我的博客用的小型 PostPreview
组件:
import { readFile } from 'fs/promises';
import matter from 'gray-matter';
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>
);
}
效果如下:
<PostPreview slug="jsx-over-the-wire" />
JSX 跨网络传输
4,052 words它能自动加载自己的数据(或者说,数据已经在那里)。
现在我想加点交互,比如点击卡片时展开显示文章首句。
在后端获取首句很简单:
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;
const firstSentence = content.split('.')[0];
const isExpanded = true; // TODO: 需要和点击事件关联
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>
{isExpanded && <p>{firstSentence} [...]</p>}
</section>
);
}
JSX 跨网络传输
4,052 words假设你有一个 API 路由,它以 JSON 的形式返回一些数据: [...]
但如何实现点击展开?点击是前端概念,状态也是。我们把前端部分提取成 ExpandingSection
组件:
import { readFile } from 'fs/promises';
import matter from 'gray-matter';
import { ExpandingSection } from './client';
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;
const firstSentence = content.split('.')[0];
const isExpanded = true; // TODO: 需要和点击事件关联
return (
<ExpandingSection>
<h5 className="font-bold">
<a href={'/' + slug} target="_blank">
{data.title}
</a>
</h5>
<i>{wordCount.toLocaleString()} words</i>
{isExpanded && <p>{firstSentence} [...]</p>}
</ExpandingSection>
);
}
'use client';
export function ExpandingSection({ children }) {
return (
<section className="rounded-md bg-black/5 p-2">
{children}
</section>
);
}
目前只是把 <section>
从数据世界(后端)移到状态和事件处理的世界(前端)。
但既然已经在前端了,我们可以加上交互逻辑:
'use client';
import { useState } from 'react';
export function ExpandingSection({ children, extraContent }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<section
className="rounded-md bg-black/5 p-2"
onClick={() => setIsExpanded(!isExpanded)}
>
{children}
{isExpanded && extraContent}
</section>
);
}
(实际应用中应使用按钮作为点击目标,并避免嵌套链接以保证可访问性。这里为简明起见略去,但你应注意。)
我们来验证下 ExpandingSection
是否如预期工作。点击“Hello”试试:
<ExpandingSection
extraContent={<p>World</p>}
>
<p>Hello</p>
</ExpandingSection>
Hello
现在 <ExpandingSection>
可以点击切换 extraContent
的显示。只需从后端传递 extraContent
即可:
async function PostPreview({ slug }) {
// ...
const firstSentence = content.split('.')[0];
return (
<ExpandingSection
extraContent={<p>{firstSentence} [...]</p>}
>
...
</ExpandingSection>
);
}
'use client';
import { useState } from 'react';
export function ExpandingSection({ children, extraContent }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<section
className="rounded-md bg-black/5 p-2"
onClick={() => setIsExpanded(!isExpanded)}
>
{children}
{isExpanded && extraContent}
</section>
);
}
再试一次:
<PostPreview slug="jsx-over-the-wire" />
初始状态和之前一样。点击卡片试试:
JSX 跨网络传输
4,052 words现在额外内容出现了!注意切换卡片时没有发起任何请求——extraContent
属性早已传递。
完整代码如下,方便你追踪 props 从后端到前端的流向:
import { readFile } from 'fs/promises';
import matter from 'gray-matter';
import { ExpandingSection } from './client';
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;
const firstSentence = content.split('.')[0];
return (
<ExpandingSection
extraContent={<p>{firstSentence} [...]</p>}
>
<h5 className="font-bold">
<a href={'/' + slug} target="_blank">
{data.title}
</a>
</h5>
<i>{wordCount.toLocaleString()} words</i>
</ExpandingSection>
);
}
'use client';
import { useState } from 'react';
export function ExpandingSection({ children, extraContent }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<section
className="rounded-md bg-black/5 p-2"
onClick={() => setIsExpanded(!isExpanded)}
>
{children}
{isExpanded && extraContent}
</section>
);
}
props 总是自顶向下流动。
注意必须把 ExpandingSection
放在前端世界(即带 'use client'
的文件)里。后端没有“状态”概念——每次请求都会重置——在后端引入 useState
会构建报错。
但你随时可以把 <section>...</section>
换成前端组件 <ExpandedSection>...</ExpandedSection>
,为普通 <section>
增加状态逻辑和事件处理。
这有点像编织。你把 children
和 extraContent
作为 <ExpandedSection>...</ExpandedSection>
的“空洞”,然后用后端内容“填充”进去。你会经常见到这种模式,因为这是在前端组件内部嵌入更多后端内容的唯一方式。
习惯它吧!
预览列表
我们新增一个 PostList
组件,渲染多个 PostPreview
。
import { readFile, readdir } from 'fs/promises';
import matter from 'gray-matter';
async function PostList() {
const entries = await readdir('./public/', { withFileTypes: true });
const dirs = entries.filter(entry => entry.isDirectory());
return (
<div className="mb-8 flex h-72 flex-col gap-2 overflow-scroll font-sans">
{dirs.map(dir => (
<PostPreview key={dir.name} slug={dir.name} />
))}
</div>
);
}
async function PostPreview({ slug }) {
// ...
}
它也必须在后端,因为用到了 readdir
。
现在它会展示我博客所有文章的预览卡片:
<PostList />
连锁反应
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你可以点击每个卡片,卡片会展开。这不是普通 HTML——而是可交互的 React 组件,props 来自后端。
可排序的预览列表
现在让我们把预览列表变成可筛选、可排序的。
最终效果如下:
连锁反应
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
其实并不难。
首先,回顾前面的 SortableList
组件。我们用和之前一样的代码,只不过 items
现在是形如 { id, content, searchText }
的对象数组:
'use client';
import { useState } from 'react';
export function SortableList({ items }) {
const [isReversed, setIsReversed] = useState(false);
const [filterText, setFilterText] = useState('');
let filteredItems = items;
if (filterText !== '') {
filteredItems = items.filter(item =>
item.searchText.toLowerCase().startsWith(filterText.toLowerCase()),
);
}
const sortedItems = isReversed ? filteredItems.toReversed() : filteredItems;
return (
<>
<button onClick={() => setIsReversed(!isReversed)}>
Flip order
</button>
<input
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
placeholder="Search..."
/>
<ul>
{sortedItems.map(item => (
<li key={item.id}>
{item.content}
</li>
))}
</ul>
</>
);
}
对于 SortableFileList
,我们把文件名作为每个字段:
import { SortableList } from './client';
import { readdir } from 'fs/promises';
async function SortableFileList({ directory }) {
const files = await readdir(directory);
return (
<SortableList
items={
files.map(file => ({
id: file,
content: file,
searchText: file,
}))
}
/>
);
}
'use client';
import { useState } from 'react';
export function SortableList({ items }) {
// ...
}
依然可以正常工作:
<SortableFileList directory="./public/impossible-components" />
- client.js
- color.txt
- color1.txt
- color2.txt
- color3.txt
- components.js
- index.md
- server.js
但现在我们可以把 <SortableList>
用于文章列表:
import { SortableList } from './client';
import { readdir } from 'fs/promises';
async function SortablePostList() {
const entries = await readdir('./public/', { withFileTypes: true });
const dirs = entries.filter((entry) => entry.isDirectory());
return (
<div className="mb-8 flex h-72 flex-col gap-2 overflow-scroll font-sans">
<SortableList
items={
dirs.map(dir => ({
id: dir.name,
searchText: dir.name.replaceAll('-', ' '),
content: <PostPreview slug={dir.name} />
}))
}
/>
</div>
);
}
'use client';
import { useState } from 'react';
export function SortableList({ items }) {
// ...
}
来看下效果:
<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
可以随意操作,确保你理解了背后的原理。
这是一棵完整的可交互 React 树。你可以点击每一项,卡片会展开/收起(靠 <ExpandingSection>
的本地状态)。如果你展开一张卡片,点击“Flip order”再点回来,你会发现卡片依然保持展开,只是位置变了又变回。
你可以筛选、排序,靠的是 <SortableList>
。注意 <SortableList>
并不知道排序的是什么,你可以放任何内容,它都能正常工作,也能直接接收后端传来的 props。
在后端,<PostPreview>
组件完全封装了读取单篇文章信息的逻辑,包括字数统计、首句提取,并传递给 <ExpandingSection>
。
注意,虽然每篇文章只渲染一个 <PostPreview>
,但整页所需数据会在一次运行中收集,并作为单次往返传递。当你访问页面时,不会有额外请求。只有 UI 需要的数据会被传递——也就是前端的 props。
我们在构建自包含的组件,每个都能加载自己的数据或管理自己的状态。你可以在树的任意位置添加更多封装的数据加载逻辑或状态逻辑——只要在合适的世界里实现。掌握这些模式需要一定练习,但回报是能实现像 <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
本地状态。
本地数据。
单次往返。
自包含。
总结
用户并不关心这些技术细节。当人们使用网站和应用时,他们不会想着“前端”和“后端”。他们看到的是屏幕上的内容:一个区块、一个标题、一条文章预览、一个可排序列表。
也许用户才是对的。
拥有自包含数据逻辑和状态逻辑的可组合抽象,让我们能用和用户一样的语言交流。像 <PostPreview slug="...">
和 <SortableList items={...}>
这样的组件 API,正好符合我们直观理解界面元素的方式。实现自包含的 <PostPreview>
和 <SortableList>
需要分别在不同“世界”运行,但如果能组合它们,这根本不是问题。
前后端的分界是物理层面的。我们无法逃避客户端/服务端应用的现实。有些逻辑天生更适合某一端。但一端不应主导另一端,也不应因边界变化而改变开发方式。
我们需要的是能跨栈组合的工具。这样我们就能创造出既有后端需求又有前端需求的自包含乐高积木,并随意拼接。任何 UI 片段都可以拥有自己的后端和前端需求。是时候让我们的工具承认、拥抱这一点,并让我们用用户的语言表达。
后续探索
我们只展示了部分组合模式,远未触及全部可能。如果你想自己玩玩,可以尝试:
- 给
PostPreview
增加更多后端逻辑,比如解析 Markdown 首句(但去除格式)。 - 在每个
PostPreview
项中高亮部分匹配的搜索文本。可以用 Context 提供filterText
,并把<h5>
提取成前端的PostHeader
,从 Context 读取。 - 如果你愿意让项目按请求动态服务(不像我的博客是静态的),可以把筛选逻辑移到后端,通过路由查询参数读取
filterText
。SortableList
可以触发路由导航而不是本地状态,并在重新获取页面时显示加载指示。这适合需要筛选大量数据(如数据库)的场景。 - 我的博客是全静态的——但如果你的应用是动态的,可以加个“刷新”按钮,无需更改组件即可刷新文章列表。刷新时不会销毁现有状态,比如已展开的卡片或输入的筛选词,新增匹配项会平滑出现,甚至可以加动画。
- 当然,如果你的应用是动态的,也可以通过
'use server'
在前端调用后端,实现数据变更。这和本文模式完美契合。 - 想象你自己的“不可能组件”!比如
<Image>
组件自动从文件系统读取并生成模糊渐变占位图?回想你上次写组件时,是否需要某些只在“后端”或“构建时”才能获得的信息?现在你可以了。
最重要的是,体会自包含数据加载和状态逻辑,以及如何组合它们。掌握了这些,你就能畅游其中。
关于术语
和我最近的几篇 文章一样,本文刻意避免使用“Server Components”和“Client Components”术语,因为它们容易引发误解和先入为主的反应。(尤其是大家容易以为“客户端从服务器加载”,而不是“服务器渲染客户端”模型。)
本文中的“后端组件”其实就是官方的 Server Components,“前端组件”就是 Client Components。如果可以,我可能还是不会改官方术语。不过我发现,等你理解了模型(希望你现在已经理解),再引入这些术语,比一开始就讲要好得多。如果 React Server Components 的前后端分离最终成为描述分布式可组合 UI 的主流模式,这个问题也许会在十年内自然消失。
完整代码
以下是最后示例的完整代码。
import { SortableList, ExpandingSection } from './client';
import { readdir, readFile } from 'fs/promises';
async function SortablePostList() {
const entries = await readdir('./public/', { withFileTypes: true });
const dirs = entries.filter((entry) => entry.isDirectory());
return (
<div className="mb-8 flex h-72 flex-col gap-2 overflow-scroll font-sans">
<SortableList
items={
dirs.map(dir => ({
id: dir.name,
searchText: dir.name.replaceAll('-', ' '),
content: <PostPreview slug={dir.name} />
}))
}
/>
</div>
);
}
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;
const firstSentence = content.split('.')[0];
return (
<ExpandingSection
extraContent={<p>{firstSentence} [...]</p>}
>
<h5 className="font-bold">
<a href={'/' + slug} target="_blank">
{data.title}
</a>
</h5>
<i>{wordCount.toLocaleString()} words</i>
</ExpandingSection>
);
}
'use client';
import { useState } from 'react';
export function SortableList({ items }) {
const [isReversed, setIsReversed] = useState(false);
const [filterText, setFilterText] = useState('');
let filteredItems = items;
if (filterText !== '') {
filteredItems = items.filter(item =>
item.searchText.toLowerCase().startsWith(filterText.toLowerCase()),
);
}
const sortedItems = isReversed ? filteredItems.toReversed() : filteredItems;
return (
<>
<button onClick={() => setIsReversed(!isReversed)}>
Flip order
</button>
<input
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
placeholder="Search..."
/>
<ul>
{sortedItems.map(item => (
<li key={item.id}>
{item.content}
</li>
))}
</ul>
</>
);
}
export function ExpandingSection({ children, extraContent }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<section
className="rounded-md bg-black/5 p-2"
onClick={() => setIsExpanded(!isExpanded)}
>
{children}
{isExpanded && extraContent}
</section>
);
}
连锁反应
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
你可以在 Next 试试这套代码——或者用 Parcel,无需框架。如果你基于这些代码搭建了完整项目,欢迎提 PR,我会加链接。
玩得开心!