普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月7日掘金 前端

深入理解react——1. jsx与虚拟dom

作者 time_rg
2026年1月7日 16:10

通过课程和博客学习react,能够应付平时的开发工作。但到了面试等环节,对于fiber,setState的同步异步问题,说是知道,但往往朝深处一问,结合实际做一些输出题,脑袋里往往没有清晰的脉络,所以我决定自己实现一份miniReact,提升自己对react的理解。

本文大部分内容都是从历史好文build your own react中参考借鉴,这确实是我看到的最好的学习react的文章,在这里表示感谢。

地址:pomb.us/build-your-…

准备工作

首先新启一个项目

npm init
npm i vite

简单配置vite.config.js

新建入口文件index.html,引入index.js

现在我们的准备工作就完成了。

一,jsx

jsx是一个语法糖,在编译后其实是使用了createElement函数。所以我们第一步就是实现createElement用于创建虚拟dom。我们miniReact只关心部分使用到的属性,不做完全详尽的处理。

(面试点,为什么老版本的react需要在顶部引入react,而新版本不需要)

现在,我们先从hello world开始

const rootDOM = document.getElementById("root");
const element = createElement("div", null, "hello world");
render(element, rootDOM);

接下来我们需要依次实现createElement用于创建虚拟dom,以及render用于将虚拟dom渲染到界面上。

简化版的虚拟dom需要三个参数,分别是type,props,以及children。

1.1 createElement

const createElement = (type, props, ...children) => {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) => (typeof child === "object" ? child : createTextElement(child))),
    },
  };
};

function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  };
}

1.2 render

function render(element, container) {
  const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type)

  const isProperty = key => key !== "children"
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name]
    })

  element.props.children.forEach(child =>
    render(child, dom)
  )

  container.appendChild(dom)
}

恭喜我们完成了第一步,成功的将一个虚拟dom渲染到了界面上,虽然简单,但是开始比什么都重要!

1.3 测试

接下来让我们做一些简单的测试

const elementList = Array.from({ length: 100 }, (_, i) => {
  const key = `Item-${i}`;
  return createElement("li", { key }, key);
});
const element = createElement("ul", null, ...elementList);
render(element, rootDOM);

将渲染的dom给得多一些,就可以看到很明显的卡顿,在此期间界面没法操作,这就是react fiber架构要解决的主要问题。

React 性能优化之道:useMemo、useCallback 与闭包陷阱的深度剖析

作者 不会js
2026年1月7日 16:01

React 性能优化之道:useMemo、useCallback 与闭包陷阱的深度剖析

大家好,今天,我们来聊聊 React 中那些让人又爱又恨的性能优化工具——useMemo 和 useCallback,以及隐藏在背后的闭包陷阱。作为一名 React 开发者,你是否曾经遇到过这样的场景:组件明明只改了一个状态,却导致整个页面重新渲染,性能像漏气的轮胎一样瘪了下去?或者,你在 useEffect 里设置了一个定时器,结果它永远捕捉不到最新的状态值,像个固执的守门员,只认旧球不认新球?这篇文章将基于 React 的核心机制,带你一步步拆解这些问题。

第一部分:React 性能优化的痛点与必要性

想象一下,你在建造一座摩天大楼(你的 React 应用)。每当大楼里的一个房间(组件)需要装修时,整个大楼都要停工重刷一遍油漆?这听起来多荒谬!但在 React 的默认行为中,这就是现实:组件函数每次渲染都会重新执行,导致不必要的计算和子组件重绘。为什么会这样?因为 React 的渲染是“响应式”的——状态变化触发重新渲染,以确保 UI 与数据同步。但这种“全量渲染”在复杂应用中会带来性能开销,比如昂贵的计算重复执行,或子组件无谓刷新。

性能优化的核心在于“惰性”:只在必要时计算,只在 props 变化时重绘。React Hooks 提供了 useMemo 和 useCallback 来实现这一点,它们就像大楼的“智能电梯”,只在特定楼层停靠,避免无谓的上下奔波。同时,我们还要警惕闭包陷阱——它像大楼里的“幽灵通道”,悄无声息地捕捉旧值,导致逻辑出错。

第二部分:useMemo —— 缓存计算结果的“懒汉守护者”

useMemo 的诞生背景与核心概念

在 Vue 中,我们有 computed 计算属性,它像个聪明的管家,只在依赖变化时重新计算。React 没有内置 computed,但 useMemo 就是它的“DIY 版”。useMemo 的本质是“记忆化”(Memoization):缓存昂贵计算的结果,避免重复劳动。

为什么需要它?考虑一个场景:你有一个列表,需要根据搜索关键词过滤。每次状态变化(哪怕无关),过滤函数都会重跑。如果列表有上万项,那就太浪费了!

useMemo 的 API 很简单:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
  • 第一个参数:一个返回计算结果的函数。
  • 第二个参数:依赖数组。只有数组中的值变化时,函数才会重执行。

底层逻辑:useMemo 利用 Hooks 的内部存储(fiber.hooks),在渲染间存储上次的计算结果和依赖。如果依赖浅比较(===)不变,就直接返回缓存值。这避免了组件重渲染时的重复计算。

扩展知识点:useMemo 不是“防抖”或“节流”,它针对纯计算。昂贵计算的例子包括:大数据排序、复杂数学运算(如斐波那契数列递归)、或处理 API 数据(如聚合统计)。

实战示例:从痛点到优化

来看一个示例。我们有一个列表 ['apple', 'banana', 'orange', 'pear'],需要根据 keyword 过滤。同时,有一个 count 状态和一个昂贵的 slowSum 计算。

原始代码(痛点版):

const filterList = list.filter(item => item.includes(keyword)); // 每次渲染都重跑
const result = slowSum(num); // 模拟昂贵计算,每次都 console.log('计算中...')

问题:count 变化时,filterList 和 slowSum 都会重执行,尽管它们不依赖 count。这导致性能浪费,尤其 slowSum 循环上百万次!

优化版(使用 useMemo):

import { useState, useMemo } from "react";

function slowSum(n) {
  console.log('计算中...');
  let sum = 0;
  for (let i = 0; i < n * 1000000; i++) {
    sum += i;
  }
  return sum;
}

export default function App() {
  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState('');
  const [num, setNum] = useState(0);
  const list = ['apple', 'banana', 'orange', 'pear'];

  const filterList = useMemo(() => {
    console.log('filter 执行'); // 只在 keyword 变时执行
    return list.filter(item => item.includes(keyword));
  }, [keyword]); // 依赖 keyword

  const result = useMemo(() => slowSum(num), [num]); // 只在 num 变时重算

  return (
    <div>
      <p>结果: {result}</p>
      <button onClick={() => setNum(num + 1)}>num+1</button>
      <input type="text" value={keyword} onChange={e => setKeyword(e.target.value)} />
      {count}
      <button onClick={() => setCount(count + 1)}>count+1</button>
      {filterList.map(item => <li key={item}>{item}</li>)}
    </div>
  );
}

现在,点击 count+1 时,filterList 和 result 不会重跑!控制台只在 keyword 或 num 变时打印日志。

易错提醒:

  1. 依赖数组漏写:如果忘了 [keyword],useMemo 只跑一次,keyword 变也不会更新——像个“失忆的管家”。
  2. 过度依赖:数组中放对象/数组时,浅比较失效(因为新对象 !== 旧对象)。解决:用 useMemo 缓存对象,或用 lodash 的 deepEqual(但不推荐,增加开销)。
  3. 返回值类型:useMemo 可以缓存任何值,包括 JSX!如 const memoizedJSX = useMemo(() => <HeavyComponent />, [deps]); 用于优化虚拟 DOM 生成。
  4. 性能陷阱:useMemo 本身有开销(比较依赖 + 存储)。只用于真正昂贵的计算。测试工具:用 React DevTools 的 Profiler 测量渲染时间。

扩展:useMemo vs useEffect。useEffect 是“副作用钩子”,适合异步操作;useMemo 是同步计算钩子。useMemo 返回值直接用在渲染中,而 useEffect 不返回。

第三部分:useCallback —— 缓存函数的“稳定器”

useCallback 的核心与 React 渲染机制

React 的数据流是单向的:父组件持数据,子组件渲染。子组件用 React.memo 包裹时,会浅比较 props。如果 props 不变,子组件跳过渲染。但问题来了:函数 props(如 onClick)每次渲染都是新函数(因为组件函数重执行),导致 === 失败,子组件总重绘!

useCallback 解决这个:缓存函数引用。只有依赖变时,才返回新函数。

API:

const memoizedCallback = useCallback(() => { doSomething(a, b); }, [a, b]);

底层逻辑:类似 useMemo,但专为函数。Hooks 存储上次的函数和依赖,依赖不变时返回相同引用。

扩展:为什么函数引用重要?因为 JavaScript 函数是对象,每次定义都是新实例。React.memo 的浅比较依赖 ===,新函数总触发重绘。

实战示例:父子组件优化

原始痛点:父组件有 count 和 num,子组件依赖 count 和 handleClick。但 handleClick 每次新生成,导致 Child 总重绘。

优化版:

import { useState, memo, useCallback } from "react";

const Child = memo(({ count, handleClick }) => {
  console.log('child重新渲染'); // 只在 count 或依赖变时打印
  return (
    <div onClick={handleClick}>
      子组件 {count}
    </div>
  );
});

export default function App() {
  const [count, setCount] = useState(0);
  const [num, setNum] = useState(0);

  const handleClick = useCallback(() => {
    console.log('click');
  }, [count]); // 如果依赖 count,count 变时返回新函数

  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1)}>count+1</button>
      <button onClick={() => setNum(num + 1)}>num+1</button>
      <Child count={count} handleClick={handleClick} />
    </div>
  );
}

点击 num+1 时,Child 不重绘!因为 handleClick 引用稳定。

易错提醒:

  1. 空依赖 []:函数永不更新,但如果函数内用闭包捕获变量,会导致“陈旧值”问题(详见闭包陷阱)。
  2. 过度使用:useCallback 缓存函数,但如果子组件不 memo,就没必要。记住:优化是针对瓶颈的。
  3. 与 useMemo 的区别:useCallback 是 useMemo 的特化版,等价于 useMemo(() => fn, deps)。但 useCallback 更语义化。
  4. 事件处理:onClick 等常依赖状态。如果不放依赖,函数捕获旧状态;放了,函数引用变,子组件重绘。权衡:如果子组件不昂贵,优先正确性。

扩展:高级用法——useCallback 在列表渲染中缓存 item 的 onClick,避免每个 item 新函数。结合 useImperativeHandle,可优化 ref 转发。

第四部分:React 闭包陷阱 —— 隐藏的“幽灵捕手”

闭包的形成与 React 中的陷阱

闭包是 JavaScript 的核心:函数记住其词法作用域。即使外部函数结束,闭包仍持有变量引用。

在 React,Hooks 如 useEffect、useCallback、useMemo 会形成闭包。因为它们在组件函数(外部作用域)中定义,捕获当前渲染的状态。

陷阱场景:useEffect([]) 只跑一次,捕获初始状态。后续状态变,effect 内看不到——像个“时间胶囊”,永远封存旧值。

为什么?因为依赖数组决定“ freshness”:空数组意味着“永不更新闭包”。

实战示例:定时器中的闭包陷阱

痛点版:

useEffect(() => {
  const timer = setInterval(() => {
    console.log('current count', count); // 永远打印 0
  }, 1000);
  return () => clearInterval(timer);
}, []); // 空依赖,只捕获初始 count=0

问题:定时器闭包捕获初始 count,状态变也不更新。

优化版:

useEffect(() => {
  const timer = setInterval(() => {
    console.log('current count', count);
  }, 1000);
  return () => clearInterval(timer); // 每次 count 变,清旧定时器,新建
}, [count]); // 依赖 count,闭包更新

现在,count 变时,effect 重跑,闭包捕获新值。但注意:这会创建多个定时器?不!返回函数先清旧的。

易错提醒:

  1. 忘记依赖: ESLint 的 react-hooks/exhaustive-deps 会警告,但别盲目加——理解后再加。
  2. 无限循环:如果 effect 内 setState,且依赖该 state,会循环。解决:用函数式更新 setCount(c => c + 1),不依赖当前值。
  3. useRef 逃脱陷阱:用 ref.current 存储可变值,不受闭包影响。如 const countRef = useRef(count); 在 effect 内更新 ref。
  4. 事件处理函数:onClick 内用状态,如果是 useCallback([]),捕获旧值。解决:依赖状态,或用 ref。

扩展底层逻辑:React Hooks 用链表存储(fiber.hooks)。每个 Hook 有 memoizedState 和 updateQueue。依赖比较用 Object.is(类似 ===)。闭包陷阱本质是词法作用域 + 渲染隔离:每个渲染是独立的“快照”。

高级避免:用 useReducer 集中状态逻辑,或自定义 Hooks 封装闭包。

第五部分:彻底分清“三兄弟”

用最直白的话总结它们的区别:

名字 本质是什么 缓存的是什么 主要解决什么问题 使用位置
useMemo Hook 任意值的计算结果(数字、字符串、对象、数组、甚至 JSX) 避免重复执行昂贵的计算 组件函数内部
useCallback Hook 函数本身(函数引用) 避免每次渲染都创建一个新函数 组件函数内部
React.memo 高阶组件(HOC) 整个组件的渲染结果 避免 props 没变时子组件无谓重渲染 组件定义外面(包裹组件)

虽然它们为什么“长得像”,但其实干的活完全不一样。

1. useMemo:缓存“值”的计算结果

核心目的:我有一个很贵的计算,只想在它真正依赖的东西变化时才重新算一遍。

const expensiveValue = useMemo(() => {
  console.log('我在做很贵的计算...');
  return heavyComputation(a, b);  // 比如大数据过滤、排序、数学运算
}, [a, b]);  // 只有 a 或 b 变了,才重新算
  • 缓存的是 heavyComputation 的返回值(一个值)。
  • 每次渲染时,如果 [a, b] 没变,就直接返回上次的缓存值,不执行函数。
  • 典型场景:过滤列表、计算衍生数据、处理复杂对象。

记住:useMemo 是“懒汉”,它懒得重复算值。

2. useCallback:缓存“函数”本身

核心目的:我定义了一个函数,每次渲染都会重新创建一个新函数,但我不想这样,因为新函数会导致子组件误以为 props 变了而重渲染。

const handleClick = useCallback(() => {
  console.log('点击了', count);
  // 做点事
}, [count]);  // 只有 count 变了,才返回一个新函数
  • 缓存的是 函数引用(也就是 handleClick 这个变量本身)。

  • 如果依赖 [count] 没变,它永远返回同一个函数实例(=== 相同)。

  • 为什么需要这个?因为 JavaScript 里这样写:

    jsx

    const handleClick = () => { ... }
    

    每次组件渲染都会创建一个全新的函数对象,即使代码一模一样。

典型场景:把函数作为 props 传给子组件,尤其是子组件被 React.memo 包裹时。

记住:useCallback 是“稳定器”,它稳定函数的引用,防止子组件误以为 props 变了。

小知识:useCallback 其实是 useMemo 的特例!它等价于:

jsx

const handleClick = useMemo(() => () => { ... }, [count]);

React 单独给它起了个名字,就是因为这个场景太常见了。

3. React.memo:缓存“整个组件”的渲染

核心目的:这个子组件渲染很贵,但它的 props 经常没变,父组件重渲染时我不想让它也跟着重渲染。

const Child = React.memo(function Child({ data, onClick }) {
  console.log('Child 渲染了');  // 只有 props 真的变了才会打印
  return <div>复杂的 UI</div>;
});
  • 缓存的是 组件上一次的渲染结果(虚拟 DOM 树)。
  • React 会自动浅比较新旧 props,如果完全一样(===),就直接复用上次渲染的结果,完全跳过这个组件的函数执行
  • 它不关心你里面用了什么 Hook,只看 props。

记住:React.memo 是“门卫”,它守着子组件的大门,只有 props 真正变了才放行渲染。

为什么感觉他们“太像了”?

因为它们都用了“记忆化”(memoization)这个思想: “如果输入没变,就别重新干活,直接用上次的结果。”

  • useMemo:输入是依赖数组,输出是值 → 记忆值
  • useCallback:输入是依赖数组,输出是函数 → 记忆函数
  • React.memo:输入是 props,输出是渲染结果 → 记忆组件渲染

经典组合拳

// 1. 子组件用 memo 包裹,防止无谓渲染
const Child = React.memo(function Child({ data, onClick }) {
  return <ExpensiveUI data={data} onClick={onClick} />;
});

// 2. 父组件里
function Parent() {
  const [count, setCount] = useState(0);
  const [filter, setFilter] = useState('');

  // 用 useMemo 缓存计算结果(稳定 data 对象引用)
  const filteredData = useMemo(() => {
    return bigList.filter(item => item.includes(filter));
  }, [filter]);

  // 用 useCallback 缓存函数(稳定 onClick 引用)
  const handleClick = useCallback(() => {
    setCount(c => c + 1);
  }, []);  // 用函数式更新避免依赖 count

  return (
    <div>
      <Child data={filteredData} onClick={handleClick} />
    </div>
  );
}

这样:

  • filteredData 引用稳定 → Child 的 data props 稳定
  • handleClick 引用稳定 → Child 的 onClick props 稳定
  • Child 被 memo 包裹 → props 没变就不渲染 完美优化!

总结口诀(背下来就行)

  • useMemo:缓存,防重复计算
  • useCallback:缓存函数,防引用变化
  • React.memo:缓存组件,防无谓渲染

三兄弟各司其职,配合起来天下无敌!

结语:从优化到 mastery

通过 useMemo 和 useCallback,我们让 React 像精密仪器一样高效;避开闭包陷阱,则让逻辑如丝般顺滑。记住,React 的美在于响应式,但优化是艺术——平衡正确性和性能。

❌
❌