从 Vue3 到 React · 快速上手系列第 7 篇:副作用与生命周期 —— watch/watchEffect 如何翻译为 useEffect

面向 Vue3 开发者的 React 快速上手系列


先建立全局认知

在 Vue3 里你想「在某某时机做点额外的事」,工具有好几个:onMountedonUnmountedwatchwatchEffect。它们各自语义清晰。

在 React 里,这些事几乎都靠 useEffect 一个 Hook 表达。它统一了「挂载后 / 更新后 / 卸载前」三类时机。理解 useEffect,你就理解了 React 里 90% 的「副作用」写法。

把第 1 篇的底层模型再拎出来:React 是 UI = f(state),每次状态变就整棵重新算一遍。所以 React 没有「这个变量变了所以跑这个函数」的自动追踪,副作用的「何时运行」得由你显式声明------这就是依赖数组存在的根本原因。


一、useEffect 的基本机制

useEffect(fn, deps?) 的两个要点:

  1. 执行时机:在渲染之后、浏览器绘制之后异步执行 (不是立刻,也不是同步)。React 先算出新的 UI 并显示,再回头跑你的 effect。这是它和 useLayoutEffect 的最大区别(后者同步、绘制前,见后文)。
  2. 第二个参数 deps(依赖数组)决定「何时重跑」。三种写法:
写法 运行时机 Vue 里对应
useEffect(fn) 不传数组 每次渲染后都跑 无直接对应(最容易误用)
useEffect(fn, []) 空数组 仅在挂载后跑一次 onMounted
useEffect(fn, [x]) 有依赖 首次挂载 + 每次 x 变化时跑 watch(x, cb)
tsx 复制代码
import { useState, useEffect } from 'react'

function Demo() {
  const [count, setCount] = useState(0)
  const [name, setName] = useState('')

  // ① 仅挂载一次(等价于 onMounted)
  useEffect(() => {
    console.log('组件挂载了')
  }, [])

  // ② count 变化时跑(等价于 watch(count, cb))
  useEffect(() => {
    console.log('count 变成', count)
  }, [count])

  // ③ 每次渲染都跑(注意:没有数组)
  useEffect(() => {
    console.log('我每次渲染都跑,小心性能')
  })

  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      <input value={name} onChange={e => setName(e.target.value)} />
    </>
  )
}

对照 Vue3:

vue 复制代码
<script setup>
import { ref, onMounted, watch } from 'vue'

const count = ref(0)
const name = ref('')

// ① 挂载
onMounted(() => console.log('组件挂载了'))

// ② count 变化
watch(count, (n) => console.log('count 变成', n))

// ③ Vue 里没有「每次渲染都跑」的概念------你只关心「某个值变没变」
</script>

关键差异先记下来:Vue 的「侦听」永远是围绕某个具体依赖的;React 的「无依赖数组 effect」不关心依赖,它每次重渲染都执行。别把 Vue 的自动追踪思维带过来。


二、清理函数:return 一个函数

Vue 里你用 onUnmounted 做清理,用 watch 的停止函数 / onCleanup 做侦听的清理。

React 里,清理逻辑写在 useEffect 的返回值里 ------你 return 一个函数,React 会在「下次 effect 重跑之前」和「组件卸载时」调用它。这一个地方,同时覆盖了 Vue 的 onUnmountedwatch 的清理。

tsx 复制代码
useEffect(() => {
  const id = setInterval(() => console.log('tick'), 1000)
  // 清理:卸载 / 下次重跑前,清除定时器
  return () => clearInterval(id)
}, [])
tsx 复制代码
useEffect(() => {
  const handler = (e: Event) => console.log(e)
  window.addEventListener('resize', handler)
  // 清理:移除监听,避免内存泄漏
  return () => window.removeEventListener('resize', handler)
}, [])

对照 Vue3:

vue 复制代码
<script setup>
import { onMounted, onUnmounted, watch, watchEffect } from 'vue'

let id: number
onMounted(() => { id = setInterval(() => console.log('tick'), 1000) })
onUnmounted(() => clearInterval(id))

// 侦听器的清理:watch/watchEffect 本身返回停止函数
const stop = watchEffect(() => { /* ... */ })
// 需要时 stop()
</script>

心智翻译:React 里「副作用的清理」不在专门的卸载钩子里,而是紧跟在产生副作用的 useEffect 。哪个 effect 创建了什么,就在哪个 effect 里 return 清理它。好处是「创建」和「销毁」写在一起,不会因为改了这边忘了那边而漏清理。


三、关键差异:watchEffect ≠ 「无依赖的 useEffect

这是全篇最重要的一节。很多教程会说「watchEffect 对应 useEffect」,含糊其辞,结果你照着写就会出 bug。

机制本质不同:

  • Vue 的 watchEffect自动收集函数体内读到的响应式依赖。函数里读了谁,就只在谁变时重跑;没读到的变了,它不跑。
  • React 的 useEffect(() => {...})不传依赖数组 ):每次渲染后都执行,跟函数里读了什么状态毫无关系------因为它根本没有依赖数组去约束。

下面这个对比最能说明问题:

vue 复制代码
<script setup>
import { ref, watchEffect } from 'vue'

const count = ref(0)
const name = ref('')

watchEffect(() => {
  console.log('watchEffect 跑了,count =', count.value)
  // 只读了 count,没读 name
})

// 改 name → 这个 watchEffect 不会跑(它没依赖 name)
// 改 count → 这个 watchEffect 才会跑
</script>
tsx 复制代码
import { useState, useEffect } from 'react'

function Demo() {
  const [count, setCount] = useState(0)
  const [name, setName] = useState('')

  useEffect(() => {
    console.log('effect 跑了,count =', count)
    // 没传依赖数组
  })

  // 无论点 count 还是改 name(都会触发重渲染),这个 effect 每次都跑
  // 它不关心你「读了什么」,只关心「重渲染了没」
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      <input value={name} onChange={e => setName(e.target.value)} />
    </>
  )
}

结论 :如果真要把 watchEffect 翻译成 React,正确的对应是 useEffect(fn, [精确列出的依赖]) ------也就是手动把 watchEffect 自动收集到的那些依赖,原样写进依赖数组。React 不会帮你收集,你得自己写,而且要写对。


四、watch(fn, cb)useEffect(fn, [dep])

watch 是「监听某个具体源,变了就执行回调」。这是 React 里最常用、也最容易一一对应的模式:

vue 复制代码
<script setup>
import { ref, watch } from 'vue'

const count = ref(0)
watch(count, (newVal, oldVal) => {
  console.log('count 从', oldVal, '变成', newVal)
})
</script>
tsx 复制代码
import { useState, useEffect } from 'react'

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

  useEffect(() => {
    console.log('count 变成', count)
    // 注意:React 不会给你 oldVal!
    // 想要「上一次的值」,得自己用 ref 存,见下方防坑
  }, [count])
  //         ^^^^^^ 依赖数组必须手写且正确,否则行为会错位

  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

两个必须强调的差异:

  1. React 不自动追踪 :依赖数组 [count] 是你手动写的,React 不会去分析函数体里用了谁。写漏了就出 stale closure 问题(见防坑)。
  2. 拿不到 oldValwatch 的回调天然有 (newVal, oldVal)useEffect 的回调没有旧值参数。如果业务逻辑需要「上一次的值」,自己用 useRef 存一份:
tsx 复制代码
import { useState, useEffect, useRef } from 'react'

function Demo() {
  const [count, setCount] = useState(0)
  const prev = useRef(count)

  useEffect(() => {
    console.log('count 从', prev.current, '变成', count)
    prev.current = count   // 更新「上一次」记录
  }, [count])

  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

关于 { immediate: true }:Vue 的 watch 默认不跑、加了 immediate 才在挂载时跑一次;React 的 useEffect 默认挂载时就跑一次 (依赖数组版和空数组版都跑首轮)。这俩恰好相反,留意一下。想模拟「非 immediate」的 watch,可在 effect 里用 useRef 跳过首轮:

tsx 复制代码
const mounted = useRef(false)
useEffect(() => {
  if (!mounted.current) { mounted.current = true; return }
  // 首次不执行,之后 count 变化才执行(模拟不带 immediate 的 watch)
}, [count])

五、生命周期映射表

Vue3 React 说明
onMounted(fn) useEffect(fn, []) 挂载后执行一次
onUnmounted(fn) useEffectreturn 清理函数 卸载时执行
onBeforeUnmount(fn) 同上清理函数(同步在卸载前) 同 onUnmounted 的清理
onUpdated(fn) useEffect(fn, [deps]) 近似:依赖变化后执行(语义不完全一致)
watch(src, cb) useEffect(cb, [dep]) 监听具体依赖
watchEffect(fn) useEffect(fn, [deps...]) 需手动列依赖,无自动收集
nextTick(fn) useLayoutEffect(fn, []) 见下方「同步时机」

需要「同步、绘制前」的时机:useLayoutEffect

前面说的 useEffect异步、绘制后 执行的。如果你要在 DOM 已经变更、但浏览器还没绘制之前,同步读取布局并改样式(比如避免闪烁、测量元素尺寸),用 useLayoutEffect ------它的执行时机是「DOM 变更后、浏览器绘制前」,同步阻塞执行。

它和 Vue 的 nextTick 有点像(都是 DOM 更新之后),但更 、更同步nextTick 是微任务、在绘制前的微任务队列里;useLayoutEffect 是 React 在 commit 阶段同步调用的,一定早于浏览器 paint。

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

function Tooltip() {
  const ref = useRef<HTMLDivElement>(null)
  const [pos, setPos] = useState({ top: 0, left: 0 })

  useLayoutEffect(() => {
    // 同步读取 DOM 尺寸,立刻修正位置,用户不会看到「闪一下」
    const el = ref.current!
    setPos({ top: el.offsetTop, left: el.offsetWidth })
  }, [])

  return <div ref={ref}>...</div>
}

日常业务里 90% 用 useEffect 就够了;只有「改了状态马上要基于 DOM 测量再改状态、且不能让用户看到中间态」才上 useLayoutEffect


六、必讲陷阱

陷阱 1:useEffect 里 setState,且 setState 的依赖又在依赖数组里 → 死循环

tsx 复制代码
const [count, setCount] = useState(0)

useEffect(() => {
  setCount(count + 1)   // effect 跑 → 改了 count → count 是依赖
}, [count])             // → count 变 → effect 又跑 → 又 setState → 无限循环

// 控制台:Maximum update depth exceeded(React 19 会直接报错)

根因 :依赖数组里的 count 被 effect 内部的 setCount 改了,于是 effect 反复重跑。

怎么破 :问自己「我到底想监听谁」。多数情况下你不该在 effect 里基于同一依赖去改它。如果确实要根据 count 派生另一个值,用 useMemo 而不是 effect;如果是「初始化后算一次」,用空依赖 [] 且不要依赖那个被改的变量。

陷阱 2:缺少依赖 → 过期闭包(stale closure)

tsx 复制代码
const [count, setCount] = useState(0)
const [step, setStep] = useState(1)

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + step)   // ❌ 闭包捕获的是「首次渲染」的 count 和 step
  }, 1000)
  return () => clearInterval(id)
}, [])                      // 空数组:effect 只建立一次,永远用最初的值

// 之后就算 step 改成 5,interval 里还是 step = 1,永远每秒 +1

根因 :effect 在首次渲染时建立,闭包把当时的 count/step 冻结了。空依赖让它再也不更新,于是用的永远是旧值。

正确写法:用函数式更新读最新值,并把真正会变的依赖补进数组:

tsx 复制代码
useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + step)   // ✅ 函数式更新,永远读最新 prev
  }, 1000)
  return () => clearInterval(id)
}, [step])                         // ✅ step 变了,重建 interval

救命工具:eslint-plugin-react-hooksexhaustive-deps

React 官方 ESLint 插件的 react-hooks/exhaustive-deps 规则会自动检查依赖数组写全没有,漏写会标红。强烈建议开起来------它能在编译期就拦住上面两个陷阱的大多数。Vue 开发者初写 React 最容易在这栽跟头,让 linter 替你盯着。


七、实战:数据请求 + 取消

最常见的副作用就是「发请求」。要求:挂载时请求,组件卸载或请求参数变化时取消,避免「组件都没了请求才回来、还去 setState 报 warning」。

Vue3 写法onMounted 发起,onUnmountedabort):

vue 复制代码
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const data = ref<null | Item[]>(null)
let controller: AbortController | null = null

async function load() {
  controller = new AbortController()
  try {
    const res = await fetch('/api/list', { signal: controller.signal })
    data.value = await res.json()
  } catch (e) {
    if ((e as Error).name !== 'AbortError') throw e
  }
}

onMounted(load)

onUnmounted(() => {
  controller?.abort()   // 卸载时取消进行中的请求
})
</script>

React 写法 (全部收进一个 useEffect,清理函数里 abort):

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

type Item = { id: number; name: string }

function List() {
  const [data, setData] = useState<null | Item[]>(null)

  useEffect(() => {
    const controller = new AbortController()
    fetch('/api/list', { signal: controller.signal })
      .then(res => res.json())
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') throw err   // 忽略主动取消
      })

    // 关键:组件卸载 / 下次 effect 重跑前,取消请求
    return () => controller.abort()
  }, [])   // 仅挂载时请求一次

  return (
    <ul>
      {(data ?? []).map(i => <li key={i.id}>{i.name}</li>)}
    </ul>
  )
}

如果请求依赖某个参数(比如搜索词),让参数进依赖数组,React 会在参数变化时先跑清理函数(abort 旧请求),再发新请求------天然实现「切换查询即取消上一次」:

tsx 复制代码
function Search({ query }: { query: string }) {
  const [data, setData] = useState<null | Item[]>(null)

  useEffect(() => {
    const controller = new AbortController()
    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(r => r.json())
      .then(setData)
      .catch(e => { if (e.name !== 'AbortError') throw e })
    return () => controller.abort()   // query 变了:先 abort 旧请求,再发新的
  }, [query])

  return <ul>{(data ?? []).map(i => <li key={i.id}>{i.name}</li>)}</ul>
}

对比体会:Vue 把「发起」和「取消」分散在 onMounted/onUnmounted 两个钩子里;React 把「发起 + 取消」包进同一个 useEffect ,靠 return 绑定清理。后者强迫你把一对操作写在一起,反而更不容易漏掉取消。


小结

  • React 用 useEffect 统一表达 Vue 的 onMounted / onUnmounted / watch / watchEffect
  • 依赖数组三种用法:[](仅挂载)、[x](x 变时)、不传(每次渲染)。不传数组 ≠ watchEffect,前者每次都跑、后者只跑在真正用到的依赖上。
  • 清理逻辑用 useEffectreturn 函数,覆盖 onUnmountedwatch 的停止。
  • 两大陷阱:依赖里包含被 setState 的值 → 死循环依赖写漏 → 过期闭包 。靠 react-hooks/exhaustive-deps 兜底。
  • 同步、绘制前的时机用 useLayoutEffect,类比(且早于)nextTick
  • 请求取消用 AbortController,卸载/重跑前在清理函数里 abort()

下一篇讲组件通信与 Context------对标插槽与 provide/inject

相关推荐
swipe1 小时前
12|(前端转全栈)点击提交订单后,后端如何用事务守住价格、库存和订单?
前端·后端·面试
半夜里咳嗽的狼1 小时前
用 React 19.2 Activity 保留页面状态前,先弄清 hidden 模式做了什么
react.js
小林ixn1 小时前
React + TypeScript 实战:从“类型体操”到“数据持久化”,一次讲透组件通信与副作用管理
前端·react.js·typescript
何时梦醒1 小时前
React + TypeScript 企业级开发实战:从零搭建到组件架构演进
前端·react.js·全栈
Coffeeee1 小时前
AGP9.0的主要变更项,给Gradle来一次大变样
android·前端·gradle
不好听6131 小时前
从困惑到理解:React 父子组件通信的三种写法
react.js·typescript
数聚天成DeepSData1 小时前
外贸海关进出口数据去哪免费下载?从统计到明细的查找指南
linux·服务器·开发语言·前端·网络·人工智能·自然语言处理
阿黎梨梨1 小时前
React + TypeScript 入门:从组件类型到数据流设计
react.js
烬羽1 小时前
React 状态归属:两个版本的用户名编辑器,告诉你 state 该放哪
react.js·typescript·前端框架