引言
在 React 16.8 发布 Hooks 以来, class 关键字在新建的项目中逐渐销声匿迹,取而代之的是更加简洁、轻量的 Function Components。
很多人认为这只是一次语法层面的"升级"------把 render 函数拆出来,把 this.state 换成 useState,一切就万事大吉了。
但事实并非如此。
如果你带着写 Class 组件的惯性思维(Lifecycle Thinking)去写 Hooks,你很快就会撞上一堵墙:无限循环的请求、过期的闭包变量(Stale Closures)、以及那个越写越长、让人望而生畏的 useEffect 依赖数组。
从 Class 到 Function,本质上不是语法的改变,而是心智模型(Mental Model)的重构:我们正在从"面向对象与生命周期"转向"函数式与逻辑聚合"。
在这篇文章中,我们将通过一个具体的 WindowTracker 案例,对比展示两种模式的本质差异;并且讨论如何避免 useEffect 陷阱。
1. 范式转移------从"生命周期"到"逻辑聚合"
在 Class 组件时代,我们的代码组织方式是被 React 的生命周期方法(Lifecycle Methods) 强行切割的。为了说明这一点,我们来看一个经典的案例:WindowTracker(一个显示当前窗口宽度,并修改 document.title 的组件)。
1.1 Class Component:被割裂的逻辑
我们先看看在Class组件中,这个需求是如何实现的
js
class WindowTracker extends React.Component {
constructor(props) {
super(props);
this.state = { width: window.innerWidth };
this.handleResize = this.handleResize.bind(this);
}
componentDidMount() {
// 🔴 逻辑 A 的开始:订阅事件
window.addEventListener('resize', this.handleResize);
// 🔵 逻辑 B 的开始:更新标题 --- 首次挂载
document.title = `Width: ${this.state.width}`;
}
// 🔵 逻辑 B 的重复:更新标题 --- state更新
componentDidUpdate() {
document.title = `Width: ${this.state.width}`;
}
// 🔴 逻辑 A 的结束:取消订阅
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
handleResize() {
this.setState({ width: window.innerWidth });
}
render() {
return <div>Current Width: {this.state.width}px</div>;
}
}
观察代码,你会发现相关的逻辑被强行拆分了 ,如果你想修改"更新标题"的逻辑,你必须同时修改 DidMount 和 DidUpdate 的代码。
这种 "关注点分离"(Scattered Concerns) 是导致大型 Class 组件难以维护的元凶。
1.2 Function Component: 逻辑的自然聚合 (Co-location)
Hooks 的出现,让我们得以按照代码的用途,而不是代码执行的时间点来组织逻辑。
js
function WindowTracker() {
const [width, setWidth] = useState(window.innerWidth);
// 🔴 逻辑 A:完整的窗口订阅逻辑
// 订阅和取消订阅在一起,像一个独立的单元
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// 🔵 逻辑 B:完整的标题同步逻辑
// 只要 width 变了,我就执行,不需要关心是 Mount 还是 Update
useEffect(() => {
document.title = `Width: ${width}`;
}, [width]);
return <div>Current Width: {width}px</div>;
}
核心优势: 这就是 Co-location(同地协作)。我们不再思考"这个组件挂载了吗?",我们思考的是"这个副作用依赖什么数据?"
1.3 自定义 Hook :逻辑复用
既然逻辑 A 已经聚合在一起了,我们就可以把提取一个自定义hooks出来以供服用:
js
// hooks/useWindowWidth.js
export function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return width;
}
// 组件代码缩减为:
function WindowTracker() {
const width = useWindowWidth(); // ✅ 逻辑复用,极度简洁
useEffect(() => { document.title = `Width: ${width}`; }, [width]);
return <div>Current Width: {width}px</div>;
}
2. 谨防Effect陷阱
当你开始享受 Function Component 的简洁时,很快就会遇到新的挑战:useEffect 并不是 componentDidMount 的简单替代品。如果你还带着命令式的思维,你一定会掉进坑里。
2.1 陷阱一:闭包陷阱 (Stale Closures)
新手最容易犯的错误,就是以为 Effect 里的变量会自动更新。
❌ 错误示范:
js
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // 永远打印 0!
setCount(count + 1); // 永远重置为 1
}, 1000);
return () => clearInterval(id);
}, []); // 依赖为空,Effect 只运行一次
}
JavaScript 的闭包机制导致 setInterval 内部引用的 count 永远是第一次渲染时的那个值 (0)。它被"冻结"在过去的时间里了。
✅ 修正方案:函数式更新 我们不需要依赖当前的 count,而是告诉 React 如何计算下一个状态:
js
setCount(prevCount => prevCount + 1); // ✅ 不需要依赖外部的 count 变量
这不仅仅是 React 的特性。 如果你用过 Jotai、Zustand 或 Redux,你会发现这种控制反转 (Inversion of Control)的设计模式无处不在。
在 Jotai 中,更新 Atom 时也是 set(atom, prev => prev + 1)。
在 Redux 中,Reducer 的 (state, action) => newState 也是同样的道理。
2.2 陷阱二:依赖地狱 (Dependency Hell)
随着业务逻辑变复杂,你的依赖数组可能会变得非常长:[user, settings, history, socket, theme...]。任何一个变量微小的变动,都会导致整个 Effect 重新执行。
面对"依赖地狱",我们需要用三大原则进行逻辑拆解:
原则 A:按职责拆分 (Split by Concern)
不要把所有逻辑塞进一个 Effect。
如果一个 Effect 既负责"埋点上报"又负责"连接 WebSocket",请把它们拆成两个 useEffect。
原则 B:区分"事件"与"副作用" (Events vs Effects)
这是最重要的原则。不要为了获取最新值,就把逻辑塞进 Effect。
❌ 错误: 用户点击按钮提交,但我需要在 Effect 里监听 isSubmitting 状态来发送请求,导致不得不把所有表单数据加入依赖。
js
function LoginForm() {
const [text, setText] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// ❌ 错误:滥用 Effect 处理交互
useEffect(() => {
// 只有当开关被打开时,才发送请求
if (isSubmitting) {
async function postData() {
await api.login(text);
setIsSubmitting(false); // 请求完把开关关掉
}
postData();
}
}, [isSubmitting, text]); // sos灾难:不得不把 text 也加入依赖!
const handleSubmit = () => {
// 点击按钮时不直接发送,而是去拨动开关
setIsSubmitting(true);
};
return (
<form>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={handleSubmit}>登录</button>
</form>
);
}
✅ 正确: 回到最朴素的编程思维-"用户点击时,发送请求"。在 onClick 事件处理函数中直接读取 State 发送请求。Effect 应该只用于同步(比如根据 ID 获取数据),而不是用于处理交互。
js
function LoginForm() {
const [text, setText] = useState('');
// 不需要 isSubmitting 这个状态来做触发器(虽然可以用它来控制 Loading UI)
// ✅ 正确:事件处理函数包含所有逻辑
const handleSubmit = async () => {
// 1. 直接在这里读取最新的 state
// 这里读取 text 不需要经过依赖数组,它是直接可用的
console.log('正在提交:', text);
try {
await api.login(text);
console.log('成功');
} catch (e) {
console.error(e);
}
};
return (
<form>
<input value={text} onChange={e => setText(e.target.value)} />
{/* 直接绑定事件处理函数 */}
<button onClick={handleSubmit}>登录</button>
</form>
);
}
原则 C:使用 Ref 保持"沉默" (Escape Hatch)
有时你确实需要在 Effect 中读取一个最新值,但不希望这个值的变化触发 Effect 重新执行。
js
// 场景:聊天室连接后,记录当前的 theme,但切换 theme 不应该导致重连
const themeRef = useRef(theme);
// 保持 Ref 最新
useEffect(() => {
themeRef.current = theme;
});
useEffect(() => {
const connection = createConnection();
connection.on('connected', () => {
// ✅ 读取最新 theme,但不需要将 theme 加入依赖数组
console.log('Connected with theme:', themeRef.current);
});
return () => connection.disconnect();
}, []); // 只有挂载时连接一次
结语:构建以"依赖"为核心的心智模型
从 Class Component 到 Function Component 的迁移,表面上是一次语法的精简,实则是一场心智模型的彻底重构。
在 Class 时代,我们习惯于命令式地控制时间:
"在组件挂载时(DidMount)做这件事,在更新时(DidUpdate)做那件事。"
而在 Hooks 时代,React 强迫我们转向声明式的数据流:
"在这个副作用中,我依赖了这些数据(Dependencies)。每当数据变化,副作用自然随之流转。"
Hooks 的代价:手动管理"闭包的新鲜度"
在 Class 组件中,this.state 像是一个永远指向最新值的全局指针,你随时去读,它都是实时的。
但在 Function 组件中,由于闭包的存在,每一次渲染都是一次独立的快照,每个 Effect 内部持有的数据都可能是"过期"的。
React 无法在运行时通过静态分析知道你的闭包里引用了哪些外部变量。因此,依赖数组(Dependency Array) 本质上是你与 React 之间签署的一份 "缓存失效协议"。
你必须显式告诉 React:"当 userId 变化时,上一次的 Effect 闭包已经失效了(因为它引用的是旧的 userId),请销毁它,并运行本次渲染产生的新 Effect。"
这种机制虽然增加了心智负担,但它强迫我们将副作用与数据状态进行严格的同步绑定,从根本上消除了 Class 组件中常见的"数据已变但逻辑未响应"的一致性 Bug。
获得的巨大回报
当你适应了这种 "数据驱动依赖" 的思维方式后,你会发现一个全新的世界:
-
逻辑高度内聚: 相关的业务代码终于可以摆脱生命周期的撕裂,聚合在一起
(Co-location)。 -
复用的极致: 提取一个
useWindowWidth或useForm变得像提取一个普通函数一样简单,逻辑复用不再需要高阶组件的嵌套。 -
原子化的思维: 正如我们在对比
Jotai时所发现的,通过函数式更新(prev => prev + 1),我们将状态管理的控制权交还给了框架,代码变得更加稳健和可预测。 -
React 的进化方向很明确:
UI只是数据的投影,而副作用只是数据变化的涟漪。
你的下一步 (Call to Action)
不要试图一次性重构整个项目。
下次当你需要修改一个复杂的 Class 组件时,试着不只是"修补"它,而是用 Hooks "重写" 它。