Skip to content
检测中

设计哲学:为什么是声明式

React 不是一套 API,而是一种思维迁移:从「命令 DOM 怎么变」转到「描述 UI 应该是什么」。没跨过这道坎,写多少年都在抄代码。


目录

  1. 引言
  2. 1.1 命令式 vs 声明式
  3. 1.2 UI 是状态的函数
  4. 1.3 状态驱动渲染
  5. 1.4 单向数据流
  6. 1.5 不可变性为什么重要
  7. 1.6 不直接操作 DOM 的边界
  8. 小结
  9. 下一步

引言

很多人写 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)
关注点怎么改 DOMUI 是什么样
状态存放散落在变量、DOM 里集中在组件 state
更新方式手动定位节点并改改 state,框架算差异
复杂度增长状态一多,更新路径爆炸状态与 UI 始终对应,可推理
心智负担追踪「谁在改这个 DOM」追踪「state 是什么」

这张表是本系列的纲领。命令式的复杂度随状态数量爆炸式增长:一个计数器还好,三个互相联动的状态(比如「展开/折叠」+「选中项」+「过滤条件」),你就得手写所有组合的更新路径——展开时要同步改选中项的样式,过滤时要重置选中……分支数随状态数相乘,很快失控。声明式把「状态 → UI」的映射交给框架,你只维护一份 state,UI 永远是它的函数投影,复杂度线性可控。

1.2 UI 是状态的函数

React 的核心可以用一个公式概括:

UI = f(state)

f 就是你的组件。它是一个纯函数:输入 propsstate,输出 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 进阶系列总纲

Released under the ISC License.