各位好,欢迎来到这场关于“副作用”的解剖课。
如果你们觉得 React 只是用来写按钮和输入框的,那你们对它的理解还停留在“石器时代”。React 的核心哲学是“声明式编程”——即“我想让 UI 看起来像什么”,而不是“我该怎么去操作 DOM 才能让它看起来像什么”。
但是,现实是残酷的。浏览器不是数学课,它充满了泥巴。当你的 UI 需要和后端服务器对话,或者需要监听用户的鼠标点击,甚至需要操作 localStorage 时,你就必须引入“副作用”。副作用是 React 的阿喀琉斯之踵,是那个怎么也擦不掉的污点。
今天,我们不谈 API,我们谈的是架构。我们要探讨的是:React 是如何一步步从“手忙脚乱地处理副作用”,进化到“用物理定律(确定性)来消灭副作用”的。
准备好了吗?系好安全带,我们要深入 React 的核心了。
第一部分:纯函数的诅咒与副作用的地狱
首先,我们要搞清楚什么是副作用。在函数式编程的世界里,一个函数就像一个精密的齿轮,你给它输入,它给你输出,除此之外,它不应该做任何事。它不应该去改全局变量,不应该去修改外部的 DOM,不应该去调用 API。
Pure Function:
// 纯函数:干净、高效、可预测
function add(a, b) {
return a + b;
}
但是,我们的应用需要和世界交互。
// 副作用函数:脏乱、不可预测、充满惊喜(惊喜个鬼)
function fetchData(url) {
// 1. 发起网络请求(外部 IO)
// 2. 解析 JSON
// 3. 更新全局的 Redux store
// 4. 甚至可能因为网络波动导致程序崩溃
return data;
}
React 的设计初衷是让组件变成纯函数。Component(props) -> UI。这意味着,只要 props 不变,UI 就不应该变。这是 React 的基石,是它的“物理定律”。
但是,浏览器是个充满了副作用的场所。用户点击了按钮,这是副作用。网络延迟了,这是副作用。屏幕旋转了,这是副作用。React 必须把这些副作用塞进这个“纯函数”的盒子里。
这就像你试图在一个真空密封的盒子里养一只哈士奇。你会看到什么?你会看到 useEffect,你会看到闭包地狱,你会看到依赖数组写错导致的无限循环。
让我们看看过去我们是怎么处理这个“脏乱差”的。
第二部分:生命周期方法的混乱
在 React 16 之前,我们用的是 Class 组件。那时候,我们有一个叫“生命周期”的概念。听起来很美好,像是一个有序的交响乐,对吧?
componentDidMount: 我来了,我加载了,我该干活了。
componentDidUpdate: 我变了,我该检查我干了没。
componentWillUnmount: 我要走了,我得清理垃圾。
问题出在哪?
问题在于,UI 逻辑和副作用逻辑被强行捆绑在了一起。
你想加个监听器?好,你去 componentDidMount 里写。
你想在 props 变了的时候发个请求?好,你去 componentDidUpdate 里写。
你想在组件销毁的时候清除定时器?好,你去 componentWillUnmount 里写。
这就像你做饭的时候,洗菜、切菜、炒菜、刷锅,全都在同一个灶台上发生,而且顺序还经常乱。有时候你切菜切到一半,突然要去刷锅,菜就烂了。
更糟糕的是,随着代码量的增加,你根本不知道 componentDidMount 里的代码到底干了什么。它可能引用了组件里的 state,可能引用了 props,可能引用了闭包里的变量。这种耦合度,简直比热恋中的情侣还难解。
代码示例:经典的“急救包”式生命周期
class UserProfile extends React.Component {
constructor(props) {
super(props);
this.state = { data: null };
this.timer = null;
}
componentDidMount() {
// 1. 获取数据
fetch(`/api/user/${this.props.userId}`)
.then(res => res.json())
.then(data => this.setState({ data }));
// 2. 设置定时器(副作用 A)
this.timer = setInterval(() => console.log("Tick"), 1000);
// 3. 监听窗口大小(副作用 B)
window.addEventListener('resize', this.handleResize);
}
componentDidUpdate(prevProps) {
// 4. 如果 ID 变了,重新获取数据
if (prevProps.userId !== this.props.userId) {
fetch(`/api/user/${this.props.userId}`)
.then(res => res.json())
.then(data => this.setState({ data }));
}
}
componentWillUnmount() {
// 5. 清理工作:必须记得关掉定时器,取消监听
clearInterval(this.timer);
window.removeEventListener('resize', this.handleResize);
}
handleResize = () => {
// 处理逻辑...
}
render() {
return <div>User Profile</div>;
}
}
看这段代码,是不是有一种窒息感?每一个生命周期方法都是一个“急救包”,你不知道什么时候会用到哪个急救包,也不知道哪个急救包会引发连锁反应。
这就是“副作用不可避”的命题。在客户端,你永远无法摆脱浏览器环境的限制,你永远要面对网络、DOM、事件这些“脏”东西。
第三部分:useEffect 的诞生与“闭包陷阱”
React 16 引入了 Hooks,试图解决这个问题。我们有了 useEffect。
useEffect 是什么?它是 React 给我们提供的“副作用容器”。它告诉 React:“嘿,兄弟,在这个组件渲染完之后,帮我做点脏活累活。”
看起来很完美吧?我们终于把 UI 逻辑和副作用逻辑分开了。
但是,React 专家都知道,useEffect 是一个巨大的坑。为什么?因为闭包。
当你写 useEffect 时,你捕获了组件作用域内的变量。如果你在 useEffect 里依赖了某个 state,而这个 state 在下一次渲染时变了,那么 useEffect 里捕获的依然是旧值。
代码示例:闭包地狱
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
// 这是一个陷阱!这里的 count 永远是 0
// 因为 useEffect 执行时捕获的 count 是 0
console.log(count);
// 你必须手动依赖 count,否则这里永远是 0
}, 1000);
return () => clearInterval(timer);
}, [count]); // 你必须把 count 写进依赖数组
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
这就是“副作用不可避”带来的代价:不确定性。你不能确定你的副作用函数到底看到了什么数据。这种不确定性导致了大量的 Bug。
为了解决这个问题,React 社区发明了各种“魔法”。useCallback,useMemo。我们试图把副作用函数“记住”,试图把计算结果“缓存”。
但这就像试图用保鲜膜把一团泥巴包起来。泥巴还是泥巴,它依然会干、会裂、会污染周围的环境。我们只是在试图掩盖副作用带来的混乱,而不是消灭它。
第四部分:转向“物理”解决方案——Server Components
既然在客户端处理副作用这么痛苦,那我们能不能把副作用移到没有副作用的地方去?
这就是 React 18/19 引入的 Server Components(服务端组件)的核心思想。
什么是“物理”解决方案?
在物理学中,如果一个过程是完全确定的,没有随机性,没有外部干扰,那它就是“物理”的。在 React 的世界里,确定性就是物理定律。
客户端的副作用是不可确定的:网络可能慢,用户可能关闭浏览器,服务器可能挂了。这些变量都是随机的。
但是,服务端呢?服务端是代码的世界,不是浏览器的世界。在服务端,你不需要处理 DOM,不需要处理事件,不需要处理浏览器兼容性。你只需要执行代码。
Server Components 就是一个没有副作用的组件。
它运行在 Node.js 环境(或 Deno/Edge Runtime)中。它没有 DOM,没有 window 对象。它只是一个纯函数的执行过程。
代码示例:服务端组件的魔法
// 这是一个 Server Component
// 它的代码运行在服务器上,而不是用户的浏览器里
// 它可以安全地调用数据库、文件系统、API,没有任何副作用(除了写数据库)
async function UserProfile({ userId }) {
// 直接调用数据库查询!这是副作用,但在服务端是“物理”的
// 因为服务端是确定性的环境
const user = await db.query("SELECT * FROM users WHERE id = ?", userId);
// 不需要 useEffect,不需要 fetch,不需要 state
// 因为数据在渲染时就已经是最终状态了
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
{/* 子组件如果也是 Server Component,数据流会继续向上传递 */}
<UserPosts userId={user.id} />
</div>
);
}
这就是“最终物理解决方案”的第一步:消除客户端的副作用。
当 Server Component 渲染时,它把数据直接作为 props 传给客户端组件。客户端组件接收到的是“最终数据”,而不是“获取数据的 Promise”。
这就像你不再需要去超市买菜(客户端请求),而是农场直接把菜送到了你家里(服务端组件渲染)。整个过程是确定的,是可预测的。
第五部分:Suspense 与“流式”渲染
但是,Server Components 并不能解决所有问题。有些数据,我们确实需要在客户端获取(比如用户当前的登录状态,或者即时的地理位置)。
这时候,我们就需要 React 的另一把利器:Suspense。
Suspense 是如何解决“副作用不可避”的?
传统的做法是:useEffect(() => { fetch().then(setData) })。这是异步的,是阻塞的。浏览器会一直转圈,直到数据回来。
Suspense 的做法是:让 UI 先渲染,然后“流”入数据。
这符合物理定律吗?符合。这就像水流进管道。水(数据)还没到的时候,管道里是空的(Suspense fallback)。水到了,管道就满了(数据渲染出来)。
Suspense 将“获取数据”这个副作用,变成了 UI 的一部分。
代码示例:Suspense 的优雅
// 客户端组件
function UserProfile() {
// 这不是一个 Promise,而是一个“数据流”
// React Compiler 会自动处理这个 fetch 的记忆化
const user = api.getUser();
return (
<div>
<h1>{user.name}</h1>
<Suspense fallback={<div>Loading user...</div>}>
<UserPosts userId={user.id} />
</Suspense>
</div>
);
}
注意,这里没有 useEffect,没有 isLoading 状态,没有 try/catch。数据获取变成了组件的输入,就像 props 一样。
为什么这是“物理”的?
因为数据流是线性的,是单向的。数据 -> UI。没有中间的异步回调,没有状态的突变。这就是物理学的本质:因果律。
Suspense 结合 React Compiler(我们后面会讲),实现了“自动记忆化”。这意味着,React 不再需要你手动管理 useMemo 和 useCallback。它自动优化了所有的依赖。
当你的组件没有手动管理的副作用时,React 就可以放心地并发渲染。因为你知道,这个组件是纯的,它是安全的。
第六部分:React Compiler —— 终极的确定性
现在,让我们来到这场演进的终点。React Compiler。
React Compiler 是什么?它是 React 团队用 Rust 写的一个编译器。它的目标只有一个:让 React 组件自动变成纯函数。
它的工作原理非常简单粗暴:
- 扫描你的组件代码。
- 找到所有的
useState,useReducer,useMemo,useCallback,useRef,useContext。 - 自动为这些 Hook 生成记忆化代码。
- 自动为这些 Hook 的依赖数组生成正确的依赖。
这意味着什么?
这意味着,你再也不需要写 useEffect 了。
因为 Compiler 会自动把副作用移除。如果某个函数调用了 API,Compiler 会把这个函数标记为“非纯函数”,然后把它移到 Server Component 里,或者使用 Suspense 来处理。
代码示例:Compiler 时代的前端组件
// 这个组件在 React Compiler 下运行
// 你不需要写 useEffect,不需要写 useMemo
// 甚至不需要写 try/catch
function UserProfile({ userId }) {
// 数据获取被编译器处理了
// 它会自动缓存这个请求,直到 userId 变化
const user = api.getUser({ id: userId });
// 任何对 user 的引用,编译器都知道它依赖于 user
// 它会自动决定何时重新渲染
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
<UserProfileStats userId={user.id} />
</div>
);
}
看这个代码,多么干净!多么纯粹!这完全符合函数式编程的教义。
这就是“最终物理解决方案”的终极形态:编译时确定性。
在编译时,React Compiler 就已经帮你排除了所有的副作用。它确保了你的运行时(浏览器)只负责渲染 UI,而所有的逻辑和数据获取都在编译阶段被优化好了。
为什么这解决了“副作用不可避”?
因为在 Compiler 时代,副作用不再是“运行时”的概念,而是“编译时”的概念。
你不再需要担心在 useEffect 里写错了依赖导致 Bug。你不再需要担心闭包陷阱。你只需要写逻辑,剩下的交给 Compiler。
副作用被“推”到了更底层的层面,或者被“隔离”在了 Server Component 里。客户端组件变成了真正的“视图层”,只负责展示数据,不负责产生数据。
第七部分:并发模式与副作用的安全网
最后,我们不能忘记 React 18 引入的并发模式。
并发模式的核心思想是:React 不再阻塞渲染。它可以在后台准备下一个状态,同时保持当前的 UI 响应。
这对于副作用处理至关重要。
假设你有一个定时器,每秒更新一次 State。然后用户突然切到了另一个页面。React 会暂停这个组件的渲染,挂起这个定时器。
当用户切回来时,React 会继续渲染,并且定时器会从暂停的地方继续。
如果没有并发模式,定时器可能会因为 React 的重渲染而丢失,或者导致状态不一致。
并发模式就像是给副作用加了一层“物理保护罩”。它保证了副作用不会干扰 UI 的流畅性,也不会因为 React 的调度而崩溃。
结语:通往纯净之路
回顾 React 的演进,我们可以看到一条清晰的路径:
- Class Components: 混乱地混合了 UI 和副作用。
- Hooks (useEffect): 试图将副作用隔离,但引入了闭包和依赖管理的复杂性。
- Server Components: 将副作用移出客户端,利用服务端的确定性。
- Suspense: 将异步数据获取变为 UI 的一部分,消除阻塞。
- React Compiler: 自动优化,让前端组件回归纯函数。
“副作用不可避”这个命题,在客户端确实很难解决,因为浏览器环境本身充满了副作用。
但是,通过架构上的演进——将计算移至服务器,将异步变为流式数据,将手动优化变为自动编译——React 正在一步步地将“副作用”这个怪物关进笼子里。
未来的 React 组件,将不再是“处理数据的容器”,而仅仅是“展示数据的画布”。
当你写下一个没有 useEffect 的组件时,你就触碰到了 React 架构的终极真理:在正确的位置做正确的事,让副作用无处遁形。
这就是物理,这就是秩序,这就是 React 的未来。
谢谢大家。