【翻译】开始给你的 useEffect 函数命名,你之后会感谢自己的

0 阅读11分钟

开始给你的 useEffect 函数命名,你之后会感谢自己的

文章头图

大约一年前,我开始给自己的 useEffect 函数命名。它改变了我阅读组件的方式、调试组件的方式,最终也改变了我组织组件结构的方式。

上个月,我打开了同事的一个 Pull Request。

那是一个我从没见过的组件,大约 200 行,用来处理与仓库 API 的库存同步。里面有四个 useEffect 调用。我花了整整一分钟去读每一个 effect,追踪依赖数组,重建哪些 state 属于哪个 effect,以及谁触发了谁。

这种事我做过上百次。你大概率也做过。

让我沮丧的点不在于代码写得差。它写得很好,effect 也确实按关注点拆开了。

但我还是得把每个 effect 的每一行都读完,才能理解组件在做什么,因为 useEffect(() => { 对意图完全没有信息。它只告诉你代码在什么时候运行,不告诉你为什么运行。

某种程度上,这是我们从 class 组件时代继承来的习惯。那时候我们只有 componentDidMountcomponentDidUpdate,每个生命周期事件里实际上只有一个地方可以放副作用代码。

这种约束塑造了一种心智模型:代码放在哪里决定了它在什么时候执行,而为什么只能靠注释或仔细阅读去推断。

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。同样的可读性提升也适用于 useCallbackuseMemo 以及 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 做的是非平凡工作时,pollServerForUpdatesstopPollingServer 这种对称性会让 setup 与 cleanup 两半都一眼明白。

命名会暴露“不该存在的 effect”

有些 effect 很难命名,而这种阻力本身就是信号。

如果你发现自己想起类似 updateStateBasedOnOtherStatesyncDerivedValue 这样的名字,停一下。

这种含糊通常意味着代码根本不该放在 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,名字往往清晰具体,比如 connectToWebSocketinitializeMapInstancesubscribeToGeolocation。动词会直接告诉你 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 到底在做什么。

我给它们命名成:initializeMapSDKsynchronizeZoomLevelsynchronizeCenterPositionhandleMarkerInteractionscleanupStaleMarkerListeners。马上我就知道排查问题该看哪里。

但命名还带来了另一个效果。

当我能把这五个名字并排看清后,我意识到 cleanupStaleMarkerListeners 其实并不是和 handleMarkerInteractions 分离的独立关注点。

它其实是同一个同步逻辑里的 cleanup 半段:setup 负责加监听,这个 effect 负责移除旧监听。

我把它们合并成了一个带正确 cleanup return 的 effect,组件因此更简单。然后我又意识到 synchronizeZoomLevelsynchronizeCenterPosition 都依赖地图实例 ready,而且它们总是一起执行,于是我把它们合并成 synchronizeMapViewport

五个 effect 变成三个,而这三个的边界比原先五个更清楚。

Sergio Xalambrí 在 2020 年就写过给 useEffect 函数命名,Cory House 也讲过同样观点。这不是新鲜事。但几乎没人做,因为社区集体把 useEffect(() => { 内化成了唯一写法。

我们从文档复制、从教程复制、从 AI 生成代码复制。匿名箭头成了默认,而默认很难被摆脱。

切换成本几乎为零。你不需要新库,也不需要构建插件。你只是在函数上加一个名字;而你会在下一次打开旧文件、发现不用再把每个 effect 重读一遍时,立刻感受到差异。

给你的 effects 起名字吧。


参考资料与延伸阅读

术语表(本篇命中)

term_enterm_zh说明
ReactReact前端框架名,沿用英文专有名词
useEffectuseEffectHook 名称,按代码标识符保留
custom hook自定义 Hook文中用于封装复用逻辑
effect副作用(effect)React 语境下的副作用逻辑