一句话

React 不是"DOM 操作库"。它是:把 UI 当成 state 的纯函数——UI = f(state)。你改 state,它重新算一遍 f,再把差异写回 DOM。

三个阶段

┌─────────┐    ┌─────────┐    ┌─────────┐
│ Trigger │ →  │ Render  │ →  │ Commit  │ →  Effects
└─────────┘    └─────────┘    └─────────┘
   触发           渲染           提交         副作用
  1. Trigger:用户点击 / 定时器 / 父组件 re-render → React 知道要重算
  2. Render:调用组件函数 → 返回 JSX(其实是 React Element 对象树)
  3. Commit:把新旧元素 diff,操作真实 DOM(这一步才碰浏览器)
  4. Effects:commit 完成后跑 useEffect 回调

Render 是纯计算,不能有副作用(不能 setState、不能改 DOM、不能 fetch)。

为什么组件函数会跑很多次

每次 state 变化,整个组件函数从头执行一遍。这不是 bug,是设计:

function Counter() {
  const [n, setN] = useState(0);
  console.log('render', n);   // 每次点都打一行
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}

新手最容易踩的坑:在组件函数里写 let x = 0; x++——下次 render x 又是 0。任何要跨 render 存活的值,必须放 state 或 ref

Re-render 不等于 DOM 更新

function App() {
  const [n, setN] = useState(0);
  return <div>固定文本</div>;   // 不依赖 n
}

setN(1) 会让 App 重新执行函数(render),但 diff 后发现 DOM 没变,所以不写 DOM(commit 是空操作)。这就是 React 比手动 DOM 快的原因之一:vdom diff 在内存里做,比 DOM 操作便宜。

什么时候触发 re-render

只有 4 种:

  1. 组件自己的 state 变化(setState
  2. 组件订阅的 Context 值变化
  3. 父组件 re-render(哪怕 props 没变)
  4. useReducer dispatch

props 改变本身不会触发——是父组件 re-render 顺带把子也 re-render 了。

StrictMode 为什么"渲染两次"

开发模式 + <StrictMode> 下,React 故意把组件函数和 effect 跑两遍,就是为了暴露

  • 副作用没正确清理
  • 组件函数依赖外部可变状态

生产环境只跑一次。看到 console.log 出两条不要慌,关掉 StrictMode 就一次。

  • 不要在 render 里 setState:会死循环。要在事件回调或 effect 里。
  • 闭包陷阱:effect / 事件回调里的变量是那次 render 的快照。要拿最新值用 useRef 或 functional setState:setN(prev => prev + 1)
  • 对象 / 数组的引用:state 是 {...obj, x: 1} 而不是 obj.x = 1; setObj(obj)——后者引用没变,React 跳过 re-render

心智锚点

写 React 时反问自己:

  • "现在是 render 阶段还是 effect 阶段?"
  • "这个值需要 React 知道吗?需要 → state;不需要 → 普通变量"
  • "我是不是在 render 里做了有副作用的事?"

这三问能解决新手 80% 的迷惑。

→ 下一篇 JSX 到底是什么