一、两种做事方式
编程里所有代码都能归为两种风格:
| 命令式(Imperative) | 声明式(Declarative) | |
|---|---|---|
| 你告诉电脑 | 怎么做(一步步指令) | 要什么(描述结果) |
| 思维方式 | “先改这个,再改那个” | “数据长这样,页面就该长这样” |
| 你的职责 | 数据 + UI 更新全管 | 只管数据,UI 自动跟着变 |
生活类比:点菜
命令式点菜:
把锅烧热 → 倒油 → 打两个鸡蛋 → 加盐翻炒 → 装盘给我
你在指挥每一个步骤。
声明式点菜:
我要一份番茄炒蛋
你只说了”我要什么”,厨师自己知道怎么做。
代码对比:最直白的
同样是”点击按钮,数字 +1”:
// 命令式:你告诉浏览器每一步做什么
let count = 0;
const span = document.getElementById("count");
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
count = count + 1; // ① 手动改数据
span.textContent = count; // ② 手动更新 DOM
});
// 声明式:你只描述"UI 长什么样"
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
区别在于:命令式你管数据和 DOM 两件事;声明式你只管数据,DOM 由框架替你更新。
二、声明式不是魔法——必须有人替你干命令式的脏活
声明式的本质是”你只管描述结果,有人替你执行过程”。但”有人”不是凭空出现的——React 造了三层基础设施来当这个”有人”:
第一层:Virtual DOM —— 让你能”描述”而不是”操作”
// 没有 Virtual DOM:必须操作真实 DOM(命令式)
document.createElement("div");
element.appendChild(child);
element.setAttribute("class", "active");
element.removeChild(oldChild);
// 有 Virtual DOM:只需要"描述"UI 长什么样(声明式)
return (
<div className="active">
<Child />
</div>
);
Virtual DOM 是一棵 JS 对象树,描述”UI 应该长什么样”。你写 JSX → React 把它变成 JS 对象 → 这个对象跟真实 DOM 没关系,它只是一份设计图纸。
去掉这层会怎样?你必须直接操作真实 DOM,那就不可能声明式了。
第二层:Reconciliation(协调 / Diff 算法)—— 让 React 知道”怎么变”
你声明了新的 UI 状态,但浏览器只认真实 DOM。React 必须搞清楚:旧图纸和旧 DOM 之间,哪些地方变了?
旧 Virtual DOM: <div><span>A</span><span>B</span></div>
新 Virtual DOM: <div><span>A</span><span>C</span></div>
Diff 算法发现:第二个 <span> 的内容从 B 变成了 C
→ 只更新这一个真实 DOM 节点,其他不动
Diff 算法做的事:对比新旧两棵 Virtual DOM 树,算出最小的真实 DOM 操作,然后执行。
去掉这层会怎样?每次状态变化就得整体替换 DOM,性能不可接受,声明式就没人用了。
第三层:状态驱动渲染 —— 让”变化”自动发生
光有图纸和 Diff 还不够,还得有一个触发机制:什么时候该重新画图纸?
const [count, setCount] = useState(0);
// ↑ 状态 ↑ 修改状态的唯一方式
return <button onClick={() => setCount(count + 1)}>{count}</button>;
完整的链条:
用户点击按钮
→ 调用 setCount(1)
→ React 知道状态变了
→ 重新执行组件函数,拿到新的 Virtual DOM
→ Diff 对比新旧 Virtual DOM
→ 只更新真实 DOM 中变化的部分
useState 保证了状态是唯一的真相来源,UI 永远是状态的映射。你不需要手动同步——改了状态,UI 自动跟着变。
去掉这层会怎样?改了数据还得手动调用更新函数,又回到命令式了。
三层缺一不可
| 层 | 解决什么问题 | 去掉会怎样 |
|---|---|---|
| Virtual DOM | 用 JS 对象描述 UI,不用碰真实 DOM | 必须手写 DOM 操作,无法声明式 |
| Diff 算法 | 高效算出”哪些真实 DOM 该改” | 每次全量重建 DOM,性能不可接受 |
| 状态驱动渲染 | 状态变了自动触发重新渲染 | 改了数据还得手动刷新 UI,回到命令式 |
三、实战:useQuery 的声明式数据获取
理解了 React 的声明式机制,再看 TanStack Query 入门指南 里的这段代码:
const { data, isPending } = useQuery({
queryKey: ["todos"],
queryFn: getTodos,
});
if (isPending) return <Spinner />;
return <List data={data} />;
这是声明式的——你在说”数据没到时显示 Spinner,到了就显示 List”,没有写什么时候发请求、请求回来后怎么更新 UI、DOM 怎么从 Spinner 换成 List。
useQuery 怎么让 isPending 变化并触发重渲染
你写的代码 useQuery 内部 React
───────── ────────────── ─────
useQuery({queryKey...})
│
├─ 创建 QueryObserver
│ └─ 订阅 QueryCache 中的 Query 对象
│
├─ 返回 { isPending: true, data: undefined }
│
│ 发起 fetch 请求...
│ ...等待...
│ 请求成功!Query 内部状态更新
│ │
│ ▼
│ Query 通知所有 Observer:
│ "我的状态变了"
│ │
│ ▼
│ Observer 调用 React 的 setState()
│ │
◄───────────────────────────┘
│
│ React 检测到 setState → 触发组件重新渲染
│
├─ useQuery 再次执行
│ 从 Observer 读最新状态
│ 返回 { isPending: false, data: [...] }
│
└─ 组件用新返回值渲染 <List />
关键角色是 QueryObserver——TanStack Query 和 React 之间的桥梁:
- 往下:它订阅 Query 对象,Query 状态一变它就收到通知
- 往上:它持有一个 React 的
setState,收到通知后调用setState触发重渲染
这和你自己写 const [x, setX] = useState(0) 然后 setX(1) 触发重渲染是完全一样的机制——useQuery 只是把这个 setState 藏在了 Observer 内部。
类比:等快递
| 角色 | 现实 | 代码 |
|---|---|---|
| 你 | 等快递,不用一直盯着门口 | 组件渲染,等 useQuery 返回值 |
| 快递员 | 送到后按门铃 | 异步操作完成,Query 更新状态 |
| 门铃 | 通知你”东西到了” | Observer 调用 setState |
| 你去取 | 听到门铃去门口拿 | React 重渲染,useQuery 返回新值 |
你不用每隔 5 分钟跑去门口看快递到了没(轮询 / 命令式),门铃响了你就知道(事件通知 / 声明式)。
四、声明式在 React 中的三种体现
| 维度 | 命令式做法 | React 声明式做法 |
|---|---|---|
| UI 渲染 | 手动创建 / 更新 / 删除 DOM 节点 | JSX 描述 UI = f(state),状态变则 UI 自动变 |
| 数据获取 | 手动调 fetch → 手动判断 loading → 手动渲染 | useQuery 返回值随状态自动变,你只写”每种状态怎么展示” |
| 异步等待 | 每个组件自己管 isLoading 判断 | Suspense:组件只管渲染,loading 统一交给外层 <Suspense> |
从左到右,声明式的程度越来越高——你管的”过程”越来越少,框架管的越来越多。Suspense 是目前 React 生态里声明式程度最高的方案:组件里连 isPending 都不用判断了。
五、一句话总结
声明式 = 你只管描述结果,有人替你执行过程。 React 花了三层基础设施来当这个”有人”——Virtual DOM 是语言(让你能描述),Diff 是大脑(算出怎么变),状态驱动是心跳(驱动变化发生)。