React 父子组件通信 Todo List 看透单向数据流与不可变数据

通过一个经典 Todo 应用,深入理解 React 组件通信的核心机制与设计哲学,以及为什么状态更新必须"不可变"

导语

如果你已经写过 React 项目,一定遇到过这样的困惑:为什么子组件不能直接修改父组件的状态?为什么用 todos.push() 后调用 setTodos 页面却不更新?props 传递回调函数到底是在做什么?

这些问题的根源,都指向 React 最核心的两个设计理念------单向数据流不可变数据(Immutability)

今天,我通过一个完整的 Todo List 项目,从 JavaScript 底层数据类型开始,帮你彻底搞懂 React 父子组件通信的完整机制。这不仅仅是一篇 API 教程,更是一次对 React 设计哲学的深度剖析。

一、核心概念提炼:数据在哪里,修改权就在哪里

1.1 用 "银行账户" 理解组件通信

想象一个场景:父组件是一家银行总行,子组件是各个支行网点

  • 数据(todos) = 总行的核心账户数据
  • 修改数据的方法(setTodos) = 银行的交易系统
  • 子组件展示数据 = 网点查询余额
  • 子组件请求修改 = 客户到柜台办理存取款

关键规则:任何一家支行都不能直接修改总行的账户数据,必须走正规流程(填单→柜员操作→系统更新)

对应到代码中:

  • 数据 todos 由父组件 App 持有
  • 修改方法 setTodos 只在父组件定义
  • 子组件通过 props 接收数据(只读)
  • 子组件通过回调函数(onAddonToggle 等)发送修改请求

1.2 本项目的核心功能

这是一个标准的 Todo 管理应用,包含四个核心功能:

二、痛点与场景:为什么需要这套通信机制?

2.1 实际开发中的痛点

场景一:表单组件需要提交数据到父页面

你在写一个登录表单,子组件 LoginForm 需要把账号密码传给父组件 App 进行登录请求。如果子组件能直接修改父组件的状态,代码会变得极度混乱------你根本不知道数据是在哪里被改的。

场景二:列表项需要删除自己

TodoList 的每个列表项都有一个 "删除" 按钮,点击后需要从父组件的 todos 数组中移除该项。子组件怎么通知父组件删除哪一条?

场景三:多个子组件共享同一份数据

TodoInput 要添加任务,TodoList 要展示任务,TodoStatus 要统计任务数量。如果每个子组件各存一份数据,数据同步将是一场噩梦。

2.2 单向数据流的价值

React 强制单向数据流的根本原因:

问题 双向绑定的弊端 单向数据流的优势
数据来源 数据在多个组件间互相修改,来源不清晰 所有数据只有一个来源(父组件)
调试难度 数据变化难以追踪,不知道谁改的 数据变化链路清晰:用户操作→子组件发请求→父组件修改
可维护性 组件耦合度高,修改一个影响一片 组件职责清晰,低耦合高内聚

三、重难点剖析(核心)

3.1 重难点一:子组件如何"安全地"修改父组件状态?

设计者为什么这么写?

React 的设计哲学是 "数据向下流动,事件向上传递" 。子组件不能直接修改父组件的状态,这是为了保证数据的可预测性

来看代码中的核心实现:

jsx

ini 复制代码
// ✅ 正确做法:父组件定义修改函数,通过 props 传给子组件
const App = () => {
    const [todos, setTodos] = useState([...]);
    
    // 父组件定义修改方法
    const addTodo = (text) => {
        if(text.trim() === '') return;
        setTodos([
            {id: +Date.now(), text, completed: false},
            ...todos,
        ]);
    };
    
    // 通过 props 传递修改方法的引用
    return <TodoInput onAdd={addTodo} />;
};

// 子组件接收并调用
const TodoInput = ({ onAdd }) => {
    const [inputValue, setInputValue] = useState('');
    
    const handleSubmit = (e) => {
        e.preventDefault();
        onAdd(inputValue);  // 📤 发送修改请求
        setInputValue('');
    };
    
    return <form onSubmit={handleSubmit}>...</form>;
};

理解思路:

  1. 借用 +Date.now() 生成唯一 ID:利用时间戳保证 id 的唯一性,避免使用索引作为 key
  2. 展开运算符创建新数组[...todos] 确保状态不可变,触发重新渲染
  3. 回调函数本质是"请求" :子组件调用 onAdd 只是发起请求,真正的修改在父组件执行

数据流时序图:

3.2 重难点二:状态更新时,为什么必须创建"新数组/新对象"?------从 JavaScript 数据类型说起

这是很多 React 新手最容易踩的坑,也是面试最高频的考点。要理解这个问题,我们得先回到 JavaScript 最基础的数据类型。

3.2.1 JavaScript 的两种数据类型

值类型(Primitive Types)

值类型存储在栈内存中,赋值时会创建完整的独立副本。

类型 示例 typeof 返回值
String "hello" "string"
Number 123 "number"
Boolean true "boolean"
Null null "object"(历史遗留)
Undefined undefined "undefined"
Symbol Symbol('id') "symbol"

javascript

ini 复制代码
let a = 10;
let b = a;  // b 获得了 a 的值的独立副本
b = 20;

console.log(a);  // 10 ------ a 没有被改变
console.log(b);  // 20

引用类型(Reference Types)

引用类型存储在堆内存中,变量保存的是内存地址(指针),赋值时只复制地址。

类型 示例 typeof 返回值
Object {name: 'Alice'} "object"
Array [1, 2, 3] "object"
Function () => {} "function"

javascript

ini 复制代码
const original = { name: 'Alice', age: 25 };
const copy = original;  // copy 只复制了内存地址,指向同一个对象

copy.age = 30;

console.log(original.age);  // 30 ------ 原始对象也被修改了!
console.log(original === copy);  // true ------ 引用相同

3.2.2 浅拷贝 vs 深拷贝

浅拷贝(Shallow Copy) :只复制第一层数据,嵌套的对象/数组仍然共享引用。

javascript

ini 复制代码
// 浅拷贝的几种方式
const copy1 = { ...original };  // 扩展运算符
const copy2 = Object.assign({}, original);  // Object.assign
const arrCopy = arr.slice();  // 数组 slice

// 浅拷贝的问题
const user = {
    name: 'Alice',
    address: { city: 'Beijing', district: 'Haidian' }
};

const shallowCopy = { ...user };
shallowCopy.address.city = 'Shanghai';

console.log(user.address.city);  // 'Shanghai' ------ 嵌套对象被修改了!

深拷贝(Deep Copy) :复制所有层级的数据,完全独立。

javascript

javascript 复制代码
// 深拷贝的几种方式
const deepCopy = JSON.parse(JSON.stringify(obj));  // 有局限性
// 或使用 Lodash:import cloneDeep from 'lodash/cloneDeep';

3.2.3 扩展运算符 ... 的作用:实现浅拷贝

回到 Todo 代码中,...todos 的作用:

javascript

ini 复制代码
// 添加新待办 ------ 创建新数组
setTodos([
    { id: +Date.now(), text, completed: false },
    ...todos,  // 展开原数组的所有元素
]);

// 修改待办 ------ 创建新对象
setTodos(todos.map(todo => {
    if (todo.id === id) {
        return { ...todo, completed: !todo.completed };  // 创建新对象
    }
    return todo;
}));

3.2.4 为什么 React 需要这样做?

对比总结:

操作 结果 React 是否重新渲染
todos.push(newTodo) 修改原数组,引用不变 ❌ 不会
setTodos([...todos, newTodo]) 创建新数组,引用改变 ✅ 会
todo.completed = true 修改原对象,引用不变 ❌ 不会
setTodos(todos.map(...)) 创建新数组/新对象,引用改变 ✅ 会

3.3 重难点三:为什么 toggleTodo 和 deleteTodo 必须判断 id?

javascript

ini 复制代码
const toggleTodo = (id) => {
    setTodos(todos.map(todo => {
        if (todo.id === id) {  // 🔍 找到目标
            return {...todo, completed: !todo.completed};
        }
        return todo;
    }));
}

const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));  // 🔍 过滤掉目标
}

核心作用:在数组中找到"唯一"的目标元素,进行精确操作。

为什么必须用 id,不能用数组下标?

方式 示例 问题
用下标 todos[index] 列表增删改后,下标错位,操作会作用于错误的元素
用 id todo.id === id 每个元素有唯一标识,不会混淆

javascript

javascript 复制代码
// ❌ 错误:使用下标
const deleteByIndex = (index) => {
    setTodos(todos.filter((_, i) => i !== index));
    // 删除第2项后,原本第3项的 index 变成 2,后续操作会错位
};

// ✅ 正确:使用唯一 id
const deleteById = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
    // 基于唯一标识过滤,不会受顺序影响
};

为什么 toggleTodo 要返回 {...todo, completed: !todo.completed}

这是为了在"不可变数据"原则下更新对象属性:

javascript

ini 复制代码
// ❌ 错误:直接修改原对象
const toggleTodo = (id) => {
    const todo = todos.find(t => t.id === id);
    todo.completed = !todo.completed;  // 修改了堆内存中的对象
    setTodos(todos);  // 数组引用没变,React 不重新渲染!
};

// ✅ 正确:创建新对象
const toggleTodo = (id) => {
    setTodos(todos.map(todo => {
        if (todo.id === id) {
            return { ...todo, completed: !todo.completed };  // 新对象
        }
        return todo;
    }));
};

3.4 重难点四:状态提升(Lifting State Up)的实战应用

设计者为什么这么写?

所有子组件(TodoInput、TodoList、TodoStatus)都需要访问 todos 数据,所以把数据提升到最近的公共父组件 App 中管理。

理解思路:

  1. 单一数据源(Single Source of Truth) :所有数据只存一份,避免数据不一致
  2. 派生状态(Derived State)activeCountcompletedCount 不存为 state,而是通过计算得到
  3. 组合优于继承:组件通过 props 组合数据和行为,而不是继承

jsx

ini 复制代码
// 派生状态:通过计算得到,不需要 useState
const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.length - activeCount;

// 传递给子组件
<TodoStatus 
    total={todos.length}
    active={activeCount}
    completed={completedCount}
    onClearCompleted={clearCompleted}
/>

四、避坑指南/最佳实践

4.1 常见坑:在子组件中直接修改 props

jsx

ini 复制代码
// ❌ 错误:直接修改 props
const TodoItem = ({ todo }) => {
    const handleClick = () => {
        todo.completed = !todo.completed;  // 严重错误!
    };
    return <span onClick={handleClick}>{todo.text}</span>;
};

// ✅ 正确:通过回调通知父组件
const TodoItem = ({ todo, onToggle }) => {
    const handleClick = () => {
        onToggle(todo.id);  // 发送修改请求
    };
    return <span onClick={handleClick}>{todo.text}</span>;
};

4.2 常见坑:忘记阻止表单默认提交

jsx

scss 复制代码
// ❌ 错误:表单提交会刷新页面
const handleSubmit = (e) => {
    onAdd(inputValue);  // 页面刷新,状态丢失
};

// ✅ 正确:阻止默认行为
const handleSubmit = (e) => {
    e.preventDefault();  // 必须调用
    onAdd(inputValue);
    setInputValue('');
};

4.3 常见坑:直接修改状态导致视图不更新

错误做法 正确做法(不可变更新)
todos.push(item) setTodos([...todos, item])
todos.splice(index, 1) setTodos(todos.filter((_, i) => i !== index))
todos[index].completed = true setTodos(todos.map((item, i) => i === index ? {...item, completed: true} : item))
user.name = 'Bob' setUser({...user, name: 'Bob'})

4.4 判断值类型 vs 引用类型的技巧

javascript

javascript 复制代码
// 1. typeof 操作符(基础判断)
typeof 'hello'   // 'string'
typeof 123       // 'number'
typeof {}        // 'object'
typeof []        // 'object' ------ 数组也是 object

// 2. Array.isArray()(判断数组)
Array.isArray([1, 2, 3])  // true
Array.isArray({})         // false

// 3. 比较引用是否相同
const a = { name: 'Alice' };
const b = a;
const c = { ...a };

console.log(a === b);  // true ------ 指向同一个对象
console.log(a === c);  // false ------ 虽然是"内容相同",但引用不同

4.5 最佳实践清单

实践项 说明
✅ 使用 +Date.now() 生成唯一 ID 简单有效,避免使用索引作为 key
✅ 使用函数式更新 setTodos(prev => [...prev, newTodo]) 确保拿到最新状态
✅ 解构 props const TodoList = ({ todos, onToggle }) => 清晰明了
✅ 条件渲染 todos.length === 0 && <li>暂无任务</li>
✅ 派生状态不存 state 能用 filter 算出来的就不存 useState
✅ 扩展运算符创建新对象/数组 确保不可变更新,触发重新渲染
✅ 使用唯一 id 操作列表项 避免下标错位导致的 bug

五、面试高频考点

Q1:为什么子组件不能直接修改父组件的状态?

回答要点:

  1. 单向数据流原则:React 设计为数据从上往下流,保证可预测性
  2. 调试便利性:数据变化链路清晰,易于追踪 bug
  3. 解耦:子组件不依赖父组件的具体实现,只通过 props 接口通信
  4. 性能优化:父组件可以控制何时更新,避免不必要的渲染

Q2:直接修改 todos[0].completed = true 后调用 setTodos(todos),为什么视图不更新?

回答要点:

  1. 引用未变化:数组引用相同,React 浅比较认为状态未变
  2. 违背不可变原则:直接修改了堆内存中的对象
  3. 正确做法setTodos(todos.map((todo, i) => i === 0 ? {...todo, completed: true} : todo))

Q3:setState 是同步还是异步的?

回答要点:

  1. React 合成事件中异步setState 会被合并批量更新,提升性能
  2. 原生事件中同步 :在 addEventListenersetTimeout 中是同步的
  3. 函数式更新获取最新值setTodos(prev => [...prev, new]) 可以拿到最新状态

Q4:扩展运算符 ... 是深拷贝还是浅拷贝?

回答要点:

  1. 是浅拷贝:只复制第一层,嵌套结构仍然共享引用
  2. 证明const copy = {...obj}; copy.nested.key = 'new' 会修改原对象
  3. React 场景 :单层对象更新用 ... 就够用,深层嵌套需要配合递归或 Immer

Q5:为什么 React 强调不可变数据(Immutability)?

回答要点:

  1. 简化比较:通过引用变化即可判断数据是否改变,无需深度对比
  2. 时间旅行:每次修改都产生新状态,方便实现撤销/重做功能
  3. 避免副作用:不可变数据让函数更容易预测,减少 bug
  4. 性能优化 :配合 React.memoPureComponent 实现精准渲染
相关推荐
软件开发技术深度爱好者2 小时前
国际音标魔法实验室工具HTML5实现
前端·html5·英语学习
FogLetter2 小时前
嘘!WebSocket正在“偷听”你的网络请求——全双工通信的魔法
前端·面试
用户938515635072 小时前
从零构建 React Todo 应用:组件化设计与数据流深度解析
前端·javascript
IT_陈寒2 小时前
Python线程池把我坑惨了,这些盲区你不踩?
前端·人工智能·后端
程序员爱钓鱼2 小时前
第一个 Go 程序:Hello World
前端·后端·go
阳光是sunny2 小时前
LangGraph 核心概念详解:从编译到可视化
前端·人工智能·后端
不好听6133 小时前
React 父子组件通信:从 Todo 应用理解单向数据流
前端·react.js
_lucas3 小时前
做了一个glsl在线调试工具
前端·javascript·three.js
不好听61311 小时前
从一行 JSX 到屏幕像素:前端开发者必须懂的浏览器渲染管线
前端