React 严格模式下的副作用双检逻辑:源码解析为何两次调用 render 函数能有效暴露非幂等副作用

副作用的双面人生:React 严格模式下的“双检”玄机

大家好,欢迎来到今天的代码“审讯室”。

我是你们的向导。今天我们不聊高深莫测的架构,也不聊那些让你头秃的并发模式。我们要聊一个藏在 React 皮肤底下,平时不显山不露水,但一旦开启就会让你瑟瑟发抖的“监工”——React Strict Mode(严格模式)

你们大概都见过这个标签吧?就像个拿着手电筒的保安,站在你的组件树最顶层,冷冷地盯着你写下的每一行代码。

<React.StrictMode>
  <App />
</React.StrictMode>

但你们真的懂他在干什么吗?为什么每次我开启这个模式,我的 setTimeout 就会像打了鸡血一样跑两遍?为什么我的 console.log 会莫名其妙地出现两行?难道 React 有了多重人格?

别慌,今天我们就来扒开 React 的裤衩(比喻),看看它在严格模式下是如何进行“双重渲染”的。这不仅仅是个 Bug,这是 React 为了让你写出更稳健代码而设下的重重陷阱。


第一部分:渲染,不是“死”,是“复生”

首先,我们要纠正一个概念。在 React 的世界里,render 函数被调用了两次,并不意味着组件“死”了一次,也不意味着它“挂了”。

在 React 的调度器里,有一个非常优雅的机制,叫做 “卸载与重新挂载”

想象一下,你在玩一个角色扮演游戏。正常情况下,你进入游戏(挂载),开始打怪(渲染),最后退出游戏(卸载)。一切都很正常。

但在 React 严格模式(开发环境)下,剧情是这样的:

  1. 你进入游戏,开始打怪(第一次 Render)。
  2. 突然,系统提示“服务器连接断开”,你被踢出了游戏(第一次 Unmount)。
  3. 你重新连接服务器,再次进入游戏,开始打怪(第二次 Render)。

两次渲染之间,组件的状态被清空了,副作用被清理了,一切从零开始。这就像是一个完美的镜像世界。

React 为什么要这么干?这就像是一个强迫症医生,他在检查你的心脏时,会先按一下,再按一下,确保没有杂音。React 在开发环境下,通过“渲染 -> 卸载 -> 渲染”这个循环,来模拟一个极端的边界情况:如果你的代码在“重新挂载”时依然健壮,那它在生产环境里绝对无敌。


第二部分:什么是“非幂等”副作用?

现在,让我们进入核心议题:为什么双检逻辑能暴露非幂等副作用?

要理解这个,我们需要先搞清楚什么是“副作用”。

在数学和编程里,函数通常分为两类:

  1. 纯函数:输入一样,输出永远一样。比如 Math.add(2, 2),不管你调用多少次,结果永远是 4。它不关心外部世界,也不改变外部世界。
  2. 副作用函数:它做的事情不仅仅是计算,还可能涉及 DOM 操作、网络请求、定时器、修改全局变量等等。比如 fetch('/api'),它不仅返回数据,还改变了网络状态。

在 React 里,我们希望副作用是“可控”的。如果 useEffect 里的代码执行了两次,你应该怎么处理?

这里就引出了一个数学概念:幂等性

  • 幂等操作:执行一次和执行两次,结果是一样的。比如 fetch,如果两次请求同一个 URL,通常服务器会返回相同的数据(除非有缓存机制)。再比如 Math.sqrt(4)
  • 非幂等操作:执行一次和执行两次,结果完全不同,甚至产生灾难。

React 严格模式就是那个拿着放大镜找“非幂等操作”的侦探。


第三部分:代码示例——那个会“双倍打击”的定时器

让我们来做一个实验。假设你写了一个 CountDown 组件,你想在组件挂载后开始倒数。

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

const BadTimer = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('🚀 定时器启动了!');

    const timer = setInterval(() => {
      console.log('⏰ 倒计时:', count);
      setCount(prev => prev + 1);
    }, 1000);

    // 清理函数:组件卸载时执行
    return () => {
      console.log('🧹 定时器被清理了!');
      clearInterval(timer);
    };
  }, []); // 空依赖数组

  return <div>Count: {count}</div>;
};

正常情况下,这个组件挂载,启动一个定时器,每秒打印一次。

在 React 严格模式下,发生了什么?

  1. 第一次渲染useEffect 执行。你看到 🚀 定时器启动了!
  2. 卸载阶段:React 模拟组件卸载。你看到 🧹 定时器被清理了!。此时定时器被销毁,count 变回 0。
  3. 第二次渲染:React 模拟组件重新挂载。你看到 🚀 定时器启动了!

结果:
你的控制台里,你会看到两条 🚀,两条 🧹

如果在这个组件里,你不仅打印日志,还做了更有破坏性的事情呢?比如,你把数据存到了 localStorage,或者向服务器发送了一个请求。

如果在严格模式下,你的 useEffect 没有写好清理函数,或者清理函数没生效:

  • 第一次请求:服务器接收到了。
  • 卸载:你以为请求取消了,其实没取消,或者服务器还在处理。
  • 第二次请求:服务器又接收到了。

这就是非幂等副作用的恐怖之处:它会导致数据重复、内存泄漏、或者不可预知的业务逻辑混乱。

React 严格模式通过强制执行“卸载 -> 挂载”的循环,强行把那些“一次就够,不能再来一次”的操作暴露出来。它就像一个严厉的教导主任,在考试结束前提醒你:“喂,那道题你写了两次,虽然都对了,但你知道这意味着什么吗?意味着你考试的时候手抖了!”


第四部分:源码透视——Fiber 树的“生死轮回”

光说不练假把式。我们来看看 React 源码层面是怎么实现这个“双检”的。

在 React 的内部,有一个核心概念叫 Fiber。你可以把 Fiber 理解为 React 的“工作单元”。每一个组件、每一个 DOM 节点都是一个 Fiber 节点。

当我们开启严格模式时,React 的渲染调度器会变得“神经质”。

ReactStrictMode.js 中,并没有什么魔法咒语,它只是在开发环境下,对渲染逻辑做了一些手脚。

源码逻辑大致是这样的(伪代码示意):

function renderWithHooks() {
  // 1. 准备阶段
  const fiber = createFiberRoot();

  // 2. 执行第一次渲染
  updateFunctionComponent(fiber);

  // 3. 【关键步骤】提交阶段
  commitRoot(fiber);

  // 4. 【关键步骤】卸载阶段
  // React 这里会强制执行 cleanup
  commitUnmount(fiber);

  // 5. 执行第二次渲染
  updateFunctionComponent(fiber);

  // 6. 再次提交
  commitRoot(fiber);
}

注意第 3 步和第 4 步。在开发环境下,React 会在渲染完成后,立即触发一次卸载

这意味着什么?意味着你的组件的 useLayoutEffect 里的清理函数会被调用,useEffect 里的清理函数会被调用。然后,一切重置,再次渲染。

这种机制在源码中被称为 “双重调用”。它不仅仅是函数调用了两次,它是生命周期被完整地过了一遍。

如果你在 useLayoutEffect 里写了类似这样的代码:

useLayoutEffect(() => {
  document.title = `Count: ${count}`;

  return () => {
    document.title = `Reset`;
  };
}, [count]);

在严格模式下,你会看到标题先变成 Count: 0,然后变成 Reset,然后变成 Count: 0。这种视觉上的“闪烁”和状态重置,就是 React 在测试你的组件是否能承受这种剧烈的变动。


第五部分:事件监听器——双倍的快乐,双倍的麻烦

除了定时器,另一个经典的“非幂等”受害者是事件监听器

很多新手(甚至老手)会犯一个错误:在 useEffect 里直接给 windowdocument 绑定事件,而不是在组件挂载时绑定,卸载时解绑。

function BadListener() {
  useEffect(() => {
    console.log('我在给窗户贴反光膜');
    window.addEventListener('resize', handleResize);
  }, []);

  const handleResize = () => {
    console.log('窗口大小变了!');
  };

  return <div>监听器组件</div>;
}

问题出在哪?

React 严格模式下,这个组件被卸载了,然后重新挂载。

  1. 挂载window 上多了一个 resize 事件监听器。现在点击窗口,打印一次。
  2. 卸载:理论上应该移除监听器。但如果你的清理函数没写,或者写错了,window 上的监听器还在。
  3. 再次挂载window 上又多了一个 resize 事件监听器。

后果
现在 window 上有两个监听器。当你调整窗口大小时,handleResize 函数会被调用两次
如果你在 handleResize 里更新了状态,React 会触发两次渲染。这会导致性能下降,甚至逻辑错误(比如计算出的尺寸是错的)。

React 严格模式通过这种“双检”,让你在还没上线前就发现这种“幽灵监听器”。它就像是在你耳边低语:“嘿,你有没有发现,调整一下浏览器窗口,日志怎么多了一行?是不是有人偷偷多贴了一张反光膜?”


第六部分:为什么生产环境不这么做?

这就好比你问教练:“为什么你在训练时让我跑两圈,但在决赛时不让我跑?”

因为性能

在开发环境下,React 想要尽可能早地发现 Bug。牺牲一点性能(多跑两圈),换取代码的稳定性,是非常划算的买卖。

在生产环境下,React 会完全跳过 ReactStrictMode 的那些“神经质”操作。它只会执行一次渲染,一次挂载,不会去管你的清理函数有没有被调用。因为生产环境假设你的代码是经过严格测试的。

所以,如果你在生产环境上线后,发现某个按钮点击了两次,或者某个请求发了两次,不要立刻怪罪 React StrictMode。那可能是你的代码逻辑本身就有问题,只是平时没暴露出来。


第七部分:如何“过审”——编写副作用的艺术

既然 React 严格模式这么严,那我们怎么写代码才能通过它的“双检”呢?秘诀只有两个字:清理

规则一:永远不要相信副作用会自动停止。

React 的 useEffect 返回的函数,就是你的“辞职信”。当组件卸载时,React 会强制阅读这封信,执行里面的清理逻辑。

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

  useEffect(() => {
    console.log('🚀 定时器启动了!');

    const timer = setInterval(() => {
      setCount(prev => prev + 1);
    }, 1000);

    // 这里的 return 就是清理函数
    return () => {
      console.log('🧹 定时器被清理了!');
      clearInterval(timer); // 必须手动停止!
    };
  }, []);

  return <div>Count: {count}</div>;
}

在这个例子中,无论 React 是否触发卸载,clearInterval 都会被执行。这样,即使 React 模拟了双重渲染,你的定时器也不会变成双倍定时器。

规则二:清理函数本身要是幂等的。

有时候,清理函数内部也有副作用。比如,你在一个 useEffect 里启动了一个 WebSocket 连接。

useEffect(() => {
  const ws = new WebSocket('ws://example.com');

  return () => {
    ws.close(); // 关闭连接
  };
}, []);

在严格模式下,React 会执行清理函数两次吗?
答案是:不会

React 有一个优化机制。如果它检测到组件正在卸载(第一次清理),它就不会再执行第二次清理(第二次挂载时的清理)。它认为你已经走人了,不需要再给你发辞职信了。

但是,为了保险起见,你的清理函数本身应该是幂等的。比如 ws.close() 调用两次通常不会报错(如果连接已经关闭的话)。但如果你在清理函数里写了 document.body.removeChild(element),而你第二次调用时元素已经被删了,那也没问题,removeChild 会优雅地处理这种情况。


第八部分:进阶案例——状态丢失与“假”清理

为了进一步加深理解,我们来看一个稍微复杂点的场景:状态重置与数据同步

假设你有一个组件,它从 URL 参数中获取用户 ID,然后请求用户信息。

function UserProfile() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    console.log('正在请求用户数据...');
    fetchUser().then(setUser);

    return () => {
      console.log('组件卸载,取消请求...');
      // 这里你可能会想取消 fetch,但 fetch 不支持 AbortController
      // 或者你可能忘了写 cancel logic
    };
  }, []);

  return <div>{user?.name}</div>;
}

在 React 严格模式下:

  1. 挂载:发起请求 A,user 变为 {name: 'Alice'}
  2. 卸载:清理函数执行。请求 A 可能还在进行中(网络延迟),或者被标记为取消。
  3. 挂载:发起请求 B,user 变为 {name: 'Bob'}

问题来了
如果请求 A 比请求 B 快(比如在本地模拟),请求 A 返回的数据会更新 user 状态吗?

答案是:不会。

因为 React 的调度机制。当请求 A 返回时,组件实际上处于“卸载后、挂载前”的中间状态。React 会丢弃这个状态更新,因为它认为组件已经不存在了。

这就是严格模式暴露的另一个深层问题:异步副作用与组件生命周期的不匹配。

如果你的清理函数没有正确处理“请求取消”,或者你的状态更新逻辑依赖于组件一直存在,那么在严格模式下,你会遇到非常诡异的数据不一致问题。

如何修复?

你需要使用 AbortController 来真正地取消请求。

useEffect(() => {
  const controller = new AbortController();

  fetchUser(controller.signal).then(setUser);

  return () => {
    // 在这里,真正地切断连接
    controller.abort();
  };
}, []);

这样,当组件卸载时,请求会被物理中断。当组件重新挂载时,会发起新的请求。严格模式通过这种方式,强迫你处理异步操作的取消逻辑。


第九部分:总结——为什么我们要感谢这个“双检”

好了,讲了这么多,我们来总结一下 React 严格模式下的“双检”逻辑到底好在哪里。

它不仅仅是一个简单的“渲染两次”。它是一个压力测试

  1. 暴露非幂等副作用:它像照妖镜一样,让你看到那些隐形的定时器、监听器和未取消的网络请求。
  2. 验证清理函数:它强迫你写出健壮的 cleanup 逻辑,确保组件在销毁时能“干干净净”地离开。
  3. 测试状态重置:它模拟了极端的状态变化,防止你在组件卸载后还在更新状态。

React 之父 Dan Abramov 曾经说过:“严格模式不是一个 Bug,它是一个特性。”

在生产环境中,React 不敢这么玩,因为它怕拖慢用户的加载速度。但在开发环境中,React 毫不犹豫地牺牲一点性能,来换取代码的纯净。

所以,当你看到控制台里疯狂输出两行日志,或者定时器被清除了又启动时,请不要生气。请拥抱这个“神经质”的 React。它是在告诉你:“嘿,哥们,你的代码有点脏,洗干净点再上线吧!”

记住,幂等性是后端设计的基石,而严格的清理是前端组件的尊严。 在 React 严格模式下,这两者都被检验得淋漓尽致。

下次再写 useEffect 的时候,记得问自己一个问题:“如果这个函数被调用了两次,我的程序会崩溃吗?”

如果答案是“不会”,恭喜你,你通过了 React 的双检测试。


附录:代码实战演练

为了彻底搞懂,我们来看一段终极代码。这段代码故意包含所有可能被双检逻辑抓到的错误。

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

const UltimateTrap = () => {
  const [count, setCount] = useState(0);
  const [logs, setLogs] = useState([]);

  // 错误 1: 没有清理定时器
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Tick');
      setCount(c => c + 1);
    }, 1000);
    // 缺少 return () => clearInterval(timer);
  }, []);

  // 错误 2: 没有清理事件监听
  useEffect(() => {
    console.log('Add listener');
    window.addEventListener('click', handleClick);
    return () => {
      console.log('Remove listener');
      // 缺少 window.removeEventListener
    };
  }, []);

  // 错误 3: 异步副作用没有取消机制
  useEffect(() => {
    console.log('Fetch data');
    fetch('/api/data')
      .then(res => res.json())
      .then(data => setCount(data.id));
    // 缺少 AbortController
  }, []);

  const handleClick = () => {
    console.log('Clicked');
    setCount(c => c + 1);
  };

  return (
    <div>
      <h1>Count: {count}</h1>
      <div className="logs">
        {logs.map((log, i) => <div key={i}>{log}</div>)}
      </div>
    </div>
  );
};

在 React 严格模式下的行为预测:

  1. 控制台输出
    • Add listener (挂载)
    • Remove listener (卸载)
    • Add listener (挂载)
    • Tick (第一次定时器触发)
    • Tick (第二次定时器触发)
    • Fetch data (挂载)
    • Fetch data (挂载)
    • Clicked (点击窗口,触发两次,因为有两个监听器)
    • Remove listener (卸载)
    • Remove listener (卸载)
    • Add listener (挂载)
    • Tick (第三次触发…等等,第三次?是的,因为组件挂载了第三次)
    • Fetch data (第三次)

后果

  • 性能:定时器会一直跑,直到页面关闭。
  • 内存:事件监听器会叠加,内存占用增加。
  • 逻辑:点击一次,count 增加两次。API 请求发了三次(虽然通常会被 React 合并,或者因为 AbortController 不存在而堆积)。

修复方案

// 修复版
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(timer);
}, []);

useEffect(() => {
  const handler = handleClick;
  window.addEventListener('click', handler);
  return () => window.removeEventListener('click', handler);
}, []);

useEffect(() => {
  const controller = new AbortController();
  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(data => setCount(data.id));
  return () => controller.abort();
}, []);

修复后的行为
无论 React 怎么挂载卸载,clearIntervalremoveEventListener 都会正确执行。AbortController 会切断旧的请求。组件始终处于一个干净、稳定的状态。


这就是 React 严格模式的魅力。它不只是一个工具,它更像是一个严师,用“双检”逻辑,帮你把代码里的隐患一个个揪出来。希望这篇讲座能让你在未来的开发中,对副作用多一份敬畏,对清理函数多一份执着。谢谢大家!

发表回复

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