🚀 探索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 方案有着本质上的优势:
- 真正的全局唯一性:基于 effect 函数引用进行判断,无论在哪个组件实例中,同一个函数只会执行一次
- 自动垃圾回收 :当组件卸载时,effect 函数的引用消失,
WeakSet
会自动清理,不会造成内存泄漏 - 语义清晰 :
WeakSet
方案的语义很明确------同一个 effect 函数引用只执行一次,没有歧义 - 零配置:不需要考虑依赖数组变化、状态重置等复杂情况
- 性能优越:查找和添加操作都是 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
很好用,但要记住:
- 依赖数组仍然重要:即使是"只执行一次",也要正确设置依赖数组
- 清理函数照常工作:返回的清理函数在组件卸载时仍会执行
- 适用场景有限:只适用于真正需要"全局只执行一次"的场景
结语:让代码更加优雅
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>
}
下次当你再遇到严格模式的"双胞胎"问题时,不妨试试这个方案。你会发现,原来解决问题可以如此优雅。