嘿,各位正在 React 门前反复横跳的新手小伙伴们!👋
是不是经常被“数据该放哪”、“怎么传给子组件”、“子组件想改父组件数据怎么办”这三个终极哲学问题搞得头大?别担心,今天咱们不聊虚的,直接通过一个经典的React + Stylus + Vite实战项目——Todos,带你一次性打通 React 组件通信的任督二脉!
不仅有代码,还有深度解析。准备好咖啡,我们要开始“套娃”了!
一、 项目背景:为什么我们要“套娃”?
在 Vue 里,你可能习惯了v-model的便捷,但在 React 的世界里,一切都是单向数据流。数据就像顺流而下的河水,从父组件流向子组件。
我们的项目结构如下:
- App.js(大管家):持有所有数据(todos),负责逻辑处理。
- TodoInput(输入框):负责产生新任务。
- TodoList(展示列表):展示任务,并允许用户勾选完成或删除。
- TodoStats(统计看板):展示剩余任务,提供一键清理。
二、 环境准备:Stylus 与 Vite 的碰撞
首先,我们使用的是 Vite 环境。在 React 中引入 CSS 预处理器(如 Stylus)非常简单。
1. 如何引入 Stylus
在 Vite 中,你只需要安装stylus
npm init stylus然后像这样在App.jsx中引入即可:
JavaScript
import './styles/app.styl' // 直接引入,Vite 会自动帮你处理编译为什么用 Stylus?因为它简洁,没有大括号和分号的束缚,和 React 的组件化思维很搭。
三、 核心灵魂:App 组件(数据中心化)
在 React 中,如果多个组件(比如输入框和列表)需要共享同一份数据,最正宗的做法就是状态提升(Lifting State Up)。我们将todos放在它们的共同父组件App中。
1. useState 的高级用法:惰性初始化
看这行代码:
JavaScript
const [todos, setTodos] = useState(() => { const saved = localStorage.getItem('todos'); return saved ? JSON.parse(saved) : []; });💡 超级关键点: useState 可以接收一个函数作为参数。这叫“惰性初始化”。
为什么要这么做? 如果直接写 localStorage.getItem,每次组件重新渲染(render)时都会执行一遍 IO 读取。传一个函数,React 只会在组件第一次挂载时执行它。性能优化,从细节做起!
2. useEffect 的副作用管理
我们要实现“持久化存储”,即刷新页面数据不丢。
JavaScript
useEffect(() => { localStorage.setItem('todos', JSON.stringify(todos)); }, [todos]); // 只有当 todos 发生变化时,才会触发保存这里使用了useEffect。它的第二个参数[todos]是依赖项,保证了我们只在数据变动时才去写磁盘,优雅!
四、 兄弟组件通信:间接的“曲线救国”
很多新手问:TodoInput 产生的数据,怎么传给 TodoList?
答案: 兄弟组件之间不能直接打招呼!它们必须通过共同的“老爹” App。
TodoInput调用父组件传来的方法,把新数据传回父组件(子传父)。- 父组件更新
todos状态。 - 父组件把更新后的
todos传给TodoList(父传子)。
这就是“父组件负责持有数据,管理数据”的核心原则。
五、 子父通信:自定义事件的“上报”
由于 React 的props 是只读的,子组件绝对不能直接修改父组件传过来的变量。
1. 子组件如何修改父组件的自由变量?
秘诀:父组件不仅把数据传给子,还把“修改数据的方法”也传过去。
JavaScript
// App.jsx 中 const addTodo = (text) => { setTodos([...todos, { id: Date.now(), // 使用时间戳作为唯一 ID text, completed: false, }]); } return ( <TodoInput onAdd={addTodo} /> // 传递方法 )💡 超级关键点:唯一 ID。遍历数据(map)时必须有key。为什么?React 用虚拟 DOM 算法比对差异时,靠key识别哪个元素变了。如果用index,删掉中间一个元素会导致后续所有元素重绘,性能炸裂。这里我们用Date.now()快速生成唯一 ID。
六、 详解 TodoInput:模拟“双向绑定”
React 不支持v-model,因为它推崇“显式优于隐式”。我们要实现类似功能,需要通过单向绑定 + onChange 监听。
JavaScript
const TodoInput = ({ onAdd }) => { const [inputValue, setInputValue] = useState(''); const handleSubmit = (e) => { e.preventDefault(); // 阻止表单默认提交刷新页面 if(!inputValue.trim()) return; onAdd(inputValue); // 调用父组件传来的函数 setInputValue(''); // 清空输入框 } return ( <form className="todo-input" onSubmit={handleSubmit}> <input type="text" value={inputValue} // 绑定状态 onChange={e => setInputValue(e.target.value)} // 监听输入 /> <button type="submit">Add</button> </form> ) }逻辑闭环:状态改变 -> 触发onChange-> 更新inputValue-> 视图重新渲染。虽然麻烦一点,但每一步都清清楚楚!
七、 详解 TodoList:Props 的清晰解构
在子组件中处理props时,推荐直接在函数参数里或者函数体第一行进行解构。
JavaScript
const TodoList = (props) => { const { todos, onDelete, onToggle } = props; // 清晰的解构 // ... 后面直接使用 todos,而不是 props.todos }这样做的好处是:一眼就能看出这个组件依赖哪些数据,代码阅读感拉满。
列表渲染与三目运算符
在TodoList中,我们使用了大量的三目运算符来控制视图:
JavaScript
{todos.length === 0 ? ( <li className="empty">No todos yet!</li> ) : ( todos.map(...) )}这是 React 的基本功。记住:React 的大括号{}里可以写任何 JS 表达式。三目运算符是实现条件渲染最干净的方式。
八、为什么 ID 必须是“唯一”的?
在TodoList组件里,我们看到todos.map循环时,每个<li>都有一个key={todo.id}。很多新手为了省事会直接用数组的索引index,但这正是万恶之源。
1. 为什么不能用 Index?
React 在更新 DOM 时,会通过key来判断哪些元素是新加的、哪些被删除了。
- 情景模拟:如果你有三个任务 A、B、C,索引分别是 0、1、2。当你删掉了中间的 B,剩下的 A 和 C 索引就变成了 0 和 1。
- React 的困惑:React 会以为你删掉了 C(原来的索引 2 没了),然后把 B 的内容改成了 C。这不仅浪费性能,在涉及表单输入或动画时,还会产生非常诡异的 UI Bug。
2. 代码中如何实现“唯一 ID”?
在我们的App.jsx的addTodo方法中,是这样处理的:
JavaScript
const addTodo = (text) => { setTodos([...todos, { // 💡 超级关键点:使用时间戳生成唯一 ID id: Date.now(), text, completed: false, }]); }专业讲解:
Date.now():它返回自 1970 年 1 月 1 日 00:00:00 UTC 以来经过的毫秒数。对于像 TodoList 这种个人使用的单机应用,用户点击按钮的速度是不可能超过 1 毫秒一次的,所以这个数字在当前应用中是绝对唯一的。- 更专业的方案:在大型商业项目中,我们通常会使用
crypto.randomUUID()或者uuid库来生成更长、更复杂、碰撞率几乎为零的字符串 ID。
3. 渲染时的“身份标识”
在TodoList.jsx中:
JavaScript
{todos.map(todo => ( <li key={todo.id} className={todo.completed ? 'completed' : ''}> {/* ...内容 */} </li> ))}有了这个todo.id,React 的Diff 算法(找差异的算法)就能像激光手术一样精准:它知道你只是删掉了 ID 为1734950400000的那一项,而其他项完全不需要重新渲染。
4. ID 的三大纪律
- 稳定性:ID 生成后就不应该变(所以不能用
Math.random(),因为它每次渲染都会变)。 - 唯一性:在当前列表中,不能有两个相同的 ID。
- 预测性:通过 ID 我们可以快速在
setTodos中定位数据,比如todos.filter(t => t.id !== id)。
九、 数据流操作:添加、删除与切换
在App.jsx中,我们定义了几个关键操作:
- 添加 (addTodo): 使用解构赋值
[...todos, newTodo]保证数据的不可变性(Immutability)。不要用push!
const addTodo = (text) => { setTodos([...todos, { id: Date.now(),// 时间戳 text, completed: false, }]); }- 删除 (deleteTodo): 使用
filter。
JavaScript
const deleteTodo = (id) => { setTodos(todos.filter(todo => todo.id !== id)); }- 切换状态 (toggleTodo): 使用
map。
JavaScript
const toggleTodo = (id) => { setTodos(todos.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo )); }- 💡 专业术语:这里体现了"数据驱动视图" 。子组件只需发出一个“请求”(调用 ID),由父组件统一更新数据,正确且高效。
十、 总结:React 通信全景图
通过这个项目,我们要记住 React 组件通信的三板斧:
- 父传子:通过
props直接传。 - 子传父:父传一个 callback 函数给子,子在需要时调用。
- 兄弟传:状态提升到父组件,通过父组件当中转站。
为什么子组件不能直接修改数据?
因为“统一,正确”。如果每个子组件都能随意修改父组件的数据,调试代码时你会发现根本找不着是谁把数据改坏了。单向数据流保证了数据的可追溯性。
希望这篇文章能帮你搞定 React 组件通信!如果觉得有用,记得点赞、收藏、关注三连哦!我们下期再见!🚀
原文: https://juejin.cn/post/75869399