React 父子组件通信:从 Todo 应用理解单向数据流

用一个简单的 Todo 应用,彻底搞懂 React 父子组件通信、不可变更新、浅拷贝深拷贝的本质。


引言

如果你刚开始学 React,一定会遇到这个问题:子组件怎么修改父组件的数据?

答案是:子组件不能直接修改。 那怎么办?通过"打小报告"------子组件通知父组件,让父组件自己来改。

这篇文章用一个完整的 Todo 应用作为例子,把父子组件通信这件事讲透,顺便搞清楚背后藏着的不可变更新、浅拷贝、深拷贝等面试高频考点。


一、一个 Todo 应用,三个组件

先看组件结构:

scss 复制代码
App (父组件)
 ├── TodoInput   ← 输入框 + 添加按钮
 ├── TodoList    ← 展示列表 + 勾选/删除
 └── TodoStats   ← 统计 + 批量清除

所有的 todo 数据只存在 App 里,三个子组件通过不同方式和 App 通信。

jsx 复制代码
const App = () => {
    const [todos, setTodos] = useState([
        { id: 1, text: '吃饭', completed: false },
        { id: 2, text: '睡觉', completed: true  },
        { id: 3, text: '打豆豆', completed: true  },
    ])

    // 所有修改方法都定义在父组件
    const addTodo    = (text) => { /* ... */ }
    const toggleTodo = (id)   => { /* ... */ }
    const removeTodo = (id)   => { /* ... */ }
    const clearCompleted = () => { /* ... */ }

    return (
        <div>
            <TodoInput onAdd={addTodo} />
            <TodoList
                todos={todos}
                onRemove={removeTodo}
                onToggle={toggleTodo}
            />
            <TodoStats
                total={todos.length}
                active={activeCount}
                completed={completedCount}
                onClearCompleted={clearCompleted}
            />
        </div>
    )
}

二、父 → 子:Props 往下流

这是最简单的方向------父组件把数据通过 props 传给子组件:

jsx 复制代码
// 父组件把数据"塞"给子组件
<TodoList todos={todos} />
<TodoStats total={total} active={activeCount} />

// 子组件接收,用于展示
const TodoStats = ({ total, active, completed }) => (
    <p>Total: {total} | Active: {active} | Completed: {completed}</p>
)

关键约束:子组件对 props 只能读,不能改。你试图改 props,React 不会报错,但页面不会更新------因为 React 不认为状态变了。


三、子 → 父:回调函数往上"打小报告"

既然子组件不能直接改父组件的数据,那用户点了删除按钮怎么办?

父组件把修改方法作为 props 传给子组件,子组件在合适的时机调用它。

jsx 复制代码
// 父组件:把"修改权"以回调函数的形式下放
<TodoList onRemove={removeTodo} onToggle={toggleTodo} />
jsx 复制代码
// 子组件:在事件处理中调用回调,把必要信息传回去
const TodoList = ({ todos, onRemove, onToggle }) => (
    <ul>
        {todos.map(todo => (
            <li key={todo.id}>
                <input
                    type="checkbox"
                    checked={todo.completed}
                    onChange={() => onToggle(todo.id)}   // 🔔 "id=2 被勾选了"
                />
                <span>{todo.text}</span>
                <button onClick={() => onRemove(todo.id)}>删除</button>
                {/*                        🔔 "id=5 要删掉" */}
            </li>
        ))}
    </ul>
)

整个过程是:

scss 复制代码
用户点击删除 → 子组件调用 onRemove(id) → 父组件执行 removeTodo(id)
→ setTodos(新数组) → React 检测到变化 → 重新渲染
→ 新的 todos 通过 props 传到子组件 → 页面更新

我把这个叫**"对讲机模式"**------子组件手里拿的不是数据的钥匙,而是一个对讲机,只能呼叫父组件说"有事情发生了",至于怎么处理,完全由父组件决定。


四、"自定义事件"到底是什么?

你可能会在代码注释里看到"自定义事件"这个词:

jsx 复制代码
{/* onAdd 是自定义事件,子组件通过它通知父组件 */}
<TodoInput onAdd={addTodo} />

自定义事件 = 父组件传给子组件的回调函数,约定以 on 开头命名。 它跟原生的 onClickonChange 本质一样,只是名字是我们自己起的。

原生 DOM 事件 React 自定义事件
谁定义的 浏览器 我们
谁触发的 浏览器 子组件手动调用
例子 onClickonChange onAddonToggleonRemove

五、为什么修改数据必须用拷贝式?

这是面试必考题。看两段代码:

jsx 复制代码
// ❌ 引用式 --- 看着没问题,实际是 Bug
const addTodoWrong = (text) => {
    todos.unshift({ id: Date.now(), text, completed: false })
    setTodos(todos)  // 引用没变 → React 不渲染 → Bug!
}
jsx 复制代码
// ✅ 拷贝式 --- 创建新数组,新引用
const addTodo = (text) => {
    setTodos([
        { id: Date.now(), text, completed: false },
        ...todos   // 展开 → 新数组
    ])
}

React 靠引用地址判等(===)来决定是否重新渲染:

arduino 复制代码
旧引用 === 新引用 ? → true  → 跳过渲染(没变化)
旧引用 === 新引用 ? → false → 重新渲染(有变化)

直接改原数组,引用地址不变,React 认为没有变化,页面就不会更新。

所以 React 中修改状态有铁律

  • 数组用 .map().filter()[...arr]------它们都返回新数组
  • 对象用 { ...obj, key: newValue }------返回新对象
  • 绝对不碰原数据,只在新副本上操作

六、浅拷贝:展开运算符不告诉你的秘密

很多人以为 [...todos] 就万事大吉了,其实它只是一个浅拷贝

内存的真相

数组里存的不是对象本身,而是对象的地址

css 复制代码
todos ──→ [ 0xA1 , 0xB2 ]
              │       │
              ▼       ▼
    0xA1: {吃饭}      0xB2: {睡觉}

浅拷贝做了什么

js 复制代码
const copy = [...todos]

// copy 是新数组 ✅
// 但里面的元素指向的還是旧对象 ⚠️

copy[0] === todos[0]  // true ← 同一个对象!
css 复制代码
todos ──→ [ 0xA1 , 0xB2 ] ──┬──→ 0xA1: {吃饭}
                              │
copy ───→ [ 0xA1 , 0xB2 ] ──┘──→ 0xB2: {睡觉}

你用了一根新绳子,但上面拴的还是原来那几把钥匙。

这会影响你的代码吗?

分情况:

  • 只增删不修改 (如 addTodoremoveTodo):浅拷贝完全够用,因为旧对象只是被引用,没被改
  • 要修改某个旧对象 (如 toggleTodo):必须给被修改的那个对象也做拷贝
jsx 复制代码
const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
        todo.id === id
            ? { ...todo, completed: !todo.completed }  // 这个必须拷贝
            : todo                                      // 这个可以共享
    ))
}

{ ...todo, completed: !todo.completed } 中的 ... 是**"抄"一份属性值**,不是"借"属性。抄完后新对象和旧对象各是各的,互不影响。

js 复制代码
const obj = { name: '吃饭', done: false }

const newObj = { ...obj, done: true }

console.log(obj.done)    // false  ← 原对象纹丝不动
console.log(newObj.done) // true   ← 新对象独立变化

一句话:旧对象不改 → 浅拷贝共享安全;旧对象要改 → 必须给它也拷贝一份。


七、组件设计的直觉:什么放哪里

这个问题没有银弹,但有一个实用的判断框架:

放父组件 放子组件
多个子组件都需要的数据 只有自己临时用一下的数据
修改后会影响全局的方法 父组件不需要关心的 UI 状态
从状态派生出来、多处展示的数据 表单输入过程中的中间值

对照我们的 Todo 应用:

markdown 复制代码
App(父组件 --- 所有共享的、全局的东西)
├── 状态:  todos              ← 三个子组件都要用
├── 方法:  addTodo 等          ← 改了todos影响所有人
├── 派生:  total / activeCount ← TodoStats 要展示
│
├── TodoInput
│   └── 私有: inputValue       ← 只有它自己关心用户正在打什么
├── TodoList
│   └── 私有: 无               ← 纯展示组件
└── TodoStats
    └── 私有: 无               ← 纯展示组件

八、总结

React 父子组件通信的核心就一张图:

scss 复制代码
父组件
  │
  ├── 数据(Props) 往下流 ────────→ 子组件(只读)
  │
  └── 回调函数(Events) 往上冒 ←──── 子组件(通知)
                │
                ▼
         父组件 setState
                │
                ▼
      拷贝式赋值(不可变更新)
                │
        ┌───────┴───────┐
        ▼               ▼
    浅拷贝够用       要改的项必须深拷贝
   (只增删)        (修改旧对象属性)

记住三点就够了:

  1. 数据向下,事件向上 --- Props Down, Events Up
  2. 永远拷贝,不修改原值 --- React 靠引用来判断变化
  3. 只读享共享,要改盖新屋 --- 浅拷贝的旧对象不修改是安全的,要修改就必须拷贝一份

搞清楚了这些,再去学 Context、Redux、Zustand 这些进阶的状态管理方案,你会发现它们只是在"如何优雅地把数据和回调传给子组件"这件事上做了包装,核心原理一点没变。

相关推荐
光影少年1 小时前
RN 路由栈管理、页面销毁、返回拦截
javascript·react native·react.js
_lucas1 小时前
做了一个glsl在线调试工具
前端·javascript·three.js
不好听6139 小时前
从一行 JSX 到屏幕像素:前端开发者必须懂的浏览器渲染管线
前端
恒拓高科WorkPlus9 小时前
企业级内网即时通讯建设模型:BeeWorks内网IM四层架构
前端
前端小李子10 小时前
前端环境变量裸奔?我用 EnvShield 给它穿了件防弹衣
前端
youtootech10 小时前
HarmonyOS《柚兔学伴》项目实战25-我的页面、Web 嵌入与项目总结
前端·华为·harmonyos
小林ixn11 小时前
从零到一理解 React 父子组件通信:手写一个 Todo 应用带你彻底搞懂单向数据流
前端·javascript·react.js
醇氧12 小时前
CountDownLatch / CyclicBarrier / Semaphore 面试高频问答清单
前端·面试·职场和发展