开始给你的 useEffect 函数命名,你之后会感谢自己的
- 原文链接:neciudan.dev/name-your-e…
- 原文作者:Neciu Dan
大约一年前,我开始给自己的 useEffect 函数命名。它改变了我阅读组件的方式、调试组件的方式,最终也改变了我组织组件结构的方式。
上个月,我打开了同事的一个 Pull Request。
那是一个我从没见过的组件,大约 200 行,用来处理与仓库 API 的库存同步。里面有四个 useEffect 调用。我花了整整一分钟去读每一个 effect,追踪依赖数组,重建哪些 state 属于哪个 effect,以及谁触发了谁。
这种事我做过上百次。你大概率也做过。
让我沮丧的点不在于代码写得差。它写得很好,effect 也确实按关注点拆开了。
但我还是得把每个 effect 的每一行都读完,才能理解组件在做什么,因为 useEffect(() => { 对意图完全没有信息。它只告诉你代码在什么时候运行,不告诉你为什么运行。
某种程度上,这是我们从 class 组件时代继承来的习惯。那时候我们只有 componentDidMount 和 componentDidUpdate,每个生命周期事件里实际上只有一个地方可以放副作用代码。
这种约束塑造了一种心智模型:代码放在哪里决定了它在什么时候执行,而为什么只能靠注释或仔细阅读去推断。
Hooks 把我们从生命周期约束里解放出来了,但匿名箭头函数又带来了另一种不透明性。
我们不再只有一个巨大的生命周期方法,而是连续出现六个匿名闭包;每一个你都得读实现,才知道它到底干什么。
我大约一年前开始给 effect 函数命名。这是我在 React 写法上做过最小的改动,却是对可读性影响最不成比例的一次。
问题
下面是那个库存组件的简化版:
function InventorySync({ warehouseId, locationId, onStockChange }) {
const [stock, setStock] = useState<StockLevel[]>([]);
const [connected, setConnected] = useState(false);
const prevLocationId = useRef(locationId);
useEffect(() => {
const ws = new WebSocket(`wss://inventory.api/ws/${warehouseId}`); // 连接库存 WebSocket
ws.onopen = () => setConnected(true);
ws.onclose = () => setConnected(false);
ws.onmessage = (event) => {
const update = JSON.parse(event.data);
setStock(prev => prev.map(s =>
s.sku === update.sku ? { ...s, quantity: update.quantity } : s
));
};
return () => ws.close();
}, [warehouseId]);
useEffect(() => {
if (!connected) return;
fetch(`/api/warehouses/${warehouseId}/stock?location=${locationId}`)
.then(res => res.json())
.then(setStock);
}, [warehouseId, locationId, connected]);
useEffect(() => {
if (prevLocationId.current !== locationId) {
setStock([]);
prevLocationId.current = locationId;
}
}, [locationId]);
useEffect(() => {
if (stock.length > 0) {
onStockChange(stock);
}
}, [stock, onStockChange]);
// ... 渲染
}
四个 effect。每个都在做什么?第一个设置了……WebSocket?好。第二个在 connected 变化时……拉取一些数据?第三个在 location 变化时重置库存。第四个……在库存更新时调用来自 props 的回调。
你的大脑刚刚做了四轮编译。
在 GitHub 代码审查场景里,你没法悬停看类型信息,只能在有限上下文的 diff 里扫代码,这里就是会慢下来的地方。
把这个成本乘以一个 PR 里的每个组件。
现在试试读同一个组件,只做一点小改动:
function InventorySync({ warehouseId, locationId, onStockChange }) {
const [stock, setStock] = useState<StockLevel[]>([]);
const [connected, setConnected] = useState(false);
const prevLocationId = useRef(locationId);
useEffect(function connectToInventoryWebSocket() {
const ws = new WebSocket(`wss://inventory.api/ws/${warehouseId}`); // 连接库存 WebSocket
ws.onopen = () => setConnected(true);
ws.onclose = () => setConnected(false);
ws.onmessage = (event) => {
const update = JSON.parse(event.data);
setStock(prev => prev.map(s =>
s.sku === update.sku ? { ...s, quantity: update.quantity } : s
));
};
return () => ws.close();
}, [warehouseId]);
useEffect(function fetchInitialStock() {
if (!connected) return;
fetch(`/api/warehouses/${warehouseId}/stock?location=${locationId}`)
.then(res => res.json())
.then(setStock);
}, [warehouseId, locationId, connected]);
useEffect(function resetStockOnLocationChange() {
if (prevLocationId.current !== locationId) {
setStock([]);
prevLocationId.current = locationId;
}
}, [locationId]);
useEffect(function notifyParentOfStockUpdate() {
if (stock.length > 0) {
onStockChange(stock);
}
}, [stock, onStockChange]);
// ... 渲染
}
现在我只要扫一眼四个函数名,就能理解整个数据流:连接 WebSocket、获取初始库存、在 location 变化时重置、通知父组件。
除非我要定位某个具体问题,否则我不需要再读任何一行实现。
变化只是在语法层面。你不再把匿名箭头函数传给 useEffect,而是传一个命名函数表达式:
// 匿名箭头(几乎所有人都这么写)
useEffect(() => {
document.title = `${count} items`;
}, [count]);
// 命名函数表达式(我主张这样写)
useEffect(function updateDocumentTitle() {
document.title = `${count} items`;
}, [count]);
你也可以把函数单独声明再按名字传入(useEffect(updateDocumentTitle, [count])),但我更喜欢内联版本,因为函数名就放在调用点旁边,不需要向上翻去找声明。
这在调试上也有收益。
匿名箭头抛错时,你的错误信息会显示 at (anonymous) @ InventorySync.tsx:14。
当文件里有四个 effect 时,这个信息几乎没用。
命名函数会给你 at connectToInventoryWebSocket @ InventorySync.tsx:14,不用打开文件就知道是哪个 effect 坏了。
这在你拿着手机、远离编辑器、在 Sentry 这类监控工具里分拣错误报告时很关键。在 React DevTools 分析里也一样:命名函数会显示名字,匿名函数只会显示成 anonymous。
命名会暴露“职责过多”
仅仅“可读性更高”这个理由就足够了,但我开始给 effect 命名后还发生了另一件事:它改变了我的写法。
试着给这个 effect 起名:
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
if (user?.preferences?.theme) {
document.body.className = user.preferences.theme;
}
return () => window.removeEventListener('resize', handleResize);
}, [user?.preferences?.theme]);
你会怎么叫它?syncWidthAndApplyTheme?这里的 “and” 是一个预警信号,说明这个 effect 在做两件不相关的事。
当你发现不给 effect 起 “and” 或 “also” 就很难命名时,往往就是这个 effect 在提醒你:它该拆分了。
useEffect(function trackWindowWidth() {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(function applyUserTheme() {
if (user?.preferences?.theme) {
document.body.className = user.preferences.theme;
}
}, [user?.preferences?.theme]);
如果你没法给它一个清晰名字,那它多半就是做太多了。React 本来也建议 effect 应该按“关注点”拆,而不是按生命周期时机拆。
命名会让这个原则变得可见,而注释通常做不到,因为注释会腐化,而名字总会被读到。
这不只适用于 useEffect。同样的可读性提升也适用于 useCallback、useMemo 以及 reducer 函数。
任何你把匿名函数传给 hook 的地方,一个名字都能帮到下一个读代码的人。但在 useEffect 上收益最大,因为 effect 是最难“一眼看懂”的 hook。它们运行时机不直观,清理逻辑是隐含语义,还要求你逆向还原依赖触发关系。
你也可以给清理函数命名。与其返回匿名箭头,不如返回命名函数:
useEffect(function pollServerForUpdates() {
const intervalId = setInterval(() => {
fetch(`/api/status/${serverId}`)
.then(res => res.json())
.then(setServerStatus);
}, 5000);
return function stopPollingServer() {
clearInterval(intervalId);
};
}, [serverId]);
我并不总是给 cleanup 命名,因为大多数时候上下文已经够清楚。但当 teardown 做的是非平凡工作时,pollServerForUpdates 和 stopPollingServer 这种对称性会让 setup 与 cleanup 两半都一眼明白。
命名会暴露“不该存在的 effect”
有些 effect 很难命名,而这种阻力本身就是信号。
如果你发现自己想起类似 updateStateBasedOnOtherState 或 syncDerivedValue 这样的名字,停一下。
这种含糊通常意味着代码根本不该放在 effect 里。命名困难,是因为这个 effect 在做一件本不该由 effect 承担的事。
// 你大概率不需要这样
useEffect(function syncFullName() {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// 直接派生就好
const fullName = `${firstName} ${lastName}`;
为什么 effect 版本更差?因为它会触发一次额外渲染。
React 会先渲染组件,再执行 effect;effect 调用 setFullName,又触发另一轮带更新值的渲染。
于是界面更新了两次而不是一次,你还引入了一个 fullName 短暂过期的帧。
派生版本在渲染阶段直接计算值,所以它始终正确、始终同步,同时不增加 React 的额外工作。
// 这个你大概率也不需要
useEffect(function resetFormOnSubmit() {
if (submitted) {
setName('');
setEmail('');
setSubmitted(false);
}
}, [submitted]);
// 放到事件处理器里
function handleSubmit() {
submitForm({ name, email });
setName('');
setEmail('');
}
表单重置属于事件处理场景:用户点击 submit,这是一次用户交互,就该在交互发生处处理。effect 版本是对 submitted 标志变化作反应,这一层额外跳转会让流程更难跟。
我见过有八九个 effect 的组件,其中一半都只是 state 到 state 的同步,本不该是 effect。
AI 代码生成工具会放大这个问题,因为它们在训练中看过海量被误用的 effect 示例,于是会很自信地复现同样的反模式。这些误用又反哺训练数据,循环继续。
回到 InventorySync 例子。第四个 effect——notifyParentOfStockUpdate——就是一个很适合被质疑的候选。
在一个响应 state 变化的 effect 里调用父组件回调,正是 React 文档 You Might Not Need an Effect 特别点名的模式之一。
父组件可以自己拉数据,或者在数据源头触发这个回调(比如在 WebSocket handler 和 fetch 的 .then 回调里触发)。
我把它保留在示例里,是因为它在真实代码库里太常见了;但一旦命名,问题就显形了。notifyParentOfStockUpdate 对行为描述得很诚实,而这种诚实会迫使你思考:它到底该不该存在。
能通过这种审视的名字,通常有共同模式:真正与外部系统同步的 effect,名字往往清晰具体,比如 connectToWebSocket、initializeMapInstance、subscribeToGeolocation。动词会直接告诉你 effect 类型:subscribe / listen 表示事件订阅,synchronize / apply 表示与外部系统保持一致,initialize 表示一次性初始化。
如果你能想到的最好名字听起来只是“内部状态倒腾”,那段代码大概率该放去别处。
React 19 把这个趋势又往前推了一步:Actions 处理变更,use() 处理数据获取,Server Components 则把数据加载的客户端 effect 直接消掉。
在现代 React 应用里最终留下的 effect,往往都是真正的同步点,而这些 effect 正是值得被好好命名的那些。
命名 vs 自定义 Hook
Kyle Shevlin 写过一篇很棒的文章 “useEncapsulation”,他主张每个 useEffect 都应放进自定义 Hook 里。
他的出发点是个真实问题:随着你在组件里增加更多 hooks,同一关注点的实现细节会被其他无关 hook 声明隔开。
自定义 Hook 可以把一个关注点对应的 state、effect 和 handlers 收拢到同一个地方:
function useWindowWidth() {
const [width, setWidth] = useState(
typeof window !== 'undefined' ? window.innerWidth : 0
);
useEffect(function trackWindowWidth() {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return width;
}
(typeof window !== 'undefined' 这个判断是给 Next.js 这类服务端渲染框架用的:组件第一次在服务端渲染时并不存在 window。如果你构建的是纯客户端应用,可以直接用 window.innerWidth。)
但注意 useWindowWidth 这个例子里的一点:我依然给自定义 Hook 里的 useEffect 命了名。
自定义 Hook 里同样可能有多个 effect,当你在里面调试时,堆栈里有名字仍然很有帮助。
不过并不是所有东西都要抽成自定义 Hook。有时一个组件只有某个行为特有的一次性 effect,永远不会复用。
把它提成 useCloseOnEscapeKeyForThisSpecificModal 只会增加间接层,没有收益。React 文档也提醒不要过早抽象:函数组件随着职责增加而变长是正常的,不是每段逻辑一出现就必须立刻拆进独立文件。
我通常用这个经验法则:如果 effect 管理自己的 state 且可能复用,就做成自定义 Hook;如果它是单次使用且不带关联 state,就给函数命名并保持内联。
不管哪种方式,都要命名。你还可以把核心逻辑提取到独立模块,这样就能在不渲染组件的情况下单测;对于与第三方 SDK 或复杂外部系统交互的 effect,这种做法尤其有效。
五个 effect 变成三个
讲个故事:大约一年前,我在一个 Next.js 项目里维护一个把 Mapbox 实例与应用状态同步的组件。它有五个 effect:一个初始化地图实例,一个同步缩放级别,一个同步地图中心坐标,一个处理 marker 点击事件,一个在选中 marker 变化时清理事件监听器。
每次打开这个文件,我都得花 30 秒重新定向,上下滚动,提醒自己每个匿名 effect 到底在做什么。
我给它们命名成:initializeMapSDK、synchronizeZoomLevel、synchronizeCenterPosition、handleMarkerInteractions、cleanupStaleMarkerListeners。马上我就知道排查问题该看哪里。
但命名还带来了另一个效果。
当我能把这五个名字并排看清后,我意识到 cleanupStaleMarkerListeners 其实并不是和 handleMarkerInteractions 分离的独立关注点。
它其实是同一个同步逻辑里的 cleanup 半段:setup 负责加监听,这个 effect 负责移除旧监听。
我把它们合并成了一个带正确 cleanup return 的 effect,组件因此更简单。然后我又意识到 synchronizeZoomLevel 和 synchronizeCenterPosition 都依赖地图实例 ready,而且它们总是一起执行,于是我把它们合并成 synchronizeMapViewport。
五个 effect 变成三个,而这三个的边界比原先五个更清楚。
Sergio Xalambrí 在 2020 年就写过给 useEffect 函数命名,Cory House 也讲过同样观点。这不是新鲜事。但几乎没人做,因为社区集体把 useEffect(() => { 内化成了唯一写法。
我们从文档复制、从教程复制、从 AI 生成代码复制。匿名箭头成了默认,而默认很难被摆脱。
切换成本几乎为零。你不需要新库,也不需要构建插件。你只是在函数上加一个名字;而你会在下一次打开旧文件、发现不用再把每个 effect 重读一遍时,立刻感受到差异。
给你的 effects 起名字吧。
参考资料与延伸阅读
- Kyle Shevlin,useEncapsulation —— 论证为什么应把 hooks 包进自定义 hooks,以及
eslint-plugin-use-encapsulationESLint 插件 - React 文档,You Might Not Need an Effect
- React 文档,Reusing Logic with Custom Hooks
- React 旧版文档,Rules of Hooks —— 示例里使用了命名函数表达式
- Dan Abramov,A Complete Guide to useEffect
- Sergio Xalambrí,Pro Tip: Name your useEffect functions
- Nate Liu,1 second refactor tip: readability and maintainability by naming your function
- deckstar,React Pro Tip #1 — Name your useEffect!
术语表(本篇命中)
| term_en | term_zh | 说明 |
|---|---|---|
| React | React | 前端框架名,沿用英文专有名词 |
| useEffect | useEffect | Hook 名称,按代码标识符保留 |
| custom hook | 自定义 Hook | 文中用于封装复用逻辑 |
| effect | 副作用(effect) | React 语境下的副作用逻辑 |