各位同学,大家好!
欢迎来到今天的技术讲座。今天我们不讲怎么写一个“Hello World”,也不教大家怎么用 map 把数组变成列表。今天我们要探讨的是 React 的灵魂——或者说,是 React 能够屹立不倒、成为前端界“泰坦尼克号”的底层逻辑。
我们要聊的话题有点枯燥,甚至有点反直觉:“纯粹性”。
你们可能听过很多次“纯函数”、“不可变性”这样的词,但很少有人告诉你们,为什么 React 非要死磕这个“纯粹性”,以及为什么我们必须把“副作用”像扔垃圾一样扔出去,才能写出可维护的代码。
准备好了吗?让我们把键盘敲得响一点,这会是一场关于代码洁癖的洗礼。
第一章:纯函数的诱惑与 React 的“不纯”
首先,我们得聊聊数学家最喜欢的东西——纯函数。
什么是纯函数?简单来说,就是输入确定,输出确定,且不改变外部世界。
// 这是一个纯函数
const add = (a, b) => a + b;
console.log(add(1, 2)); // 3
console.log(add(1, 2)); // 还是 3
console.log(add(1, 2)); // 依然是 3
// 它不依赖外部变量,不修改全局变量,不读取文件,不发送网络请求。
// 它就像一个完美的瑞士钟表,给多少齿轮,它就给你多少时间。
纯函数是可预测的。如果你给一个纯函数同样的输入,它永远给你同样的输出。这对于测试来说简直是天堂:你不需要 mock 数据,不需要造假的网络请求,只需要把参数传进去,跑一下,看结果是不是你想要的。
但是,React 组件不是纯函数。
为什么?因为 React 组件的核心职责是渲染 UI。UI 是什么?UI 是用户能看到的、摸到的、感知到的现实世界。现实世界充满了变量,充满了时间、网络、DOM 操作。
当一个组件开始工作时,它在做的事情是:
- 读取状态(State)。
- 读取 Props。
- 读取外部 API。
- 修改 DOM(把
div画在屏幕上)。 - 更新状态(触发下一次渲染)。
在这个过程中,组件不仅依赖了输入,还改变了世界(修改了 DOM)。所以,React 组件天生就是“不纯”的。
但是,我们人类的大脑喜欢秩序。我们讨厌混乱。我们希望我们的代码里,有一部分是像数学公式一样优雅的,另一部分是专门处理那些乱七八糟的外部世界的。
于是,React 的发明者们提出了一个天才的方案:副作用分离。
第二章:当逻辑遇上副作用,就像让猫去开飞机
在 React 出现之前,或者说在 Hooks 出现之前,我们是怎么写的?
通常是这样的:在一个 render 函数里,我们既写业务逻辑,又写 API 调用,还写 DOM 操作。这就像是你让一只猫去开飞机。猫负责喵喵叫(渲染 UI),飞机负责飞上天(处理数据),结果猫在驾驶舱里睡着了,飞机一头栽进了太平洋。
看看这个经典的“上帝组件”:
class OrderComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
orders: [],
loading: false,
error: null
};
}
componentDidMount() {
// 1. 立即开始获取数据
this.fetchOrders();
}
fetchOrders = () => {
this.setState({ loading: true });
fetch('/api/orders')
.then(res => res.json())
.then(data => {
// 2. 数据来了,更新状态
this.setState({ orders: data, loading: false });
})
.catch(err => {
// 3. 出错了,记录一下
this.setState({ error: err.message, loading: false });
});
};
handleDelete = (id) => {
// 4. 业务逻辑:删除订单
const newOrders = this.state.orders.filter(o => o.id !== id);
this.setState({ orders: newOrders });
// 5. 副作用:删除成功后,通知服务器
fetch(`/api/orders/${id}`, { method: 'DELETE' });
};
render() {
// 6. UI 渲染:根据数据画界面
if (this.state.loading) return <div>Loading...</div>;
if (this.state.error) return <div>Error: {this.state.error}</div>;
return (
<ul>
{this.state.orders.map(order => (
<li key={order.id}>
{order.name}
<button onClick={() => this.handleDelete(order.id)}>
Delete
</button>
</li>
))}
</ul>
);
}
}
看到这段代码,我不仅感到头痛,我还感到一种深深的焦虑。
在这个 OrderComponent 里,render 函数(或者说渲染阶段)被污染了。它不仅要负责画图,还要关心数据是怎么来的,怎么删的,怎么删完还要发个请求。
这带来了什么问题?
- 可读性极差:你一眼看过去,不知道这个组件到底是干嘛的。它是个数据获取器?是个表单处理器?还是个 DOM 操作员?
- 逻辑复用困难:如果你想把这个“获取数据”的逻辑提取到一个单独的文件里,或者想在其他组件里复用,你发现它死死地嵌在
render逻辑里,或者嵌在componentDidMount里,根本无法独立测试。 - 难以调试:当
fetchOrders失败了,是因为网络问题?还是因为状态更新逻辑有问题?因为逻辑和副作用混在一起,你很难一眼看出来。
这就是副作用分离要解决的问题。
我们要把“数据获取”、“DOM 修改”、“订阅事件”这些副作用,从“计算 UI”的纯逻辑中剥离出来。
第三章:useEffect —— 你的“以后再说”占位符
React 16 引入了 useEffect Hook。它就像是你在渲染逻辑里写的一个占位符,它的签名是:
useEffect(setup, dependencies?)
它的核心哲学是:“渲染完了,别管了,这个 Effect 以后再说。”
让我们看看,把上面的 OrderComponent 重构一下,变得多么清爽:
function OrderComponent() {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// 纯逻辑:只是定义了怎么获取数据
const fetchOrders = useCallback(() => {
setLoading(true);
setError(null);
fetch('/api/orders')
.then(res => res.json())
.then(data => {
setOrders(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []); // 空依赖数组,意味着这个函数只在组件挂载时创建一次
// 副作用:在渲染完成后执行
useEffect(() => {
fetchOrders();
}, [fetchOrders]); // 依赖数组:告诉 React,“只有当 fetchOrders 变了,我才重新跑一遍这个 Effect”
const handleDelete = (id) => {
// 纯逻辑:过滤数组
const newOrders = orders.filter(o => o.id !== id);
setOrders(newOrders);
// 副作用:发个请求
fetch(`/api/orders/${id}`, { method: 'DELETE' });
};
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{orders.map(order => (
<li key={order.id}>
{order.name}
<button onClick={() => handleDelete(order.id)}>
Delete
</button>
</li>
))}
</ul>
);
}
看!现在 render 函数非常干净。它只负责根据 orders 数组画出列表。至于数据是怎么来的,怎么删的,那是 useEffect 的事。
这就是分离。渲染逻辑是静态的、声明式的;副作用逻辑是动态的、命令式的。
第四章:渲染阶段 vs. 提交阶段 —— React 的分身术
为了理解为什么 useEffect 这么重要,我们必须深入 React 的内部机制。React 有两个核心阶段:渲染阶段和提交阶段。
- 渲染阶段:这是 React 计算下一步 UI 的过程。这个过程是同步的,也是可中断的。在这个过程中,React 会调用你的组件函数。因为组件函数在这里调用,所以绝对不能做任何副作用(比如发网络请求、修改 DOM)。如果在这里做副作用,一旦 React 中断了计算,你的副作用就执行了一半,数据就乱了。更重要的是,如果在这里做副作用,React 的优化机制(比如 Fiber 树的 Diff 算法)就会失效,因为你的代码在“计算 UI”的同时还在“改变世界”。
- 提交阶段:这是 React 把计算好的 UI 挂载到 DOM 上的过程。这个过程是同步的。
useEffect就是在这个阶段执行的。此时,DOM 已经更新了(浏览器已经画出来了),React 才跑你的useEffect代码。
这就是 React 的分身术。
React 让你的组件函数在渲染阶段表现得像一个“纯函数”,让你在里面只管算出新的 UI 是什么样;而把所有“脏活累活”(副作用)推后到渲染完成后,在 useEffect 里统一处理。
这不仅仅是性能优化,这是逻辑的隔离。
第五章:依赖数组 —— 信任,但要验证
useEffect 的第二个参数是依赖数组。这是 React 逻辑纯粹性中最容易被滥用,也最容易让人抓狂的地方。
useEffect(() => {
console.log('我的组件挂载了');
}, []); // 空数组:只在挂载时跑一次
useEffect(() => {
console.log('我的组件挂载了或者 count 变了');
}, [count]); // [count]:只要 count 变了,我就跑一次
依赖数组告诉 React:“嘿,如果下面这些变量没变,你就别动我。”
这看起来很简单,但这里隐藏着一个巨大的陷阱:闭包陷阱。
让我们来看一个经典的例子。我们要写一个聊天组件,当用户发送消息时,把消息显示在列表里,并且滚动到底部。
function ChatComponent() {
const [messages, setMessages] = useState([]);
const [inputValue, setInputValue] = useState('');
const sendMessage = () => {
setMessages(prev => [...prev, inputValue]);
setInputValue(''); // 发送后清空输入框
};
useEffect(() => {
const messagesEnd = document.getElementById('chat-box');
if (messagesEnd) {
messagesEnd.scrollIntoView({ behavior: 'smooth' });
}
}, [messages]); // 依赖 messages
return (
<div>
<div id="chat-box">
{messages.map(m => <div key={m}>{m}</div>)}
<div ref={el => messagesEnd = el} />
</div>
<input
value={inputValue}
onChange={e => setInputValue(e.target.value)}
/>
<button onClick={sendMessage}>Send</button>
</div>
);
}
这个代码看起来没问题吧?每次 messages 变化,我们就滚动到底部。
但是,如果我们在 sendMessage 函数里有一个 console.log 呢?
const sendMessage = () => {
console.log('当前消息列表长度:', messages.length); // 这里打印的是 0
setMessages(prev => [...prev, inputValue]);
setInputValue('');
};
你可能会惊讶地发现,每次点击发送,控制台打印的都是 0,而不是你期望的 1,2…
为什么?
因为在 sendMessage 函数的定义中,它捕获了 messages 的值。虽然 sendMessage 是在组件顶层定义的(所以它只会在组件挂载时创建一次),但它内部的闭包引用了当时的 messages。
更糟糕的是,如果你把 sendMessage 放进 useEffect 里面,或者把 sendMessage 作为 useEffect 的依赖,那情况会更混乱。
这就是副作用分离带来的挑战: 我们把逻辑分开了,但它们之间还是通过闭包在通信。React 告诉你“不要依赖未列出的值”,这是为了防止数据过时。
如何解决这个问题?
- 函数式更新:在
sendMessage里,使用setMessages(prev => ...),而不是直接读取messages。这保证了你拿到的永远是最新状态。 - 添加依赖:如果
sendMessage必须依赖messages,那你必须把它放进依赖数组里。
useEffect(() => {
// ... 滚动逻辑
}, [messages, sendMessage]); // 把 sendMessage 加进去
// 但是,如果 sendMessage 依赖 messages,这就成了死循环!
// 因为 sendMessage 变了 -> 触发 useEffect -> sendMessage 变了 -> 触发 useEffect ...
这时候,我们需要使用 useCallback 来稳定 sendMessage 的引用,或者使用 useRef 来保存最新的消息列表。
function ChatComponent() {
const [messages, setMessages] = useState([]);
const messagesEndRef = useRef(null);
// 使用 ref 来保存最新的消息列表,避免闭包陷阱
const messagesRef = useRef(messages);
messagesRef.current = messages;
const sendMessage = () => {
setMessages(prev => [...prev, messagesRef.current[messagesRef.current.length - 1] || 'default']);
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]); // 只依赖 messages
}
这就是“逻辑纯粹性”在实战中的复杂性。我们追求纯粹,是为了让代码可预测;但为了维持这种纯粹,我们需要小心地处理变量之间的依赖关系。
第六章:清理函数 —— 优雅的退出
useEffect 的强大之处不仅在于它能“做事”,还在于它能“收尾”。
在 useEffect 的返回值中,你可以定义一个清理函数。这就像是你在进入一个房间前把灯打开,离开时把灯关掉。
useEffect(() => {
console.log('订阅了 WebSocket 连接');
const connection = new WebSocket('ws://example.com');
connection.onmessage = (event) => {
console.log('收到消息:', event.data);
};
// 返回清理函数
return () => {
console.log('断开连接,清理资源');
connection.close();
};
}, []);
为什么这很重要?
- 防止内存泄漏:如果你在
useEffect里创建了一个定时器,但组件在定时器触发前就被卸载了,清理函数会确保定时器被清除,否则内存泄漏了都不知道。 - 防止状态不一致:如果你在
useEffect里订阅了一个事件,组件卸载时你必须取消订阅。否则,组件卸载了,但事件监听还在,一旦事件触发,React 还会尝试更新已经不存在的组件状态,导致报错。
副作用分离的终极形态,就是确保每一次副作用都有明确的开始和明确的结束。
第七章:useLayoutEffect —— 想要同步,就要付出代价
既然 useEffect 是在渲染完成后(浏览器绘制之后)才执行的,那有没有办法在渲染之前、浏览器绘制之前执行代码呢?
有,那就是 useLayoutEffect。
useLayoutEffect 的签名和 useEffect 完全一样,但它会在所有的 DOM 变更之后、浏览器绘制之前同步调用。
什么时候用 useLayoutEffect?
通常用于需要读取 DOM 尺寸,或者在 DOM 更新后立即执行某些逻辑,以避免出现“闪烁”的情况。
useLayoutEffect(() => {
const element = document.getElementById('box');
console.log(element.clientWidth); // 这是一个同步操作
}, []);
警告: useLayoutEffect 是同步的,它会阻塞浏览器的绘制。如果你的 Effect 计算量很大(比如处理几万条数据),或者涉及复杂的 DOM 操作,会导致页面卡顿,用户体验极差。
所以,原则是:能用 useEffect 就用 useEffect,除非你遇到了浏览器绘制的闪烁问题。
第八章:useMemo 和 useCallback —— 不仅仅是性能优化
除了 useEffect,还有两个 Hook 经常和副作用联系在一起:useMemo 和 useCallback。
很多人误以为这两个只是性能优化工具。其实,它们也是副作用分离的一部分。
useMemo 和 useCallback 返回的都是缓存的值。它们的本质是:“我不希望这个函数/计算结果因为父组件的重新渲染而频繁地重新计算。”
这为什么重要?
假设你有一个父组件 Parent,它有一个 heavyComputation 函数。子组件 Child 接收这个函数作为 prop。
function Parent() {
const [count, setCount] = useState(0);
// 每次父组件渲染,这个函数都会重新创建
const heavyComputation = (data) => {
// ... 一堆复杂的计算
return result;
};
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Click</button>
<Child data={count} compute={heavyComputation} />
</div>
);
}
function Child({ data, compute }) {
useEffect(() => {
console.log('Child 组件挂载了');
}, [compute]); // 如果 compute 每次都变,这个 Effect 就会疯狂触发
return <div>Child: {data}</div>;
}
如果不加 useCallback,每次父组件的 count 变化,heavyComputation 就会重新创建一个新的函数引用。子组件收到新的 prop,useEffect 检测到依赖变化,就会执行。
这就导致了不必要的副作用执行。
使用 useCallback:
const heavyComputation = useCallback((data) => {
// ... 计算逻辑
return result;
}, []); // 空依赖,函数引用永远不变
这样,无论父组件怎么渲染,传给子组件的 compute 函数引用都是同一个。子组件的 useEffect 也就不会触发。
总结一下:
useEffect处理外部世界的副作用(API、DOM、事件监听)。useMemo和useCallback处理内部引用的副作用(防止子组件重渲染、防止 Effect 重复触发)。
第九章:可维护性的底层支撑 —— 为什么要折腾?
讲了这么多技术点,大家可能会觉得:“不就是换个写法吗?以前也能写,为什么要搞得这么复杂?”
因为可维护性。
当你的项目增长到一定程度,代码量达到几万行,几十个开发者协作时,逻辑纯粹性和副作用分离就是救命稻草。
-
单元测试的福音:
如果你的逻辑是纯函数,你可以写这样的测试:test('add 函数应该返回正确结果', () => { expect(add(1, 2)).toBe(3); });简单、直接、快速。如果逻辑和副作用混在一起,你需要 mock 浏览器环境,mock 网络,mock DOM,测试变得极其痛苦。
-
逻辑复用:
当你把数据获取逻辑从组件中剥离出来,它就变成了一个独立的函数或 Hook。// useOrders.js export function useOrders() { const [orders, setOrders] = useState([]); useEffect(() => { fetch('/api/orders').then(setOrders); }, []); return orders; }你可以在任何组件里调用这个 Hook,它不会污染组件的渲染逻辑。
-
重构的勇气:
如果你的代码里,渲染逻辑和副作用混在一起,你敢动吗?动一下可能整个页面就崩了。
如果逻辑分离了,你就可以把业务逻辑移到一个单独的文件里,重构它,优化它,甚至用 TypeScript 重写它,而不用担心破坏 UI。
第十章:实战演练 —— 一个完整的“订单管理”重构
让我们来实战一下。我们有一个购物车组件,它需要:
- 显示商品列表。
- 计算总价。
- 当总价超过 1000 元时,自动应用优惠券。
- 发送数据到后端保存。
重构前(混乱版):
function ShoppingCart() {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [couponApplied, setCouponApplied] = useState(false);
// 1. 渲染时计算总价
const calculateTotal = () => {
let sum = 0;
items.forEach(item => sum += item.price * item.quantity);
setTotal(sum);
};
// 2. 添加商品
const addItem = (item) => {
setItems(prev => [...prev, item]);
};
// 3. 计算总价和优惠券逻辑混在一起
const handleAddItem = (item) => {
addItem(item);
calculateTotal(); // 这里的副作用是计算总价,但它混在添加逻辑里
if (total > 1000 && !couponApplied) {
setCouponApplied(true);
// 发送优惠券逻辑
saveCoupon();
}
};
// 4. 保存到后端
const saveCoupon = () => {
fetch('/api/cart', { method: 'POST', body: JSON.stringify({ items, total }) });
};
// 5. 删除商品
const removeItem = (id) => {
setItems(prev => prev.filter(i => i.id !== id));
// 这里的副作用是计算总价和保存,逻辑很乱
calculateTotal();
saveCoupon();
};
// 6. 重新渲染时也要计算总价
useEffect(() => {
calculateTotal();
saveCoupon();
}, [items]);
return (
<div>
{/* UI 代码 */}
</div>
);
}
重构后(纯粹版):
// 纯函数:计算总价
const calculateTotal = (items) => {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
};
// 纯函数:判断是否满足优惠券条件
const shouldApplyCoupon = (total) => total > 1000;
// 纯函数:应用优惠券
const applyCoupon = (total) => total > 1000 ? total * 0.9 : total;
function ShoppingCart() {
const [items, setItems] = useState([]);
const [couponApplied, setCouponApplied] = useState(false);
// 副作用 1:监听 items 变化,自动计算总价并保存
useEffect(() => {
const total = calculateTotal(items);
setTotal(total); // 这里的 setTotal 会在渲染阶段触发,但逻辑是清晰的
if (shouldApplyCoupon(total) && !couponApplied) {
setCouponApplied(true);
// 副作用 2:发送保存请求
saveCartToServer(items, total);
}
}, [items, couponApplied]);
// 副作用 3:组件挂载时加载购物车
useEffect(() => {
loadCartFromServer();
}, []);
const handleAddItem = (item) => {
setItems(prev => [...prev, item]);
};
const handleRemoveItem = (id) => {
setItems(prev => prev.filter(i => i.id !== id));
};
return (
<div>
{/* 渲染逻辑非常纯粹,只负责画 UI */}
</div>
);
}
看,现在 handleAddItem 和 handleRemoveItem 变得多么简单。它们只负责修改数据。所有的计算逻辑、副作用逻辑都交给了 useEffect。即使你想把 calculateTotal 移到一个单独的文件里,或者想把它改成 TypeScript,也变得轻而易举。
结语:拥抱纯粹,拥抱秩序
同学们,React 的哲学核心在于声明式编程。声明式编程的核心在于描述“是什么”,而不是“怎么做”。
副作用分离,就是把“怎么做”(副作用:发请求、改 DOM)从“是什么”(渲染 UI:返回 JSX)中剥离出去。
当你开始尝试在代码中分离副作用时,你会发现你的代码结构变得更加清晰了。你的组件变小了,职责变单一了。你不再是一个写“面条代码”的码农,你是一个构建秩序的工程师。
记住,纯粹的逻辑是可预测的,可测试的,可复用的。而混乱的逻辑是不可控的,脆弱的,难以维护的。
下次当你准备在 useEffect 里写一堆逻辑,或者在 render 函数里发个请求时,请停下来想一想:“这个逻辑是‘是什么’的一部分,还是‘怎么做’的一部分?”
如果是“怎么做”,请把它移出去。这是对代码的尊重,也是对你未来维护代码时发际线的尊重。
今天的讲座就到这里。下课!记得把你的 useEffect 依赖数组写对,别再被 React 报错骂了!