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 片段(SearchBox、ProfileCard、CheckoutForm)
  • Props:它需要显示的内容(name、price、disabled)
  • State:它记住的内容(isOpen、query、selectedId)

示例:模态框。父组件可以传 title 和 onClose 作为 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 精简。一个常见陷阱是存储可以计算出来的值。如果你已经有了 items 和 filter,不要再把 filteredItems 存进 state。在渲染时计算它。更少的 state 变量意味着更少的值走失不同步的方式。

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

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

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

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

Keep full control
Export the source to review state, effects, and handlers like a normal React project.

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

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

一个常见的混淆:你有 firstName 和 lastName,然后用 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,因为它总能从 items 和 query 重新计算。存派生数据会让值逐渐偏离。

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

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

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

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

常见错误会破坏心智模型

Go from idea to live
Ship your React app with hosting and deployments when you are ready to share it.

大多数 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)?
  • 如果一个值改变,哪些组件应该因为读取它而重渲染,哪些不该?

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

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

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

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

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

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

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

  • “创建一个 OrdersPage,它拥有 filters 和 selectedOrderId。OrdersTable 由 filters 控制,并在编辑时调用 onEdit(orderId)。”
  • “从 orders 和 filters 派生 visibleOrders。不要把 visibleOrders 存到 state。”
  • “添加一个 EditOrderDialog,接收 order 和 open。保存时调用 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