避免 useEffect 严格模式双重执行的艺术

🚀 探索100+强大的React Hooks可能性!访问 www.reactuse.com 获取完整文档和MCP支持,或通过 npm install @reactuses/core 安装,让我们丰富的Hook集合为您的React开发效率注入强劲动力!

前言:当你的副作用变成了"双胞胎"

你有没有遇到过这样的场景:明明只想发送一次 API 请求,结果在开发环境中却发现网络面板里躺着两个一模一样的请求?或者你精心设置的计数器,莫名其妙地从 2 开始计数?

恭喜你,你遇到了 React 18 严格模式的"双重保险"机制。

严格模式:React 的"强迫症"设计

React 严格模式就像一个过度谨慎的朋友,总是要确认两遍:"你确定要执行这个副作用吗?让我再试一次看看会不会出问题。"

javascript 复制代码
// 你以为会发生的事情
useEffect(() => {
  console.log('我只会执行一次') // 天真!
}, [])

// 严格模式下实际发生的事情
useEffect(() => {
  console.log('我只会执行一次') // 第一次
  console.log('我只会执行一次') // 第二次:惊不惊喜?
}, [])

这种设计的初衷是好的------帮你提前发现那些不够"纯净"的副作用。但在实际开发中,有时候我们就是需要某些操作只执行一次,比如:

  • 发送统计数据
  • 初始化第三方库
  • 设置全局监听器
  • 记录用户行为

传统解决方案:各有各的尴尬

方案一:useRef 大法

javascript 复制代码
const hasRun = useRef(false)

useEffect(() => {
  if (hasRun.current) return
  hasRun.current = true
  
  // 你的副作用逻辑
  console.log('终于只执行一次了')
}, [])

问题:每个需要"只执行一次"的地方都要写这堆模板代码,像在每道菜里都加同样的调料。

方案二:全局标记

javascript 复制代码
let hasInitialized = false

useEffect(() => {
  if (hasInitialized) return
  hasInitialized = true
  
  // 初始化逻辑
}, [])

问题:全局变量污染,多个组件实例会互相干扰,就像几个室友共用一个冰箱,总有人会拿错东西。

为什么不基于 useRef 来封装?

你可能会想:"既然 useRef 方案能用,为什么不直接把它封装成一个 hook 呢?"这确实是一个很自然的想法:

javascript 复制代码
// 看起来很合理的 useRef 封装
function useOnceEffect(effect, deps) {
  const hasRun = useRef(false)
  
  useEffect(() => {
    if (hasRun.current) return
    hasRun.current = true
    return effect()
  }, deps)
}

但这个方案有几个致命问题:

问题一:依赖数组变化时的困惑

javascript 复制代码
function MyComponent({ userId }) {
  useOnceEffect(() => {
    console.log(`为用户 ${userId} 发送统计`)
    analytics.track('user_action', { userId })
  }, [userId]) // 当 userId 变化时会怎样?
}

userId 变化时,我们是希望:

  • 重新执行 effect(因为是新用户)?
  • 还是继续阻止执行(因为是"once")?

useRef 方案在这里的语义是模糊的,而 WeakSet 方案则很清晰:同一个 effect 函数引用只执行一次

问题二:内存管理的复杂性

使用 useRef 封装时,你需要考虑何时重置 hasRun.current

javascript 复制代码
function useOnceEffect(effect, deps) {
  const hasRun = useRef(false)
  const lastDeps = useRef(deps)
  
  // 需要复杂的依赖比较逻辑
  const depsChanged = !lastDeps.current || 
    deps.some((dep, i) => dep !== lastDeps.current[i])
  
  if (depsChanged) {
    hasRun.current = false // 重置状态
    lastDeps.current = deps
  }
  
  useEffect(() => {
    if (hasRun.current) return
    hasRun.current = true
    return effect()
  }, deps)
}

这样一来,代码变得复杂且容易出错,还不如直接用原生的 useEffect。

优雅解决方案:createOnceEffect

让我们来看看一个更优雅的解决方案:

javascript 复制代码
import { useEffect, useLayoutEffect } from 'react'

type EffectHookType = typeof useEffect | typeof useLayoutEffect

const record = new WeakSet()

const createOnceEffect: (hook: EffectHookType) => EffectHookType
  = hook => (effect, deps) => {
    const onceWrapper = () => {
      const shouldStart = !record.has(effect)
      if (shouldStart) {
        record.add(effect)
        return effect()
      }
    }
    hook(() => {
      return onceWrapper()
    }, deps)
  }

export const useOnceEffect = createOnceEffect(useEffect)

设计哲学:WeakSet 的巧妙运用

这个方案的核心思想是使用 WeakSet 来记录已经执行过的 effect 函数。为什么选择 WeakSet 而不是普通的 Set 或数组?

WeakSet 的优势

相比 useRef 方案,WeakSet 方案有着本质上的优势:

  1. 真正的全局唯一性:基于 effect 函数引用进行判断,无论在哪个组件实例中,同一个函数只会执行一次
  2. 自动垃圾回收 :当组件卸载时,effect 函数的引用消失,WeakSet 会自动清理,不会造成内存泄漏
  3. 语义清晰WeakSet 方案的语义很明确------同一个 effect 函数引用只执行一次,没有歧义
  4. 零配置:不需要考虑依赖数组变化、状态重置等复杂情况
  5. 性能优越:查找和添加操作都是 O(1) 时间复杂度,没有复杂的依赖比较逻辑

工作原理

javascript 复制代码
const onceWrapper = () => {
  const shouldStart = !record.has(effect) // 检查是否已执行
  if (shouldStart) {
    record.add(effect) // 标记为已执行
    return effect() // 执行原始 effect
  }
}

这就像给每个 effect 函数发了一张"已执行"的身份证,第二次执行时门卫一看:"哦,你已经进去过了,这次就不用再进了。"

实际使用:从此告别重复执行

javascript 复制代码
// 替换原来的 useEffect
useOnceEffect(() => {
  // 发送页面访问统计
  analytics.track('page_view', { page: 'home' })
  
  // 初始化第三方库
  initThirdPartySDK()
  
  // 设置全局监听
  window.addEventListener('beforeunload', handleBeforeUnload)
  
  return () => {
    window.removeEventListener('beforeunload', handleBeforeUnload)
  }
}, [])

扩展:支持 useLayoutEffect

这个方案还贴心地支持了 useLayoutEffect

javascript 复制代码
export const useOnceLayoutEffect = createOnceEffect(useLayoutEffect)

当你需要在 DOM 更新前同步执行某些操作,但又只想执行一次时,这就派上用场了。

注意事项:不是银弹

虽然 useOnceEffect 很好用,但要记住:

  1. 依赖数组仍然重要:即使是"只执行一次",也要正确设置依赖数组
  2. 清理函数照常工作:返回的清理函数在组件卸载时仍会执行
  3. 适用场景有限:只适用于真正需要"全局只执行一次"的场景

结语:让代码更加优雅

React 严格模式的双重执行机制虽然带来了一些困扰,但它的存在是为了帮助我们写出更好的代码。而 createOnceEffect 这样的工具,则帮助我们在保持代码整洁的同时,优雅地处理那些确实需要"只执行一次"的场景。

记住,好的代码不仅要解决问题,还要让人读起来愉悦。就像这个 WeakSet 的巧妙运用,既解决了实际问题,又展现了 JavaScript 语言的优雅特性。

现成的解决方案

如果你不想自己实现,也可以直接使用现成的方案。在 ReactUse 库中已经为你实现好了 useOnceEffect,开箱即用:

bash 复制代码
npm install @reactuse/core
javascript 复制代码
import { useOnceEffect } from '@reactuse/core'

function MyComponent() {
  useOnceEffect(() => {
    console.log('只会执行一次,即使在严格模式下')
  }, [])
  
  return <div>My Component</div>
}

下次当你再遇到严格模式的"双胞胎"问题时,不妨试试这个方案。你会发现,原来解决问题可以如此优雅。

相关推荐
招风的黑耳1 小时前
Axure 高阶设计:打造“以假乱真”的多图片上传组件
javascript·图片上传·axure
拾光拾趣录1 小时前
基础 | 🔥6种声明方式全解⚠️
前端·面试
flashlight_hi1 小时前
LeetCode 分类刷题:209. 长度最小的子数组
javascript·算法·leetcode
PineappleCoder2 小时前
深入浅出React状态提升:告别组件间的"鸡同鸭讲"!
前端·react.js
kfepiza2 小时前
Promise,then 与 async,await 相互转换 笔记250810
javascript
拉罐3 小时前
Intersection Observer API:现代前端懒加载和无限滚动的最佳实践
javascript
咕噜分发企业签名APP加固彭于晏3 小时前
腾讯云eo激活码领取
前端·面试
Jolyne_4 小时前
树节点key不唯一的勾选、展开状态的处理思路
前端·算法·react.js
岁忧4 小时前
(LeetCode 面试经典 150 题) 104. 二叉树的最大深度 (深度优先搜索dfs)
java·c++·leetcode·面试·go·深度优先