写了三遍 Todo List,我终于搞懂了 React 父子组件到底怎么通信

上来就踩了个最经典的坑

我上周写这个 Todo List 的时候,第一版写得特别快,二十分钟就把界面和逻辑堆完了。然后点复选框试了一下 ------ 纹丝不动。

控制台没报错,代码看着也没写错,我对着 checked={todo.completed} 这行盯了十分钟,来回改了好几种写法,勾选状态就是不更新。当时我人都懵了,心想难道我学的 React 是假的?

后来随手打印了一下 todo 对象,发现值其实已经变了,但界面就是不刷新。那一刻我突然反应过来:我直接在子组件里改了 props 传过来的对象属性。

就是这么个入门级的坑,结结实实卡了我半小时。也正是因为这个坑,我才停下来好好捋了一遍 React 父子组件通信的逻辑,而不是照着教程抄完就完事。

先别急着写代码,先想清楚组件怎么拆

最开始我所有代码全塞在 App.js 里,输入框、列表、统计栏挤成一团。加个 "清除已完成" 按钮都要翻半天找位置,越写越烦躁。

后来干脆停手不写了,先在草稿纸上画组件结构。其实拆分逻辑特别朴素:功能独立的块,就单独拎出来做一个组件

  • 顶部输入框,只负责接收用户输入、添加新 todo,单独拆成 TodoInput
  • 中间的列表,只负责渲染每一条 todo、处理勾选和删除,拆成 TodoList
  • 底部的统计数字和清除按钮,只负责展示数据、触发清空操作,拆成 TodoStats

拆完之后整个项目目录一下就清爽了:

scss 复制代码
src/
├── App.js       // 父组件,管所有数据
├── App.css
└── components/
    ├── TodoInput.js
    ├── TodoList.js
    └── TodoStats.js

说实话以前我总觉得组件拆分是面试用的套话,真自己写一遍才明白好处:以后想改输入框的样式,直接去 TodoInput 里找;想调整列表的排版,就去 TodoList 改。不用在几百行代码里大海捞针。

父子通信到底是个啥逻辑

拆完组件问题就来了:数据放哪?总不能每个组件自己存一份 todo 数据吧,那数据不同步肯定乱套。

答案很简单:数据全部放在最顶层的父组件 App 里统一管理,子组件自己不存数据,只负责渲染和触发事件

我当时想了个特别土的比方,一下就通了: 父组件 App 就是公司老板,手里攥着唯一的一份任务清单(也就是 todos 状态)。下面三个子组件是三个员工:

  • TodoInput 是前台,负责接新任务,但不能自己往清单上写,得报告老板,由老板加进去
  • TodoList 是执行部,负责展示任务,员工完成了也不能自己打勾,得告诉老板,老板来改状态
  • TodoStats 是行政,只负责数数字,想清掉已完成的任务,也得请示老板

所有修改都必须经过老板,老板改完清单,再把最新版发给所有员工同步。这样就永远不会出现 "你改了我不知道,我改了你不知道" 的情况,数据永远是统一的。

换成 React 的术语就是:数据通过 props 向下传递,事件通过回调函数向上触发。子组件没有权力修改父组件的数据,只能调用父组件传下来的方法,通知父组件 "该改数据了"。

一步步把代码写出来

道理讲通了,写代码其实就顺了。我把完整的可运行代码拆开来,说一下每部分我当时是怎么想的。

父组件:所有数据都攥在手里

父组件的核心就两件事:定义 todos 状态,以及写一堆修改状态的方法。

javascript 复制代码
import { useState } from "react";
import TodoInput from "./components/TodoInput"
import TodoList from "./components/TodoList"
import TodoStats from "./components/TodoStats"
import "./App.css"

const App = () => {
  // 所有 todo 数据都存在这里,唯一数据源
  const [todos, setTodos] = useState([
    { id: 1, text: "学习 React", completed: false },
    { id: 2, text: "学习 React Native", completed: false },
    { id: 3, text: "学习 React Hooks", completed: true },
  ])

  // 添加 todo
  const addTodo = (text) => {
    if(text.trim() === "") return
    
    // 注意这里:必须返回全新的数组,别直接 push 原数组
    // 别问我为什么知道,点添加按钮界面纹丝不动的时候你就懂了
    setTodos([
      { id: +Date.now(), text: text, completed: false },
      ...todos
    ])
  }

  // 切换 todo 的完成状态
  const toggleTodo = (id) => {
    // 不仅数组要新,匹配到的那一项也要返回新对象
    // 直接 todo.completed = !todo.completed 是没用的
    setTodos(todos.map(todo => 
      todo.id === id ? {...todo, completed: !todo.completed} : todo
    ))
  }

  // 删除单条 todo
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id))
  }
  
  // 清除所有已完成的 todo
  const clearCompleted = () => {
    setTodos(todos.filter(todo => !todo.completed))
  }

  // 统计数据直接根据状态算就行,不用单独存
  const activeCount = todos.filter(todo => !todo.completed).length;
  const completedCount = todos.length - activeCount;

  return (
    <div>
      <h1>My Todo List</h1>
      {/* 把添加方法传给输入组件 */}
      <TodoInput onAdd={addTodo}/>
      {/* 把数据和两个操作方法传给列表组件 */}
      <TodoList 
        todos={todos} 
        onToggle={toggleTodo} 
        onDelete={deleteTodo}
      />
      {/* 把统计数字和清除方法传给统计组件 */}
      <TodoStats
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={clearCompleted}
      />
    </div>
  )
}

export default App

这里最关键的一点:每次修改状态,都要返回一个全新的数组 / 对象。 React 是通过对比引用地址来判断数据有没有变的。你直接改原数组里的属性,数组本身的地址没变,React 就认为数据没改,自然不会刷新界面。所以 map、filter、展开运算符该用就用,别偷懒。

输入组件:自己的小状态自己管

TodoInput 是我觉得最有意思的一个组件,它既有自己的内部状态,又要和父组件通信。

javascript 复制代码
import { useState } from "react";

const TodoInput = ({ onAdd }) => {
    // 输入框的临时值是组件自己的事,不用上报给父组件
    const [inputValue, setInputValue] = useState("");

    const handleSubmit = (e) => {
        e.preventDefault();
        // 只有用户点提交的时候,才通知父组件加数据
        onAdd(inputValue);
        // 加完清空输入框,自己的状态自己管
        setInputValue("");
    }

    return (
        <form className="todo-input" onSubmit={handleSubmit}>
            <input 
                type="text" 
                value={inputValue} 
                onChange={(e) => setInputValue(e.target.value)}
                placeholder="添加新的 todo"
                autoFocus
            />
            <button type="submit">添加</button>
        </form>
    )
}

export default TodoInput

不是所有状态都要往父组件塞的。像输入框里的临时文字,用户打字的过程中根本不需要影响别的组件,就放在子组件自己这里就行。只有用户按下回车、点了添加按钮,真正要新增数据的时候,再调用父组件传过来的 onAdd 方法把内容传上去。

这就是局部状态全局状态的区别。该放哪就放哪,别什么都堆到顶层,不然父组件会越来越臃肿。

列表组件:我只负责渲染,改数据别找我

TodoList 就是个典型的 "纯渲染组件",它拿到什么数据就渲染什么界面,自己不存任何数据。

javascript 复制代码
const TodoList = ({ todos, onToggle, onDelete }) => {
    return (
        <ul className="todo-list">
            {
                todos.length === 0 ? (
                    <li className="empty">暂无 todo</li>
                ) : (
                    todos.map(todo => 
                        <li key={todo.id} className={todo.completed ? "completed" : ""}>
                            <label>
                                <input 
                                    type="checkbox"
                                    checked={todo.completed}
                                    // 勾选的时候,把 id 传给父组件的方法
                                    onChange={() => onToggle(todo.id)} 
                                />
                                <span>{todo.text}</span>
                            </label>
                            {/* 这里一定要包箭头函数!别写成 onClick={onDelete(todo.id)} */}
                            {/* 我第一次写的时候页面一刷新,所有 todo 全没了,人都傻了 */}
                            <button onClick={() => onDelete(todo.id)}>删除</button>
                        </li>
                    )
                )
            }
        </ul>
    )
}

export default TodoList

提个很容易踩的坑 key 别图省事用数组下标 index。如果后面有删除、排序的操作,用 index 当 key 会出现渲染错乱、状态错位的问题。每条数据有唯一 id 就一定要用 id。

另外就是事件回调传参的问题。如果你直接写 onClick={onDelete(todo.id)},页面渲染的时候这行代码就会立刻执行,根本等不到你点击。所以必须用箭头函数包一层,或者用 bind 绑定参数。

统计组件:最纯粹的 "工具人"

TodoStats 就更简单了,连自己的状态都没有,完全靠父组件传进来的 props 渲染。

javascript 复制代码
const TodoStats = ({ total, active, completed, onClearCompleted }) => {
    return (
        <div className="todo-stats">
            <p>Total: {total} | Active: {active} | Completed: {completed}</p>
            {
                // 只有已完成数量大于 0 才显示按钮
                completed > 0 && (
                    <button 
                        className="clear-btn" 
                        onClick={onClearCompleted}
                    >
                        清除已完成
                    </button>
                )
            }
        </div>
    )
}

export default TodoStats

这种组件也叫 "无状态组件" 或者 "展示组件",它的全部职责就是把传进来的数据显示出来,再把点击事件交还给父组件。逻辑越纯粹,以后越不容易出 bug。

全部写完跑起来就是这个效果:

回头再想:为什么非要绕这一圈?

说实话最开始我觉得这设计也太反人类了,改个数据还要绕一大圈,子组件直接改不就完了?

直到我后来加功能,有一次数据对不上出了 bug。我不用挨个去三个子组件里翻代码,直接打开 App.js,盯着那四个修改状态的函数看,两分钟就定位到问题了。

那一刻我才明白单向数据流的好处。

所有数据修改的入口都收口在父组件里,数据从哪里来、被谁改了、怎么改的,一目了然。如果每个子组件都能随便改数据,项目一大、组件一多,出了问题你根本不知道是哪个组件偷偷改了状态,查 bug 查到怀疑人生。

这就是 React 推崇的单一数据源思想:同一份数据,只在一个地方存储和修改,所有组件都从这里拿数据。规矩定死了,代码就不会乱。

我踩过的三个坑,你们别再踩了

1. 直接修改 state 里的对象 / 数组,界面不更新

这应该是每个 React 初学者都会踩的坑。直接 todo.completed = true 或者 todos.push(xxx),数据变了但界面不刷新。

原因就是 React 靠引用变化判断是否更新。你改了对象里的属性,但对象本身的地址没变,React 就认为数据没变化,不会触发重渲染。

正确姿势:数组用 map、filter、展开运算符返回新数组;对象用展开运算符返回新对象。

2. 事件回调加了括号,页面一加载就执行

onClick={handleDelete(id)} 的时候,React 渲染 JSX 就会直接执行这个函数,根本不会等点击事件触发。

正确姿势 :写成 onClick={() => handleDelete(id)},用箭头函数包裹一层,或者用 bind 绑定参数。

3. 刚调用完 setState 就读数据,拿到的是旧值

我当时加完 todo 想立刻打印一下最新的列表,结果每次都少一条。以为是添加逻辑写错了,查了半天才知道 useState 的更新是异步批量的。

调用 setTodos 之后,当前函数作用域里的 todos 还是旧值,要等下一次渲染才会变成新的。如果需要用到最新的数据,直接用你准备 set 进去的那份新数据就行,别去读状态变量。

最后说两句

折腾完这三遍 Todo List,我心里才算真的踏实了,不是那种照着教程抄完的似懂非懂。

第一个感触是,组件拆分真不是面试题里的标准答案,是实实在在能提升开发体验的东西。每个组件只干一件事,以后改哪里动哪里,不会牵一发而动全身。

第二个就是父子通信的核心逻辑其实特别朴素:数据向下传,事件向上走。别总想着走捷径直接改父组件的数据,省了几行代码,以后埋的坑全要自己填。

第三个,处理引用类型的状态,永远记得返回新的。展开运算符、map、filter 用起来,比起找半天界面不更新的 bug,这点性能开销根本不算事。

当然也不是说这种写法就万能。如果以后项目做大了,组件嵌套五六层,要把一个方法从顶层传到最底层,中间每层都要接一下 props,那也挺崩溃的。那时候就该上 Context 或者 Zustand、Redux 这类状态管理库了。

但就像 Todo List 这样的小功能,甚至很多中小型项目,原生的 props 通信完全够用。别上来就整一堆状态管理库,过度设计也是种病。

你们刚学 React 的时候,有没有踩过直接修改 state 的坑?或者有别的什么经典入门 bug?评论区聊聊,我也找点平衡,看看不是我一个人这么菜。

相关推荐
勇往直前plus2 小时前
Vue3(篇一) 核心概念——响应式模板语法与组件基础
前端·javascript·vue.js
咩咩啃树皮2 小时前
第43篇:Vue3计算属性(computed)完全精讲——缓存机制、依赖计算、业务最优解
前端·vue.js·缓存
用户938515635073 小时前
从零搭建 AI 日记助手:用 Milvus 向量数据库 + RAG 让机器读懂你的每一天
javascript·人工智能·全栈
徐小夕4 小时前
花了一周,3亿tokens,我开源了一款 Word 文档智能审查平台,文稿自动质检+可视化分析,告别低效人工审核
前端·算法·github
a1117764 小时前
坦克大战3D Three.js 3D (开源项目)
开发语言·javascript·3d
kyriewen5 小时前
别再乱用useEffect了——你写的10个里有8个不该存在
前端·javascript·react.js
Ivanqhz5 小时前
Rust &‘static str浅析
java·前端·javascript·rust
IT_陈寒6 小时前
SpringBoot这个分页坑,我踩了三天才爬出来
前端·人工智能·后端
颜酱6 小时前
05 | 召回前置准备:根据业务数据库生成各数据库(读取配置阶段)
前端·人工智能·后端