陌上人如玉
公子世无双

8.5 StrictMode 的双调用

8.5 StrictMode 的双调用

一句话:StrictMode 在开发环境故意「双调用组件函数」与「Effect 挂载 → 卸载 → 再挂载」——目的是暴露「渲染不纯」与「清理缺失」,是并发渲染对代码的硬性要求,不是 bug。

一、StrictMode 做了什么

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>
);
检查 内容 目的
组件函数双调用 每次渲染调用组件函数两次(结果丢弃一次) 暴露「渲染不纯」(有副作用)
Effect 双执行 挂载 → 卸载 → 再挂载(Effect 执行 2 次、清理 1 次) 暴露「清理缺失」
检测废弃 API 字符串 ref、findDOMNode、defaultProps(函数组件)等 提示迁移
检测可疑用法 渲染中调用 setState、在 useMemo 里做副作用 提示修正
// 观察:StrictMode 下会打印两次
function Component() {
  console.log('渲染');            // ⚠️ 开发环境打印两次
  return <div />;
}

// 观察:Effect 会执行两次(中间夹一次清理)
useEffect(() => {
  console.log('effect 执行');
  return () => console.log('effect 清理');
}, []);
// 输出:effect 执行 → effect 清理 → effect 执行
⚠️ 三个必须记住的事实:
① 这些检查【只在开发环境】生效(生产构建没有)
② 只影响「开发体验」,不影响「生产行为」
③ 它们暴露的是「真的问题」——如果代码正确,双调用不会造成任何异常

二、为什么 React 要这么做

因为【并发渲染】要求「render 可以被执行多次」:

① 渲染可以被【中断】→ 之后「重放」(重新执行未完成的部分)
② 渲染可以被【丢弃】→ 换优先级更高的任务先做
③ 未来 React 可能支持「保留状态地卸载/重挂载组件」
   (如「离屏渲染」「预渲染」优化)

所以「render 必须是纯函数」从「最佳实践」升级为【正确性要求】。
StrictMode 的双调用就是【提前】把这个场景制造出来,
让你在开发阶段就发现「不纯的代码」,而不是在生产环境出诡异 bug。
一句话类比:
   React 在说:「你的组件可能在任意时刻被重复执行、被反复挂载卸载,
              请确保它在这种情况下依然正确。」
   StrictMode 就是在替你「模拟」这种压力场景。

三、会暴露的四类问题

1) 渲染不纯

// ❌ 在渲染中修改外部变量
let renderCount = 0;

function Impure() {
  renderCount++;                      // ⚠️ StrictMode 下会变成 2
  return <div>{renderCount}</div>;
}
// ❌ 在渲染中修改 props / state 的对象
function Impure2({ items }: { items: Item[] }) {
  items.push(newItem);                // ⚠️ 修改了 props(引用也不变 → 不更新)
  return <div>{items.length}</div>;
}

// ✅ 用不可变方式
function Pure({ items }: { items: Item[] }) {
  const next = [...items, newItem];
  return <div>{next.length}</div>;
}
// ❌ 在渲染中产生「不确定的值」
function Impure3() {
  const id = Math.random();           // ⚠️ 两次调用结果不同
  const now = Date.now();             // ⚠️ 同上(SSR 下还会 mismatch)
  return <div id={id}>{now}</div>;
}
// ❌ 在渲染中调用 setState(无限循环风险)
function Impure4() {
  const [n, setN] = useState(0);
  setN(n + 1);                        // ❌ 渲染 → setState → 渲染 → ...
  return <div>{n}</div>;
}

// ✅ 需要「派生」→ 直接算;需要「响应 props 变化」→ 在事件处理或用 key

2) 清理缺失

// ❌ 订阅没清理 → StrictMode 下建立两个,旧的一直存在
useEffect(() => {
  const ws = new WebSocket(url);
  ws.onmessage = handle;
  // ⚠️ 没有 return () => ws.close()
}, [url]);
// ❌ 事件监听没清理
useEffect(() => {
  window.addEventListener('scroll', onScroll);       // ⚠️
}, []);
// ❌ 定时器没清理
useEffect(() => {
  setInterval(tick, 1000);                            // ⚠️
}, []);
// ✅ 清理完整后,双执行不再有任何影响
useEffect(() => {
  window.addEventListener('scroll', onScroll);
  return () => window.removeEventListener('scroll', onScroll);
}, []);
// 输出:添加 → 移除 → 添加(最终只有一个监听,正确)

3) 依赖了「只执行一次」的假设

// ❌ 认为 Effect 只会跑一次
const initializedRef = useRef(false);

useEffect(() => {
  if (initializedRef.current) return;
  initializedRef.current = true;
  registerGlobalHandler();            // ⚠️ 这只是「绕过」,不是「修复」
}, []);
⚠️ 这里的陷阱:
① 用 ref 标志位能让「初始化只跑一次」→ 看起来「修好了」
② 但如果「初始化」本身「不该只跑一次」(比如它需要响应 props),
   那这个写法只是「掩盖了真正的问题」
③ 更常见的问题是:这个「初始化」其实「忘了清理」(应该写清理函数)

✅ 正确的思考方式是:「这个副作用【应该】执行几次?【应该】怎么撤销?」
   · 应该只执行一次且无需撤销 → 用 ref 标志位是可以的(但要注释说明)
   · 应该在依赖变化时重新执行 → 写对依赖 + 清理

4) 使用已废弃的 API

// ❌ StrictMode 会告警
<input ref="inputRef" />                     // 字符串 ref
this.refs.inputRef;
// ❌ StrictMode 会告警(函数组件上)
Component.defaultProps = { ... };             // 用默认参数替代
Component.propTypes = { ... };                // 用 TypeScript 替代

四、正确应对方式

✅ 三条原则:

① 让「渲染」纯:
   · 只做「计算」——不修改外部变量、不改 props/state、不用随机值/时间
   · 派生数据直接算(或用 useMemo)
   · 副作用放到 Effect 或事件处理里

② 让「Effect」可执行多次:
   · 每个 Effect 都写清理(建立了什么就撤销什么)
   · 依赖写全(不要靠删依赖消警告)
   · 需要的「最新值」用 ref 保存

③ 对「确实只该执行一次」的初始化,用「幂等 + 注释说明」的处理:
   · 如「第三方库的全局初始化」:用 ref 标志位 + 注释
   · 更好的方式:把初始化放到「模块作用域」或「应用入口」
// ✅ 完整示例:既有「只初始化一次」又「清理干净」
function MapView({ center }: { center: LatLng }) {
  const mapRef = useRef<MapInstance | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  // ① 只初始化一次(地图实例创建很贵,不该反复创建)
  useEffect(() => {
    if (!containerRef.current) return;

    const map = createMap(containerRef.current, { center });
    mapRef.current = map;

    return () => {
      map.destroy();                   // ⭐ 清理(StrictMode 下会「创建 → 销毁 → 创建」)
      mapRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- 只初始化一次,center 由下面的 effect 更新
  }, []);

  // ② center 变化时「只更新」,不重建
  useEffect(() => {
    mapRef.current?.setCenter(center);
  }, [center]);

  return <div ref={containerRef} />;
}

五、生产环境不生效

⚠️ 关键事实:
① StrictMode 的所有检查【只在开发构建中执行】
   → 生产构建会「编译掉」这些检查(不会有双调用、不会有额外的挂载卸载)
② 所以「开发环境报错、生产正常」不代表「生产没问题」——
   它说明「你的代码依赖了『只执行一次』的假设」,
   而这个假设在「并发渲染的某些场景」下是【不成立】的

③ 常见的「生产才出问题」的现实场景:
   · 路由快速切换(组件被快速卸载重挂载)
   · 条件渲染导致组件重建
   · 并发特性(useTransition)导致渲染被中断重放
   → 这些场景与 StrictMode 的双调用【本质相同】
// 一个「开发环境被 StrictMode 暴露、生产环境才炸」的例子
useEffect(() => {
  const ws = new WebSocket(url);
  ws.onmessage = (e) => setMessages((prev) => [...prev, JSON.parse(e.data)]);
  // ⚠️ 没写清理
}, [url]);

// 开发环境(StrictMode):立刻看到「两个 WebSocket」→ 提示你补清理
// 生产环境:用户快速切换频道 → 旧的 WebSocket 没关闭 → 
//           两个连接同时推消息 → 消息重复、内存泄漏、服务端连接数暴涨

六、常见误判

❌ 误判一:「React 18 有 bug,组件渲染两次」
   → 不是 bug,是 StrictMode 的开发检查

❌ 误判二:「我的代码没问题,只是 StrictMode 太严格」
   → 双调用暴露的问题在「生产环境的并发/快速切换场景」下同样存在

❌ 误判三:「用 ref 标志位绕过就好了」
   → 这只是掩盖「清理缺失」或「渲染不纯」

❌ 误判四:「把 StrictMode 删掉就行了」
   → 你只是「关掉了体检」,问题依然存在(而且失去了提前发现的能力)

❌ 误判五:「双调用会消耗两倍性能,生产也会」
   → 生产环境没有双调用(只有开发环境)
// ✅ 一个「看起来像 StrictMode 问题、其实是代码问题」的例子
function usePolling(url: string, interval: number) {
  const [data, setData] = useState(null);

  useEffect(() => {
    const id = setInterval(() => {
      fetch(url).then((r) => r.json()).then(setData);
    }, interval);
    // ⚠️ 缺清理 → StrictMode 下「两个定时器」(请求量翻倍)
  }, [url, interval]);

  return data;
}

// ✅ 修复
useEffect(() => {
  const ac = new AbortController();
  const id = setInterval(() => {
    fetch(url, { signal: ac.signal }).then((r) => r.json()).then(setData).catch(() => {});
  }, interval);

  return () => {
    clearInterval(id);
    ac.abort();
  };
}, [url, interval]);

七、什么时候「合理地」保留「只执行一次」

// ✅ 场景:全局/昂贵的初始化(模块级或应用入口更合适,但有时只能在组件里)
function App() {
  const initializedRef = useRef(false);

  useEffect(() => {
    if (initializedRef.current) return;      // 幂等保护
    initializedRef.current = true;

    initAnalytics();
    initErrorReporting();
    // ⭐ 没有清理,因为这些是【应用级】的全局副作用,不需要按组件生命周期撤销
    // ⭐ 必须加注释说明「为什么只做一次、为什么不清理」
  }, []);

  return <RouterProvider router={router} />;
}
⚠️ 判断标准:
① 这个副作用「属于组件」还是「属于应用」?
   · 属于组件 → 必须写清理(它会被反复挂载卸载)
   · 属于应用(全局)→ 可以考虑「只执行一次 + 不清理」,但更该放到「入口/模块」

② 「不清理的后果」是什么?
   · 如果「重复执行」会导致「重复初始化、重复上报、重复连接」→ 必须幂等或清理
   · 如果「重复执行」完全无害(如「设置一个全局变量」)→ 可以不处理

③ 优先选择「把全局初始化放到模块作用域或应用入口」——
   那里本来就「只执行一次」,不需要任何 hack

八、常见坑

// ① 在渲染中修改外部变量(结果不可预测)
let count = 0;
function Comp() { count++; return <div>{count}</div>; }     // ❌

// ② 在渲染中修改 props/state 对象
items.push(x);                                              // ❌

// ③ 用 Math.random() / Date.now() 产生「渲染结果相关的值」
const id = Math.random();                                    // ❌

// ④ Effect 缺清理(订阅/定时器/监听/Observer/WebSocket)
// ⑤ 用 ref 标志位「绕过」双调用而不修问题
// ⑥ 认为「双调用会加倍请求 = 生产环境也是」
// ⑦ 把 StrictMode 删掉来「解决」问题
// ⑧ 在「初始化一次」的 Effect 里依赖了会变的 props(导致「用了过期的值」)
useEffect(() => {
  initThirdParty(options);            // ⚠️ options 变化后不会重新初始化
  // eslint-disable-next-line
}, []);

// ⑨ 用「全局标志位」(模块级变量)做幂等(多实例时互相干扰)
let inited = false;                   // ⚠️ 应该用 useRef
// ⑩ 在 useMemo 里做副作用(StrictMode 下可能执行两次)
// ⑪ 认为「渲染两次会让 useRef 的计数器翻倍是正常的」(说明渲染不纯)
// ⑫ 在「已经挂载的组件」上看到「两次网络请求」却不检查清理
// ⑬ 用 `Date.now()` 作为 key(每次渲染都变 → 全量重建)
// ⑭ 在 Effect 里「先 setState 再读它」(拿不到新值)
// ⑮ 把「双调用导致的告警」当成「可以忽略的噪音」
// ⑧ 的正确处理:区分「初始化一次」与「响应变化」
// ① 初始化一次(不依赖 props)
useEffect(() => {
  const inst = createInstance(containerRef.current!);
  instanceRef.current = inst;
  return () => inst.destroy();
}, []);

// ② 响应 props 变化(只更新,不重建)
useEffect(() => {
  instanceRef.current?.update(options);
}, [options]);

九、面试延伸

  1. 「StrictMode 做了什么?」
    它在开发环境额外执行三类检查:① 组件函数被调用两次——暴露「渲染不纯」(在渲染中改外部变量、改 props/state、用随机值);② Effect 执行「挂载 → 卸载 → 再挂载」——暴露「清理缺失」(订阅、定时器、监听、WebSocket 没关闭);③ 检测已废弃的 API 与可疑用法(字符串 ref、findDOMNode、函数组件的 defaultProps、渲染中 setState)。这些检查只在开发构建中生效。

  2. 「为什么 StrictMode 要让组件渲染两次?」
    因为 React 18 的并发渲染要求「render 可以被执行多次」——渲染可能被中断后重放(重新执行未完成部分)、可能被整体丢弃(换更高优先级任务),未来 React 还可能支持「保留状态地卸载/重挂载组件」。所以「渲染必须是纯函数」从「好习惯」变成了「正确性要求」。StrictMode 的双调用就是提前制造这个压力场景,让你在开发阶段发现不纯的代码,而不是在生产环境的「快速切换路由」「并发特性」场景下出诡异 bug。

  3. 「双调用会导致请求发两次,是不是性能问题?」
    不会——生产环境没有双调用(StrictMode 的检查只在开发构建中)。但在开发环境看到「请求两次」是有价值的信号:它说明你的 Effect 缺少取消/清理逻辑——而在生产环境的「路由快速切换」「参数快速变化」「组件被条件渲染重建」等场景下,同样的代码会导致真实的竞态与资源泄漏。所以正确应对是「补齐取消逻辑」(加 AbortController,第一次请求会被立刻取消)。

  4. 「看到『渲染两次』应该怎么办?」
    按「暴露的是什么问题」分两类处理:① 如果是「渲染不纯」——把渲染里的一切副作用(改外部变量、改 props、调用 API、读写 DOM)移到 Effect 或事件处理里;派生数据直接算;不要用 Math.random()/Date.now() 参与「影响渲染结果」的计算。② 如果是「清理缺失」——给 Effect 补清理(订阅 unsubscribe、定时器 clear、监听 remove、Observer disconnect、请求 abort)。这两类修复本身就是「让代码更正确」,与 StrictMode 无关。

  5. 「可以用 ref 标志位绕过双调用吗?」
    在特定场景下可以,但必须说明理由:如果某段逻辑确实应该只执行一次、并且不需要清理(如「应用级的全局初始化」——初始化埋点、错误上报),那么用 useRef(false) 做幂等保护是合理的,但要加注释说明「为什么只做一次、为什么不清理」。不合理的用法是「用标志位掩盖『清理缺失』」——那只是让问题隐藏起来,在生产环境的真实场景下依然会出现。更好的做法是把「全局初始化」放到模块作用域或应用入口(那里本来就只执行一次)。

  6. 「为什么说『开发环境报错、生产正常』很危险?」
    因为它说明「你的代码依赖了『只执行一次』的假设」——而 StrictMode 的双调用模拟的正是生产环境真实存在的场景:① 路由快速切换(组件被快速卸载重挂载);② 条件渲染导致组件重建;③ 并发特性(useTransition)导致渲染被中断重放;④ 未来的「离屏渲染/预渲染」优化。所以「生产环境暂时正常」只是因为这个场景还没被触发(用户操作不够快、或者恰好在单线程下没暴露),一旦触发就是线上问题(数据错乱、请求翻倍、内存泄漏)。

  7. 「StrictMode 能关掉吗?」
    能(不包裹 <StrictMode> 就行),但强烈不建议:① 它只在开发环境生效,没有生产开销;② 它帮你提前发现并发渲染下的隐患(不纯的渲染、缺失的清理);③ 它还能检测废弃 API(升级 React 时很有价值)。关掉它相当于「关掉体检」——问题依然在那里,只是你更晚才发现。合理的做法是「保留 StrictMode,并修掉它暴露的问题」。

  8. 「怎么判断一个『只执行一次』的 ref 标志位是『合理』还是『掩盖问题』?」
    问三个问题:① 「这段副作用属于组件还是应用?」——属于组件(依赖 props/state)→ 必须能响应变化 + 有清理;属于应用(全局初始化)→ 可以考虑一次性。② 「如果不清理,重复执行会造成什么?」——重复连接/重复上报/重复订阅 → 必须幂等或清理(用标志位也行,但要幂等)。③ 「能不能把它移到模块作用域或应用入口?」——能移就移(那里天然只执行一次,不需要任何 hack)。三个问题的答案是「属于应用 + 重复无害 + 移不了」时,ref 标志位才合理。

一句话速记

StrictMode 在开发环境做三类检查——「组件函数双调用(暴露渲染不纯)」「Effect「挂载→卸载→再挂载」(暴露清理缺失)」「检测废弃 API 与可疑用法」,生产构建不生效;它的背景是「并发渲染要求 render 可以被执行多次」(纯函数是正确性要求而非最佳实践);正确应对是「让渲染纯 + 给 Effect 补清理」,不要用 ref 标志位掩盖问题(那是把 bug 藏到生产环境的「路由快速切换 / 并发特性」场景里);不要删 StrictMode(它零生产开销且能提前发现隐患);只有「应用级、重复执行无害、无法移到入口」的初始化用 ref 幂等才合理(并写注释)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 8.5 StrictMode 的双调用

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫