REACT · Vol.II · LESSON 14 · Hooks 精讲
状态提升与单向数据流
两个兄弟组件,各说各话
第 13 篇末尾留了个尖锐的问题:自定义 Hook 复用的是「逻辑」,状态各自隔离——那如果两个组件需要的就是同一份状态呢?看个具体场景:温度换算器,左边输摄氏、右边输华氏,两边要实时联动。
import { useState } from "react";
function CelsiusInput() {
const [celsius, setCelsius] = useState("");
return (
<fieldset>
<legend>摄氏度</legend>
<input value={celsius} onChange={e => setCelsius(e.target.value)} />
</fieldset>
);
}
function FahrenheitInput() {
const [fahrenheit, setFahrenheit] = useState("");
return (
<fieldset>
<legend>华氏度</legend>
<input value={fahrenheit} onChange={e => setFahrenheit(e.target.value)} />
</fieldset>
);
}
两份 state 各自为政:左边输入 100,右边纹丝不动。列表页的「筛选条件」和「列表」、导航上的「购物车角标」和「购物车详情」——本质都是同一道题:兄弟组件要共享数据。
第 3 篇立过规矩:单向数据流,props 只能父传子。兄弟之间没有直达通道,谁也不该偷改谁的 state。出路不是开通道,而是把状态挪到它该在的地方——两个组件的最近公共父组件。
状态提升三步走
所谓状态提升(Lifting State Up),就是把子组件私有的 state,挪到需要它的组件们的最近公共父组件,子组件降级为「受控的视图」。固定三步:
- 提升 state:把温度数据挪进公共父组件(只存一份「用户正在编辑的值 + 温标」,别存两份换算结果互相追);
- 父传 props:父组件把当前值通过 props 下发给每个子组件——数据向下;
- 子传回调:子组件不再自己 setState,改用 props 里的回调函数向父组件「申报修改」——改动请求向上。
按这个配方改造后的子组件长这样——注意它内部已经没有任何 state 了:
function TemperatureInput({
scale,
temperature,
onTemperatureChange,
}: {
scale: "C" | "F";
temperature: string;
onTemperatureChange: (t: string) => void;
}) {
return (
<fieldset>
<legend>{scale === "C" ? "摄氏度(℃)" : "华氏度(℉)"}</legend>
<input
value={temperature}
onChange={e => onTemperatureChange(e.target.value)}
/>
</fieldset>
);
}
value 完全由 props 决定,子组件自己「说了不算」——这就是受控组件(第 24 篇专题)。它仅剩的自由,是把用户输入通过 onTemperatureChange 上报。
换个壳,同一道题:筛选框与列表
温度换算器很小,这套模式却无处不在。再看一例:页面上方是关键词输入框,下方列表按关键词过滤。输入框和列表是兄弟组件,keyword 是要共享的状态——提升到页面级父组件:
import { useState } from "react";
// 假设 User = { id: number; name: string }
function UserPage({ users }: { users: User[] }) {
// 共享状态提升到公共父组件,只存一份
const [keyword, setKeyword] = useState("");
// 过滤结果是派生值:渲染时现算,绝不另存一份 state(第 9 篇)
const filtered = users.filter(u => u.name.includes(keyword));
return (
<>
<FilterBar keyword={keyword} onKeywordChange={setKeyword} />
<UserList users={filtered} />
</>
);
}
公式与温度换算器一字不差:state 住父组件,FilterBar 领「值 + 回调」做纯受控组件,UserList 领结果只管渲染。日后想再加第三个组件——「显示命中条数的徽标」——只需把它挂在同一个父组件下,分发同一份数据的切片即可,前两个组件一行不用改。数据源头唯一,扩展就只是「多挂一个读者」。
完整可运行:父组件统一持有,换算只算一份
import { useState } from "react";
function toFahrenheit(celsius: number) { return (celsius * 9) / 5 + 32; }
function toCelsius(fahrenheit: number) { return ((fahrenheit - 32) * 5) / 9; }
// 尽力解析:输入非法就返回空串,别让 NaN 渲染到界面上
function tryConvert(temperature: string, convert: (t: number) => number): string {
const num = Number(temperature);
if (temperature.trim() === "" || Number.isNaN(num)) return "";
return String(Math.round(convert(num) * 1000) / 1000);
}
function Calculator() {
// 状态唯一:原始字符串 + 它属于哪个温标
const [temperature, setTemperature] = useState("");
const [scale, setScale] = useState<"C" | "F">("C");
function handleCelsiusChange(t: string) { setScale("C"); setTemperature(t); }
function handleFahrenheitChange(t: string) { setScale("F"); setTemperature(t); }
// 另一个框的值是「派生」的:渲染时现算,不进 state(第 9 篇的教训)
const celsius = scale === "F" ? tryConvert(temperature, toCelsius) : temperature;
const fahrenheit = scale === "C" ? tryConvert(temperature, toFahrenheit) : temperature;
return (
<>
<TemperatureInput scale="C" temperature={celsius}
onTemperatureChange={handleCelsiusChange} />
<TemperatureInput scale="F" temperature={fahrenheit}
onTemperatureChange={handleFahrenheitChange} />
<p>{celsius === "" ? "请输入温度" : `${celsius}℃ = ${fahrenheit}℉`}</p>
</>
);
}
拆三个设计决策。其一,状态只存一份:只存「用户正在编辑的那个框」的原始字符串和它的温标,另一个框渲染时现算。如果摄氏和华氏各存一份 state,你就得写「改 A 时顺手同步 B」的双向同步逻辑——第 9 篇刚批过的「用 effect 复制状态」会借尸还魂。凡能推导出来的值,绝不提升成第二份 state。
其二,连「非法输入」也在状态里:空串、半个数字都原样上收,两个框显示什么完全由同一份数据推导,天然不会打架。
其三,子组件纯受控:内部零 state,只剩「显示 props + 上报输入」,随便测试随便复用——扔进任何页面,给值和回调就能跑。这正呼应了第 3 篇的公式 UI = f(state):现在 f 的输入只有一处来源,整个换算器变成一台可预测的纯函数机器。
你会让两个 Controller 各自维护一份购物车副本吗?不会——购物车状态收归 Session/服务层统一持有,Controller 只做两件事:读出来返回(对应 props 下发)、接请求转调服务层修改(对应回调上呈)。状态提升就是前端版的状态上收:state 的归属权跟着「共享范围」走,视图层不私藏数据。
状态到底该放哪:一张决策清单
提升是手段,不是信仰。给 state 选家的原则一句话:放在「需要读写它的所有组件」的最小公共处,就低不就高:
提升前自检三问
- 真的是「同一份」吗?——两个组件需要的是同一份数据,还是 B 的值能从 A 推导?能推导就只提升源头,派生值渲染时现算(温度换算器只存一个框,正是此问的示范);
- 最近的公共父组件在哪一层?——提升到恰好那一层,一步不多。提得越高,中间越多组件沦为搬运站(props drilling 的苗头,见第 5 站);
- 子组件的私有状态要不要一起动?——只提升「需要共享的那部分」。输入框焦点、悬浮高亮这类纯 UI 细节留在子组件内部,全提上去只会平白放大渲染面。
| 谁要用这份数据 | 放哪 | 典型例子 |
|---|---|---|
| 只有当前组件 | 组件局部 useState | 输入框草稿、开关、悬浮高亮 |
| 几个相邻组件(父子/兄弟) | 提升到最近公共父组件(本篇) | 温度联动、筛选条件 + 列表 |
| 组件树深处大量组件 | Context(第 15 篇) | 主题色、当前登录用户、语言偏好 |
| 全应用共享,且多来自服务端 | 全局 store / 服务端状态库(第 17、18 篇) | 订单列表、用户资料、购物车 |
这张表是逐级升级的关系:默认放局部;共享诉求出现了,提一层;提了两三层还嫌不够高,才轮到 Context 和全局 store。反过来,「一上来就把所有状态塞进全局 store」是后端同学最容易犯的过激反应——就像什么数据都往应用级缓存里放,看着省事,实际把所有模块糊死在一起。客户端状态和服务端状态为什么是两回事、要不要分库管理,第 17 篇专门掰扯。
别提过头:props drilling 的苗头
提升也有副作用:state 一层层往下传,中间组件可能沦为「快递中转站」——自己不用,只为搬运:
<Layout user={user}>
<Sidebar user={user}> // Sidebar 自己不用 user,纯搬运
<UserCard user={user} /> // 真正用它的在第三层
</Sidebar>
</Layout>
这叫 props drilling(属性钻探)。两个苗头信号:中间组件的 props 里出现「自己根本不读、只为往下传」的字段;改一处状态,要动一串无关节点。偶尔两层无伤大雅——显式透传其实可读性很好——但钻到三层以上就该换工具了:Context 给组件树开「广播频道」,跳过中间层直达(第 15 篇);更重的共享走状态库(第 18 篇)。别提前优化,苗头出现再动手。
- 兄弟共享状态 = 提升到最近公共父组件:父管 state,props 下发值,回调上报改动。
- 单向数据流的纪律:数据只能向下(props)、改动请求只能向上(回调),状态源头唯一 → 界面可预测。
- 能推导出来的值不要提升成第二份 state——另一个输入框的值,渲染时现算。
- 提升前自检三问:是不是「同一份」、最近的公共父组件在哪层、子组件私有状态要不要一起动。
- 状态放哪逐级升级:局部 → 提升到父 → Context → 全局 store,就低不就高,别一上来就全局。
- props drilling 三层以上是换 Context 的信号;但先透传,苗头出现再优化。
本章回顾
本篇把第 3 篇的单向数据流落成了可操作的工程手法:状态上收到最近公共父组件,props 携值下行,回调携请求上行——一份温度数据、两个受控输入框,联动却不打架。后端视角记一句话:这就是会话状态上收到服务层,视图层只读和提交。但「提升」提得越高,透传链条越长,钻探到第三层就该换官方通道了。下一篇第 15 篇:useContext,让深层组件不必层层签收快递。见。
Comments · 评论