Appearance
设计哲学:为什么是声明式
React 不是一套 API,而是一种思维迁移:从「命令 DOM 怎么变」转到「描述 UI 应该是什么」。没跨过这道坎,写多少年都在抄代码。
目录
引言
很多人写 React 三五年,仍在靠 Stack Overflow 拼凑代码。典型症状:useEffect 里塞满业务逻辑、setState 后直接 push 数组、组件之间状态乱跳、动不动就操作 DOM。这些都不是「不熟 API」的问题,而是同一个根因——没有建立 React 的运行模型心智。
API 可以查文档,心智模型查不到。React 的所有设计决策都建立在一个底座之上:声明式 + 状态驱动渲染。理解了它,Hooks、性能优化、并发特性都是顺理成章的推论;不理解它,每个 API 都是孤立的黑魔法。
本篇建立这个底座。我们从最根本的对比开始。
1.1 命令式 vs 声明式
命令式:一步步告诉机器怎么做。声明式:描述目标状态,让机制负责落地。
同一个计数器,先看 jQuery(命令式):
ts
// 命令式:取节点 → 绑事件 → 手动改 DOM
const btn = document.getElementById("inc")!;
const label = document.getElementById("count")!;
let count = 0;
btn.addEventListener("click", () => {
count += 1;
label.textContent = String(count); // 你亲手改 DOM
});每一步都是你写的指令:取到节点、监听点击、计算新值、把新值写回 DOM。DOM 的每一次变化,背后都有一行你亲手敲下的代码。
再看 React(声明式):
tsx
// 声明式:描述 UI 长什么样,改 state 即可
function Counter() {
const [count, setCount] = useState(0);
return (
<>
<span id="count">{count}</span>
<button id="inc" onClick={() => setCount((c) => c + 1)}>
+1
</button>
</>
);
}你没有写任何「把数字写进 span」的代码。你只做了两件事:声明状态 count,并声明 UI 应该如何呈现它。点击按钮时,你只是更新了状态——至于 DOM 怎么变,React 负责。
| 维度 | 命令式(jQuery) | 声明式(React) |
|---|---|---|
| 关注点 | 怎么改 DOM | UI 是什么样 |
| 状态存放 | 散落在变量、DOM 里 | 集中在组件 state |
| 更新方式 | 手动定位节点并改 | 改 state,框架算差异 |
| 复杂度增长 | 状态一多,更新路径爆炸 | 状态与 UI 始终对应,可推理 |
| 心智负担 | 追踪「谁在改这个 DOM」 | 追踪「state 是什么」 |
这张表是本系列的纲领。命令式的复杂度随状态数量爆炸式增长:一个计数器还好,三个互相联动的状态(比如「展开/折叠」+「选中项」+「过滤条件」),你就得手写所有组合的更新路径——展开时要同步改选中项的样式,过滤时要重置选中……分支数随状态数相乘,很快失控。声明式把「状态 → UI」的映射交给框架,你只维护一份 state,UI 永远是它的函数投影,复杂度线性可控。
1.2 UI 是状态的函数
React 的核心可以用一个公式概括:
UI = f(state)f 就是你的组件。它是一个纯函数:输入 props 与 state,输出 UI 的描述(注意——是一棵描述 UI 的对象树,不是 DOM 节点本身)。相同的输入,永远得到相同的输出。
tsx
// 组件签名:(props) => UI 描述
type GreetingProps = { name: string };
function Greeting({ name }: GreetingProps) {
return <h1>Hello, {name}</h1>; // 给定 name,输出永远确定
}这是函数式编程思想在 UI 的落地——呼应站内 函数式思维:把 UI 当作纯函数计算的结果,而非一堆可变 DOM 的集合。「纯」意味着渲染过程中不产生副作用:不发请求、不读 Date.now()、不调用 Math.random()、不修改外部变量。副作用是受控的、被隔离的(交给 useEffect,见系列第 5 篇)。
接受这个视角,React 的很多「怪规矩」就有了理由:为什么渲染函数要纯?因为它是 f,纯函数才可被反复调用而不出错。为什么不能在渲染里改 state?因为 f 只负责把输入映射到输出,改 state 是「产生新输入」,那是事件处理的职责,不是渲染的职责。把这两件事分开,代码的因果关系才清晰。
1.3 状态驱动渲染
在 React 里,你实际上只做两件事:声明状态,更新状态。其余的,框架包办。
setState(count + 1)
│
▼
┌───────────────────┐
│ React 调度一次渲染 │ ← 不一定立即,可能批处理
└─────────┬─────────┘
▼
┌───────────────────┐
│ 重新调用组件函数 │ ← f(state) 再跑一遍
└─────────┬─────────┘
▼
┌───────────────────┐
│ 生成新的虚拟 DOM 树│ ← 新的 UI 描述
└─────────┬─────────┘
▼
┌───────────────────┐
│ diff 新旧两棵树 │ ← Reconciliation
└─────────┬─────────┘
▼
┌───────────────────┐
│ 提交真实 DOM 更新 │ ← 只改真正变化的部分
└───────────────────┘关键洞察:组件函数被反复调用,是 React 的常态,不是异常。每次 state 变化,React 重新调用你的组件函数,得到新的 UI 描述,再和上一次的描述做差异比对(diff),最后只把变化的部分提交到真实 DOM。这正是为什么组件函数必须保持纯净——它随时可能被重新调用。
你不写「更新 DOM 的代码」,React 替你算出差异并应用。这就是「状态驱动渲染」。
这里埋一个伏笔:diff 的效率取决于 React 如何判断「哪些节点变了、哪些没变」。当列表里出现新增、删除、重排时,React 靠 key 来匹配新旧节点——这正是系列第 3 篇 Reconciliation 的核心。现在只需记住:状态一变,整棵虚拟树重新生成,React 在内存里做高效比对,最后只把必要的几次 DOM 写操作提交出去。真实 DOM 操作是昂贵的,虚拟 DOM 的意义就在于把昂贵的真实操作推迟并合并到最小。
1.4 单向数据流
数据在 React 里只往一个方向流:自顶向下。状态属于某个组件,通过 props 一层层传给子组件;子组件不能改父组件传来的 props(它们是只读的)。
那子组件怎么触发变化?自下向上——通过回调。父组件把一个「修改状态的函数」也当作 prop 传下去,子组件在需要时调用它。
tsx
type ChildProps = { value: number; onChange: (v: number) => void };
function Child({ value, onChange }: ChildProps) {
// 子组件不持有「真相」,只通过回调报告意图
return <button onClick={() => onChange(value + 1)}>子:+1</button>;
}
function Parent() {
const [count, setCount] = useState(0);
// 真相在父,向下传值,向下传改值的能力
return <Child value={count} onChange={setCount} />;
}为什么 React 拒绝双向绑定(如 Vue 的 v-model、Angular 的双向绑定)?因为双向绑定让数据流不可追踪——一个状态可能被多处同时修改,调试时你根本不知道是谁改的、改动按什么顺序生效。单向流强制你回答两个问题:状态从哪来(唯一来源)、被谁改(唯一入口)。复杂应用里,这种可追踪性是救命的。
当两个组件需要共享状态时,规则是状态上提(lifting state up):把状态挪到它们的共同最近祖先,再通过 props 向下分发。状态的真相永远只有一个。
初学者常犯的错是给每个子组件各塞一份 state,然后试图在它们之间同步——这等于重建了双向绑定的混乱。正确做法是承认「真相唯一」:找到那个能看到所有需要该状态的组件的共同祖先,把 state 放那儿,向下传值。当祖先太深、props 穿透太多层(prop drilling)时,再考虑状态管理方案(系列第 7 篇)。单向流的代价是 props 传递的样板代码,换来的是任何时候都能回答「这份数据从哪来、谁能改它」。
1.5 不可变性为什么重要
这是初学者栽跟头最多的地方。React 更新状态的唯一合法方式是:传入一个新值。
tsx
function Bad() {
const [state, setState] = useState({ count: 0 });
const broken = () => {
state.count++; // ❌ 直接改原对象
setState(state); // 传的还是同一个引用 → React 认为没变 → 不渲染
};
const works = () => {
setState((s) => ({ ...s, count: s.count + 1 })); // ✅ 新对象,引用变了
};
// ...
}数组同理:用 [...arr, item] 而非 arr.push(item)。
为什么 React 非要你传新对象?因为它判断「状态是否变化」用的是引用相等(oldState !== newState),这是 O(1) 的浅比较。如果改用深比较去逐字段比对,每次渲染都要遍历整个状态树,代价昂贵。不可变性让你能用最廉价的引用比较,换取正确的更新判断。
这同样是函数式思想的约束:纯函数不 mutate 输入。React 把不可变作为状态更新的硬性规矩——你每次都在「生成下一个状态」,而非「修改当前状态」。这种心智下,时间旅行(撤销/重做)、状态快照、并发渲染才成为可能,因为历史状态永远不会被后续操作破坏:每一次渲染对应一个不可变的状态快照,它们彼此独立、可随时重放。
一个常被忽略的细节:不可变不是「不修改」,而是「要修改就生成新对象」。对象展开 {...obj}、数组展开 [...arr]、以及 structuredClone、Immer 这类工具,都是为这件事服务。当你发现自己在写 obj.a.b.c = x 这类深层 mutate 时,停下来——它既绕过了 React 的更新检测,也让代码难以推理。
一个连带坑:useEffect 的依赖数组也靠引用相等判断。如果你 mutate 了对象却传同一个引用进去,effect 同样不会重新触发——这条规矩在 Hooks 时代贯穿始终。
1.6 不直接操作 DOM 的边界
总纲里说「永远不该直接操作 DOM」。原则成立,但要讲清边界。
为什么不碰 DOM?因为声明式模型有一个隐含前提:React 是 DOM 的唯一写手。组件函数描述 UI,React 算出差异并提交到 DOM。如果你在旁边手动改 DOM,就出现了两个写手——你改的值会在下次 React 渲染时被它覆盖,或者和它的提交顺序打架,结果不可预测。
但有合法的逃生口:ref。以下场景必须直接接触 DOM,React 也认可:
tsx
function AutoFocusInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // 受控地碰一次 DOM
}, []);
return <input ref={inputRef} />;
}典型场景:
- 焦点管理:
autofocus属性在动态挂载时不可靠,模态框的焦点陷阱需要手动focus - 滚动控制:滚动到某个元素、虚拟列表的滚动位置
- 测量尺寸:
getBoundingClientRect获取布局信息,用于动画或定位 - 集成非 React 库:图表库(D3、Chart.js)、富文本编辑器(Quill)需要拿到真实 DOM 初始化
要点:ref 是受控的逃生口,不是默认手段。能用 state 表达的行为,绝不用 ref。判断标准很简单——如果这个值会影响渲染输出,它就该是 state;只有那些「不影响渲染、纯粹是命令式的 DOM 操作」才用 ref。一个常见误用:拿 useRef 存本来该是 state 的数据(比如「我点了多少次按钮,但不想重渲染」),结果代码里到处是 ref.current 的手动读取,绕过了声明式模型。能避免就避免。
小结
声明式不是一套语法,而是一场思维迁移:从「命令 DOM 怎么变」转到「描述 UI 是什么」。它的三块基石是:
- UI = f(state):组件是纯函数,状态映射出 UI。
- 状态驱动渲染:你改 state,React 算差异、改 DOM。
- 单向数据流 + 不可变性:状态有唯一来源、用新值更新,让一切可追踪、可推理。
跨过这道坎,React 就从「黑盒魔法」变成「可推理的系统」。后续 Hooks、性能优化、并发特性,都是这三块基石的自然推论。
下一步
本篇建立心智底座,下一篇进入它的实现细节:你写的 JSX,经过编译、经过 createElement,最终变成内存里的一棵对象树。理解了「元素是对象」,状态驱动渲染才真正落到实处。
下一篇:JSX 与元素——编译时发生了什么 · 系列第 1 篇 / 共 8 篇
回到 React 进阶系列总纲