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

React 刚开始会让人沮丧,因为你能看到 UI 变化,却不总能解释为什么会变化。你点了一个按钮,某处更新,然后页面的另一部分让你惊讶。通常这不是“React 很奇怪”,而是“我对 React 在做什么的认识模糊”。
心智模型就是你用来解释某件事如何工作的简短故事。如果故事错了,你会做出自信但导致困惑结果的决策。想想恒温器:糟糕的模型是“我把温度设为 22°C,房间就马上变成 22°C”。更好的模型是“我设定了目标,暖气会随着时间开关以达到目标”。有了更好的故事,行为就不再显得随机。
React 也是一样。一旦你采纳几个清晰的想法,React 就变得可预测:你可以看当前数据并可靠地猜到屏幕上会是什么。
Dan Abramov 帮助普及了这种“让它可预测”的心态。目标不是死记规则,而是在脑中保留一小组真理,这样你可以通过推理而不是反复试错来调试。
把这些想法记在心里:
抓住这些,React 不再像魔法,开始像一个你可以信赖的系统。
当你停止把界面当成“屏幕”而开始把它拆成小块时,React 会变得更简单。组件是可复用的 UI 单位。它接收输入并返回一份在这些输入下 UI 应该长什么样的描述。
把组件当作纯描述会很有帮助:"给定这些数据,展示这些内容。" 这种描述可以在很多地方使用,因为它不依赖于组件所在的位置。
Props 是输入。它们来自父组件。Props 不是组件“拥有”的,也不是组件应该悄悄改变的东西。如果一个按钮收到 label="Save",按钮的工作是渲染该标签,而不是决定它应该不同。
State 是拥有的数据。它是组件随时间记住的东西。State 在用户交互、请求完成或你决定某事应该不同的时候改变。与 props 不同,state 属于该组件(或你选择拥有它的那个组件)。
关键思想的简单版本:UI 是 state 的函数。如果 state 表示“loading”,就显示一个 loading。若 state 表示“error”,就显示一条消息。如果 state 表示“items = 3”,就渲染三行。你的工作是让 UI 从 state 读取,而不是漂移到隐藏的变量里。
快速区分概念的方法:
SearchBox、ProfileCard、CheckoutForm)name、price、disabled)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(包括在 Koder.ai 上),将更改请求为小的补丁会很有帮助:“添加一个布尔标志”或“不可变地更新这个列表”。小而明确的更改能让生成器和你的 React 代码保持一致。
渲染描述 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,寻找以下信号:两个组件以不同方式显示相同的值;某处动作必须更新远处的东西;你不断把 props 复制到 state“以防万一”;或者你在写 effect 只是为了让两个值保持同步。
这个模型也有助于通过聊天工具(如 Koder.ai)构建时:为每一块共享 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 仍有效吗?如果你能在纸上回答这些问题,React 代码通常就很直接了。
大多数 React 的困惑并不是语法问题,而是代码不再匹配你脑中简单的故事。
存储派生 state。 你把 fullName 存到 state,实际上它只是 firstName + lastName。表面上可行,直到某个字段改变而另一个没变,UI 显示过时的值。
effect 循环。 effect 获取数据、设置 state,而依赖列表又让它再次运行。症状是重复请求、界面抖动或状态无法稳定。
闭包过时(stale closures)。 点击处理器读取了旧值(比如过时的计数或过滤器)。症状是“我点了,但它用了昨天的值”。
到处都是全局状态。 把每个 UI 细节放进全局存储,会让很难判断谁拥有什么。症状是你改了一个东西,三个屏幕以惊讶的方式响应。
修改嵌套对象。 你就地更新对象或数组,却不明白为什么 UI 没更新。症状是“数据变了,但没有触发重渲染”。
这里有个具体示例:一个“搜索和排序”面板。如果你把 filteredItems 存到 state,它会在新数据到来时与 items 偏离。相反,应存输入(搜索文本、排序选项),在渲染时计算过滤后的列表。
对于 effects,把它们用于与外界同步(fetch、订阅、定时器)。如果一个 effect 在做基础 UI 工作,它通常应该放到渲染或事件处理里。
通过聊天生成或编辑代码时,这些错误更容易暴露,因为更改可能以大块方式到来。好习惯是把请求表述为所有权问题:"这个值的事实来源是哪儿?" 和 "我们能计算这个值而不是存它吗?"
当你的 UI 开始不可预测时,通常不是“React 太多”,而是 state 太多、放错位置或做了不该做的事。在你再加一个 useState 之前,暂停并问自己:
小例子:搜索框、过滤下拉、列表。如果你同时把 query 和 filteredItems 存到 state,你现在有两个真相来源。相反,保留 query 和 filter 作为 state,然后在渲染时从完整列表派生 filteredItems。
当你通过聊天工具快速构建时也很重要。速度很好,但一直问自己:“我们是添加了 state,还是不小心添加了一个派生值?” 如果是派生的,就删掉那个 state 并计算它。
一个小团队在构建管理后台:订单表格、几个过滤器和一个编辑订单的对话框。最初的需求可能很模糊:“加上过滤和一个编辑弹窗。” 听起来简单,但常变成到处都是随机 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) 并关闭。”filters 同步到 URL,而不是用它来计算过滤后的行。”在生成或更新 UI 后,用一个快速检查回顾更改:每个 state 值只有一个拥有者,派生值不被存储,effects 只用于与外部世界同步(URL、网络、存储),事件向下为 props,向上为回调。
当 state 可预测时,迭代就很安全。你可以改表格布局、添加新过滤器或调整对话框字段,而不用猜测哪个隐藏的 state 会先坏掉。
速度只有在应用仍然易于推理时才有用。最简单的保护措施是把这些心智模型当作在写(或生成)UI 前的核对表。
每次开始功能时都按同样方式做:写下需要的 state、会改变它的事件以及谁拥有它。如果你不能说清楚“这个组件拥有这个 state,这些事件更新它”,你很可能会得到分散的 state 和令人惊讶的重渲染。
如果你通过聊天构建,先进入规划模式。用普通语言描述组件、state 结构和转换,然后再请求代码。例如:"过滤面板更新 query state;结果列表从 query 派生;选择一个项设置 selectedId;关闭则清除它。" 一旦这段描述读起来清晰,生成 UI 就变成机械步骤。
如果你在使用 Koder.ai (koder.ai) 生成 React 代码,值得在继续前做一个快速合理性检查:每个 state 值有一个清晰拥有者,UI 从 state 派生,effects 只用于同步,不存在重复的真相来源。
然后以小步迭代。如果你想更改 state 结构(比如从几个布尔改成一个 status 字段),先做快照、实验、必要时回滚。如果需要更深入的审查或交接,导出源码会更容易回答真正的问题:state 结构是否仍然讲述着 UI 的故事?
一个好的起点模型是:UI = f(state, props)。 你的组件不是“直接编辑 DOM”;它们描述在当前数据下屏幕应该显示什么。如果界面看起来不对,检查产生它的 state/props,而不是 DOM。
Props 是来自父组件的输入;你的组件应把它们当作只读。State 是组件拥有的记忆(或者你选定的拥有者组件)。如果一个值需要被共享,就把它抬升到父组件,然后通过 props 传下去。
重新渲染意味着 React 重新运行你的组件函数来计算下一次的 UI 描述。它并不自动意味着整个页面被重绘。React 会用最小的变更去更新真实 DOM。
因为状态更新是被调度的,不是立即赋值。如果下一个值依赖于当前值,使用 updater 形式,这样即便有多个更新排队也能保持正确:
setCount(c => c + 1)这能在多次更新被合并时保持正确。
避免把任何可以从现有输入计算出来的东西存到 state。存储输入,在渲染时派生其余部分。
示例:
items, filtervisibleItems = items.filter(...)这样可以防止值不同步。
把 effect 用来与 React 不控制的东西同步:网络请求、订阅、定时器、浏览器 API,或必要的命令式 DOM 操作。
不要用 effect 来计算基于 state 的 UI 值——在渲染时计算它们(如果代价高,再用 useMemo)。
把依赖数组当作一个触发器列表:"当这些值变化时,重新同步"。把 effect 中读取的每个响应式值都列进去。
如果漏掉,会用到过期的数据(比如旧的 userId 或令牌)。如果加错东西,可能制造循环——通常说明该工作应该在事件或渲染里完成,而不是放在 effect。
如果两个 UI 部分必须保持一致,把 state 放到它们的最近公共父组件,向下传值,向上传回调。
快速检测:如果你把同一个值复制到两个组件并写 effect 去“保持它们同步”,那么这个 state 很可能应该只有一个拥有者。
通常是因为处理函数在闭包中“捕获”了旧值。常见修复:
setX(prev => ...)如果点击用了“昨天”的值,考虑是否为 stale closure 问题。
先做个小计划:组件、state 的拥有者和事件。然后以小补丁生成代码(添加一个 state 字段、一个处理器、派生一个值),而不是一次性大改。
如果用聊天构建工具(例如 Koder.ai),要求:
这样可以保持生成代码符合 React 的心智模型。