为什么 React 强调 Props 的不可变性?Vue vs React 渲染更新机制深度解析、什么时候用 useMemo与 useCallback?滥用会有什么后果?
文章目录
为什么 React 强调 Props 的不可变性(Immutability)?
在 React 中,props 的不可变性并不是为了限制开发者,而是为了确保整个框架的可预测性和渲染效率。
1. 性能优化:快速引用比较
React 在决定是否需要重新渲染某个组件时,最常用的手段是对比新旧 props。
- 浅比较(Shallow Comparison):React(特别是
React.memo或PureComponent)只会检查新旧props的内存地址是否一致。 - 效率对比:
- 如果 Props 是可变的:React 必须递归地检查对象内部的每一个属性是否变化(深比较),在大型应用中极其耗费性能。
- 如果 Props 是不可变的:React 只需要执行
prevProps === nextProps。如果引用没变,直接跳过渲染。
2. 单向数据流(One-Way Data Flow)
React 遵循单向数据流原则:数据从父组件流向子组件。
- 责任明确:
props属于父组件的状态(State),子组件只是这些数据的“消费者”。 - 避免副作用:如果子组件能够修改
props,就会直接影响到父组件甚至其他兄弟组件的状态。这种“逆流而上”的篡改会导致数据流向混乱,产生难以追踪的 Bug。
3. 可预测性与调试
根据公式 U I = f ( p r o p s ) UI = f(props) UI=f(props),组件应该表现得像纯函数。
- 快照视图:如果
props是不可变的,那么对于给定的props,组件渲染出的 UI 永远是固定的。 - 调试友好:你可以确信当前的
props就是父组件传下来的原始状态。如果子组件能改props,你将无法确定数据是在哪一层、被哪个逻辑篡改的。
4. 开发模式的保护
React 在开发环境下有严格的检测机制。如果你尝试直接修改 props(例如 props.name = 'new name'),React 会在控制台抛出警告。
底层机制:在开发模式下,
props对象通常会被执行Object.freeze(),强制开发者遵循函数式编程规范。
💡 核心总结
| 维度 | 如果 Props 可变 | 必须不可变(React 规范) |
|---|---|---|
| 性能 | 必须进行耗时的递归深比较 | 仅需极速的引用地址比较 |
| 数据流 | 双向篡改,数据源头难以追踪 | 严格单向流,逻辑清晰可控 |
| 组件性质 | 变成带副作用的“黑盒” | 纯函数,满足 U I = f ( p r o p s ) UI = f(props) UI=f(props) |
一句话总结:props 的不可变性让 React 能通过内存地址的简单对比,快速判断 UI 是否需要更新,从而在保持单向数据流的同时,实现极致的渲染性能。
正确做法:父组件修改自己的
state,然后通过props将新的引用下发给子组件。
深度笔记:Vue vs React 渲染更新机制深度解析
这份笔记深度解析了 Vue 和 React 在更新机制上的底层设计,涵盖了从响应式原理到 Fiber 架构的细节补充,旨在提供一份全面的技术备忘录。
1. Vue 的响应式追踪机制:依赖地图
Vue 的设计目标是 “自动化”。它通过透明的依赖追踪,确保只有真正受影响的组件才会运行。
1. 核心链路:观察者模式的工程化
-
数据劫持(Observation)
- Vue 2.x: 使用
Object.defineProperty递归遍历data,将属性转为getter/setter。其局限性在于无法检测对象属性的添加/删除以及数组索引的变化。 - Vue 3.x: 使用 ES6
Proxy直接拦截整个对象。支持动态属性、Map/Set等集合,且嵌套对象仅在访问时才进行递归代理(Lazy Proxy),极大提升了初始化性能。
- Vue 2.x: 使用
-
依赖收集(Dependency Collection)
- 每个响应式属性都会持有一个
Dep(依赖管理器)实例。 - 当组件执行
render函数时,会触发属性的getter。 - 此时,全局唯一的
activeEffect(当前运行的渲染函数)会被作为订阅者记录在Dep中。
- 每个响应式属性都会持有一个
-
派发更新(Dispatching Updates)
- 当修改属性触发
setter时,对应的Dep会立即通知所有记录在案的Effect执行。 - 组件级粒度:Vue 的更新通知是组件级的。如果父组件传给子组件的
props没变,且子组件没访问被修改的数据,子组件绝对不会重新执行渲染逻辑。
- 当修改属性触发
2. 细节补充
- 异步更新队列(NextTick):为了避免同一个事件循环(Tick)内多次修改数据导致重复渲染,Vue 会将所有 Watcher 推入一个队列,并在微任务(Microtask)阶段进行去重并一次性执行。
- 静态提升(Hoisting):Vue 3 编译器会识别出模板中的静态部分(永远不变的 HTML),将其提到渲染函数外。在 Diff 过程中,这些部分会被直接跳过。
2. React 的不可变与 Diff 机制:状态快照
React 的设计目标是 “可预测性”。它通过 UI 的全量变换,确保状态与视图始终高度同步。
1. 核心链路:Fiber 架构与协调(Reconciliation)
-
不可变性驱动:React 强制要求使用
setState。框架并不关心对象内部具体哪个属性变了,它通过比较Object.is(oldState, newState)发现引用变化,就认为组件“变脏”了。 -
虚拟 DOM 树生成:一旦组件变脏,React 默认会递归重新生成该组件及其整个子树的虚拟 DOM。
-
Fiber 节点(最小工作单元):
- React 16 后,虚拟 DOM 被重构为 Fiber 树(链表结构)。
- 每个 Fiber 节点存储了组件状态、Props 以及指向父、子、兄弟节点的指针。
-
双缓存技术(Double Buffering):React 内存中维护两棵树:
current(当前屏幕显示的)和workInProgress(正在内存中构建的)。更新完成后,只需交换指针即可完成替换。
2. 更新细节与调度
- Diff 算法的三大假设:
- 只做同层比较:不跨层级移动节点。
- 类型变了就销毁:如果元素从
<div>变成<span>,React 会直接卸载旧节点及其子树。 - Key 的重要性:通过
key标识兄弟节点,实现节点复用而非暴力重建。
- 并发模式(Concurrent Mode):借助 Fiber 的链表特性,React 可以中断长任务。如果渲染耗时太长,React 可以暂停渲染去响应高优先级的用户输入,等浏览器空闲后再继续,解决卡顿问题。
3. 深度对比:开发者需要做什么?
| 维度 | Vue (自动挡) | React (手动挡) |
|---|---|---|
| 检测变动 | 属性级别的 setter 拦截 |
组件级别的 setState 显式调用 |
| 性能瓶颈 | 初始化阶段:大量数据需要递归转化为响应式,内存占用相对高 | 更新阶段:无优化时,深层树的重复渲染和 Diff 计算量大 |
| 手动优化 | 极少需要。复杂长列表可用 v-once 或 shallowRef 减少开销 |
高频需要。必须使用 React.memo 包装子组件,useCallback 缓存函数引用 |
| 心智模型 | Mutable (易变)。直接修改数据,框架帮你处理副作用 | Immutable (不可变)。每次都是全新的快照,逻辑纯粹,易于调试 |
4. 总结:为什么 React 不采用精准追踪?
这是一个深思熟虑的设计选择:
- 数据流透明:React 希望一切更新都是显式的,
setState是唯一入口。这让 Redux 等状态管理工具和“时间旅行调试”的实现变得非常简单。 - 避免副作用开销:Vue 的响应式需要大量内存来存储
Dep和Watcher的相互引用。在数据量极大的极端场景下,React 的“暴力 Diff”配合“时间切片”可能反而更具性能弹性。 - 跨平台潜力:React 的协调逻辑(Reconciler)与具体平台无关,这使得它能轻松适配 React Native、Canvas 或 VR 环境。
笔记金句:
- Vue 是在数据上“装监控”,让数据变动自动触发通知。
- React 是在 UI 上“拍快照”,靠新旧对比找出差异。
useMemo 知识点笔记
useMemo 是 React 提供的 Memoization(记忆化) Hook。它的核心作用是:在组件重新渲染时,缓存复杂计算的结果,避免重复执行高能耗逻辑。
1. 核心语法
const memoizedValue = useMemo(() => {
// 执行复杂的计算逻辑
return computeExpensiveValue(a, b);
}, [a, b]); // 依赖项数组
-
参数 1:一个必须有返回值的函数。
-
参数 2:依赖项数组。
-
返回值:该 Hook 会返回函数的执行结果。只有当依赖项发生变化时,它才会重新执行函数;否则,它直接返回上一次缓存的值。
2. 为什么要用 useMemo?(两大场景)
场景 A:跳过昂贵的计算
如果某个计算需要处理成千上万条数据,或者涉及复杂的正则、数学运算,如果不使用 useMemo,父组件任何微小的状态改变(如点击一个无关的计数器)都会导致该计算重新运行。
import React, { useState, useMemo } from 'react';
function DataProcessor({ items }) {
const [count, setCount] = useState(0);
// 假设这是一个耗时 100ms 的操作
const sortedItems = useMemo(() => {
console.log('正在执行排序...');
return [...items].sort((a, b) => a.value - b.value);
}, [items]); // 仅当 items 改变时才重新排序
return (
<div>
<button onClick={() => setCount(count + 1)}>无关紧要的刷新: {count}</button>
<ul>
{sortedItems.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</div>
);
}
场景 B:跳过子组件的重复渲染(保持引用一致性)
在 React 的 Diff 算法中,对象和数组的引用比较 非常关键。如果父组件在渲染时生成了一个新的对象传给 memo 包装的子组件,子组件会判定 Props 变了,从而导致优化失效。
function Parent() {
const [text, setText] = useState("");
// 如果不加 useMemo,每次 Parent 渲染时 options 都是一个新对象 {}
const options = useMemo(() => ({ color: 'blue' }), []);
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<ExpensiveChild options={options} />
</>
);
}
const ExpensiveChild = React.memo(({ options }) => {
console.log("子组件渲染了");
return <div style={options}>我是昂贵的子组件</div>;
});
3. useMemo vs useCallback:本质区别
这是 React 开发中最容易混淆的知识点。虽然两者都用于“记忆(Memoization)”,但侧重点不同:
useMemo:缓存的是函数的执行结果(可以是任何值:对象、数组、字符串或计算出的数值)。useCallback:缓存的是函数本身(即保持函数引用的稳定)。
底层等价关系:
useCallback(fn, deps)实际上等同于useMemo(() => fn, deps)。useCallback只是为了减少嵌套函数写法的语法糖。
4. 注意事项与“坑”(必读)
① 不要过度优化(Avoid Premature Optimization)
不要盲目地给每个变量或函数都套上 Hook。
- 内存开销:缓存本身需要占用内存空间。
- 对比开销:每次渲染时,React 都要遍历并对比依赖项数组(Dependency Array),这本身就是 JS 计算。
- 结论:对于基础的加减法、获取数组长度等简单操作,直接计算的开销远小于维护
useMemo的开销。
② 依赖项必须“诚实”
这是最容易产生 Bug 的地方。
- 如果你在
useMemo或useCallback内部引用了某个变量(如props.id),但没有将其写进依赖项数组中,那么该 Hook 将永远锁死在初始值。 - 后果:即使外部变量变了,Hook 内部依然在使用旧值,导致 UI 显示异常或逻辑错误。
③ 不保证绝对不重新计算
React 官方明确指出:React 可能会因为释放内存等原因,偶尔丢弃已缓存的记忆值并重新计算。
- 原则:你的代码逻辑必须保证“即便
useMemo失效重新运行了,程序依然正确”。它应该被视为性能优化的建议,而非强制的逻辑保障。
5. 总结:什么时候该用 useMemo?
在满足以下三个条件之一时,建议使用 useMemo:
- 计算真的慢:通过
console.time实测,该逻辑在每次渲染时占用了明显时间(如处理几千条数据的转换)。 - 引用传值(Referential Identity):你需要将一个对象或数组作为
props传给被React.memo包裹的子组件,防止子组件因为父组件刷新而产生不必要的重绘。 - 作为其它 Hook 的依赖:比如你有一个
useEffect依赖于某个计算出的对象,为了防止useEffect在每次渲染时都触发,必须先用useMemo固定该对象的引用。
终结金句
“
useMemo不是用来改变逻辑结果的,它是用来在逻辑正确的前提下,通过牺牲一点内存换取执行时间的。”
React 进阶笔记:useCallback 深度全解析
本篇笔记重点梳理 useCallback 的底层逻辑、闭包陷阱以及实战中的最佳实践。它是 React 性能优化版图中的关键拼图,主要负责维持函数引用的稳定性。
1. 核心语法
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b], // 依赖项数组
);
-
返回值:返回的是传进去的 那个函数本身。通过缓存,该函数在组件多次渲染间保持相同的内存地址。
-
触发时机:只有当依赖项 [a, b] 发生变化时,useCallback 才会生成并返回一个新的函数引用。
2. 为什么需要它?(核心场景)
在 React 函数组件中,每次渲染都会重新执行函数体。这意味着:组件内声明的所有普通函数,在每次渲染时都会被重新创建。
场景:配合 React.memo 防止子组件“误伤”
即使子组件使用了 React.memo,如果父组件传递的是一个动态创建的函数,memo 会通过浅比较发现 props.onClick 的引用变了,从而导致子组件强制重绘。
import React, { useState, useCallback } from 'react';
// 子组件:只有 props 变了才重绘
const Child = React.memo(({ onClick }) => {
console.log("子组件渲染...");
return <button onClick={onClick}>点击我</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState("");
// ✅ 正确做法:使用 useCallback 锁定引用
const handleClick = useCallback(() => {
console.log("Button clicked");
}, []); // 依赖为空,该函数引用在 Parent 整个生命周期内保持不变
return (
<div>
<p>计数: {count} <button onClick={() => setCount(count + 1)}>+</button></p>
<input value={text} onChange={(e) => setText(e.target.value)} />
<Child onClick="{handleClick}"/>
</div>
);
}
3. 危险的“闭包陷阱”(Stale Closures)
这是 useCallback 最容易翻车的地方。如果依赖项数组不完整,函数内部拿到的变量将永远是函数被创建那一刻的“旧值”。
const [count, setCount] = useState(0);
const handleLog = useCallback(() => {
console.log("当前计数是:", count);
}, []); // ❌ 依赖项为空,函数被“锁死”在 count=0 的快照中
// 结果:无论 count 增加到多少,handleLog 永远打印 0。
-
诚实地添加依赖:把 count 加入依赖数组。但这会导致每次 count 变动时函数引用也跟着变。
-
使用函数式更新(针对 set 操作):
const increment = useCallback(() => {
setCount(prev => prev + 1); // 通过回调获取最新 state,无需依赖 count 变量
}, []);
更多推荐



所有评论(0)