1. useEffect 的三大场景与执行时机
useEffect 是处理副作用的核心 Hook,它的行为完全由依赖数组决定。
🟢 场景一:空依赖数组 []
-
代码:
useEffect(() => { ... }, []) -
行为:仅在组件首次挂载(Mount) 后执行一次。
-
类组件等价物:
componentDidMount -
典型用途:
- 初始化数据请求(API Call)。
- 设置全局事件监听(如
window.resize)。 - 初始化第三方库实例。
-
⚠️ 陷阱:如果在依赖数组中漏写了内部使用的 state/prop,会导致闭包陷阱(读取到旧值)。
🟡 场景二:有依赖数组 [a, b]
-
代码:
useEffect(() => { ... }, [a, b]) -
行为:
- 首次挂载时执行。
- 当且仅当
a或b的值发生变化(浅比较Object.is)时,再次执行。
-
类组件等价物:
componentDidMount+componentDidUpdate(特定条件) -
典型用途:
- 根据 URL 参数 (
id) 重新获取数据。 - 当某个状态变化时,更新文档标题或发送埋点。
- 根据 URL 参数 (
-
⚠️ 陷阱:
- 无限循环:如果在 effect 内部修改了依赖项(如
setCount(count + 1)且count在依赖中),会触发死循环。 - 对象/数组依赖:如果依赖是对象
{}或数组[],每次渲染都会生成新引用,导致 effect 频繁执行。解决:使用useMemo包裹对象,或只依赖对象的原始属性。
- 无限循环:如果在 effect 内部修改了依赖项(如
🔴 场景三:返回清理函数 return () => { ... }
-
代码:
useEffect(() => { // 设置副作用 const timer = setInterval(...); // 返回清理函数 return () => { clearInterval(timer); }; }, []); -
行为:
- 组件卸载前执行(类似
componentWillUnmount)。 - 下次 effect 执行前执行(如果依赖变了,先清理旧的,再执行新的)。
- 组件卸载前执行(类似
-
典型用途:
- 清除定时器 (
clearInterval,clearTimeout)。 - 取消未完成的网络请求 (
AbortController)。 - 移除事件监听 (
removeEventListener)。 - 断开 WebSocket 连接。
- 销毁第三方库实例。
- 清除定时器 (
-
核心价值:防止内存泄漏和竞态条件(Race Conditions)。
2. useEffect 清理函数详解:为什么必须写?
很多初学者认为“组件没了浏览器会自动回收”,但在单页应用(SPA)中,逻辑上的卸载不等于物理资源的释放。
核心作用拆解:
-
防止内存泄漏 (Memory Leaks)
- 场景:组件 A 启动了一个定时器,用户快速切换路由导致 A 卸载。如果不清除定时器,它仍在后台运行,尝试更新一个已不存在的组件状态,导致报错
Can't perform a React state update on an unmounted component,且定时器占用内存。 - 对策:在
return中clearInterval。
- 场景:组件 A 启动了一个定时器,用户快速切换路由导致 A 卸载。如果不清除定时器,它仍在后台运行,尝试更新一个已不存在的组件状态,导致报错
-
避免竞态条件 (Race Conditions)
-
场景:用户快速切换 ID(从 1 到 2)。
- 请求 ID=1 发出(慢)。
- 请求 ID=2 发出(快,先回来)。
- 请求 ID=1 回来了(晚),覆盖了 ID=2 的数据。
-
对策:在清理函数中取消上一个请求。
useEffect(() => { let ignore = false; // 标记位 fetchData(id).then(data => { if (!ignore) setData(data); }); return () => { ignore = true; }; // 清理时标记为忽略 }, [id]); // 或者使用 AbortController -
-
解除外部订阅
- 场景:订阅了 Redux Store、WebSocket 或 DOM 事件。如果不取消订阅,即使组件销毁,回调函数仍会被触发,可能导致对已销毁 DOM 的操作报错。
3. HOC (高阶组件) 深度解析
✅ 标准定义
HOC (Higher-Order Component) 是一个函数,它接受一个组件作为参数,并返回一个新的增强组件。
- 公式:
const EnhancedComponent = withSomething(WrappedComponent) - 本质:React 中复用组件逻辑的高级模式(在 Hooks 出现前是主流)。
- 原则:纯函数,不修改原组件,通过组合(Composition)创造新功能。
🛡️ 权限控制场景(标准实现)
这是 HOC 最经典的应用场景:权限守卫。
// withAuth.js
import { Navigate } from 'react-router-dom';
export function withAuth(WrappedComponent, requiredRole) {
return function AuthenticatedComponent(props) {
const { user } = useAuth(); // 假设有一个获取用户信息的 Hook
// 1. 未登录
if (!user) {
return <Navigate to="/login" replace />;
}
// 2. 权限不足
if (requiredRole && user.role !== requiredRole) {
return <Navigate to="/403" replace />;
}
// 3. 权限通过,渲染原组件
return <WrappedComponent {...props} user={user} />;
};
}
// 使用
const AdminPage = withAuth(Dashboard, 'admin');
- 优势:业务页面组件不需要关心“怎么判断权限”,只需要关注“页面长什么样”。逻辑解耦。
⚔️ HOC vs 普通封装函数
| 特性 | HOC (高阶组件) | 普通工具函数 |
|---|---|---|
| 输入/输出 | 输入组件 → 输出新组件 | 输入数据 → 输出数据/结果 |
| 复用层级 | 组件级复用(包含 UI、生命周期、状态) | 逻辑级复用(纯计算、格式化) |
| 能力范围 | 可以拦截渲染、注入 Props、控制生命周期、处理副作用 | 只能处理传入的数据,无法触达 DOM 或生命周期 |
| 典型场景 | 权限控制、日志埋点、数据注入、错误边界 | 日期格式化、数学计算、字符串处理 |
面试加分点:提到 HOC 的缺点(嵌套地狱 Wrapper Hell、静态方法丢失、Ref 转发问题),并说明现在更推荐使用 Custom Hooks 来替代 HOC 进行逻辑复用,但 HOC 在控制渲染结构(如权限守卫、布局包裹)上仍有不可替代的价值。
4. React 组件更新机制(触发重渲染的条件)
React 的更新是自顶向下的。以下三种情况会触发组件重新渲染(Re-render):
-
State 变化:调用
setState或 Hook 的 setter (如setCount)。 -
Props 变化:父组件重新渲染,传递给子组件的 Props 发生了改变(引用不同或值不同)。
-
父组件渲染:默认情况下,只要父组件渲染,无论子组件的 Props 是否变化,子组件也会跟着重新渲染。
- 这是性能优化的重点:如果子组件渲染开销大,需要使用
React.memo阻断这种默认行为。
- 这是性能优化的重点:如果子组件渲染开销大,需要使用
注意:Context 的值变化,也会导致所有消费该 Context 的组件重新渲染。
5. useCallback vs useMemo:缓存的艺术
这两个 Hook 都是为了性能优化,避免不必要的计算或渲染,但侧重点不同。
🧠 useMemo:缓存计算结果
-
目的:避免昂贵的计算在每次渲染时重复执行。
-
返回值:缓存后的数据值。
-
场景:
- 过滤/排序大型列表。
- 复杂的数学运算。
- 保持对象引用的稳定性(作为
useEffect的依赖)。
-
代码:
const sortedList = useMemo(() => { return list.sort((a, b) => a.value - b.value); }, [list]); // 只有 list 变了才重新排序
⚡ useCallback:缓存函数引用
-
目的:保持函数引用的稳定性,防止子组件因 Props 中的函数变化而无谓渲染。
-
返回值:缓存后的函数本身。
-
本质:
useCallback(fn, deps)等价于useMemo(() => fn, deps)。 -
场景:
- 传递给被
React.memo包裹的子组件的事件处理函数。 - 作为
useEffect的依赖项(避免 effect 频繁触发)。
- 传递给被
-
代码:
const handleClick = useCallback(() => { doSomething(id); }, [id]); // 只有 id 变了,handleClick 才会变 // 传给子组件,防止子组件无谓渲染 <Child onClick={handleClick} />
误区警示:不要滥用!创建缓存本身也有开销。只有在计算真的很慢,或者确实导致了子组件频繁渲染时才使用。
6. React.memo:组件级的防抖
-
定义:一个高阶组件(HOC),用于包裹函数组件。
-
作用:浅比较(Shallow Compare) 组件的 Props。
- 如果 Props 没变 👉 跳过渲染,直接复用上一次的渲染结果。
- 如果 Props 变了 👉 正常重新渲染。
-
等价物:类组件中的
PureComponent。 -
代码:
const MyComponent = React.memo(function MyComponent({ data }) { return <div>{data}</div>; }); -
进阶用法:可以传入第二个参数,自定义比较逻辑。
React.memo(Component, (prevProps, nextProps) => { // 返回 true 表示跳过渲染,false 表示重新渲染 return prevProps.id === nextProps.id; }); -
配合:通常与
useCallback和useMemo配合使用。如果父组件传下来的函数每次都是新的,React.memo就会失效。
7. 为什么要用 Hooks?(核心优势)
Hooks 不仅仅是语法糖,它是 React 编程范式的转变。
-
逻辑复用更优雅 (Logic Reuse)
- 以前:需要用 HOC 或 Render Props,导致组件树嵌套过深(Wrapper Hell),调试困难。
- 现在:使用 Custom Hooks,将逻辑提取为简单的函数,按需调用,扁平化代码结构。
-
逻辑聚合 (Co-location)
- 以前:相关逻辑分散在
componentDidMount,componentDidUpdate,componentWillUnmount等不同生命周期中,难以维护。 - 现在:使用
useEffect等,将相关的逻辑(如订阅/取消订阅)写在同一个 Hook 里,内聚性更强。
- 以前:相关逻辑分散在
-
代码简洁,去 Class 化
- 无需处理
this指向问题(新手噩梦)。 - 代码量更少,更易阅读。
- 更容易进行静态分析和类型推断(TypeScript 友好)。
- 无需处理
-
拥抱并发未来
- Hooks 是为 React 的并发模式(Concurrent Mode)设计的,类组件很难适配可中断渲染等高级特性。
💡 面试总结口诀
- Effect:空挂有变清尾巴,泄漏竞态全靠它。
- HOC:函参组返做门禁,逻辑复用防嵌套。
- 更新:自变爹给爹动身,子随父动是默认。
- 缓存:Memo 算值 Callback 函,组件 Memo 挡渲染。
- Hooks:去 Class 来聚逻辑,无 This 复用更给力。