你在写一个取色器。一个颜色块展示当前颜色,三个滑块分别调 R、G、B。
代码写了一坨放在一个组件里,功能是跑通了,但你想拆------颜色展示拆成 ColorBrowser,滑块控制拆成 ColorPicker。 代码写了一坨放在一个组件里,功能是跑通了,但你想拆------颜色展示拆成 ColorBrowser,滑块控制拆成 ColorPicker。
问题来了:color 这个 state 到底放哪?
放 ColorPicker 里?那 ColorBrowser 读不到。放 App 里?那 ColorPicker 怎么改它?各存一份?那两边怎么同步?
这篇文章就是解决这个问题的。
一句话说清楚:什么是状态提升
状态提升:当多个组件需要共享同一份 state 时,把 state 移到它们最近的公共祖先组件中,通过 props 向下传递,通过回调函数向上通知变更。
数据流向像一棵树:
不是"子组件互相传数据",而是把共享数据提到它们共同的父组件里,用它当数据的唯一来源。
从代码看:从一坨到拆开
📦 先看最终的项目结构
css
src/
├── App.tsx ← 状态"持有者"
├── model/color.ts ← Color 类型定义
└── components/
├── ColorBrowser.tsx ← 纯展示:接收颜色,显示色块
└── ColorPicker.tsx ← 控制面板:滑块 + 向上通知变更
第一步:把颜色数据的"形状"定下来
typescript
// src/model/color.ts
export interface Color {
red: number;
green: number;
blue: number;
}
没什么特别的,就是一个 RGB 三元组。单独定义接口的好处:App、ColorBrowser、ColorPicker 三个组件都用到同一个类型,改类型只改一处。
第二步:App --- 状态的真正拥有者
tsx
// src/App.tsx
import { useState } from 'react'
import ColorBrowser from './components/ColorBrowser'
import { type Color } from './model/color'
import ColorPicker from './components/ColorPicker'
function App() {
// 🔑 关键:color state 放在 App,它是 ColorBrowser 和 ColorPicker 的最近公共祖先
const [color, setColor] = useState<Color>({
red: 20,
green: 240,
blue: 180
})
return (
<>
<ColorBrowser color={color} />
<ColorPicker color={color} onColorUpdated={setColor} />
</>
)
}
export default App
🔑 这一行
const [color, setColor] = useState<Color>({...})是整个状态提升的唯一数据源。整个应用里只有一个地方知道当前颜色是什么,就是这里。
注意 onColorUpdated={setColor} --- 直接把 setColor 作为回调传下去 。ColorPicker 不需要知道 color 怎么存的、存在哪里,它只需要在用户拖滑块时调用 onColorUpdated(newColor)。
第三步:ColorBrowser --- 纯展示,只读
tsx
// src/components/ColorBrowser.tsx
import type { Color } from '../model/color'
interface Props {
color: Color;
}
const ColorBrowser: React.FC<Props> = (props) => {
const divStyle: React.CSSProperties = {
width: "11rem",
height: "7rem",
// 🔑 从 props 读取颜色,自己不持有任何 state
backgroundColor: `rgb(${props.color.red},${props.color.green},${props.color.blue})`
}
return <div style={divStyle} />
}
export default ColorBrowser
这个组件连一行 useState 都没有。它收到什么 color 就显示什么 color,自己没有任何"自己的颜色"。这种组件在 React 里叫"受控组件"------它的表现完全由父组件的 props 决定。
第四步:ColorPicker --- 控制面板,读写分离
tsx
// src/components/ColorPicker.tsx
import type { Color } from '../model/color'
interface Props {
color: Color;
onColorUpdated: (color: Color) => void; // ⚠️ 注意:接收新 Color,没有返回值
}
const ColorPicker: React.FC<Props> = (props) => {
return (
<div>
<input
type="range"
min={0}
max={255}
value={props.color.red}
onChange={event => {
// 🔑 关键:不直接改 props.color,而是构造新对象传给回调
// ⚠️ 易错:event.target.value 是 string,用 + 转 number
props.onColorUpdated({
...props.color, // 保留绿和蓝的当前值
red: +event.target.value // 只覆盖红色通道
})
}}
/>
{props.color.red}
<br />
{/* 绿色滑块:同样模式 */}
<input
type="range" min={0} max={255}
value={props.color.green}
onChange={event => {
props.onColorUpdated({
...props.color,
green: +event.target.value
})
}}
/>
{props.color.green}
<br />
{/* 蓝色滑块:同样模式 */}
<input
type="range" min={0} max={255}
value={props.color.blue}
onChange={event => {
props.onColorUpdated({
...props.color,
blue: +event.target.value
})
}}
/>
{props.color.blue}
</div>
)
}
export default ColorPicker
这里有三件事值得拆开讲。
三个关键细节
⚠️ 坑 1:event.target.value 是 string,不是 number
typescript
// ❌ 容易犯的写法
red: event.target.value // "128" --- 字符串!rgb("128","0","0") 不能正确显示
// ✅ 推荐写法
red: +event.target.value // 128 --- 数字。一元 + 是最简洁的 string→number 方式
为什么这个坑值得注意 :CSS 的
rgb()函数其实能容错接收字符串数字,但 TypeScript 的类型系统不认------Color.red的类型是number,传string会在 build 时报错。更关键的是,后续如果有数学运算(如对比度计算),"128" * 2 = 256能碰巧工作,但"128" + 1 = "1281"------字符串拼接,不是数学加法。
⚠️ 坑 2:...props.color 为什么必须先展开?
typescript
// 改红色滑块时:
props.onColorUpdated({
...props.color, // ① 先把当前完整的 color 平铺
red: +event.target.value // ② 再覆盖红色通道
})
如果不展开,就变成了只传 { red: 128 }------绿色和蓝色丢了!Color 变成 { red: 128 },浏览器里色块直接消失。
换句话说 :setState 不是"部分更新",是整体替换 。这一点和 class 组件的 this.setState 完全不同。
🔑 设计决策:为什么 ColorPicker 不直接调用 setColor,而是通过回调?
如果写成这样:
tsx
// ❌ 反模式:ColorPicker 直接从父组件导入 setColor
import { setColor } from '../App'
那 ColorPicker 就和 App 死死绑定在一起了,换个页面就复用不了。
回调模式的好处:
- 可复用:同一个 ColorPicker 可以用在不同的父组件里,父组件决定怎么处理颜色变更(存 API、写 localStorage、什么都不做)
- 可测试 :给 ColorPicker 传一个 mock 的
onColorUpdated,不需要渲染整个 App 就能测试滑块逻辑 - 职责清晰:ColorPicker 只知道"用户改了颜色,我向上通知",不关心"通知了之后会发生什么"
回头看:如果不提升状态会怎样?
假设你把 color state 放在 ColorPicker 里:
csharp
❌ ColorBrowser 读不到 color → 无法展示当前颜色
❌ 或者:ColorBrowser 自己也存一份 color → 两个 state 独立,滑块拖了半天色块不变
❌ 再或者:通过 ref / forceUpdate / event bus 同步 → 代码越来越难维护
这才是状态提升真正的价值------不是"让代码看起来更高级",而是从根本上消灭了"两份数据对不齐"的 bug 类。
📊 完整数据流回顾
6 步走完,两个组件同步更新。整条链路里只有 App 是"知道真相"的,ColorBrowser 和 ColorPicker 都只是"收到通知"。
一个检查清单:以后拆组件时问自己
| # | 问题 | 回答"是"怎么办 |
|---|---|---|
| 1 | 这个 state 有多个组件需要读吗? | 提到共同祖先 |
| 2 | 这个 state 需要被子组件修改吗? | 传回调函数下去 |
| 3 | 子组件能不经父组件直接改 state 吗? | 不能------这就是受控组件的核心约束 |
| 4 | 有没有在两个地方存了同一份数据? | 删掉一个,只保留"唯一数据源" |
记住这 4 个问题的答案,你就掌握了状态提升。
延伸思考
如果你回头看 ColorPicker 组件的代码,会发现三个滑块的结构几乎一模一样------都是"一个 range input + 显示数值",只是操作的字段不同。这种重复可以用什么方式消除?
评论区聊聊:你会怎么重构 ColorPicker,把三个滑块抽象成一个通用的
ColorSlider组件?这样做有什么好处,又会引入什么新的权衡?
状态提升不是 React 的规则,而是"单一数据源"这一软件工程原则在 React 里的自然表达。当你纠结 state 该放哪里时,问自己:这份数据的"真相"应该由谁持有?答案从来只有一个。