首页 / React 学习笔记 / 14

REACT · Vol.II · LESSON 14 · Hooks 精讲

状态提升与单向数据流

进阶核心#状态#数据流

两个兄弟组件,各说各话

第 13 篇末尾留了个尖锐的问题:自定义 Hook 复用的是「逻辑」,状态各自隔离——那如果两个组件需要的就是同一份状态呢?看个具体场景:温度换算器,左边输摄氏、右边输华氏,两边要实时联动。

Broken.tsx · ❌ 各持一份 state,无法联动
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。出路不是开通道,而是把状态挪到它该在的地方——两个组件的最近公共父组件。

想想后端里同类的事故:订单页和购物车页各自维护一份「商品数量」副本,改了这边忘了那边,最后你把两边都改成读同一份 Session/服务层数据才消停。前端的答案同款:数据只留一份,存在公共上级那里。

状态提升三步走

所谓状态提升(Lifting State Up),就是把子组件私有的 state,挪到需要它的组件们的最近公共父组件,子组件降级为「受控的视图」。固定三步:

  1. 提升 state:把温度数据挪进公共父组件(只存一份「用户正在编辑的值 + 温标」,别存两份换算结果互相追);
  2. 父传 props:父组件把当前值通过 props 下发给每个子组件——数据向下
  3. 子传回调:子组件不再自己 setState,改用 props 里的回调函数向父组件「申报修改」——改动请求向上

按这个配方改造后的子组件长这样——注意它内部已经没有任何 state 了:

TemperatureInput.tsx · 纯受控子组件
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 上报。

Calculator(持有 state) props:值 回调:改值 props:值 回调:改值 温度输入 · ℃ 温度输入 · ℉ 数据只能向下(props),改动请求只能向上(回调)——兄弟之间没有直达通道 状态源头唯一:任何时刻两个输入框读的都是同一份数据,界面天然一致
图 1状态提升后的单向数据流:props 向下、回调向上,兄弟组件经父组件中转

换个壳,同一道题:筛选框与列表

温度换算器很小,这套模式却无处不在。再看一例:页面上方是关键词输入框,下方列表按关键词过滤。输入框和列表是兄弟组件,keyword 是要共享的状态——提升到页面级父组件:

UserPage.tsx · 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 领结果只管渲染。日后想再加第三个组件——「显示命中条数的徽标」——只需把它挂在同一个父组件下,分发同一份数据的切片即可,前两个组件一行不用改。数据源头唯一,扩展就只是「多挂一个读者」。

完整可运行:父组件统一持有,换算只算一份

Calculator.tsx · 父组件持有唯一 state
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 选家的原则一句话:放在「需要读写它的所有组件」的最小公共处,就低不就高

提升前自检三问

  1. 真的是「同一份」吗?——两个组件需要的是同一份数据,还是 B 的值能从 A 推导?能推导就只提升源头,派生值渲染时现算(温度换算器只存一个框,正是此问的示范);
  2. 最近的公共父组件在哪一层?——提升到恰好那一层,一步不多。提得越高,中间越多组件沦为搬运站(props drilling 的苗头,见第 5 站);
  3. 子组件的私有状态要不要一起动?——只提升「需要共享的那部分」。输入框焦点、悬浮高亮这类纯 UI 细节留在子组件内部,全提上去只会平白放大渲染面。
谁要用这份数据放哪典型例子
只有当前组件组件局部 useState输入框草稿、开关、悬浮高亮
几个相邻组件(父子/兄弟)提升到最近公共父组件(本篇)温度联动、筛选条件 + 列表
组件树深处大量组件Context(第 15 篇)主题色、当前登录用户、语言偏好
全应用共享,且多来自服务端全局 store / 服务端状态库(第 17、18 篇)订单列表、用户资料、购物车

这张表是逐级升级的关系:默认放局部;共享诉求出现了,提一层;提了两三层还嫌不够高,才轮到 Context 和全局 store。反过来,「一上来就把所有状态塞进全局 store」是后端同学最容易犯的过激反应——就像什么数据都往应用级缓存里放,看着省事,实际把所有模块糊死在一起。客户端状态和服务端状态为什么是两回事、要不要分库管理,第 17 篇专门掰扯。

自测一下:第 13 篇的 useLocalStorage 抽出来的是「逻辑」,本篇提升起来的是「数据」。抽 Hook 复用行为,提升 state 归并数据——两件事方向相反,别混。

别提过头:props drilling 的苗头

提升也有副作用:state 一层层往下传,中间组件可能沦为「快递中转站」——自己不用,只为搬运:

App.tsx · ❌ user 一路被无关组件搬运
<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 · 评论