面向 Vue3 开发者的 React 快速上手系列
先建立全局认知
在 Vue3 里你想「在某某时机做点额外的事」,工具有好几个:onMounted、onUnmounted、watch、watchEffect。它们各自语义清晰。
在 React 里,这些事几乎都靠 useEffect 一个 Hook 表达。它统一了「挂载后 / 更新后 / 卸载前」三类时机。理解 useEffect,你就理解了 React 里 90% 的「副作用」写法。
把第 1 篇的底层模型再拎出来:React 是
UI = f(state),每次状态变就整棵重新算一遍。所以 React 没有「这个变量变了所以跑这个函数」的自动追踪,副作用的「何时运行」得由你显式声明------这就是依赖数组存在的根本原因。
一、useEffect 的基本机制
useEffect(fn, deps?) 的两个要点:
- 执行时机:在渲染之后、浏览器绘制之后异步执行 (不是立刻,也不是同步)。React 先算出新的 UI 并显示,再回头跑你的 effect。这是它和
useLayoutEffect的最大区别(后者同步、绘制前,见后文)。 - 第二个参数
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 的 onUnmounted 和 watch 的清理。
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>
}
两个必须强调的差异:
- React 不自动追踪 :依赖数组
[count]是你手动写的,React 不会去分析函数体里用了谁。写漏了就出 stale closure 问题(见防坑)。 - 拿不到
oldVal:watch的回调天然有(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) |
useEffect 的 return 清理函数 |
卸载时执行 |
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-hooks 的 exhaustive-deps
React 官方 ESLint 插件的 react-hooks/exhaustive-deps 规则会自动检查依赖数组写全没有,漏写会标红。强烈建议开起来------它能在编译期就拦住上面两个陷阱的大多数。Vue 开发者初写 React 最容易在这栽跟头,让 linter 替你盯着。
七、实战:数据请求 + 取消
最常见的副作用就是「发请求」。要求:挂载时请求,组件卸载或请求参数变化时取消,避免「组件都没了请求才回来、还去 setState 报 warning」。
Vue3 写法 (onMounted 发起,onUnmounted 里 abort):
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,前者每次都跑、后者只跑在真正用到的依赖上。 - 清理逻辑用
useEffect的return函数,覆盖onUnmounted和watch的停止。 - 两大陷阱:依赖里包含被 setState 的值 → 死循环 ;依赖写漏 → 过期闭包 。靠
react-hooks/exhaustive-deps兜底。 - 同步、绘制前的时机用
useLayoutEffect,类比(且早于)nextTick。 - 请求取消用
AbortController,卸载/重跑前在清理函数里abort()。
下一篇讲组件通信与 Context------对标插槽与 provide/inject