适用技术栈:React、Vue、SwiftUI、Flutter、Jetpack Compose。以 React 为主要教学语言,思维跨框架通用。
核心理念:不讲空理论,每个知识点都落地到能直接用于工作的实用技能。
本课产出: 能判断什么该放 Effect、什么不该;能写出正确的依赖数组;能处理清理函数;能避免无限循环、竞态条件、闭包陷阱;能实现数据获取、事件订阅、定时器的标准模式。
一句话预览: 渲染是纯函数,副作用是与外部世界的交互。把副作用管好,应用就稳了;管不好,bug 会像野草一样疯长。
一、本课目标
学完本课,你应该能:
- 判断一个操作是不是副作用,该不该放 Effect。
- 理解依赖数组的语义,写出正确的依赖。
- 写清理函数:取消请求、取消订阅、清除定时器。
- 避免无限循环、竞态条件、闭包陷阱。
- 实现数据获取的标准模式:加载、成功、失败、取消。
- 实现事件监听、订阅、定时器的标准模式。
- 识别并消除"用 Effect 同步派生状态"的反模式。
二、从一个真实痛点说起
场景:一个用户资料页
需求:根据 userId 加载用户信息,显示姓名、邮箱。加载中显示 Spinner,失败显示错误。
一个开发者这样写:
jsx
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
setIsLoading(true)
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data)
setIsLoading(false)
})
.catch(err => {
setError(err.message)
setIsLoading(false)
})
}, [userId])
if (isLoading) return <Spinner />
if (error) return <Error message={error} />
return <div>{user?.name}</div>
}
能跑,但问题很多:
- 竞态条件。 用户快速切换
userId,请求 A 后发但先返回,请求 B 先发但后返回,界面显示的是 A 的数据。 - 组件卸载后 setState。 请求未完成时组件卸载,
setUser会警告"在已卸载组件上 setState"。 - 三个状态可能非法组合。
isLoading和error可能同时为 true。 - 没有清理函数。 无法取消请求,无法避免竞态。
这些问题的根源,都是 Effect 用得不正确。 本课要讲的,就是如何写出健壮的副作用。
三、什么是副作用
副作用的定义
副作用是:与外部世界交互的操作。
它不属于渲染,因为它影响了渲染函数之外的东西。
副作用的分类
| 类型 | 例子 |
|---|---|
| 网络请求 | fetch、axios、WebSocket |
| 存储读写 | localStorage、sessionStorage、IndexedDB |
| DOM 操作 | document.title、window.scrollTo、focus |
| 订阅 | 事件监听、消息订阅 |
| 定时器 | setTimeout、setInterval |
| 日志上报 | console.log、埋点、错误上报 |
| 第三方库 | 图表、地图、富文本编辑器 |
什么不是副作用
- 计算派生数据:
items.reduce(...) - 字符串拼接:
${a} ${b} - 条件判断:
if (loading) ... - 调用纯函数:
formatDate(date)
判断标准:这个操作会不会影响外部?会 → 副作用。不会 → 纯计算。
副作用的三个归属
归属一:事件处理器。 用户触发的副作用。
jsx
function handleClick() {
fetch("/api/delete", { method: "POST" }) // 副作用
setStatus("deleting") // 状态更新
}
归属二:Effect。 组件生命周期相关的副作用。
jsx
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(timer) // 清理
}, [])
归属三:渲染。 不允许有副作用。
jsx
// ❌ 渲染中发请求
function Bad() {
fetch("/api/data")
return <div>...</div>
}
原则:副作用只能出现在事件处理器和 Effect 中。渲染必须纯。
四、Effect 的本质
Effect 是什么
Effect 是让组件与外部系统同步的机制。
它回答的问题是:"当某些状态变化时,我需要对外部做什么?"
jsx
useEffect(() => {
// 当依赖变化时执行
// 这里做副作用
return () => {
// 清理上一次的副作用
}
}, [依赖])
Effect 的三个部分
- 依赖: 什么时候执行。
- 副作用函数: 执行什么。
- 清理函数: 怎么收拾。
执行时机
text
组件渲染 → DOM 更新 → 浏览器绘制 → Effect 执行
Effect 在浏览器绘制之后执行。这意味着:
- 不会阻塞渲染。
- 用户能看到界面后再执行。
- 适合大多数副作用。
React 18 的 useLayoutEffect: 在 DOM 更新后、浏览器绘制前执行,会阻塞渲染。适合需要同步读取布局的场景。除非有明确需求,否则用 useEffect。
依赖数组的语义
依赖数组是 Effect 的"触发条件"。
jsx
useEffect(() => {
// ...
}, [a, b])
含义:当 a 或 b 变化时,重新执行 Effect。
三种依赖数组:
jsx
// 每次渲染都执行(几乎不用)
useEffect(() => { ... })
// 只在挂载时执行(空数组)
useEffect(() => { ... }, [])
// a 或 b 变化时执行
useEffect(() => { ... }, [a, b])
依赖数组的判断规则: Effect 里用到的所有外部值(props、state、context),都应该在依赖数组里。
jsx
// ❌ 依赖缺失
useEffect(() => {
fetch(`/api/users/${userId}`) // userId 用了但没在依赖里
}, [])
// ✅ 依赖完整
useEffect(() => {
fetch(`/api/users/${userId}`)
}, [userId])
为什么依赖数组这么重要
因为它决定了 Effect 的"正确性"。
- 依赖缺失 → 拿不到最新的值(闭包陷阱)。
- 依赖过多 → 频繁执行(性能问题)。
- 依赖是对象 → 每次渲染都是新引用,每次都执行(无限循环)。
依赖数组是 Effect 最容易出错的地方。
五、什么该放 Effect,什么不该
不该放 Effect 的操作
反模式一:用 Effect 同步派生状态。
jsx
// ❌ canSubmit 能从 email 和 password 算出来
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [canSubmit, setCanSubmit] = useState(false)
useEffect(() => {
setCanSubmit(email && password)
}, [email, password])
// ✅ 直接派生
const canSubmit = email && password
判断信号: 当你写 useEffect(() => setX(...), [y]) 时,先问"x 是不是能从 y 算出来?"如果是,就不该用 Effect。
反模式二:用 Effect 响应事件。
jsx
// ❌ 用户点击后需要发请求,用 Effect 监听状态变化
const [submitted, setSubmitted] = useState(false)
useEffect(() => {
if (submitted) {
api.submit(form)
}
}, [submitted])
function handleClick() {
setSubmitted(true)
}
// ✅ 直接在事件处理器里做
function handleClick() {
api.submit(form)
}
判断信号: 如果 Effect 的触发源是用户事件,应该在事件处理器里直接做,不要绕道状态。
反模式三:用 Effect 初始化状态。
jsx
// ❌ 用 Effect 设置初始值
const [user, setUser] = useState(null)
useEffect(() => {
setUser({ name: "默认" })
}, [])
// ✅ 直接用初始值
const [user, setUser] = useState({ name: "默认" })
判断信号: 如果 Effect 只用来设置一次初始值,直接用 useState 的初始值。
反模式四:用 Effect 处理用户操作后的连锁更新。
jsx
// ❌ 提交成功后重置表单,用 Effect 监听
useEffect(() => {
if (status === "success") {
setForm(INITIAL_FORM)
}
}, [status])
// ✅ 在提交逻辑里直接重置
async function submit() {
await api.submit(form)
setStatus("success")
setForm(INITIAL_FORM)
}
判断信号: 如果连锁更新是由某个事件引起的,直接在事件处理器里做。
该放 Effect 的操作
场景一:与外部系统同步。
jsx
// 同步 document.title
useEffect(() => {
document.title = `${unreadCount} 条未读`
}, [unreadCount])
场景二:订阅。
jsx
// 订阅 WebSocket
useEffect(() => {
const ws = new WebSocket("ws://...")
ws.onmessage = e => setMessages(prev => [...prev, e.data])
return () => ws.close()
}, [])
场景三:数据获取。
jsx
// 根据 userId 加载用户
useEffect(() => {
const controller = new AbortController()
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(e => { if (e.name !== "AbortError") setError(e) })
return () => controller.abort()
}, [userId])
场景四:定时器。
jsx
// 每秒更新一次时间
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(timer)
}, [])
判断流程
text
这个操作是副作用吗?
│
├─ 不是 → 不放 Effect,直接计算或渲染
│
└─ 是 → 触发源是什么?
│
├─ 用户事件 → 放事件处理器
│
├─ 组件生命周期 → 放 Effect
│
└─ 状态变化引起的连锁 → 先在事件处理器里做,
实在不行才用 Effect
核心原则:能用事件处理器解决的,不要用 Effect。
六、清理函数:Effect 的"收尾"
为什么需要清理
Effect 可能产生需要撤销的东西:
- 定时器:需要
clearInterval - 订阅:需要
unsubscribe - 请求:需要
abort - 事件监听:需要
removeEventListener - 连接:需要
close
如果不清理,会导致:
- 内存泄漏。
- 组件卸载后仍在执行。
- 多次订阅导致重复执行。
- 竞态条件。
清理函数的时机
jsx
useEffect(() => {
// 执行副作用
return () => {
// 清理上一次的副作用
}
}, [依赖])
清理函数在两个时机执行:
- 依赖变化时: 先清理上一次,再执行新的。
- 组件卸载时: 清理最后一次。
标准模式一:定时器
jsx
useEffect(() => {
const timer = setInterval(() => {
setNow(Date.now())
}, 1000)
return () => clearInterval(timer)
}, [])
标准模式二:事件监听
jsx
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth)
}
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [])
注意: 添加和移除必须用同一个函数引用。如果函数定义在 Effect 内部,每次 Effect 执行都是新函数,removeEventListener 会失效。
标准模式三:订阅
jsx
useEffect(() => {
const subscription = store.subscribe(() => {
setState(store.getState())
})
return () => subscription.unsubscribe()
}, [store])
标准模式四:取消请求
jsx
useEffect(() => {
const controller = new AbortController()
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(err => {
if (err.name !== "AbortError") setError(err)
})
return () => controller.abort()
}, [userId])
AbortController 是取消请求的标准方式:
- 创建
AbortController。 - 把
signal传给fetch。 - 清理时调用
controller.abort()。 - 请求被取消时,
fetch会抛出AbortError,需要区分处理。
标准模式五:WebSocket
jsx
useEffect(() => {
const ws = new WebSocket(`ws://api/chat/${roomId}`)
ws.onmessage = e => {
setMessages(prev => [...prev, JSON.parse(e.data)])
}
ws.onerror = e => setError(e.message)
return () => ws.close()
}, [roomId])
清理函数不是万能的
有些副作用无法清理:
- 已经发送的请求(除非用
AbortController)。 - 已经写入的存储。
- 已经上报的日志。
对于无法清理的,用"标记"方式避免竞态:
jsx
useEffect(() => {
let cancelled = false
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
if (cancelled) return
setUser(data)
})
return () => { cancelled = true }
}, [userId])
这个模式叫"取消标记",是 Effect 中最常用的竞态处理方式。
七、三个经典陷阱
陷阱一:无限循环
原因: 依赖数组里的值每次渲染都是新引用。
jsx
// ❌ options 是对象,每次渲染都是新引用
function Component({ userId }) {
const options = { headers: { "X-User": userId } }
useEffect(() => {
fetch("/api/data", options)
}, [options]) // 每次渲染 options 都不同,无限循环
}
// ✅ 把依赖拆解到具体值
function Component({ userId }) {
useEffect(() => {
const options = { headers: { "X-User": userId } }
fetch("/api/data", options)
}, [userId])
}
修复方式:
- 拆解依赖: 用对象里的具体字段作为依赖。
useMemo稳定引用: 如果对象确实需要稳定,用useMemo。- 移进 Effect: 如果对象只在 Effect 里用,移到 Effect 内部。
其他无限循环的原因:
jsx
// ❌ Effect 改状态,状态又触发 Effect
useEffect(() => {
setCount(count + 1)
}, [count])
陷阱二:竞态条件
原因: 多个异步操作,返回顺序不确定。
jsx
// ❌ 快速切换 userId,可能显示旧数据
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser)
}, [userId])
场景: 用户从 A 切到 B,A 的请求后发但先返回,界面显示 A 的数据,但 userId 是 B。
修复方式:用取消标记。
jsx
useEffect(() => {
let cancelled = false
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
if (cancelled) return
setUser(data)
})
return () => { cancelled = true }
}, [userId])
或用 AbortController:
jsx
useEffect(() => {
const controller = new AbortController()
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(err => {
if (err.name === "AbortError") return
setError(err)
})
return () => controller.abort()
}, [userId])
陷阱三:闭包陷阱
原因: Effect 捕获的是创建时的状态值,不是最新的。
jsx
// ❌ count 永远是 0
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1) // count 是 Effect 创建时的值
}, 1000)
return () => clearInterval(timer)
}, []) // 空依赖,count 永远是初始值
// ✅ 用函数式更新
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1) // 拿最新值
}, 1000)
return () => clearInterval(timer)
}, [])
// ✅ 或把 count 加进依赖
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(timer)
}, [count]) // 每次 count 变化重建定时器
两种修复方式的取舍:
- 函数式更新: 不重建定时器,但需要
setX(fn)形式。 - 加依赖: 每次重建定时器,性能有开销。
推荐:能用函数式更新就用函数式更新。
陷阱四:依赖缺失
jsx
// ❌ 依赖缺失,name 变化不触发 Effect
useEffect(() => {
document.title = `${greeting}, ${name}`
}, [greeting]) // 缺 name
// ✅ 依赖完整
useEffect(() => {
document.title = `${greeting}, ${name}`
}, [greeting, name])
ESLint 的 exhaustive-deps 规则能自动检测依赖缺失。建议开启。
八、数据获取的标准模式
数据获取是 Effect 最常见的用途,也是最容易出错的。下面给出标准模式。
四态状态机
jsx
const [status, setStatus] = useState("idle")
// "idle" | "loading" | "success" | "error"
const [data, setData] = useState(null)
const [error, setError] = useState(null)
完整实现
jsx
function useFetch(url) {
const [status, setStatus] = useState("idle")
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
if (!url) return
const controller = new AbortController()
setStatus("loading")
setError(null)
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then(data => {
setData(data)
setStatus("success")
})
.catch(err => {
if (err.name === "AbortError") return
setError(err.message)
setStatus("error")
})
return () => controller.abort()
}, [url])
return { status, data, error }
}
// 用法
function UserProfile({ userId }) {
const { status, data: user, error } = useFetch(`/api/users/${userId}`)
if (status === "idle") return null
if (status === "loading") return <Spinner />
if (status === "error") return <Error message={error} />
return <div>{user.name}</div>
}
关键点
- 四态状态机:
idle / loading / success / error。 AbortController: 取消上一次请求,避免竞态。- 错误处理: 区分
AbortError和真实错误。 res.ok检查:fetch不会因为 4xx/5xx 报错,需要手动检查。- 依赖
url: URL 变化时重新请求。
手动重试
jsx
function useFetch(url) {
const [status, setStatus] = useState("idle")
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [retryCount, setRetryCount] = useState(0)
useEffect(() => {
// ... 同上
}, [url, retryCount])
const retry = () => setRetryCount(c => c + 1)
return { status, data, error, retry }
}
重试的触发源是用户事件,但重试需要"重新执行 Effect"。 用一个计数状态作为依赖,用户点击时增加计数,触发 Effect 重新执行。
用库替代手写
手写数据获取很繁琐。实际项目中推荐用库:
- TanStack Query(React Query): 最流行,功能全。
- SWR: 轻量,Vercel 出品。
- RTK Query: Redux Toolkit 配套。
jsx
// TanStack Query 示例
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
})
if (isLoading) return <Spinner />
if (error) return <Error message={error.message} />
return <div>{data.name}</div>
}
库帮你处理了:
- 缓存
- 去重
- 竞态
- 重试
- 轮询
- 分页
- 乐观更新
推荐:生产项目用库。本课讲原理,理解原理才能用好库。
九、其他常见副作用模式
同步 document.title
jsx
function Page({ title }) {
useEffect(() => {
document.title = title
return () => {
document.title = "默认标题" // 恢复
}
}, [title])
return <div>...</div>
}
监听滚动
jsx
function useScrollPosition() {
const [position, setPosition] = useState(0)
useEffect(() => {
function handleScroll() {
setPosition(window.scrollY)
}
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [])
return position
}
性能优化:用节流。
jsx
useEffect(() => {
let ticking = false
function handleScroll() {
if (!ticking) {
requestAnimationFrame(() => {
setPosition(window.scrollY)
ticking = false
})
ticking = true
}
}
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [])
监听媒体查询
jsx
function useMediaQuery(query) {
const [matches, setMatches] = useState(false)
useEffect(() => {
const mql = window.matchMedia(query)
setMatches(mql.matches)
function handleChange(e) {
setMatches(e.matches)
}
mql.addEventListener("change", handleChange)
return () => mql.removeEventListener("change", handleChange)
}, [query])
return matches
}
// 用法
function Component() {
const isMobile = useMediaQuery("(max-width: 768px)")
return isMobile ? <MobileView /> : <DesktopView />
}
键盘快捷键
jsx
function useKeyboard(key, callback) {
useEffect(() => {
function handleKeyDown(e) {
if (e.key === key) callback(e)
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [key, callback])
}
注意: 如果 callback 每次渲染都是新函数,Effect 会频繁重建。用 useCallback 稳定:
jsx
const handleSave = useCallback(() => {
save()
}, [save])
useKeyboard("s", handleSave)
埋点
jsx
function ProductPage({ productId }) {
useEffect(() => {
trackEvent("view_product", { productId })
}, [productId])
return <div>...</div>
}
本地存储同步
jsx
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key)
return stored ? JSON.parse(stored) : initialValue
})
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value))
}, [key, value])
return [value, setValue]
}
十、常见误区与避坑
误区 1:用 Effect 同步派生状态
jsx
// ❌
useEffect(() => {
setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])
// ✅
const fullName = `${firstName} ${lastName}`
误区 2:依赖数组不完整
jsx
// ❌ userId 变了不重新请求
useEffect(() => {
fetch(`/api/users/${userId}`)
}, [])
// ✅
useEffect(() => {
fetch(`/api/users/${userId}`)
}, [userId])
开启 ESLint 的 react-hooks/exhaustive-deps 规则自动检测。
误区 3:依赖是对象或函数
jsx
// ❌ options 每次渲染都是新对象
useEffect(() => {
fetch(url, options)
}, [options])
// ✅ 用具体值
useEffect(() => {
fetch(url, { method: "POST" })
}, [url])
误区 4:忘记清理
jsx
// ❌ 内存泄漏
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000)
}, [])
// ✅
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(timer)
}, [])
误区 5:闭包陷阱
jsx
// ❌ count 永远是初始值
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(timer)
}, [])
// ✅ 函数式更新
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(timer)
}, [])
误区 6:竞态条件
jsx
// ❌ 快速切换 userId 可能显示旧数据
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser)
}, [userId])
// ✅ 用取消标记或 AbortController
useEffect(() => {
let cancelled = false
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => { if (!cancelled) setUser(data) })
return () => { cancelled = true }
}, [userId])
误区 7:在 Effect 里改状态导致无限循环
jsx
// ❌ 无限循环
useEffect(() => {
setCount(count + 1)
}, [count])
// ✅ 明确触发条件
useEffect(() => {
if (someCondition) {
setCount(c => c + 1)
}
}, [someCondition])
误区 8:多个 Effect 合并
jsx
// ❌ 多个不相关的副作用合并,难以维护
useEffect(() => {
document.title = title
const timer = setInterval(...)
window.addEventListener("resize", ...)
}, [title])
// ✅ 拆开,各管各的
useEffect(() => {
document.title = title
}, [title])
useEffect(() => {
const timer = setInterval(...)
return () => clearInterval(timer)
}, [])
useEffect(() => {
window.addEventListener("resize", ...)
return () => window.removeEventListener("resize", ...)
}, [])
每个 Effect 只做一件事。这是 Effect 的单一职责原则。
误区 9:在 Effect 中读取 ref.current
jsx
// ❌ ref.current 变化不触发 Effect
useEffect(() => {
doSomething(ref.current)
}, []) // ref.current 不是依赖
// ✅ 如果需要响应 ref 的变化,用状态或回调 ref
ref 是命令式的,它的变化不触发渲染。需要响应变化的,用状态。
误区 10:滥用 Effect
jsx
// ❌ 这个 Effect 什么也没做
useEffect(() => {
console.log("渲染了")
})
// ❌ 用 Effect 处理点击
useEffect(() => {
if (clicked) handleClick()
}, [clicked])
每次写 Effect,先问:这个操作是不是副作用?是不是必须放在 Effect 里?
十一、跨框架对照
副作用的位置
React:
jsx
useEffect(() => {
// 副作用
return () => { /* 清理 */ }
}, [deps])
Vue 3:
js
import { watch, onMounted, onUnmounted } from 'vue'
// 监听变化
watch(source, (newVal) => {
// 副作用
})
// 生命周期
onMounted(() => {
// 挂载时
})
onUnmounted(() => {
// 卸载时清理
})
SwiftUI:
swift
.onAppear {
// 出现时
}
.onDisappear {
// 消失时
}
.task(id: userId) {
// 异步任务,自动取消
}
Flutter:
dart
@override
void initState() {
super.initState();
// 初始化副作用
}
@override
void dispose() {
// 清理
super.dispose();
}
Jetpack Compose:
kotlin
LaunchedEffect(userId) {
// 副作用,key 变化时重启
}
DisposableEffect(Unit) {
// 副作用
onDispose {
// 清理
}
}
关键差异
React: 用一个 useEffect 统一处理,依赖数组决定时机。
Vue: watch 处理状态变化,onMounted / onUnmounted 处理生命周期。
SwiftUI: 用修饰符 .onAppear、.task,.task 自动取消。
Flutter: 用 initState / dispose 生命周期方法。
Compose: LaunchedEffect 处理副作用,DisposableEffect 处理需要清理的。
思维一致: 都是把副作用从渲染中抽离,隔离管理。学好 React 的 Effect,其他框架只是换语法。
十二、练一练:15 道多元化习题
1. 选择题
题目: 以下哪项不是副作用?
A. 发网络请求
B. 计算数组总和
C. 修改 document.title
D. 设置定时器
参考答案: B
解读: 计算数组总和是纯计算,不涉及外部世界,不是副作用。A、C、D 都会影响外部,是副作用。判断标准:这个操作会不会影响渲染函数之外的东西。
2. 判断题
题目: 只要在 useEffect 里调用 setState,就会无限循环。
参考答案: 错误。
解读: 只有在"Effect 依赖这个状态,又改这个状态"时才会无限循环。如果 Effect 改的是不在依赖里的状态,或者有条件地改,不会循环。但在 Effect 里改状态是危险信号,通常有更好的写法。
3. 填空题
题目: useEffect 的依赖数组有三种写法:、 、,分别表示 、、。
参考答案: 不写、空数组、有依赖;每次渲染都执行、只在挂载时执行、依赖变化时执行。
解读: 不写依赖数组几乎不用,因为每次渲染都执行。空数组表示只在挂载时执行一次。有依赖表示依赖变化时重新执行。依赖数组是 Effect 最容易出错的地方,必须理解清楚。
4. 代码阅读题
题目: 下面代码有什么问题?
jsx
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1)
}, 1000)
}, [])
参考答案: 闭包陷阱。count 永远是 Effect 创建时的值(初始值),setCount(count + 1) 每次都设成同一个值。
修复:
jsx
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1) // 函数式更新,拿最新值
}, 1000)
return () => clearInterval(timer) // 也别忘清理
}, [])
解读: Effect 捕获的是创建时的状态值。用函数式更新 setX(fn) 可以拿最新值。另外,原代码还缺少清理函数,会导致内存泄漏。
5. 找错题
题目: 下面代码有什么问题?
jsx
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser)
}, [])
参考答案:
- 依赖缺失:
userId变了不会重新请求。 - 没有清理: 快速切换
userId会有竞态条件。 - 组件卸载后 setState: 请求未完成时卸载会警告。
修复:
jsx
useEffect(() => {
const controller = new AbortController()
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(err => {
if (err.name !== "AbortError") setError(err)
})
return () => controller.abort()
}, [userId])
解读: 数据获取的 Effect 必须处理三件事:依赖完整、清理函数、错误处理。这是标准模式。
6. 判断该不该用 Effect
题目: 下面场景该不该用 Effect?
a. 用户点击按钮后发送请求
b. 根据 userId 加载用户信息
c. 根据 firstName 和 lastName 计算 fullName
d. 页面加载时设置 document.title
e. 用户提交表单后重置表单
参考答案:
a. 不该。放事件处理器。
b. 该。组件生命周期相关的副作用。
c. 不该。派生数据,直接计算。
d. 该。与外部系统同步。
e. 不该。在提交逻辑里直接重置。
解读: 判断标准:是不是副作用?触发源是什么?用户事件引起的放事件处理器,生命周期相关的放 Effect,派生数据直接算。能用事件处理器解决的,不要用 Effect。
7. 清理函数题
题目: 下面代码缺少什么?
jsx
useEffect(() => {
window.addEventListener("resize", handleResize)
}, [])
参考答案: 缺少清理函数。组件卸载时,监听器不会被移除,导致内存泄漏。
修复:
jsx
useEffect(() => {
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [])
解读: 任何"订阅"类操作都需要清理:事件监听、定时器、WebSocket、Observable 订阅。清理函数保证组件卸载时外部资源被释放。
8. 无限循环题
题目: 下面代码为什么会无限循环?
jsx
function Component({ userId }) {
const options = { headers: { "X-User": userId } }
useEffect(() => {
fetch("/api/data", options)
}, [options])
}
参考答案: options 是对象,每次渲染都会创建新引用。[options] 依赖变化,Effect 重新执行。Effect 里的 fetch 可能触发状态更新,导致重新渲染,又创建新 options,无限循环。
修复:
jsx
function Component({ userId }) {
useEffect(() => {
const options = { headers: { "X-User": userId } }
fetch("/api/data", options)
}, [userId])
}
解读: 依赖数组里的值必须是"稳定"的。对象、数组、函数每次渲染都是新引用,会导致无限循环。修复方式:拆解依赖到具体值,或用 useMemo / useCallback 稳定引用。
9. 竞态条件题
题目: 下面代码在什么情况下会显示错误数据?
jsx
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser)
}, [userId])
参考答案: 用户快速切换 userId 时。比如从 A 切到 B:
userId = A,发起请求 A。- 用户切到 B,
userId = B,发起请求 B。 - 请求 A 后发但先返回,
setUser(A 的数据)。 - 请求 B 先发但后返回,
setUser(B 的数据)。
最终显示的是 B 的数据,但如果顺序反过来(A 后返回),就会显示 A 的数据,而 userId 是 B。 这就是竞态条件。
修复: 用取消标记或 AbortController。
解读: 竞态条件是异步 Effect 的经典问题。任何根据参数加载数据的 Effect,都要处理竞态。 用 AbortController 是最标准的方式。
10. 数据获取模式题
题目: 写一个 useFetch Hook,要求:支持 URL 变化、取消请求、错误处理、四态状态机。
参考答案:
jsx
function useFetch(url) {
const [status, setStatus] = useState("idle")
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
if (!url) return
const controller = new AbortController()
setStatus("loading")
setError(null)
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then(data => {
setData(data)
setStatus("success")
})
.catch(err => {
if (err.name === "AbortError") return
setError(err.message)
setStatus("error")
})
return () => controller.abort()
}, [url])
return { status, data, error }
}
解读: 关键点:
- 四态状态机:
idle / loading / success / error。 AbortController取消请求,处理竞态。- 区分
AbortError和真实错误。 - 检查
res.ok,因为fetch不会因 4xx/5xx 报错。 - 依赖
url,URL 变化时重新请求。
11. 闭包陷阱题
题目: 下面代码有什么问题?
jsx
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(timer)
}, [])
参考答案: 闭包陷阱。count 永远是 Effect 创建时的值(初始值),setCount(count + 1) 每次都设成同一个值。
修复方式一:函数式更新。
jsx
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(timer)
}, [])
修复方式二:加依赖。
jsx
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(timer)
}, [count])
解读: 推荐函数式更新,因为它不重建定时器。加依赖的方式每次 count 变化都重建定时器,有性能开销。闭包陷阱是 Effect 最容易出错的地方之一。
12. Effect 拆分题
题目: 下面代码有什么问题?
jsx
useEffect(() => {
document.title = title
const timer = setInterval(() => setNow(Date.now()), 1000)
window.addEventListener("resize", handleResize)
}, [title])
参考答案: 三个不相关的副作用合并在一个 Effect 里,问题:
- 定时器和 resize 监听器依赖
title,title变化时全部重建。 - 没有清理函数。
- 职责不清。
修复:
jsx
useEffect(() => {
document.title = title
}, [title])
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(timer)
}, [])
useEffect(() => {
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [])
解读: 每个 Effect 只做一件事。这是 Effect 的单一职责原则。拆分后,每个 Effect 的依赖和清理逻辑都独立,更清晰、更不容易出错。
13. 依赖数组题
题目: 下面代码的依赖数组该写什么?
jsx
useEffect(() => {
const filtered = items.filter(i => i.name.includes(keyword))
trackEvent("search", { keyword, count: filtered.length })
}, [???])
参考答案: [items, keyword]。
解读: Effect 里用到的所有外部值都应该在依赖里。这里用了 items 和 keyword,所以依赖是 [items, keyword]。filtered 是派生的,不需要在依赖里。
注意: 这个 Effect 本身可能有问题------如果它只是用于埋点,可以。但如果是想同步 filtered 到状态,那就是反模式,应该直接派生。
14. 同步外部系统题
题目: 实现一个 useDocumentTitle Hook,让组件的 title prop 同步到 document.title。
参考答案:
jsx
function useDocumentTitle(title) {
useEffect(() => {
const prevTitle = document.title
document.title = title
return () => {
document.title = prevTitle
}
}, [title])
}
// 用法
function Page({ title }) {
useDocumentTitle(title)
return <div>...</div>
}
解读: 关键点:
- 保存上一次的 title,卸载时恢复。
- 依赖
title,变化时重新设置。 - 清理函数保证卸载时恢复。
注意: 如果多个组件都用这个 Hook,会有冲突(后挂载的覆盖先挂载的)。实际项目中,通常只在页面级组件用一次。
15. 综合题
题目: 实现一个"聊天室"组件,要求:
- 连接 WebSocket
- 显示消息列表
- 发送消息
- 断线重连
- 组件卸载时断开连接
参考答案:
jsx
function ChatRoom({ roomId }) {
const [status, setStatus] = useState("connecting")
const [messages, setMessages] = useState([])
const wsRef = useRef(null)
useEffect(() => {
let cancelled = false
let reconnectTimer = null
function connect() {
const ws = new WebSocket(`ws://api/chat/${roomId}`)
wsRef.current = ws
ws.onopen = () => {
if (!cancelled) setStatus("connected")
}
ws.onmessage = (e) => {
if (!cancelled) {
setMessages(prev => [...prev, JSON.parse(e.data)])
}
}
ws.onerror = () => {
if (!cancelled) setStatus("error")
}
ws.onclose = () => {
if (cancelled) return
setStatus("reconnecting")
reconnectTimer = setTimeout(connect, 3000)
}
}
connect()
return () => {
cancelled = true
clearTimeout(reconnectTimer)
wsRef.current?.close()
}
}, [roomId])
function sendMessage(text) {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ text }))
}
}
return (
<div>
<div className={`status status-${status}`}>
{status === "connected" ? "已连接" :
status === "reconnecting" ? "重连中..." :
status === "error" ? "连接错误" : "连接中..."}
</div>
<ul>
{messages.map((msg, i) => (
<li key={i}>{msg.text}</li>
))}
</ul>
<input
onKeyDown={e => {
if (e.key === "Enter") {
sendMessage(e.target.value)
e.target.value = ""
}
}}
placeholder="输入消息,回车发送"
/>
</div>
)
}
解读: 关键点:
cancelled标记: 防止组件卸载后更新状态。- 重连机制:
onclose时setTimeout重连。 - 清理函数: 清除重连定时器,关闭 WebSocket。
wsRef: 用于发送消息,因为sendMessage在 Effect 外。- 依赖
roomId: 切换房间时重连。
注意: 这道题涉及"非受控输入"(用 e.target.value 直接读)。实际项目中应该用受控 + useState。这里为了简洁省略。
十三、本课检查清单
写 Effect 时,按顺序问自己:
该不该用 Effect:
- 这个操作是副作用吗?不是 → 不要用 Effect。
- 触发源是用户事件吗?是 → 放事件处理器。
- 是派生数据吗?是 → 直接计算。
- 是组件生命周期相关的吗?是 → 用 Effect。
依赖数组:
- Effect 里用到的所有外部值都在依赖里吗?
- 依赖里有对象、数组、函数吗?有 → 拆解或用
useMemo/useCallback。 - 空依赖的 Effect 里,有没有用到会变化的值?
清理函数:
- 有订阅吗?→ 需要取消订阅。
- 有定时器吗?→ 需要
clearInterval/clearTimeout。 - 有请求吗?→ 需要
AbortController或cancelled标记。 - 有事件监听吗?→ 需要
removeEventListener。 - 有连接吗?→ 需要
close。
竞态处理:
- 数据获取的 Effect 有竞态风险吗?
- 用了
AbortController或cancelled标记吗? - 区分了
AbortError和真实错误吗?
闭包陷阱:
- Effect 里用了状态或 props 吗?
- 用函数式更新
setX(fn)了吗? - 或者把值加进依赖了吗?
Effect 拆分:
- 一个 Effect 是不是做了多件事?
- 拆开后是不是更清晰?
反模式自查:
- 有没有用 Effect 同步派生状态?
- 有没有用 Effect 处理用户事件?
- 有没有用 Effect 初始化状态?
十四、课后作业
基础题
题目 1: 下面代码有什么问题?如何修复?
jsx
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1)
}, 1000)
}, [])
参考答案:
问题:
- 闭包陷阱:
count永远是初始值。 - 缺少清理函数:内存泄漏。
修复:
jsx
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(timer)
}, [])
解读: 两个经典陷阱:闭包陷阱用函数式更新解决,内存泄漏用清理函数解决。这两个问题在定时器场景中极其常见。
题目 2: 下面代码有什么问题?
jsx
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser)
}, [])
参考答案:
问题:
- 依赖缺失:
userId变化不重新请求。 - 竞态条件:快速切换
userId可能显示旧数据。 - 组件卸载后 setState 警告。
修复:
jsx
useEffect(() => {
const controller = new AbortController()
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(err => {
if (err.name !== "AbortError") setError(err)
})
return () => controller.abort()
}, [userId])
解读: 数据获取的 Effect 必须处理三件事:依赖完整、清理函数、错误处理。这是标准模式。
题目 3: 判断下列场景该不该用 Effect。
a. 用户点击后发请求
b. 根据 userId 加载数据
c. 根据 items 计算总价
d. 页面加载时同步 document.title
e. 提交成功后重置表单
参考答案:
a. 不该。放事件处理器。
b. 该。生命周期相关的副作用。
c. 不该。派生数据。
d. 该。与外部系统同步。
e. 不该。在提交逻辑里直接重置。
解读: 判断标准:是不是副作用?触发源是什么?能用事件处理器解决的,不要用 Effect。 这是 Effect 的第一原则。
进阶题
题目 4: 实现一个 useDebounce Hook。输入一个值,返回延迟后的值。
参考答案:
jsx
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
// 用法
function SearchBox() {
const [keyword, setKeyword] = useState("")
const debouncedKeyword = useDebounce(keyword, 300)
useEffect(() => {
if (debouncedKeyword) {
search(debouncedKeyword)
}
}, [debouncedKeyword])
return (
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
)
}
解读: 关键点:
useEffect监听value变化。- 每次变化重置定时器(清理上一次)。
- 延迟后更新
debouncedValue。 - 依赖
[value, delay]。
这个 Hook 的妙处: 用清理函数实现了"取消上一次定时器",是 Effect 清理函数的经典应用。
题目 5: 实现一个 useIntersectionObserver Hook,检测元素是否进入视口。
参考答案:
jsx
function useIntersectionObserver(options = {}) {
const [isIntersecting, setIsIntersecting] = useState(false)
const ref = useRef(null)
useEffect(() => {
const element = ref.current
if (!element) return
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting)
}, options)
observer.observe(element)
return () => observer.disconnect()
}, [options.root, options.rootMargin, options.threshold])
return [ref, isIntersecting]
}
// 用法
function LazyImage({ src }) {
const [ref, isVisible] = useIntersectionObserver()
return (
<div ref={ref}>
{isVisible ? <img src={src} /> : <div className="placeholder" />}
</div>
)
}
解读: 关键点:
ref用于获取 DOM 元素。useEffect里创建IntersectionObserver。- 清理时
observer.disconnect()。 - 依赖
options的具体字段,不是整个options对象(避免每次渲染新对象)。
注意: 这是"命令式 API 集成"的典型例子。IntersectionObserver 是命令式的,用 useRef + useEffect 封装成声明式 Hook。这是第 13 课会深入讲的模式。
题目 6: 实现一个 useLocalStorage Hook,读写 localStorage,支持 SSR。
参考答案:
jsx
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
if (typeof window === "undefined") return initialValue
try {
const stored = window.localStorage.getItem(key)
return stored ? JSON.parse(stored) : initialValue
} catch {
return initialValue
}
})
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch (err) {
console.error("localStorage 写入失败", err)
}
}, [key, value])
return [value, setValue]
}
// 用法
function Component() {
const [theme, setTheme] = useLocalStorage("theme", "light")
return (
<button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>
当前主题:{theme}
</button>
)
}
解读: 关键点:
- 初始值用函数形式:
useState(() => ...),避免每次渲染都读 localStorage。 - SSR 兼容: 检查
typeof window。 - 错误处理: localStorage 可能被禁用或超限。
- Effect 同步: value 变化时写入 localStorage。
注意: 这个 Hook 在多个标签页之间不同步。需要跨标签页同步的,要监听 storage 事件。
思考题
题目 7: 为什么"用 Effect 同步派生状态"是反模式?
参考答案:
因为派生状态根本不该是状态。用 Effect 同步它,会导致:
- 多一次渲染: 状态变化 → 渲染 → Effect 执行 → 改派生状态 → 再渲染。
- 中间态不一致: 第一次渲染时,派生状态还是旧值,界面可能显示错乱。
- 增加复杂度: 本来一行计算搞定,现在要维护状态 + Effect。
- 可能死循环: 依赖处理不当,Effect 改状态又触发 Effect。
正确做法:
jsx
// ❌
useEffect(() => {
setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])
// ✅
const fullName = `${firstName} ${lastName}`
解读: 判断信号:当你写 useEffect(() => setX(...), [y]) 时,先问"x 是不是能从 y 算出来?"如果是,就不该用状态 + Effect,应该直接派生。这个反模式极其常见,是 Effect 的头号错误。
题目 8: Effect 的依赖数组为什么要求"完整"?
参考答案:
因为 Effect 捕获的是创建时的值。如果依赖不完整:
- 闭包陷阱: Effect 里用的是旧值,不是最新值。
- 行为不一致: 依赖变化时 Effect 不重新执行,逻辑出错。
- 难以调试: 表面上代码正确,但运行时行为诡异。
例子:
jsx
// ❌ 依赖缺失,name 变化不更新 title
useEffect(() => {
document.title = `${greeting}, ${name}`
}, [greeting])
为什么 ESLint 强制要求完整依赖: 因为它能检测到 Effect 里用到的所有外部值,帮你避免闭包陷阱。
如果依赖完整导致频繁执行怎么办?
- 用函数式更新
setX(fn)避免依赖状态。 - 用
useMemo/useCallback稳定引用。 - 重新设计,把逻辑移出 Effect。
不要用"故意省略依赖"来优化性能。 这是 bug 的温床。
解读: 依赖完整性是 Effect 正确性的基础。ESLint 的 exhaustive-deps 规则必须开启。 不要为了"优化"而故意省略依赖。
题目 9: 什么情况下必须用 Effect,不能用事件处理器?
参考答案:
以下场景必须用 Effect:
- 组件挂载时的初始化: 比如订阅 WebSocket、设置事件监听。
- 组件卸载时的清理: 比如取消订阅、清除定时器。
- 与外部系统同步: 比如
document.title、window.scrollTo。 - 响应 props 或状态变化的副作用: 比如
userId变化时重新加载数据。 - 定时器: 比如每秒更新时间。
为什么不能用事件处理器:
- 事件处理器只在用户操作时触发,无法覆盖"挂载时执行一次"。
- 事件处理器无法感知"props 变化"。
- 事件处理器无法感知"组件卸载"。
但要注意: 很多看似需要 Effect 的场景,实际上能用事件处理器。
- "提交成功后重置表单" → 在提交逻辑里直接重置。
- "输入时同步到另一个状态" → 派生。
- "点击后发请求" → 事件处理器。
解读: Effect 是"组件生命周期相关的副作用"的载体。只有当副作用和生命周期相关(挂载、卸载、props 变化)时,才用 Effect。
挑战题
题目 10: 实现一个 useWebSocket Hook,要求:
- 自动连接
- 自动重连(指数退避)
- 组件卸载时断开
- 提供
send方法 - 提供连接状态
参考答案:
jsx
function useWebSocket(url) {
const [status, setStatus] = useState("connecting")
const [lastMessage, setLastMessage] = useState(null)
const wsRef = useRef(null)
const retryCountRef = useRef(0)
const maxRetries = 5
useEffect(() => {
let cancelled = false
let reconnectTimer = null
function connect() {
if (cancelled) return
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => {
if (cancelled) return
retryCountRef.current = 0
setStatus("connected")
}
ws.onmessage = (e) => {
if (!cancelled) setLastMessage(e.data)
}
ws.onerror = () => {
if (!cancelled) setStatus("error")
}
ws.onclose = () => {
if (cancelled) return
if (retryCountRef.current < maxRetries) {
setStatus("reconnecting")
const delay = Math.min(1000 * 2 ** retryCountRef.current, 30000)
retryCountRef.current += 1
reconnectTimer = setTimeout(connect, delay)
} else {
setStatus("failed")
}
}
}
connect()
return () => {
cancelled = true
clearTimeout(reconnectTimer)
wsRef.current?.close()
}
}, [url])
const send = useCallback((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(data)
}
}, [])
return { status, lastMessage, send }
}
// 用法
function Chat() {
const { status, lastMessage, send } = useWebSocket("ws://api/chat")
return (
<div>
<div>状态:{status}</div>
<div>最新消息:{lastMessage}</div>
<button onClick={() => send("hello")}>发送</button>
</div>
)
}
解读: 关键点:
- 指数退避重连:
delay = 1000 * 2^retryCount,最大 30 秒。 - 重试上限:
maxRetries次后停止,避免无限重连。 retryCountRef: 用 ref 保存重试次数,因为它不影响渲染。cancelled标记: 防止卸载后更新状态。- 清理函数: 清除重连定时器,关闭 WebSocket。
send用useCallback: 稳定引用,方便作为依赖。
注意: 重试次数重置的逻辑------连接成功后重置,避免下次断开时直接失败。
十五、本课小结
什么是副作用
与外部世界交互的操作。 网络请求、存储、DOM 操作、订阅、定时器、日志。
副作用的三个归属
- 事件处理器: 用户触发的。
- Effect: 组件生命周期相关的。
- 渲染: 不允许。
Effect 的本质
让组件与外部系统同步的机制。
jsx
useEffect(() => {
// 副作用
return () => { /* 清理 */ }
}, [依赖])
判断该不该用 Effect
text
是副作用吗?
├─ 不是 → 不用 Effect
└─ 是 → 触发源是什么?
├─ 用户事件 → 事件处理器
├─ 生命周期 → Effect
└─ 状态连锁 → 优先事件处理器
核心原则:能用事件处理器解决的,不要用 Effect。
依赖数组
- 完整:Effect 里用到的所有外部值都要在依赖里。
- 稳定:不要放对象、数组、函数(每次渲染新引用)。
- 空数组:只在挂载时执行。
清理函数
订阅、定时器、请求、监听、连接都要清理。
jsx
useEffect(() => {
const timer = setInterval(...)
return () => clearInterval(timer)
}, [])
三个经典陷阱
- 无限循环: 依赖是对象,每次渲染新引用。
- 竞态条件: 多个异步操作,返回顺序不定。
- 闭包陷阱: Effect 捕获旧值,用函数式更新或加依赖。
数据获取标准模式
jsx
useEffect(() => {
const controller = new AbortController()
setStatus("loading")
fetch(url, { signal: controller.signal })
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
.then(data => { setData(data); setStatus("success") })
.catch(err => {
if (err.name === "AbortError") return
setError(err.message)
setStatus("error")
})
return () => controller.abort()
}, [url])
四条铁律
- 渲染保持纯。 副作用只在事件处理器和 Effect 中。
- 依赖要完整。 开启 ESLint 的
exhaustive-deps。 - 有订阅必清理。 定时器、监听、请求、连接。
- 能用事件解决,不用 Effect。
一句话记住本课
渲染是纯函数,副作用是外交互。把副作用管好,应用就稳了。
实用价值回顾
- 避免无限循环。 依赖数组正确,不会循环执行。
- 避免竞态条件。
AbortController或cancelled标记。 - 避免内存泄漏。 清理函数释放资源。
- 避免闭包陷阱。 函数式更新或完整依赖。
- 代码更清晰。 每个 Effect 单一职责。
- 跨框架通用。 所有框架都把副作用隔离管理。
十六、下一课预告
第 7 课 组件拆分与组合
本课讲了副作用管理,下一课讲组件结构。核心内容:
- 什么时候拆组件:三个信号
- 容器组件 vs 展示组件
children与插槽式组合- 避免过度拆分和拆分不足
- 组件命名与文件组织
- 复合组件的设计
副作用是与外部交互,组件拆分是内部组织。两者结合,才能写出可维护的应用。