React 严格模式双重挂载副作用检测逻辑

React 严格模式:你的代码的严厉教练与“精神分裂”测试

大家好,欢迎来到今天的讲座。我是你们的编程向导。今天我们要聊一个在 React 开发中听起来有点“神经质”,但实际上是救命稻草的功能——React Strict Mode(严格模式)

大家可能都在 App.js 的最外层见过它:

import React, { StrictMode } from 'react';

function App() {
  return (
    <StrictMode>
      <YourComponents />
    </StrictMode>
  );
}

你可能会想:“这玩意儿到底是干嘛的?是让我写代码的时候更小心吗?还是说 React 的开发者觉得自己不够严格,非要加个‘超级严格模式’来吓唬我?”

答案是:它确实有点吓人,但它更像是一个严厉的代码健身教练。它不仅要求你肌肉发达(代码健壮),还要求你时刻注意呼吸(副作用管理)。

今天,我们将深入骨髓,扒开 React 严格模式的双重挂载逻辑,看看它到底是如何像个强迫症患者一样,把你的组件拿出来,狠狠摔在地上,再捡起来,再摔一次的。


第一部分:为什么我们需要“精神分裂”测试?

在 React 的世界里,有两种函数:一种是纯函数,一种是副作用函数

纯函数就像数学公式:输入 A,输出 B,中间绝不产生任何“副作用”(不修改外部变量,不打印日志,不调用 API)。纯函数很可爱,但纯函数做不出网站。

副作用函数,比如 useEffectsetTimeoutfetch,就像是你生活中的“麻烦事”。你订阅了一个新闻推送,这会消耗内存;你设置了一个定时器,这会占用 CPU;你修改了 DOM 的样式,这可能会影响其他组件。

React 的核心理念是“声明式”:你告诉 React 你发生什么,React 负责去实现。但问题是,当 React 在后台疯狂地重新渲染组件、卸载组件、挂载组件时,那些副作用函数很容易被遗忘,或者被重复执行,从而导致内存泄漏、重复请求、控制台刷屏等一系列令人头秃的问题。

为了解决这个问题,React 严格模式登场了。它的核心任务就是:模拟一种极端的“精神分裂”环境,检测你的副作用管理能力。


第二部分:双重挂载的魔法——它是怎么工作的?

让我们来揭开它的面纱。严格模式在开发环境下,会对组件树执行以下操作:

  1. 第一次挂载:React 创建组件实例,执行 render 函数。
  2. 卸载:React 销毁组件实例,执行所有 useEffectuseLayoutEffect 的清理函数。
  3. 第二次挂载:React 重新创建组件实例,再次执行 render 函数。

注意,这不仅仅是渲染两次。它还会卸载! 这意味着你的组件会被彻底销毁,然后再重新创建。

为了让你直观地感受到这种“精神分裂”,我们来写一段代码。

示例 1:基础渲染日志

import React, { useState, useEffect, useLayoutEffect } from 'react';

function StrictModeDemo() {
  const [count, setCount] = useState(0);

  // 我们在三个不同的生命周期钩子上都加上日志
  useEffect(() => {
    console.log('🔵 [useEffect] 组件已挂载 (第一次)');
    return () => console.log('🟢 [useEffect] 组件即将卸载');
  }, []);

  useLayoutEffect(() => {
    console.log('🟣 [useLayoutEffect] 布局已更新 (第一次)');
    return () => console.log('🟡 [useLayoutEffect] 布局即将卸载');
  }, []);

  console.log('🔴 [Render] 函数正在执行 (第一次)');

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>增加</button>
    </div>
  );
}

当你把这个组件放入 <StrictMode> 中时,控制台的输出会是这样:

🔴 [Render] 函数正在执行 (第一次)
🟣 [useLayoutEffect] 布局已更新 (第一次)
🔵 [useEffect] 组件已挂载 (第一次)
🔴 [Render] 函数正在执行 (第二次)
🟣 [useLayoutEffect] 布局已更新 (第二次)
🟢 [useEffect] 组件即将卸载
🟡 [useLayoutEffect] 布局即将卸载
🟢 [useEffect] 组件即将卸载
🟡 [useLayoutEffect] 布局即将卸载

看到了吗?这就是双重挂载。React 先把你造出来,然后又把你拆了,最后再把你造出来。这就像是一个演员上台演戏,演完第一遍,导演喊“卡!重演!把刚才的戏词再念一遍,然后退场,再上台!”。

这种机制是为了检测什么?

  1. 重复订阅:如果你在 useEffect 里订阅了某个服务,但没有在清理函数里取消订阅,那么在严格模式下,你会有两个订阅者同时存在!
  2. 重复定时器:如果你创建了 setTimeout,清理函数里没有清除它,那么你会得到两个定时器在后台同时运行,两个定时器同时触发,疯狂地更新你的状态。
  3. 内存泄漏:如果你在 useEffect 里保存了外部引用,而没有清理,那么组件卸载后,这个引用依然存在,指向一个已经销毁的组件实例,这叫内存泄漏。

第三部分:实战演练——那些年我们踩过的坑

光说不练假把式。让我们看看在实际开发中,严格模式是如何像侦探一样发现 Bug 的。

场景 1:忘记清理的定时器

这是一个经典的错误。新手经常觉得:“反正组件卸载了,定时器也没人看,就不管了。”

错误代码:

function TimerComponent() {
  useEffect(() => {
    const id = setInterval(() => {
      console.log('滴答滴答');
    }, 1000);

    // 忘了写 return 清理函数!
  }, []);

  return <div>定时器组件</div>;
}

在普通模式下,这个定时器会一直运行,直到你刷新页面。但在严格模式下:

  1. 组件挂载,定时器启动。
  2. 组件卸载,定时器没有清理,依然在后台运行!
  3. 组件重新挂载,又启动了一个定时器!

现在你有两个定时器在后台疯狂 console.log。控制台会瞬间刷屏,CPU 飙升。严格模式会立刻尖叫:“喂!你到底有几个定时器在跑?你这是在搞 DDoS 攻击吗?”

正确写法:

function TimerComponent() {
  useEffect(() => {
    const id = setInterval(() => {
      console.log('滴答滴答');
    }, 1000);

    // 必须清理!
    return () => {
      console.log('🧹 清理定时器');
      clearInterval(id);
    };
  }, []);

  return <div>定时器组件</div>;
}

在严格模式下,你会看到:

滴答滴答
🧹 清理定时器
滴答滴答
🧹 清理定时器

这就强迫你养成了良好的习惯:凡事必清理

场景 2:副作用中的 API 调用

这是最令人头疼的。假设你有一个登录组件,每次进入页面都要获取用户信息。

错误代码:

function Profile() {
  useEffect(() => {
    // 获取用户数据
    fetch('/api/user').then(res => res.json()).then(data => {
      console.log('用户数据:', data);
    });
  }, []);

  return <div>个人中心</div>;
}

在普通模式下,这看起来没问题。但在严格模式下,组件会卸载再挂载。

  1. 发起请求 A。
  2. 组件卸载,请求 A 还在跑。
  3. 组件重新挂载。
  4. 发起请求 B。

现在你的网络面板里有两个相同的请求,而且请求 A 的回调函数依然在执行(虽然没人看它了,但它依然在消耗带宽和 CPU)。

如何解决?

不要在 useEffect 里直接写 API 调用逻辑(除非你极其小心)。更常见的做法是使用状态来控制数据获取,或者使用第三方库如 SWR、React Query,它们内部已经处理好了重复请求的问题。

或者,你可以利用 useRef 来标记是否已经加载过:

function Profile() {
  const hasLoaded = useRef(false);

  useEffect(() => {
    if (hasLoaded.current) return; // 如果已经加载过,直接返回
    hasLoaded.current = true;

    fetch('/api/user').then(res => res.json()).then(data => {
      console.log('用户数据:', data);
    });
  }, []);

  return <div>个人中心</div>;
}

这样,即使严格模式让你重跑一遍,它也会因为 hasLoaded.current 的存在而乖乖躺平。


第四部分:useLayoutEffect 与 useEffect 的区别

既然提到了清理,就不得不提 useLayoutEffect。它是 useEffect 的“同步兄弟”。

  • useEffect:在浏览器绘制完成后运行(异步)。你可以在这里做网络请求、设置定时器、记录日志。
  • useLayoutEffect:在浏览器绘制之前运行(同步)。它阻塞了浏览器的绘制,直到清理函数执行完毕。

为什么严格模式对它们都“下手”?

因为 useLayoutEffect 是同步的,它直接操作 DOM。如果在 useLayoutEffect 里没有正确清理 DOM 引用,或者没有正确处理卸载逻辑,会导致严重的布局抖动或者 DOM 节点重复。

代码示例:

function LayoutDemo() {
  const [width, setWidth] = useState(0);

  // 同步执行,在页面还没画出来之前就执行了
  useLayoutEffect(() => {
    console.log('🚀 [useLayoutEffect] 正在操作 DOM...');
    const element = document.getElementById('my-box');
    if (element) {
      element.style.backgroundColor = 'red';
      // 假设这里我们要做一个复杂的布局计算
      setWidth(element.offsetWidth);
    }

    return () => {
      console.log('🛑 [useLayoutEffect] 清理:把背景色还原');
      const element = document.getElementById('my-box');
      if (element) {
        element.style.backgroundColor = '';
      }
    };
  }, []);

  return <div id="my-box" style={{ width: '100px', height: '100px', background: 'yellow' }} />;
}

在严格模式下,你会看到 🚀 出现,然后 🛑 出现,然后 🚀 又出现。这确保了你的 DOM 操作是幂等的(可重复执行的)。


第五部分:React 18 的新特性与并发模式

如果你现在用的是 React 18 或更高版本,那么严格模式的含义更深了。它不仅仅是为了检测副作用,更是为了模拟并发模式

并发模式是 React 的未来,它允许 React“暂停”当前的渲染任务,去处理更高优先级的任务(比如用户输入),然后再回来继续渲染。

在并发模式下,React 可能会这样做:

  1. 开始渲染组件 A。
  2. 暂停渲染 A,去处理用户的点击事件(渲染组件 B)。
  3. 回来继续渲染 A。
  4. 关键点:在渲染 A 的过程中,React 可能会“抛弃”这次渲染结果,重新开始一次新的渲染(卸载 -> 挂载)。

严格模式就是在开发环境下,提前模拟这种“抛弃渲染”的行为

这意味着,如果你的代码在严格模式下能经受住“卸载 -> 重新挂载”的考验,那么在未来的并发模式下,你的组件也能更稳定,不会因为 React 的渲染中断和恢复而崩溃。

useInsertionEffect:新来的“三弟”

React 还有一个新的钩子 useInsertionEffect。它的位置介于 useLayoutEffectuseEffect 之间。

  • 它在 DOM 更新之前运行(类似 useLayoutEffect)。
  • 不会阻塞浏览器的绘制(类似 useEffect)。

这主要是为了解决 CSS-in-JS 库的性能问题。在严格模式下,useInsertionEffect 也会运行两次,但它的设计初衷就是为了让你在并发模式下也能安全地操作样式。


第六部分:深度剖析——为什么 useRef 会重置?

很多开发者对严格模式有个误解:“是不是所有东西都重置了?”

答案是:只有组件实例重置了。

这意味着:

  • useState:重置。
  • useRef重置
  • Context:重置。
  • Props:重置。
  • 全局变量不重置

为什么 useRef 会重置?

因为 useRef 返回的对象是保存在组件实例里的。当组件被卸载(StrictMode 卸载组件)时,整个实例被销毁了,里面的 useRef 对象自然也就没了。

代码示例:

function Counter() {
  const countRef = useRef(0);

  useEffect(() => {
    console.log('当前 Ref 值:', countRef.current);
  }, []);

  return (
    <div>
      <button onClick={() => countRef.current++}>点我增加 Ref</button>
      <p>Ref: {countRef.current}</p>
    </div>
  );
}

在严格模式下:

  1. 组件挂载,countRef.current 是 0。
  2. 点击按钮,变成 1。
  3. 组件卸载,countRef 销毁,重置为 0。
  4. 组件重新挂载,countRef.current 又是 0 了。

这会让很多依赖 useRef 来保存“上一次的状态”或“临时缓存”的逻辑失效。如果你在 useRef 里存了什么东西,你必须在 useEffect 的清理函数里把它取出来,存到别的地方(比如 useState 或全局状态),否则它就丢了。


第七部分:生产环境 vs 开发环境

最后,也是最重要的一点:React 严格模式只在开发环境下生效!

npm run build 之后,或者在 create-react-app 的生产构建中,严格模式会被完全移除。你的代码运行速度会更快,副作用只会执行一次。

这是为了性能考虑的。虽然双重挂载能帮你发现 Bug,但它确实增加了 CPU 的负担。在生产环境,我们不需要这种“精神分裂”式的测试,我们需要的是极致的流畅。

验证方法:

你可以写一个 console.log('生产环境'),看看在 npm startnpm run build 后的表现。你会发现,npm run build 后只打印一次。


总结与实战建议

通过今天的讲座,我们了解了 React 严格模式的双重挂载逻辑。它不是一个简单的包装器,而是一个强大的开发工具。

给你的行动清单:

  1. 开启它:永远在根组件包裹 <StrictMode>。不要觉得它麻烦,它是你的免费 QA。
  2. 清理一切:养成习惯,在 useEffectuseLayoutEffect 里写 return () => { ...清理逻辑... }。这是 React 社区的铁律。
  3. 警惕副作用:不要在 useEffect 里直接做 API 调用,除非你有防重机制。
  4. 理解销毁:当你看到控制台里打印了两次日志,不要慌,那是 React 在帮你检查代码。它是你的朋友,不是你的敌人。

React 严格模式就像是代码界的“魔鬼教练”。它把你推向悬崖,逼你学会如何刹车,如何平衡。当你习惯了这种“精神分裂”式的测试,当你习惯了凡事必清理,那么在真正的生产战场上,你的代码将坚如磐石,无懈可击。

好了,今天的讲座就到这里。希望大家都能写出没有副作用、没有泄漏、逻辑清晰的 React 组件。下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注