1 分钟

React 心智模型:Dan Abramov 风格的思考方式

React 心智模型能让 React 感觉更简单:理解组件、渲染、状态与副作用背后的关键思路,然后通过聊天将它们应用到快速构建 UI。

React 心智模型:Dan Abramov 风格的思考方式

在没有合适心智模型前,为什么 React 感觉很难

React 刚开始会让人沮丧,因为你能看到 UI 变化,却不总能解释为什么会变化。你点了一个按钮,某处更新,然后页面的另一部分让你惊讶。通常这不是“React 很奇怪”,而是“我对 React 在做什么的认识模糊”。

心智模型就是你用来解释某件事如何工作的简短故事。如果故事错了,你会做出自信但导致困惑结果的决策。想想恒温器:糟糕的模型是“我把温度设为 22°C,房间就马上变成 22°C”。更好的模型是“我设定了目标,暖气会随着时间开关以达到目标”。有了更好的故事,行为就不再显得随机。

React 也是一样。一旦你采纳几个清晰的想法,React 就变得可预测:你可以看当前数据并可靠地猜到屏幕上会是什么。

Dan Abramov 帮助普及了这种“让它可预测”的心态。目标不是死记规则,而是在脑中保留一小组真理,这样你可以通过推理而不是反复试错来调试。

把这些想法记在心里:

  • UI 是 state 的结果,而不是一系列手动的 DOM 操作。
  • 组件是描述 UI 应该是什么样子的函数。
  • 数据向下流;变化通过事件向上流。
  • 副作用与渲染分离,并且有特定的目的。

抓住这些,React 不再像魔法,开始像一个你可以信赖的系统。

组件、props 和 state:核心画面

当你停止把界面当成“屏幕”而开始把它拆成小块时,React 会变得更简单。组件是可复用的 UI 单位。它接收输入并返回一份在这些输入下 UI 应该长什么样的描述。

把组件当作纯描述会很有帮助:"给定这些数据,展示这些内容。" 这种描述可以在很多地方使用,因为它不依赖于组件所在的位置。

Props 是输入。它们来自父组件。Props 不是组件“拥有”的,也不是组件应该悄悄改变的东西。如果一个按钮收到 label="Save",按钮的工作是渲染该标签,而不是决定它应该不同。

State 是拥有的数据。它是组件随时间记住的东西。State 在用户交互、请求完成或你决定某事应该不同的时候改变。与 props 不同,state 属于该组件(或你选择拥有它的那个组件)。

关键思想的简单版本:UI 是 state 的函数。如果 state 表示“loading”,就显示一个 loading。若 state 表示“error”,就显示一条消息。如果 state 表示“items = 3”,就渲染三行。你的工作是让 UI 从 state 读取,而不是漂移到隐藏的变量里。

快速区分概念的方法:

  • 组件:UI 片段(SearchBoxProfileCardCheckoutForm
  • Props:它需要显示的内容(namepricedisabled
  • State:它记住的内容(isOpenqueryselectedId

示例:模态框。父组件可以传 titleonClose 作为 props。模态框可能拥有 isAnimating 作为 state。

即便你通过聊天生成 UI(例如在 Koder.ai 上),这种分离仍然是保持头脑清晰的最快方式:先决定什么是 props,什么是 state,然后让 UI 遵循它们。

渲染与重渲染:真正发生了什么

一个有用的把 React 放在脑中的方式(很像 Dan Abramov 的思路)是:渲染是一次计算,而不是一次绘制任务。React 运行你的组件函数来确定在当前 props 和 state 下 UI 应该是什么样子。输出是 UI 的描述,而不是像素。

重渲染只是意味着 React 重复那次计算。它并不意味着“整个页面重绘”。React 将新结果与之前的进行比较,并对真实 DOM 应用最小的一组更改。许多组件会重渲染,但实际只有少数 DOM 节点更新。

大多数重渲染发生在几个简单原因:组件的 state 变化了、它的 props 变化了,或者父组件重渲染并且 React 要求子组件再次渲染。最后一种常让人惊讶,但通常没问题。如果你把渲染当作“廉价且无趣”的事情,你的应用会更容易推理。

保持这一点清晰的经验法则:让渲染保持纯净。给定相同的输入(props + state),你的组件应该返回相同的 UI 描述。把惊喜排除在渲染之外。

具体示例:如果你在 render 里用 Math.random() 生成 ID,重渲染会改变它,结果一个复选框会失去焦点或列表项被重新挂载。把 ID 只创建一次(state、memo,或组件外部),渲染就会稳定。

记住一句话:重渲染意味着“重新计算 UI 应该是什么”,而不是“重建一切”。

状态更新:保持更改小且可预测

另一个有用的模型是:状态更新是请求,而不是即时赋值。当你调用像 setCount(count + 1) 这样的 setter,你是在请求 React 安排一次带有新值的渲染。如果你在之后立刻读取 state,可能仍然看到旧值,因为 React 还没渲染。

这就是为什么“小且可预测”的更新很重要。优先描述更改,而不是去抓取你认为的当前值。当下一个值依赖于前一个值时,使用 updater 形式:setCount(c => c + 1)。它匹配 React 的工作方式:多个更新可以排队,然后按顺序应用。

不可变性是另一半。不要就地修改对象或数组。创建一个带有变更的新对象或新数组。React 才能看到“这是一个新值”,你的思路也能跟踪发生了什么变化。

示例:切换一个 todo 项。安全的方法是创建一个新数组,并针对你更改的那一项创建一个新的 todo 对象。危险的方法是在现有数组内直接做 todo.done = !todo.done

还要保持 state 精简。一个常见陷阱是存储可以计算出来的值。如果你已经有了 itemsfilter,不要再把 filteredItems 存进 state。在渲染时计算它。更少的 state 变量意味着更少的值走失不同步的方式。

判定什么属于 state 的简单测试:

  • 如果它会随时间变化并且 UI 需要响应,就存储它。
  • 如果它可以从其他 state 或 props 推导出来,就不要存储它。
  • 不要在两个地方存储同一个真相的副本。

若你通过聊天构建 UI(包括在 Koder.ai 上),将更改请求为小的补丁会很有帮助:“添加一个布尔标志”或“不可变地更新这个列表”。小而明确的更改能让生成器和你的 React 代码保持一致。

副作用用于同步,而不是基础 UI 逻辑

Use Planning Mode
Plan state owners and data flow before code so rerenders stop feeling random.

渲染描述 UI。副作用用于与外部世界同步。“外部”指 React 无法控制的东西:网络调用、定时器、浏览器 API,有时还有命令式的 DOM 操作。

如果某件事可以从 props 和 state 计算出来,通常不应该放在 effect 里。把它放在 effect 会增加一步(渲染、运行 effect、设置 state、再次渲染)。这一额外跳转就是闪烁、循环和“为什么这东西过时了?”类错误出现的地方。

一个常见的混淆:你有 firstNamelastName,然后用 effect 把 fullName 存到 state。但 fullName 并不是副作用,它是派生数据。在渲染时计算它,它就会始终匹配。

养成习惯:在渲染时派生 UI 值(或者当确实昂贵时用 useMemo),用 effect 做“去做某事”的工作,而不是“推断某事”。

依赖数组是触发器列表

把依赖数组视为:"当这些值变化时,重新与外面世界同步"。它不是性能技巧,也不是用来消除警告的地方。

示例:如果你在 userId 变化时获取用户详情,那么 userId 应该在依赖数组里,因为它应该触发同步。如果 effect 也使用 token,也把它包括进去,否则你可能会用旧的 token 去请求。

一个好的直觉检查:如果移除一个 effect 只是会让 UI 错误,那它很可能不是真正的 effect。如果移除它会停止定时器、取消订阅或跳过一次请求,那它很可能就是 effect。

数据流:一个真相来源胜过分散的状态

最有用的心智模型之一很简单:数据向树下流,用户动作向上流。

父组件把值传给子组件。子组件不应该在两个地方秘密地“拥有”同一个值。它们通过调用一个函数来请求更改,父组件决定新值是什么。

当 UI 的两个部分需要达成一致时,选择一个地方来存储该值,然后向下传。这就是“抬升 state”。看起来像额外的管线工作,但它能避免更糟的问题:两个会偏离的状态,并迫使你加入各种 hack 来保持同步。

示例:搜索框和结果列表。如果输入框自己存储 query,而列表也存储自己的 query,最终你会看到“输入显示 X,但列表用的是 Y”。解决办法是让父组件保存 query,把它传给两者,并传一个 onChangeQuery(newValue) 给输入框。

抬升 state 并非总是答案。如果某个值只在一个组件内部相关,就把它放在那里。把 state 保持在使用它的最近位置通常能让代码更易读。

一个实用边界:

  • 本地 state:仅限 UI 的细节(下拉是否打开、哪个标签高亮)
  • 共享 state:多个组件必须达成一致的数据(过滤器、已选项、认证状态)

如果不确定是否抬升 state,寻找以下信号:两个组件以不同方式显示相同的值;某处动作必须更新远处的东西;你不断把 props 复制到 state“以防万一”;或者你在写 effect 只是为了让两个值保持同步。

这个模型也有助于通过聊天工具(如 Koder.ai)构建时:为每一块共享 state 指定一个单一拥有者,然后生成向上的处理器。

逐步:先画出 state 来设计一个功能

选一个足够小、能在脑中掌握的功能。常见的好例子是一个可搜索列表,点击条目即可在模态框中查看详情。

先画出 UI 的部分和可能发生的事件。先别想代码。想想用户能做什么、能看到什么:有一个搜索输入、一个列表、被选中的行高亮、以及一个模态框。事件是输入搜索、点击条目、打开模态、关闭模态。

现在“画出 state”。写下必须存储的少量值,并决定谁拥有它们。一个简单规则:所有需要该值的地方的最近公共父组件应该拥有它。

对这个功能来说,存储的 state 可以很少:query(字符串)、selectedId(id 或 null)、isModalOpen(布尔)。列表读取 query 并渲染条目。模态读取 selectedId 来显示详情。如果列表和模态都需要 selectedId,把它放在父组件,而不是两处都存。

接着,把派生数据和存储数据区分开。过滤后的列表是派生的:filteredItems = items.filter(...)。不要把它存到 state,因为它总能从 itemsquery 重新计算。存派生数据会让值逐渐偏离。

只有在需要时才考虑 effect:如果 items 已在内存中,就不需要。如果输入应触发请求以获取结果,就需要。如果关闭模态应保存某些东西,也需要。Effect 用来同步(fetch、save、subscribe),而非基础 UI 布线。

最后,用几个边界情况测试流程:

  • 快速输入,然后清空:列表是否恢复正常?
  • 点击某条目,然后列表因搜索变化:selectedId 仍有效吗?
  • 关闭模态:保留选择还是重置?
  • 从服务器刷新 items:如果选中的项消失会怎样?

如果你能在纸上回答这些问题,React 代码通常就很直接了。

常见错误会破坏心智模型

Tame useEffect quickly
Generate effect-safe code that syncs with APIs without loops or stale values.

大多数 React 的困惑并不是语法问题,而是代码不再匹配你脑中简单的故事。

错误模式(以及你会注意到的症状)

存储派生 state。 你把 fullName 存到 state,实际上它只是 firstName + lastName。表面上可行,直到某个字段改变而另一个没变,UI 显示过时的值。

effect 循环。 effect 获取数据、设置 state,而依赖列表又让它再次运行。症状是重复请求、界面抖动或状态无法稳定。

闭包过时(stale closures)。 点击处理器读取了旧值(比如过时的计数或过滤器)。症状是“我点了,但它用了昨天的值”。

到处都是全局状态。 把每个 UI 细节放进全局存储,会让很难判断谁拥有什么。症状是你改了一个东西,三个屏幕以惊讶的方式响应。

修改嵌套对象。 你就地更新对象或数组,却不明白为什么 UI 没更新。症状是“数据变了,但没有触发重渲染”。

这里有个具体示例:一个“搜索和排序”面板。如果你把 filteredItems 存到 state,它会在新数据到来时与 items 偏离。相反,应存输入(搜索文本、排序选项),在渲染时计算过滤后的列表。

对于 effects,把它们用于与外界同步(fetch、订阅、定时器)。如果一个 effect 在做基础 UI 工作,它通常应该放到渲染或事件处理里。

通过聊天生成或编辑代码时,这些错误更容易暴露,因为更改可能以大块方式到来。好习惯是把请求表述为所有权问题:"这个值的事实来源是哪儿?" 和 "我们能计算这个值而不是存它吗?"

当 state 变得混乱时的快速核对清单

当你的 UI 开始不可预测时,通常不是“React 太多”,而是 state 太多、放错位置或做了不该做的事。在你再加一个 useState 之前,暂停并问自己:

  • 哪一组最小的值能完整描述用户当前看到的画面?
  • 每个值该放在哪里,这样需要它的部分能读到,而不需要它的部分保持简单?
  • 这个值是真正的新信息吗,还是可以从 props、URL 参数或其他 state 计算出来?
  • 你是在用 effect 计算 UI 还是只用于与 React 外部的东西同步(网络、定时器、浏览器 API)?
  • 如果一个值改变,哪些组件应该因为读取它而重渲染,哪些不该?

小例子:搜索框、过滤下拉、列表。如果你同时把 queryfilteredItems 存到 state,你现在有两个真相来源。相反,保留 queryfilter 作为 state,然后在渲染时从完整列表派生 filteredItems

当你通过聊天工具快速构建时也很重要。速度很好,但一直问自己:“我们是添加了 state,还是不小心添加了一个派生值?” 如果是派生的,就删掉那个 state 并计算它。

示例:快速迭代 UI 而不丢失可预测性

Build from state first
Describe your state shape and let Koder.ai generate a predictable React UI from it.

一个小团队在构建管理后台:订单表格、几个过滤器和一个编辑订单的对话框。最初的需求可能很模糊:“加上过滤和一个编辑弹窗。” 听起来简单,但常变成到处都是随机 state。

把请求具体化为 state 和事件。不要只说“过滤器”,把状态命名:querystatusdateRange。不要只说“编辑弹窗”,把事件命名为:“用户点击行上的 Edit”。然后决定每块 state 归谁(页面、表格或对话框)并确定哪些是派生的(比如过滤后的列表)。

示例性的提示(也适用于像 Koder.ai 这样的聊天构建器):

  • “创建一个 OrdersPage,它拥有 filtersselectedOrderIdOrdersTablefilters 控制,并在编辑时调用 onEdit(orderId)。”
  • “从 ordersfilters 派生 visibleOrders。不要把 visibleOrders 存到 state。”
  • “添加一个 EditOrderDialog,接收 orderopen。保存时调用 onSave(updatedOrder) 并关闭。”
  • “如果要抓取数据,把它放在一个地方。只用 effect 去把 filters 同步到 URL,而不是用它来计算过滤后的行。”

在生成或更新 UI 后,用一个快速检查回顾更改:每个 state 值只有一个拥有者,派生值不被存储,effects 只用于与外部世界同步(URL、网络、存储),事件向下为 props,向上为回调。

当 state 可预测时,迭代就很安全。你可以改表格布局、添加新过滤器或调整对话框字段,而不用猜测哪个隐藏的 state 会先坏掉。

下一步:用聊天加速构建,但保持 React 模型

速度只有在应用仍然易于推理时才有用。最简单的保护措施是把这些心智模型当作在写(或生成)UI 前的核对表。

每次开始功能时都按同样方式做:写下需要的 state、会改变它的事件以及谁拥有它。如果你不能说清楚“这个组件拥有这个 state,这些事件更新它”,你很可能会得到分散的 state 和令人惊讶的重渲染。

如果你通过聊天构建,先进入规划模式。用普通语言描述组件、state 结构和转换,然后再请求代码。例如:"过滤面板更新 query state;结果列表从 query 派生;选择一个项设置 selectedId;关闭则清除它。" 一旦这段描述读起来清晰,生成 UI 就变成机械步骤。

如果你在使用 Koder.ai (koder.ai) 生成 React 代码,值得在继续前做一个快速合理性检查:每个 state 值有一个清晰拥有者,UI 从 state 派生,effects 只用于同步,不存在重复的真相来源。

然后以小步迭代。如果你想更改 state 结构(比如从几个布尔改成一个 status 字段),先做快照、实验、必要时回滚。如果需要更深入的审查或交接,导出源码会更容易回答真正的问题:state 结构是否仍然讲述着 UI 的故事?

常见问题

What’s the simplest mental model to make React feel predictable?

一个好的起点模型是:UI = f(state, props)。 你的组件不是“直接编辑 DOM”;它们描述在当前数据下屏幕应该显示什么。如果界面看起来不对,检查产生它的 state/props,而不是 DOM。

How do I decide if something should be props or state?

Props 是来自父组件的输入;你的组件应把它们当作只读。State 是组件拥有的记忆(或者你选定的拥有者组件)。如果一个值需要被共享,就把它抬升到父组件,然后通过 props 传下去。

When React “re-renders,” is it rebuilding the whole page?

重新渲染意味着 React 重新运行你的组件函数来计算下一次的 UI 描述。它并不自动意味着整个页面被重绘。React 会用最小的变更去更新真实 DOM。

Why does my state look “one step behind” after calling setState?

因为状态更新是被调度的,不是立即赋值。如果下一个值依赖于当前值,使用 updater 形式,这样即便有多个更新排队也能保持正确:

  • setCount(c => c + 1)

这能在多次更新被合并时保持正确。

How do I avoid “derived state” bugs?

避免把任何可以从现有输入计算出来的东西存到 state。存储输入,在渲染时派生其余部分。

示例:

  • 存:items, filter
  • 派生:visibleItems = items.filter(...)

这样可以防止值不同步。

When should I use useEffect, and when should I avoid it?

把 effect 用来与 React 不控制的东西同步:网络请求、订阅、定时器、浏览器 API,或必要的命令式 DOM 操作。

不要用 effect 来计算基于 state 的 UI 值——在渲染时计算它们(如果代价高,再用 useMemo)。

What’s the right way to think about the useEffect dependency array?

把依赖数组当作一个触发器列表:"当这些值变化时,重新同步"。把 effect 中读取的每个响应式值都列进去。

如果漏掉,会用到过期的数据(比如旧的 userId 或令牌)。如果加错东西,可能制造循环——通常说明该工作应该在事件或渲染里完成,而不是放在 effect。

How do I know when to lift state up?

如果两个 UI 部分必须保持一致,把 state 放到它们的最近公共父组件,向下传值,向上传回调。

快速检测:如果你把同一个值复制到两个组件并写 effect 去“保持它们同步”,那么这个 state 很可能应该只有一个拥有者。

Why do my event handlers sometimes use stale values?

通常是因为处理函数在闭包中“捕获”了旧值。常见修复:

  • 使用 updater:setX(prev => ...)
  • 把会变化的值放到 state 或 refs,而不是普通变量
  • 确保 effect 列表包含它们读取的值

如果点击用了“昨天”的值,考虑是否为 stale closure 问题。

How can I build React UI quickly via chat without creating messy state?

先做个小计划:组件、state 的拥有者和事件。然后以小补丁生成代码(添加一个 state 字段、一个处理器、派生一个值),而不是一次性大改。

如果用聊天构建工具(例如 Koder.ai),要求:

  • 每个 state 值有明确拥有者
  • 派生值在渲染时计算
  • effect 只用于同步(fetch/URL/存储),不要用于基础 UI 逻辑

这样可以保持生成代码符合 React 的心智模型。

Related posts