找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

925

积分

0

好友

119

主题
发表于 昨天 23:44 | 查看: 0| 回复: 0

原文链接:https://medium.com/interview-series/i-was-asked-about-react-re-renders-in-a-frontend-interview-heres-what-they-actually-wanted-433cf331d656
翻译:谢杰
审校:谢杰

React 渲染与重新渲染的清晰心智模型:为什么大多数性能“优化”都抓错了重点

在一次前端面试中,面试官让我解释 React 的重渲染机制。我自信地回答:“当 state 发生变化时,组件会重新渲染。”

面试官礼貌地点了点头,随即抛出一个让我措手不及的问题:“那么,为什么我只在父组件中修改了 state,它所有的子组件也都会重新渲染?”

那一刻我才意识到,自己对重渲染的理解流于表面。带着这个问题,我深入研究了 React 的底层工作机制,这段经历彻底改变了我编写 React 代码的方式。本文正是我希望在初学时就有人能告诉我的全部关键知识。

什么是重新渲染?

在深入探讨“何时”与“为何”会发生重渲染之前,我们必须先厘清“重新渲染”的确切含义。这一点至关重要,因为很多开发者会将重渲染与 DOM 更新混为一谈,而它们实际上是两件不同的事。

当 React 重新渲染一个组件时,它所做的仅仅是重新调用你的组件函数。就这么简单。React 执行你的函数,获取返回的 JSX,然后将其与上一次渲染的结果进行比较。你可以把这个过程想象成:React 给你的组件拍了一张新的“快照”,然后拿这张新快照和旧快照对比,找出差异。

下面这个简单的例子清晰地展示了这个概念:

function Counter() {
  const [count, setCount] = React.useState(0);

  // 每次组件重新渲染时,都会执行这个 console.log
  console.log('Counter component rendered, count is:', count);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

每当你点击按钮,都会在控制台看到一条新的日志。整个函数会重新执行一遍,useState 返回最新的 count 值,React 会拿到一份全新的 JSX 进行处理。这就是一次重新渲染。

关键在于:组件发生重新渲染,绝不等于真实的 DOM 会被更新。React 足够智能,它会判断 JSX 的输出是否有实际变化。如果没有,它根本不会去操作真实 DOM。重渲染过程完全发生在 JavaScript 的内存中,只有当 React 检测到确实存在差异时,才会对 DOM 进行必要的更新。正是这种“渲染与更新分离”的机制,赋予了 React 卓越的性能。

重新渲染的三个根本触发因素

经过长期实践和研读官方文档,我发现 React 组件发生重新渲染,归根结底只有三个根本原因。所有其他情况,都是这三条规则的延伸或结果。

触发因素一:State 变化

这是大多数开发者都熟悉的情况。当你调用一个 state 的设置函数(如 setCountsetState),你是在告知 React 某些重要数据已变,组件需要重新渲染以反映此变化。

有趣的是 React 内部的处理方式。当你调用设置函数时,React 并不会立即重渲染组件,而是调度(schedule)一次重渲染。为了提升性能,React 会对多个 state 更新进行批处理(batching)。这意味着,如果你在同一个函数中连续调用了三个不同的 state 设置函数,React 足够智能,只会触发一次重渲染,而非三次。

下面的例子展示了 React 的批处理行为:

function MultiStateComponent() {
  const [name, setName] = React.useState('');
  const [age, setAge] = React.useState(0);
  const [email, setEmail] = React.useState('');

  console.log('Component rendered');
  const handleSubmit = () => {
    // 以下三个 state 更新会被批处理
    setName('John');
    setAge(30);
    setEmail('john@example.com');
    // 所以组件只会在所有更新之后重新渲染一次
  };
  return (
    <form>
      <input value={name} onChange={e => setName(e.target.value)} />
      <input value={age} onChange={e => setAge(e.target.value)} />
      <input value={email} onChange={e => setEmail(e.target.value)} />
      <button type="button" onClick={handleSubmit}>Submit</button>
    </form>
  );
}

当你点击提交按钮时,控制台只会输出一条“Component rendered”日志,而非三条。这正是因为 React 将这些更新批处理在了一起。

触发因素二:父组件重新渲染

这正是那场面试中让我愣住的关键点,也是许多 React 应用性能问题的根源:当父组件重新渲染时,它的所有子组件默认也会重新渲染。

即使传递给子组件的 props 没有任何变化,子组件依然会执行其渲染逻辑。

来看一个清晰的例子:

function ParentComponent() {
  const [parentCount, setParentCount] = React.useState(0);

  console.log('Parent rendered');
  return (
    <div>
      <h1>Parent Count: {parentCount}</h1>
      <button onClick={() => setParentCount(parentCount + 1)}>
        Increment Parent
      </button>
      <ChildComponent />
    </div>
  );
}

function ChildComponent() {
  // 这个子组件没有接收任何 props,也没有自己的 state
  console.log('Child rendered');
  return <p>I am a child component</p>;
}

当你点击按钮增加父组件的计数时,控制台会同时输出“Parent rendered”和“Child rendered”。即便子组件的内容纹丝未动,它依然被重新渲染了。这是 React 的默认行为,其背后有合理的设计考量。

React 采取一种保守策略:它默认认为,当父组件重新渲染时,其子组件可能依赖于父组件的数据或 context,因此也需要重新渲染。与其耗费资源去“聪明地”判断子组件是否真的需要更新,React 选择统一重渲染,然后通过高效的 diff 算法来决定是否需要更新实际 DOM。

这看似有些浪费,但请记住:JavaScript 层面的重新渲染实际上非常快。只有当组件树极其庞大、或渲染逻辑本身开销巨大时,这种默认行为才可能成为性能瓶颈,值得我们去优化。

触发因素三:Context 变化

第三个触发因素是 context 值的变化。如果一个组件通过 useContext 或 Context.Consumer 消费了某个 context,那么只要该 context 的值发生改变,无论该组件的 props 或 state 是否变化,它都会重新渲染。

这一点尤其需要注意,因为 context 的变化可能导致组件树中完全不同位置的组件重新渲染。例如,一个应用顶层的 context provider 更新了值,即使中间层组件都未消费此 context,但只要某个深层组件使用了它,该深层组件就会重新渲染。

下面的例子展示了 context 变化引发的连锁反应:

const ThemeContext = React.createContext();

function App() {
  const [theme, setTheme] = React.useState('light');
  return (
    <ThemeContext.Provider value={theme}>
      <div>
        <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
          Toggle Theme
        </button>
        <ParentComponent />
      </div>
    </ThemeContext.Provider>
  );
}

function ParentComponent() {
  // 这个组件没有使用 context
  console.log('Parent rendered');
  return <ChildComponent />;
}

function ChildComponent() {
  // 这个组件消费了 context
  const theme = React.useContext(ThemeContext);
  console.log('Child rendered with theme:', theme);
  return <div className={theme}>Child content</div>;
}

点击按钮切换主题时,子组件会重新渲染(因为它消费了 context)。但有趣的是,父组件也会重新渲染。为什么?因为 App 组件(作为 provider)修改了自己的 state,触发了它自身的重渲染——根据触发因素二的规则,父组件重渲染会导致其所有子组件默认也重渲染。

常见误区:很多人都会踩的坑

理解了基本原理后,我们来澄清几个我曾深陷其中、颇具迷惑性的常见误区。

误区一:props 变化会直接触发重新渲染

许多开发者认为,只要 props 改变,组件就会重新渲染。这个理解在技术层面上并不准确,尽管表面现象常常如此。

真实情况是:父组件由于自身 state 变化或接收到新 props 而重新渲染,在它重新渲染的过程中,它向子组件传入了新的 props,子组件也因此被重新渲染。换言之,props 变化并非直接触发重渲染的“原因”,而是父组件重渲染这一事件所导致的“结果”

来看一个能说明问题的例子:

function Parent() {
  const [count, setCount] = React.useState(0);

  // 每次渲染都会创建一个新的对象
  const userData = { name: 'John', id: count };
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <Child user={userData} />
    </div>
  );
}

function Child({ user }) {
  console.log('Child rendered with user:', user);
  return <div>User: {user.name}</div>;
}

每次点击按钮,子组件都会重新渲染。你可能会想:“因为 props 变了,所以子组件重渲染了。”但实际发生的链条是:父组件 state 改变 → 父组件重渲染 → 父组件创建新的 userData 对象 → 将新对象作为 props 传给子组件 → 子组件因父组件重渲染而被动重渲染。理解这个区别,对于后面理解 React.memo 等优化手段至关重要。

误区二:重新渲染一定是坏事

这是最具破坏性的误解之一。很多开发者视重渲染为洪水猛兽,动辄使用 React.memouseMemouseCallback 等优化手段,进行“预防式优化”。这常常导致代码可读性下降、维护困难,甚至可能比未优化的版本更慢。

但真相是:重新渲染是 React 的核心工作机制,不是 bug,而是设计特性。React 本身就是为高效处理大量重渲染而设计的。其虚拟 DOM diff 算法非常高效,大多数重渲染并不会导致任何实际的 DOM 更新

只有在明确存在性能问题时(例如通过 Chrome DevTools 的 Profiler 工具定位到具体渲染缓慢的组件),才应该考虑针对性优化。过早的优化(Premature optimization) 往往会带来比它解决的问题更多的麻烦。

误区三:将 state 设置为相同的值可以避免重新渲染

这是一个比较隐晦的误区。许多人认为,如果把 state 设置为当前已经存储的相同值,React 就不会重新渲染。这种说法部分正确,但关键细节不容忽视。

function StateComparisonExample() {
  const [count, setCount] = React.useState(0);

  console.log('Component rendered');
  const handleClick = () => {
    // 将 state 设置为相同的值
    setCount(0);
  };
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Set to Zero</button>
    </div>
  );
}

点击按钮时,组件会在第一次点击时重新渲染,即便你设置的 count 值(0)与当前值相同。如果继续点击,React 会跳过后续的重渲染,但第一次点击仍会触发一次

这是因为 React 内部使用 Object.is 来比较状态值是否相等。对于数字、字符串等原始值,Object.is 判断可靠;但对于对象、数组等引用类型,则需要格外小心。即使结构完全相同,只要引用不同,React 就会认为值已变化,从而触发重渲染。

理解 React.memo 及其真正的使用场景

掌握了重渲染原理后,我们可以更清晰地理解 React.memo 的作用——它是 React 中用于阻止子组件不必要重新渲染的主要工具。

React.memo 是一个高阶组件,用于记忆化你的组件。它的作用是:当父组件重新渲染时,如果传给子组件的 props 经过浅比较后没有发生变化,则跳过对该子组件的重新渲染。这打破了 React“父组件渲染必导致子组件渲染”的默认规则。

下面是一个 React.memo 真正发挥作用的场景:

function ExpensiveComponent({ data }) {
  console.log('ExpensiveComponent rendered');

  // 模拟耗时的计算过程
  const processedData = React.useMemo(() => {
    let result = 0;
    for (let i = 0; i < 1000000; i++) {
      result += data.value * i;
    }
    return result;
  }, [data]);

  return <div>Processed: {processedData}</div>;
}

// 使用 React.memo 包裹,避免在 props 没变时重新渲染
const MemoizedExpensiveComponent = React.memo(ExpensiveComponent);

function ParentComponent() {
  const [count, setCount] = React.useState(0);
  const [data, setData] = React.useState({ value: 10 });

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Increment Count (doesn‘t affect data)
      </button>
      <button onClick={() => setData({ value: data.value + 1 })}>
        Update Data
      </button>
      <p>Count: {count}</p>
      <MemoizedExpensiveComponent data={data} />
    </div>
  );
}

在这个例子中,点击第一个按钮增加 count 时,MemoizedExpensiveComponent 不会重新渲染,因为它接收的 data props 没有变化。这正是 React.memo 的典型应用场景:当某个子组件渲染开销大,但其 props 并不随父组件的每次更新而变化时,使用它可以有效避免不必要的性能损耗。

然而,React.memo 有一个关键“陷阱”:对于对象和函数,它判断“相同”的标准是引用相等性(reference equality)。如果你每次渲染都传入新的对象或函数引用,React.memo 将完全失效。

function Parent() {
  const [count, setCount] = React.useState(0);

  // 每次渲染都会创建一个新的对象
  const config = { theme: 'dark', size: 'large' };
  // 每次渲染都会创建一个新的函数
  const handleClick = () => console.log('clicked');

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <MemoizedChild config={config} onClick={handleClick} />
    </div>
  );
}

const MemoizedChild = React.memo(function Child({ config, onClick }) {
  console.log('Child rendered');
  return <div>Child component</div>;
});

尽管使用了 React.memo,但只要父组件重渲染,子组件依然会跟着渲染。原因在于:confighandleClick 每次都是全新的引用,React.memo 的浅比较会认为 props 已变。这正是 useMemouseCallback 需要出场与 React.memo 搭配使用的原因。

控制重新渲染的 Hooks:useMemouseCallback

这两个 Hook 与 React.memo 协同,能让你对重渲染行为实现更精细的控制。它们常被误解和误用,因此有必要明确其作用和适用场景。

useMemo:缓存计算结果

useMemo 用于在组件重新渲染之间缓存某个计算结果。只要其依赖项数组中的值未变,它就会返回上一次缓存的值,避免重复执行开销大的计算。

function DataVisualization({ rawData }) {
  // 这个耗时的计算只会在 rawData 变化时执行
  const processedData = React.useMemo(() => {
    console.log('Processing data...');
    return rawData.map(item => ({
      ...item,
      computed: item.value * 2 + item.offset,
      formatted: `$${item.value.toFixed(2)}`
    }));
  }, [rawData]);

  return (
    <div>
      {processedData.map(item => (
        <div key={item.id}>{item.formatted}</div>
      ))}
    </div>
  );
}

关键点:useMemo 并不会阻止组件重新渲染,它只是避免在依赖项未变时重复执行高开销的计算。当父组件重渲染导致当前组件也被渲染时,只要 rawData 引用没变,useMemo 内的计算逻辑就不会再次执行。

useMemo 另一个重要用途是保持传递给被记忆化子组件的对象引用稳定

function Parent() {
  const [count, setCount] = React.useState(0);

  // 只要依赖不变(这里是空数组),config 对象的引用就不会变
  const config = React.useMemo(() => ({
    theme: 'dark',
    size: 'large'
  }), []); // 空依赖数组表示永远不会变化

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <MemoizedChild config={config} />
    </div>
  );
}

这里,config 对象的引用在每次重渲染时保持不变,因此配合 React.memo 包裹的 MemoizedChild,就能有效阻止不必要的子组件重渲染。

useCallback:缓存函数引用

useCallback 本质上是专门为函数设计的 useMemo。只要依赖项不变,它就返回相同的函数引用,避免因函数引用变化导致依赖它的子组件重新渲染。

function SearchComponent() {
  const [query, setQuery] = React.useState('');
  const [filter, setFilter] = React.useState('all');

  // 只要 filter 不变,handleSearch 的引用就不会变
  const handleSearch = React.useCallback((searchTerm) => {
    console.log('Searching with filter:', filter);
    // 使用当前 filter 执行搜索
    return performSearch(searchTerm, filter);
  }, [filter]); // 仅当 filter 改变时才重新创建函数

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      <MemoizedSearchResults onSearch={handleSearch} />
    </div>
  );
}

如果不使用 useCallback,每次输入框更新(query 变化)引发重渲染时,都会创建一个全新的 handleSearch 函数。这会导致即使 MemoizedSearchResults 使用了 React.memo,也会因为 onSearch props 的引用变化而重新渲染。通过 useCallback 保持函数引用稳定,可以避免这种不必要的子组件重渲染

核心原则:不要滥用这些 Hooks

这是我踩过坑后才深刻领悟的一点:useMemouseCallback 本身是有代价的。它们会增加代码复杂度,并占用内存来存储缓存值。如果“以防万一”地到处使用,反而可能让代码更慢、更难维护。

仅在以下特定场景下,才值得使用这些优化 Hook:

  1. 你需要将 props 传递给使用了 React.memo 的子组件,并且需要保持引用稳定(对象或函数)。
  2. 你确实有开销巨大的计算逻辑,并且经过性能分析工具(如 Profiler)确认其是性能瓶颈
  3. 你需要将函数作为依赖项传递给子组件的 Hook(如 useEffect),且希望子组件仅在函数逻辑变化时执行该 Hook

对于绝大多数组件而言,React 的默认行为已足够高效。过早地、无差别地使用这些优化手段,往往只会引入不必要的复杂性,而不会带来可感知的性能提升。

一个值得坚持的健康心智模型

在思考和设计组件时,秉持以下心智模型会非常有帮助:

  • 渲染(Rendering)是廉价的(发生在 JavaScript 内存中)。
  • DOM 更新才是昂贵的(涉及浏览器重排重绘)。
  • React 的核心目标是最小化对真实 DOM 的操作
  • 你的职责是避免在渲染过程中执行不必要的、高开销的计算

只要你围绕这个思路来设计和优化组件,大多数性能问题都会迎刃而解。

结语

React 的重新渲染机制并非需要恐惧或对抗的敌人。恰恰是它,使得 React 能够实现声明式、可预测且具备良好扩展性的开发体验。

深入理解重渲染的原理——知道它何时发生、为何发生以及如何恰当地控制它——是你真正掌握 React,并能够自信应对前端面试中相关问题的关键一步。希望这篇文章能帮助你建立清晰的心智模型,写出更高效、更易维护的 React 代码。

技术成长离不开交流与实践,欢迎到技术社区探讨更多前端框架与性能优化的话题。




上一篇:CSRF与Ajax跨域:为何浏览器的安全边界未能奏效?
下一篇:Spring Boot Actuator生产实战:从健康检查到自定义监控端点的完整指南
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-30 23:16 , Processed in 0.269670 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表