React05:useEffect 与副作用

第 05 课 · useEffect 与副作用

Vue 里「组件挂载后做事」「监听数据变化」「卸载前收尾」分属 onMounted / watch / onUnmounted 三个 API;React 把它们合成一个 useEffect。这是两个框架心智差异最大的地方,也是 Vue 开发者最常写出 bug 的地方------本课一半篇幅都在纠偏。

🎯 本课目标

  • 建立「useEffect = onMounted + watch + onUnmounted 三合一」的心智模型,分清它和 watch 的关键差异
  • 熟练使用依赖数组三形态(不写 / [] / [a, b]),能预判每种写法在「任何状态变化」下的行为
  • 给定时器、事件监听、订阅写清理函数,说清 cleanup 的执行时机
  • 写出企业标准的请求数据模式:loading/error/data 三态 + ignore 标志处理竞态
  • 用官方「You Might Not Need an Effect」的判断标准,识别 code review 里该删的 useEffect

1. 副作用与「三合一」心智

Vue 里你这样写

ts 复制代码
import { onMounted, onUnmounted, watch, ref } from 'vue'
const keyword = ref('')

onMounted(() => { document.title = '搜索页' })        // 挂载后做事
watch(keyword, kw => { console.log('搜索:', kw) })   // 数据变化后做事
onUnmounted(() => { window.removeEventListener('resize', onResize) }) // 卸载前收尾

React 里这样写

tsx 复制代码
import { useEffect, useState } from 'react'

export default function SearchPage() {
  const [keyword, setKeyword] = useState('')

  // ① 挂载后做事(= onMounted)
  useEffect(() => {
    document.title = '搜索页'
  }, [])

  // ② 数据变化后做事(= watch(keyword, ...))
  useEffect(() => {
    console.log('搜索:', keyword)
  }, [keyword])

  // ③ 卸载前收尾(= onUnmounted,写在 return 里)
  useEffect(() => {
    const onResize = () => console.log('resize')
    window.addEventListener('resize', onResize)
    return () => window.removeEventListener('resize', onResize)
  }, [])

  return <input value={keyword} onChange={e => setKeyword(e.target.value)} />
}

关键差异与注意点

  • 副作用 :渲染之外的事------请求数据、操作 DOM、定时器、订阅、打点。React 里「渲染必须是纯的」,这些事统一交给 useEffect。
  • 执行时机 :每次渲染提交到屏幕之后 异步执行(Vue 的 watch 默认是 flush: 'pre',在组件更新前)。所以 effect 里再改 state 不会卡住首屏绘制。
  • 一个组件可以有任意多个 useEffect,按「这件事」拆分,而不是像 Vue 那样按「生命周期」聚堆。
  • effect 的两个参数:(执行函数, 依赖数组)。依赖数组决定「什么时候再执行」,return 的函数决定「执行前怎么收尾」。

2. 依赖数组三形态

Vue 里你这样写

ts 复制代码
watchEffect(() => {})                 // 自动收集依赖
watch([a, b], () => {})               // 显式指定依赖
onMounted(() => {})                   // 只跑一次

React 里这样写

依赖数组没有自动收集,必须手写、写全:

tsx 复制代码
// 形态一:不写第二个参数 ------ 每次渲染后都执行(几乎不用,用了多半是 bug)
useEffect(() => {
  console.log('任何 state 变化、任何父组件重渲染导致的重渲染,我都会跑')
})

// 形态二:空数组 ------ 仅首次挂载后执行(= onMounted)
useEffect(() => {
  fetchConfig()
}, [])

// 形态三:[a, b] ------ 首次执行 + a 或 b 变化后执行(= onMounted + watch(a/b))
useEffect(() => {
  console.log('a 或 b 变了', a, b)
}, [a, b])

关键差异与注意点

  • 与 watch 的关键差异(最容易理解偏差的地方) :watch 默认不立即执行 ,只有依赖变化才跑;useEffect 挂载后必执行一次 ,然后才按依赖数组决定是否重跑。所以 useEffect(fn, [a]) ≈ watch(a, fn, { immediate: true }),而不是普通 watch。
  • 依赖数组比较用的是 Object.is(浅比较)。对象/数组依赖是重灾区 :useEffect(..., [form]) 里 form 是每次渲染新建的对象时,effect 每次渲染都会跑。要么放具体字段 [form.keyword],要么记忆化(第 7 课 useMemo)。
  • 诚实填写:effect 里用到的响应式值(state、props、以及组件内定义的函数)都要进依赖。漏写不会报错,但会读到「上一次渲染的旧值」------这类 bug 比报错难查得多。关掉 exhaustive-deps 的 lint 规则来逃避填依赖,是企业 code review 一票否决项。
  • 想要「仅依赖变化时执行、跳过首次」?用 ref 记录是否首次,不要 hack 依赖数组。

3. 清理函数:return 出去的收尾逻辑

Vue 里你这样写

ts 复制代码
onMounted(() => { window.addEventListener('resize', onResize) })
onUnmounted(() => { window.removeEventListener('resize', onResize) }) // 收尾写在另一个 API 里

React 里这样写

tsx 复制代码
import { useEffect, useState } from 'react'

export default function Clock() {
  const [now, setNow] = useState(() => new Date())

  useEffect(() => {
    const timer = setInterval(() => setNow(new Date()), 1000)
    const onResize = () => console.log('resize')

    window.addEventListener('resize', onResize)
    // cleanup:下一次 effect 执行前 + 组件卸载时,各调用一次
    return () => {
      clearInterval(timer)
      window.removeEventListener('resize', onResize)
    }
  }, [])

  return <p>{now.toLocaleTimeString()}</p>
}

关键差异与注意点

  • 执行时机 :不是只在卸载时!cleanup 在下一次 effect 执行之前 也会执行(依赖变化 → 先清理上一轮,再跑新一轮),以及组件卸载时 。这一点比 onUnmounted 覆盖的场景多------竞态处理(第 4 节)全靠它。
  • cleanup 必写清单 :setInterval/setTimeout(clearInterval/clearTimeout)、addEventListener(removeEventListener)、WebSocket/EventSource、订阅(store、PubSub)、手动修改的 DOM/第三方实例(destroy/disconnect)。
  • 不写 cleanup 的后果:组件卸载后定时器还在跑、监听还在触发------内存泄漏 + 日志错乱 + 对已卸载组件 setState。
  • effect 里只能 return 函数 ,所以 async 函数不能直接写在 effect 里(见第 4 节的正确姿势)。
  • useEffect 的 cleanup 里 setState 是允许的(React 18+ 已移除卸载后 setState 的警告),但「清理」本身不该做业务动作(比如退出登录),那是别处的事。

4. 请求数据标准模式:三态 + 竞态

Vue 里你这样写

ts 复制代码
const loading = ref(false)
const error = ref('')
const user = ref(null)

async function loadUser(id: number) {
  loading.value = true
  error.value = ''
  try {
    user.value = await fetchUser(id)
  } catch (e) {
    error.value = String(e)
  } finally {
    loading.value = false
  }
}
watch(() => props.id, id => loadUser(id), { immediate: true })

React 里这样写

请求随 id 变化自动重发,ignore 标志保证「慢的旧响应」不会覆盖新结果:

tsx 复制代码
import { useEffect, useState } from 'react'

interface User { id: number; name: string; role: string }

export default function UserProfile({ id }: { id: number }) {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')
  const [user, setUser] = useState<User | null>(null)

  useEffect(() => {
    let ignore = false // 本轮 effect 的「过期开关」

    async function load() {
      setLoading(true)
      setError('')
      try {
        const data = await fetchUser(id)
        if (!ignore) setUser(data)          // 已过期就丢弃
      } catch (e) {
        if (!ignore) setError((e as Error).message)
      } finally {
        if (!ignore) setLoading(false)
      }
    }
    load()

    return () => { ignore = true } // 下一次 effect 前 / 卸载时执行
  }, [id])

  if (loading) return <p>加载中...</p>
  if (error) return <p style={{ color: 'red' }}>{error}</p>
  if (!user) return null
  return <p>{user.name}({user.role})</p>
}

关键差异与注意点

  • 三态拆开存 :loading / error / data 三个 state(或合成一个带类型的对象),UI 按优先级渲染:loading → error → data。
  • 竞态 :请求是异步的,切到用户 2 后用户 1 的慢响应可能后到,把新数据覆盖掉。let ignore = false + cleanup 里 ignore = true 是官方推荐的最小方案;也可以用 AbortController 真·取消请求(fetchUser 内部把 signal 传给 fetch)。
  • async 不能直接写在 effect 里 :useEffect(async () => {}) 会报错------effect 必须返回函数或什么都不返回。标准做法是 effect 里定义 async function load() 然后 load()。
  • 初次挂载就能拿到数据(不需要 immediate: true),因为 useEffect(fn, [id]) 挂载时必执行一次------这正好符合列表页「进页面就请求」的习惯。
  • 企业现状 :手写这套模式只用于理解原理,实际项目用 TanStack Query(本课程第 11 课),缓存、重试、竞态、失效全帮你做好了。面试要能手写,上班要用库。

5. StrictMode:开发环境为什么执行两次

React 里这样写

tsx 复制代码
// main.tsx ------ 企业项目都会包一层 StrictMode,不要删
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

关键差异与注意点

  • 开发环境挂载时,React 会故意执行两遍 :组件函数渲染两次、effect 执行 → cleanup → 再执行一次。生产环境不会。
  • 目的:模拟「组件随时可能被卸载重挂」,逼你把 cleanup 写对。
  • 正确反应是补 cleanup,而不是删 StrictMode「修复」------删掉它等于关掉烟雾报警器,泄漏会留到生产。
  • Vue 没有这一层,所以第一次看到「请求发了两次」会很惊讶:先检查是不是 StrictMode,再查依赖数组。

6. 什么时候不该用 useEffect(企业 code review 高频意见)

Vue 里你这样写

ts 复制代码
const full = computed(() => `${first.value} ${last.value}`) // 派生数据用 computed

React 里这样写

tsx 复制代码
// ❌ 多余的 effect:为「渲染时能算出来的东西」专门跑一次副作用
const [full, setFull] = useState('')
useEffect(() => {
  setFull(`${first} ${last}`)
}, [first, last])

// ✅ 直接算:渲染就是一次「用最新 state 重算 UI」的过程
const full = `${first} ${last}`

关键差异与注意点

  • 判断口诀 (官方 You Might Not Need an Effect 精华):
    1. 这份值能由现有 state/props 直接算出来 ?→ 渲染时直接写表达式,别进 effect(Vue 里你本来就会用 computed,同理)。
    2. 这件事是某个具体交互引起的?→ 写在那个事件回调里(提交表单时发请求,而不是「监听到表单变了就发」),别进 effect。
    3. 是多处状态变化都要触发、且来源不止用户操作?→ 这才是 effect(同步第三方、埋点、操作 DOM 标题)。
  • effect 里 setState 去存「另一个 state 的派生值」会导致多一轮渲染,并且容易写出链式 effect(A 的 effect 改 B,B 的 effect 改 C)------review 里叫「effect 地狱」。
  • 事件回调能做就别进 effect(Demo 4 左右对照):事件回调只响应真实交互,没有挂载那次多余执行,逻辑也更内聚。
  • 真正适合 effect 的:请求/订阅/定时器、document.title 这类 DOM 副作用、和 React 外部系统同步。React 内部的数据加工,一律不用 effect。

📊 Vue ↔ React 对照速查表

Vue 3 写法 React 写法 一句话差异
onMounted(fn) useEffect(fn, []) 语义一致,写法换个位置
onUnmounted(fn) useEffect(() => fn, []) 的 return 收尾逻辑写在同一个 effect 里
watch(a, fn) useEffect(fn, [a]) useEffect 挂载后必执行一次,watch 默认不会
watch(a, fn, { immediate: true }) useEffect(fn, [a]) 这才是完全等价物
watch([a, b], fn) useEffect(fn, [a, b]) 依赖手写,不会自动收集
watchEffect(fn) useEffect(fn)(不写依赖) 都容易过度执行,慎用
computed 渲染中直接算 / useMemo 派生数据不要进 effect
事件回调里发请求 事件回调里发请求 一样!能回调就别进 effect
watch 请求 + 手动防竞态 ignore 标志 / AbortController cleanup 让竞态处理有落点
onBeforeUnmount 清定时器 cleanup 里 clearInterval 同一件事,换个位置写

🏢 企业实战要点

  1. 依赖数组诚实填写 ,配合 eslint-plugin-react-hooks 的 exhaustive-deps 规则;「为了少跑几次而故意漏依赖」是 review 一票否决项,正确做法是 useMemo/useCallback 稳定依赖(第 7 课)。
  2. 对象/数组依赖的引用陷阱 :[form]、[list]、[options] 这类写法,只要上游每次渲染新建对象就会次次触发。放原始值字段([form.keyword])或记忆化,review 里见到直接打回。
  3. cleanup 必写清单贴在脑子里:定时器、事件监听、订阅、WebSocket、第三方实例 destroy。PR 描述里能一句话说清「这个 effect 的清理是什么」。
  4. 请求逻辑封装成自定义 Hook (第 8 课 useUser(id) / 第 11 课 TanStack Query),组件里只留「用」,别让每个组件都手写三态 + ignore。
  5. 一个 effect 只做一件事 :需要多个副作用就写多个 useEffect,依赖数组各自精确;一个大 effect 揉五个职责是「effect 地狱」的起点。
  6. onChange 里能做的不进 effect(上一课的防抖、这一课的联动同理):review 里最常见的多余 effect 就是「监听 input 值去更新另一个 state」。
  7. 别删 StrictMode,也别为了「请求只发一次」去改全局配置;用 cleanup + 正确依赖解决。
  8. effect 里不要做重计算 :它跑在渲染之后,卡的是用户可感知的交互;重活挪到事件回调或用 useMemo 记忆化。

💼 面试高频

1. useEffect 的依赖数组是怎么工作的?漏写会怎样?

React 在每次渲染后用 Object.is 逐项比较新旧依赖:全相等就跳过 effect,否则先执行上一次的 cleanup 再跑新 effect。漏写依赖不会报错,但 effect 闭包会引用旧的 state/props(闭包陷阱),表现为「数据明明变了逻辑却用旧值」,是最难排查的一类 bug。所以依赖要诚实写全。

2. useEffect 和 Vue 的 watch 有什么区别?

最大差异是执行时机:watch 默认不立即执行,依赖变化才跑;useEffect 挂载后必执行一次,之后依赖变化才重跑,等价于 watch(..., { immediate: true })。另外 watch 靠响应式系统自动追踪依赖,useEffect 必须手写依赖数组且比较是浅比较(对象引用变了就算变了)。

3. cleanup 函数的意义和执行时机?

用于撤销本次副作用:清定时器、移除监听、取消订阅/请求。执行时机有两个:下一次 effect 执行之前(依赖变化时先清理上一轮)和组件卸载时。它不只是「卸载收尾」,更是处理竞态(ignore 标志)、避免重复订阅的关键机制。

4. 开发环境 useEffect 为什么执行两次?

因为 React 18+ 的 StrictMode 会在开发环境对每个组件模拟一次「挂载 → 卸载 → 重挂载」(渲染两次、effect 执行 → cleanup → 再执行),用来暴露缺失 cleanup 的副作用。生产环境只执行一次。正确做法是补全 cleanup,而不是移除 StrictMode。

5. 请求的竞态问题怎么处理?

典型场景:快速切换 id,先发出的慢响应后到,覆盖了新结果。最小方案是 effect 里 let ignore = false,响应回来先判断 if (ignore) return,cleanup 里 ignore = true 把上一轮作废;更彻底用 AbortController 真正中断请求。生产上直接用 TanStack Query,它内置 keyed 缓存与过期丢弃。

📌 小结 + 下节预告

这一课的核心是心智转换:useEffect 把 Vue 的 onMounted / watch / onUnmounted 合成一个函数,挂载必执行一次 是与 watch 最关键的区别;依赖数组要诚实手写、对象依赖小心引用陷阱;定时器/监听/订阅必须 cleanup;请求数据 = 三态 + ignore 竞态处理;最后用「能在渲染里算、能在事件回调里做的,都别进 effect」来给代码瘦身。

不过你可能已经注意到 Demo 2 里 appendLog 需要包一层 useCallback、依赖数组里不敢放对象------这些「函数身份」和「引用稳定性」问题,正是下一课的主题:组件通信 (props 回调、Context、useImperativeHandle),以及它背后引出的性能优化三件套。

相关推荐
ManageEngineITSM1 小时前
ITSM系统如何与企业微信、钉钉、飞书集成?提升工单处理效率的方法
java·大数据·开发语言·钉钉·企业微信·变更管理
Akamai中国1 小时前
PHP 爆出两大高风险漏洞!Akamai 即时部署防护机制
开发语言·人工智能·云计算·云服务
学逆向的1 小时前
C 悬空指针
c语言·开发语言·网络安全
狮子座的男孩1 小时前
html+css基础:24、2D变换(位移/缩放/旋转/扭曲(了解)/多重变换/变换原点)
前端·css·经验分享·html·2d位移·原点变换·缩放/旋转/扭曲
weixin_417396831 小时前
酒店弱电怎么验收?四个阶段与逐项检查清单
开发语言·前端·php·综合布线·弱电
风骏时光牛马1 小时前
探索AI开源工具,解锁原生创造新体验
前端
u0111026751 小时前
Vue 页面离开后还在轮询?统一清理计时器、监听器与观察器
前端·javascript·vue.js
大猩猩X1 小时前
vue 表格组件 vxe-table 实现多行编辑,支持增删改查与校验
前端·javascript·vue.js·vxe-table
跨境数据猎手3 小时前
跨境电商系统三大 Web 漏洞的防护:SQL 注入、XSS、CSRF
前端·sql·xss