Chapter 01

心智模型:UI 是 state 的纯函数

起点给了一张概念地图。这一章把地图正中心的 UI = f(state) 拆开:它到底在说什么,以及它如何把工程师从“手动同步 DOM”里解放出来。这是后面四章全部内容的地基。

本章你将建立的 schema

  • 把“命令式地一步步改 DOM”换成“声明式地描述 UI 该长什么样”
  • 把组件看成一个纯函数:输入 props 与 state,输出一段 UI 描述(JSX)
  • 数据单向向下流,事件通过回调向上传——全程只有一个数据源

1.1从命令式到声明式

命令式:一步步告诉浏览器“怎么改”。声明式:只描述“当前状态下 UI 该是什么样”,怎么改交给 React。

为什么需要它

手动改 DOM 时,界面此刻的样子 = 初始 HTML + 你写过的每一次修改的累积。状态一多,每条状态变化都要配一段 DOM 更新代码,还得保证它们彼此不打架。绝大多数 UI bug 是同一种:某个状态变了,但某处 DOM 忘了跟着改。

下面是同一个计数器的两种写法。先看命令式——注意有多少行在“手动同步 DOM”。

counter-imperative.js JavaScript
let count = 0;
const label = document.getElementById("label");
const btn = document.getElementById("btn");

btn.addEventListener("click", () => {
  count = count + 1;
  label.textContent = "计数:" + count;   // 手动同步 DOM 第 1 处
  btn.disabled = count >= 5;              // 手动同步 DOM 第 2 处
  btn.textContent = count >= 5 ? "到上限" : "加一"; // 第 3 处……
});

每多一个“依赖 count 的界面元素”,就多一行手动同步。漏写任意一行,那块 DOM 就和 count 不一致。现在看声明式的 React 版本。

Counter.jsx JSX
function Counter() {
  const [count, setCount] = useState(0);

  // 只描述“count 是这个值时,UI 长什么样”
  return (
    <div>
      <p>计数:{count}</p>
      <button
        disabled={count >= 5}
        onClick={() => setCount(count + 1)}>
        {count >= 5 ? "到上限" : "加一"}
      </button>
    </div>
  );
}

逐行解读(代码 → 概念)

useState(0)声明一个会变的输入 count,初始 0(第 3 章详解)。 return (…)这就是 f 的返回值:一份完整的 UI 描述,不是 DOM 操作。 disabled / 文本这些“依赖 count 的地方”不再各写一行同步代码,而是直接写成 count 的表达式。count 一变,整个描述重新算一遍,三处一起更新——不可能漏。

底层机制 · 比文档深一层

声明式不是“更高级的写法”,而是把“计算 DOM 差异”这件事从你手里转移给 React。你每次产出一份完整的目标描述,React 拿它和上一份描述做 diff,自己算出最小的 DOM 操作。代价:React 要在内存里保留描述并做对比(有开销,第 2 章讲它如何把开销压到最低)。收益:UI 不可能和 state 不同步——因为你根本不碰 DOM。

命令式 你 手写每步 DOM 真实 DOM 同步责任 = 你 声明式 你 给描述 React diff + 更新 真实 DOM 同步责任 = React
图 1.1两种写法的差别,本质是“谁来算 DOM 差异”。注意:声明式把一个 React 节点插进了你和 DOM 之间——同步责任随之从你转移给它。你只负责描述,不再负责“怎么改”。
想一想

给命令式版本再加一个“在标题里也显示 count”的需求,要改几处?给声明式版本加同样的需求呢?

展开答案(先停 10 秒)

命令式:至少 +1 处手动同步(在 click 回调里再写一行更新标题),而且这处和已有三处都得记得在每次 count 变化时一起跑。声明式:在 return 的描述里多写一个 {count} 即可,同步由 React 负责,不存在“忘了更新标题”这种 bug。

这就是声明式的复利:界面越复杂,“手动保持同步”的成本差距越大。

1.2UI = f(state):把组件看成函数

一个组件就是一个函数:输入 props 和 state,输出一段 UI 描述。界面此刻为何这样,只取决于此刻的 state。

为什么需要它

如果 UI 永远等于 f(当前 state),那么“界面现在为什么长这样”就只需要看 state 一个地方——不必回放历史上发生过的所有点击和更新。调试从“追踪一连串操作”变成“检查一个值”。

底层机制 · 比文档深一层

React 真的把你的组件当函数调用。它返回的不是 DOM,是 React 元素(普通 JS 对象,见 §1.3)。state 一变,React 重新调用这个函数得到新描述,再 diff。所以“渲染”这个词,物理含义就是“React 调用了一次你的组件函数”。这也解释了为什么函数必须纯:React 要能随时调用它、跳过它、甚至调用了又把结果丢弃(并发渲染会这么干)——只有纯函数经得起这种反复调用。

类比 · 带边界声明

像 Excel 单元格公式 =A1+B1:你不手动更新结果,改了 A1,公式自动重算。边界:Excel 是细粒度的,只重算依赖那个单元格的公式;React 默认重新调用整个组件函数(第 2 章讲它如何做到“函数重跑、真实 DOM 却几乎不动”)。

state 当前值 作为输入 组件函数 f 纯函数 返回描述 UI 描述 → 真实 DOM 用户交互 setState(新值) 触发新一轮
图 1.2这个循环就是 React 的全部。注意:UI 改变的唯一途径是顶部这条链重新跑一遍,而它重新跑的唯一触发是底部的 setState 产生了新 state。没有别的入口——这是“界面为何这样只看 state”的根据。

1.3组件与 JSX:返回值是描述,不是 DOM

JSX 不是 HTML,是 React.createElement 的语法糖,求值后得到一个描述 UI 的普通对象。

为什么需要它

JSX 让你用近似 HTML 的写法表达“UI 该长什么样”,同时保留 JavaScript 的全部能力——条件、循环、变量、函数组合都能直接用。它解决的痛点是:用纯 createElement 调用手写 UI 树极其啰嗦。

jsx-is-an-object.jsx JSX
// 你写的 JSX
<button onClick={handleClick}>加一</button>

// 编译后(概念上)等价于一次函数调用
React.createElement("button", { onClick: handleClick }, "加一");

// 这次调用的返回值,是一个普通对象(一个 React 元素)
{
  type: "button",
  props: { onClick: handleClick, children: "加一" }
}
底层机制 · 比文档深一层

关键在最后那个对象:它是一份描述,不是真实 DOM 节点。创建它,没有发生任何 DOM 操作——它便宜得像写一个字面量对象。React 把组件返回的这些对象拼成一棵元素树,再拿它去和上一棵树 diff。这就解释了为什么 JSX 里不能塞 document.appendChild(...) 这类操作:你在搭一棵“描述树”,不是在直接指挥浏览器。

类比 · 带边界声明

React 元素像建筑图纸,真实 DOM 像盖好的楼。图纸便宜、可丢弃、能互相对比;楼昂贵、改动慢。边界:现实里图纸画一次就盖楼;React 每次渲染都重画一份图纸,再对比上一版,只把差异落到楼上。

想一想

执行 const el = <h1>Hi</h1>; 这一行之后,页面上出现 <h1> 了吗?

展开答案(先停 10 秒)

没有。el 此刻只是内存里的一个对象 { type: "h1", props: { children: "Hi" } }。它要被 React 渲染进某个根节点(如 createRoot(...).render(el))之后,才会变成真实 DOM。“写出 JSX”和“DOM 发生改变”是两件被刻意分开的事——这正是声明式的前提。

1.4单向数据流

数据有单一来源(state 住在某个组件里),通过 props 向下流。子组件要改父的数据,只能调用父传下来的回调。

为什么需要它

单一数据源 + 单向流动,让“谁能改这个数据”始终可追踪。若允许任意一端双向改写同一份数据,调试时就得四处找“到底是谁动了它”。React 用单向流把这个问题从根上消掉。

底层机制 · 比文档深一层

props 是只读的函数参数。子组件改 props 不会触发任何重渲染——React 根本不监听 props 对象。数据“向下流”,物理上就是“父函数把值作为参数调用子函数”。事件“向上传”,物理上是“子组件调用了父亲传下来的那个回调函数”,回调体里 setState,于是父组件重渲染、把新 props 再次作为参数传下来。所谓数据流,全程只是函数调用与参数传递。

App 持有 count Display props: count Button props: onClick count ↓ onClick ↓ 事件:调用回调 ↑
图 1.3props 实线向下,事件虚线向上绕行。注意:Button 永远不能直接改 App 的 count——它只能调用 App 传下来的 onClick 回调,由 App 自己 setCount。数据所有权始终留在 App,没有第二个人能写它。

把本章四个概念串起来:App 持有 count(§1.2 的 state),把 count 和一个回调作为 props 向下传(§1.4)。点击 Button 时回调被调用,回调里 setCount 产生新 state,于是 App 这个函数(§1.2 的 f)被重新调用,返回新的 JSX 描述(§1.3),React diff 后只更新真实 DOM 里变了的部分(§1.1 的声明式)。四个概念是同一个循环的四个侧面。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再展开对照——直接点开等于把这一节当成又读了一遍。

  1. 用一句话说明:“声明式”把原本属于你的哪一项工作,转移给了 React?
  2. <h1>Hi</h1> 求值后得到的是 DOM 节点还是普通对象?这件事对“渲染”这个词的含义意味着什么?
  3. (设计题)为什么 React 要求组件是纯函数?如果允许组件在渲染过程中直接修改外部变量,React 的哪一项能力会失效?
  4. 图 1.3 里,Button 想让计数加一,为什么不能在自己内部直接做、必须调用 App 传来的回调?
答案(先做完再展开)
  1. 把“计算并执行 DOM 差异更新”这项工作转移给了 React。你只产出目标描述,怎么把 DOM 改成那样由 React 负责。
  2. 得到的是普通 JS 对象(React 元素),不是 DOM 节点。这说明“渲染”分两步:先调用组件函数得到描述(廉价、随时可重做),再由 React 把描述落到真实 DOM。写 JSX ≠ 改 DOM。
  3. 因为 React 需要能随时调用、跳过、或调用后丢弃组件函数(并发渲染、StrictMode 双调用都依赖这点)。若组件在渲染时改外部变量,这些反复调用就会产生重复或错乱的副作用,React 就不再能安全地“多调用几次”——并发能力和可预测性都会失效。
  4. 因为 count 的所有权在 App,props 是只读的。Button 改自己收到的 props 不会触发任何重渲染(React 不监听 props)。唯一能改 count 的是它的拥有者 App,所以 Button 只能请求 App 去改——即调用回调。
进阶挑战 · 刚好够不着

子组件能直接 push 到收到的数组 prop 吗?

一个父组件持有 todos(数组 state),把它作为 prop 传给子组件 <AddTodo todos={todos} />。子组件里有个输入框,提交时想把新条目加进去。它能不能直接 todos.push(newItem)?界面会更新吗?正确的数据流应该长什么样?

提示(卡住再展开)

两个独立的问题。其一:push 改的是数组内容,但 props 是只读契约,更关键的是——即便改了,谁来触发重渲染?回顾图 1.2,重渲染的唯一触发是 setState。其二:正确做法是父组件把一个回调(如 onAdd)传下去,子组件调用 onAdd(newItem),由父组件用 setTodos([...todos, newItem]) 产生一个新数组。为什么必须是新数组而不是 push 原数组,留到第 3 章的不可变性揭晓。