React 状态提升:一个 ColorPicker,彻底搞懂状态该放哪里

你在写一个取色器。一个颜色块展示当前颜色,三个滑块分别调 R、G、B。

代码写了一坨放在一个组件里,功能是跑通了,但你想拆------颜色展示拆成 ColorBrowser,滑块控制拆成 ColorPicker。 代码写了一坨放在一个组件里,功能是跑通了,但你想拆------颜色展示拆成 ColorBrowser,滑块控制拆成 ColorPicker

问题来了:color 这个 state 到底放哪?

ColorPicker 里?那 ColorBrowser 读不到。放 App 里?那 ColorPicker 怎么改它?各存一份?那两边怎么同步?

这篇文章就是解决这个问题的。


一句话说清楚:什么是状态提升

状态提升:当多个组件需要共享同一份 state 时,把 state 移到它们最近的公共祖先组件中,通过 props 向下传递,通过回调函数向上通知变更。

数据流向像一棵树:

graph TD App["App 持有 color state"] App -->|"props: color (只读)"| Browser["ColorBrowser"] App -->|"props: color + onColorUpdated"| Picker["ColorPicker"] Picker -->|"onColorUpdated(newColor) 回调向上通知"| App

不是"子组件互相传数据",而是把共享数据提到它们共同的父组件里,用它当数据的唯一来源


从代码看:从一坨到拆开

📦 先看最终的项目结构

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 类


📊 完整数据流回顾

sequenceDiagram participant U as 用户 participant CP as ColorPicker participant App as App participant CB as ColorBrowser U->>CP: 拖动红色滑块到 200 CP->>App: onColorUpdated({ red:200, green:240, blue:180 }) App->>App: setColor() → 触发重渲染 App->>CP: props.color = { red:200, green:240, blue:180 } App->>CB: props.color = { red:200, green:240, blue:180 } CP->>U: 滑块位置更新 + 数值 200 显示 CB->>U: 色块变为 rgb(200,240,180)

6 步走完,两个组件同步更新。整条链路里只有 App 是"知道真相"的,ColorBrowser 和 ColorPicker 都只是"收到通知"。


一个检查清单:以后拆组件时问自己

# 问题 回答"是"怎么办
1 这个 state 有多个组件需要读吗? 提到共同祖先
2 这个 state 需要被子组件修改吗? 传回调函数下去
3 子组件能不经父组件直接改 state 吗? 不能------这就是受控组件的核心约束
4 有没有在两个地方存了同一份数据? 删掉一个,只保留"唯一数据源"

记住这 4 个问题的答案,你就掌握了状态提升。


延伸思考

如果你回头看 ColorPicker 组件的代码,会发现三个滑块的结构几乎一模一样------都是"一个 range input + 显示数值",只是操作的字段不同。这种重复可以用什么方式消除?

评论区聊聊:你会怎么重构 ColorPicker,把三个滑块抽象成一个通用的 ColorSlider 组件?这样做有什么好处,又会引入什么新的权衡?


状态提升不是 React 的规则,而是"单一数据源"这一软件工程原则在 React 里的自然表达。当你纠结 state 该放哪里时,问自己:这份数据的"真相"应该由谁持有?答案从来只有一个。


相关推荐
张元清1 天前
React useThrottle Hook:节流值与回调(2026)
javascript·react.js
console.log('npc')1 天前
React 跨项目集成实战:iframe 实现子项目详情弹窗
前端·javascript·react.js
无人生还1 天前
从 Vue3 到 React · 快速上手系列第 8 篇:组件通信与 Context(对标插槽与 provide/inject)
前端·vue.js·react.js
光影少年1 天前
React Native Module 注册流程
前端·react native·react.js
BreezeJiang1 天前
RGB 滑块和颜色预览不同步?关键不是 CSS,而是状态该放在哪里
react.js
Flynt1 天前
React useEffect闭包陷阱:一个值卡了我两天,最后发现是渲染快照在作怪
前端·javascript·react.js
李明卫杭州2 天前
Vue3 响应式概念 ↔ React 对应方案(类组件与函数组件)对照笔记
前端·vue.js·react.js
半个落月2 天前
React useEffect 详解:执行时机、依赖数组与副作用清理
react.js
tntxia2 天前
React 的常用 Hook
react.js