为什么 React 强调 Props 的不可变性(Immutability)?

在 React 中,props 的不可变性并不是为了限制开发者,而是为了确保整个框架的可预测性渲染效率


1. 性能优化:快速引用比较

React 在决定是否需要重新渲染某个组件时,最常用的手段是对比新旧 props

  • 浅比较(Shallow Comparison):React(特别是 React.memoPureComponent)只会检查新旧 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),极大提升了初始化性能。
  • 依赖收集(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 算法的三大假设
    1. 只做同层比较:不跨层级移动节点。
    2. 类型变了就销毁:如果元素从 <div> 变成 <span>,React 会直接卸载旧节点及其子树。
    3. Key 的重要性:通过 key 标识兄弟节点,实现节点复用而非暴力重建。
  • 并发模式(Concurrent Mode):借助 Fiber 的链表特性,React 可以中断长任务。如果渲染耗时太长,React 可以暂停渲染去响应高优先级的用户输入,等浏览器空闲后再继续,解决卡顿问题。

3. 深度对比:开发者需要做什么?

维度 Vue (自动挡) React (手动挡)
检测变动 属性级别的 setter 拦截 组件级别的 setState 显式调用
性能瓶颈 初始化阶段:大量数据需要递归转化为响应式,内存占用相对高 更新阶段:无优化时,深层树的重复渲染和 Diff 计算量大
手动优化 极少需要。复杂长列表可用 v-onceshallowRef 减少开销 高频需要。必须使用 React.memo 包装子组件,useCallback 缓存函数引用
心智模型 Mutable (易变)。直接修改数据,框架帮你处理副作用 Immutable (不可变)。每次都是全新的快照,逻辑纯粹,易于调试

4. 总结:为什么 React 不采用精准追踪?

这是一个深思熟虑的设计选择:

  1. 数据流透明:React 希望一切更新都是显式的,setState 是唯一入口。这让 Redux 等状态管理工具和“时间旅行调试”的实现变得非常简单。
  2. 避免副作用开销:Vue 的响应式需要大量内存来存储 DepWatcher 的相互引用。在数据量极大的极端场景下,React 的“暴力 Diff”配合“时间切片”可能反而更具性能弹性。
  3. 跨平台潜力: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 的地方。

  • 如果你在 useMemouseCallback 内部引用了某个变量(如 props.id),但没有将其写进依赖项数组中,那么该 Hook 将永远锁死在初始值。
  • 后果:即使外部变量变了,Hook 内部依然在使用旧值,导致 UI 显示异常或逻辑错误。

③ 不保证绝对不重新计算

React 官方明确指出:React 可能会因为释放内存等原因,偶尔丢弃已缓存的记忆值并重新计算。

  • 原则:你的代码逻辑必须保证“即便 useMemo 失效重新运行了,程序依然正确”。它应该被视为性能优化的建议,而非强制的逻辑保障

5. 总结:什么时候该用 useMemo?

在满足以下三个条件之一时,建议使用 useMemo

  1. 计算真的慢:通过 console.time 实测,该逻辑在每次渲染时占用了明显时间(如处理几千条数据的转换)。
  2. 引用传值(Referential Identity):你需要将一个对象或数组作为 props 传给被 React.memo 包裹的子组件,防止子组件因为父组件刷新而产生不必要的重绘。
  3. 作为其它 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 变量
}, []);

更多推荐