likes
comments
collection
share

React useEvent,彻底解决依赖问题

作者站长头像
站长
· 阅读数 27

React Hooks 用起来很爽,但总是要写一大堆繁琐的依赖。而且依赖应该怎么写,在社区里一直有很多不同的声音。 有人觉得应该严格遵循官方建议,有人觉得可以有条件忽略它,还有人完全另起炉灶、跳过这个问题。

下面是我随手找的一些关于 Hooks 的不同论点:

React 官方也知道这个问题,所以有了一个新的提案,可以解决大多数依赖问题。 这个提案在 Twitter 上发布一天就获得了超过两千赞,说明大家都觉得它解决了一个痛点。

下面是提案的中文翻译,英文好的同学可以去看原文和对应讨论。觉得提案太生硬,啃起来有困难的同学也可以看一下其他中文解析:新的原生Hook?useEvent:一个显著降低Hooks心智负担的原生HookReact官方团队出手,补齐原生Hook短板


摘要

一个用来定义事件处理函数的 Hook,它生成函数的标识不会在组件重新渲染时改变。

基本示例

你可以用 useEvent 包装任何事件处理函数。

function Chat() {
  const [text, setText] = useState('');

  const onClick = useEvent(() => {
    sendMessage(text);
  });

  return <SendButton onClick={onClick} />;
}

useEvent 里的代码在调用时“查找” props 或 state 的最新值。 而且返回的函数标识是稳定的,不随 props 或 state 变化。 也没有依赖项数组。

动机

在事件处理函数内读取 state 或 props 会阻碍代码优化

这个 onClick 事件处理函数需要读取当前输入的 text

function Chat() {
  const [text, setText] = useState('');

  // 🟡 每次渲染产生一个新函数
  const onClick = () => {
    sendMessage(text);
  };

  return <SendButton onClick={onClick} />;
}

例如你想用 React.memo 包裹 SendButton 来优化它。要想让这里的优化产生作用,传入的 props 在每次重渲染时应该有相同的引用。 但是 onClick 函数的标识在每次重渲染时都会发生改变,所以这里的优化被阻碍了。

这种问题的常见解决方法是用 useCallback 包裹一下函数来保持它的标识不变。 但是因为 onClick 需要读取最新的 text,所以在这个例子里是不行的:

function Chat() {
  const [text, setText] = useState('');

  // 🟡 每次 `text` 改变都会变成一个不同的函数
  const onClick = useCallback(() => {
    sendMessage(text);
  }, [text]);

  return <SendButton onClick={onClick} />;
}

在上面的例子中,每次按键text 都会改变,所以每次按键 onClick 还是会变成一个不同的函数。 (我们不能从 useCallback 的依赖项数组中去除 text,否则 onClick 响应函数只会永远“查找”到初始的 text。)

相比之下,useEvent 不需要依赖项数组、即使 text 也总是返回同一个的函数标识。 同时,useEvent 里的 text 总是最新的值。

function Chat() {
  const [text, setText] = useState('');

  // ✅ 总是同一个函数(即使 `text` 改变了)
  const onClick = useEvent(() => {
    sendMessage(text);
  });

  return <SendButton onClick={onClick} />;
}

因为 onClick prop 总是收到相同的函数标识,所以记忆化 SendButton 现在可行了。

useEffect 不应该在事件处理函数改变时重复触发

在这个例子中,Chat 组件有一个连接到选定房间的 effect。 当你加入房间或者收到消息时会展示一个选定 theme 的提示,并根据 muted 设置而播放声音。

function Chat({ selectedRoom }) {
  const [muted, setMuted] = useState(false);
  const theme = useContext(ThemeContext);

  useEffect(() => {
    const socket = createSocket('/chat/' + selectedRoom);
    socket.on('connected', async () => {
      await checkConnection(selectedRoom);
      showToast(theme, 'Connected to ' + selectedRoom);
    });
    socket.on('message', (message) => {
      showToast(theme, 'New message: ' + message);
      if (!muted) {
        playSound();
      }
    });
    socket.connect();
    return () => socket.dispose();
  }, [selectedRoom, theme, muted]); // 🟡 任何一个改变都会重复运行
  // ...
}

这个实现有个问题:更改 thememuted 会导致 socket 重连。 这是因为 effect 里使用了 thememuted,所以它们被列入 effect 的依赖项数组。 当它们改变时,effect 也会重复执行,让 scoket 断开重连。

如果你把 socket 回调移出 effect,改用 useCallback 包装,它们的依赖项数组还是需要包含 thememuted。 这样如果 thememuted 改变,回调函数的标识也会改变,然后 effect(依赖于回调函数)还是会重复执行。 所以 useCallback 没法解决这个问题。

你可能想忽略 linter,在依赖项数组中“略过”thememuted。 但是这会导致一个 bug。 如果你把它们从依赖项数组中省略掉,那么 effect 就只能“查找”到它们的初始值。 因此,即使用户从浅色主题切换到深色主题,后续的提示还是展示为浅色主题。 切换 muted 设置也一样没用。 (通常来说,在组件中“捕获”值是符合预期的行为。它只会在你忽略 linter 的报错时出问题。)

useEvent 是这个问题的一个解决方案:

function Chat({ selectedRoom }) {
  const [muted, setMuted] = useState(false);
  const theme = useContext(ThemeContext);

  // ✅ 稳定的标识
  const onConnected = useEvent(connectedRoom => {
    showToast(theme, 'Connected to ' + connectedRoom);
  });

  // ✅ 稳定的标识
  const onMessage = useEvent(message => {
    showToast(theme, 'New message: ' + message);
    if (!muted) {
      playSound();
    }
  });

  useEffect(() => {
    const socket = createSocket('/chat/' + selectedRoom);
    socket.on('connected', async () => {
      await checkConnection(selectedRoom);
      onConnected(selectedRoom);
    });
    socket.on('message', onMessage);
    socket.connect();
    return () => socket.disconnect();
  }, [selectedRoom]); // ✅ 只在切换房间时重复运行
}

我们把 effect(“初始化 socket”)和它导致的事件(“连接到房间”、“收到消息”)拆分开。 通过这样做,我们也修复了那个问题(改变主题时不再会导致 socket 重连)。

依赖项的 linter 也需要改变。 linter 会知道 useEvent 返回的函数有稳定的标识,所以在依赖项数组中省略掉 onConnectedonMessage 是正确的,因为它们是用 useEvent 声明的。 (类似于如果 linter 检测到同一个组件里声明的 setState,你也可以省略掉它。) 就算你的依赖项数组里包含了 onConnectedonMessage,它们也不会导致 effect 重复运行,因为它们的标识是稳定的。

effect 依赖于 selectedRoom,所以切换房间时 socket 需要重连。 但是 effect 不需要依赖于 thememuted,因为 effect 里没有用到它们。 useEvent 回调可以在被调用时读取到任何“新鲜”的值而无需改变函数自身的标识。

在事件中传递参数

当你调用 onConnectedonMessage 时,内部的 thememuted 变量的值是“新鲜”的,在事件调用时即时捕获。 但是,你可能还想传递一些“过去”的信息。

在上面的例子中,如果 selectedRoomcheckConnection("Room A") 等待时改变了(例如从“Room A”到“Room B”),在 onConnected 里读取 selectedRoom 会给你最新的值(“Room B”)。 但是你刚刚连接的房间(应该在提示中展示的房间)是“Room A”。 我们想要的不是“最新”值而是“触发”事件的值。 它是事件的一部分(“连接到 Room A”),onConnected 收到 connectedRoom 作为参数。

const onConnected = useEvent(connectedRoom => {
  console.log(selectedRoom); // 已经是 "Room B"
  showToast(theme, 'Connected to ' + connectedRoom); // "Room A" 是 effect 传过来的参数
});

theme 不是“发生了什么”的一部分(不是“连接到 Room A 时是浅色主题”),所以在事件中读取最新值是应当的。 你可以根据实际需要,向事件函数传递参数、在事件函数中读取最新值、或者两种一起用。

在使用时包装事件函数

函数不一定要在定义的地方使用 useEvent 包装——例如在一个自定义 Hook 中:

function Chat({ selectedRoom }) {
  const [muted, setMuted] = useState(false);
  const theme = useContext(ThemeContext);
  
  const onConnected = (connectedRoom) => {
    showToast(theme, 'Connected to ' + connectedRoom);
  };
  
  const onMessage = (message) => {
    showToast(theme, 'New message: ' + message);
    if (!muted) {
      playSound();
    }
  };
  
  useRoom(selectedRoom, { onConnected, onMessage });
  // ...
}

function useRoom(room, events) {
  const onConnected = useEvent(events.onConnected); // ✅ 稳定的标识
  const onMessage = useEvent(events.onMessage); // ✅ 稳定的标识

  useEffect(() => {
    const socket = createSocket(room);
    socket.on('connected', async () => {
      await checkConnection(room);
      onConnected(room);
    });
    socket.on('message', onMessage);
    socket.connect();
    return () => socket.disconnect();
  }, [room]); // ✅ 只在切换房间时重复运行
}

在这里传递来的回调是否已经记忆化或用 useEvent 包装过不重要。 useRoom 自定义 Hook 确保了传递过来的事件处理函数被包装过,所以它们有着稳定的标识,不会重复触发 effect。

如果调用方传递过来一个 useEvent 包装过的函数作为 prop 或自定义 Hook 的参数,然后它在子组件或自定义 Hook 里又被 useEvent 包装了一遍,它也能正常工作(两次包装会产生一点额外开销)。 useEvent 类似于静态类型中的不透明类型,自定义 Hook 或组件可以声明某个属性或参数的类型必须是“事件处理函数”。 这引出了一些本提案之外的问题(参见后面的“静态类型检查”部分)。

从 effect 中提取事件函数

在前面的例子中,很容易看出 onConnectedonMessage 是事件函数,因为它们被传递给 socket.on(...) 事件订阅。 但是这个概念更加宽泛,可以应用于更多情况。 只要你有一个不需要在某些数据改变时重复触发的 effect,这个解决方案一般都可以从中提取事件函数。

例如这个统计页面浏览事件的例子:

function Page({ route, currentUser }) {
  useEffect(() => {
    logAnalytics('visit_page', route.url, currentUser.name);
  }, [route.url, currentUser.name]);
  // ...
}

刚开始它可能正常工作。 后来你添加了一个设置页面,可以更改用户名。 现在你发现每次用户输入都会触发这个日志,因为 currentUser.name 变了。 但这不对:修改用户名不算浏览新页面!

这提醒了我们:概念上说,“用户浏览页面”是一个事件——它在特定时间“发生”(例如响应用户交互)。 就算数据改变了,“重新触发”事件也是不对的。 让我们提取事件函数:

function Page({ route, currentUser }) {
  // ✅ 稳定的标识
  const onVisit = useEvent(visitedUrl => {
    logAnalytics('visit_page', visitedUrl, currentUser.name);
  });

  useEffect(() => {
    onVisit(route.url);
  }, [route.url]); // ✅ 只在路由改变时重复运行
  // ...
}

现在我们的代码拆分成了两部分。 代码的“响应式”部分——每次输入改变时重复触发——在 effect 里。 具体来说,改变 route.url 会导致 effect 重复触发。 每次 URL 改变,“某个页面被浏览了”事件就会触发并调用 onVisit(route.url)。 代码的“非响应式”部分——可以读取到像 currentUser.name 这样的最新值,但是不需要在它改变时重复触发——在事件函数里。

如果一个 effect 除了调用一个事件函数不做任何事情,这通常是把代码放到 effect 之外的其它地方的标志。 比如,统计日志的调用放在一个路由改变的处理函数(它也是个事件!)应该比放在一个会被页面重新渲染触发的 effect 里更好。 从事件和 effect 的角度思考有助于发现不必要的 effect。

具体设计

内部实现

useEvent Hook 的内部大概会是这样:

// (!) 近似行为

function useEvent(handler) {
  const handlerRef = useRef(null);

  // 在实际实现时它会在 layout effect 之前执行
  useLayoutEffect(() => {
    handlerRef.current = handler;
  });

  return useCallback((...args) => {
    // 在实际实现时,如果在渲染时调用会报错
    const fn = handlerRef.current;
    return fn(...args);
  }, []);
}

换句话说,它会返回一个有稳定标识的函数,在函数内部调用你传递的最新函数。

内置的 useEvent 和上面的用户端实现有一些区别。

useEvent 包装的事件处理函数如果在渲染时调用会报错。 (在 effect 或其它时机是可以的) 这确保了在渲染过程中这些函数被当作“不透明的”,不会被调用。 使得就算改变了内部的 props/state,也可以安全的保持它们的函数标识不变。 因为它们不能在渲染过程中调用,所以它们不会影响渲染结果——因此它们不需要在输入改变时改变标识(它们不是“响应式”的)。

在所有 layout effect 运行之前,事件处理函数就已经切换为“当前”版本。 这避免了用户端实现版本的缺陷:一个组件的 effect 可能获取到另一个组件的旧数据。 但这个切换的具体时机还是一个待解决的问题(和其它待解决的问题一起列在最后)。

作为优化,当在服务端渲染时,useEvent 的所有调用会返回同样的占位函数。 因为服务端没有事件,所以这是安全的。 这个优化可以让打包 SSR 代码的框架从 SSR 代码包中剥离事件处理函数(和它们的依赖),可以提升 SSR 性能。 (注意这意味着不能用 fn1 === fn2 这样的对比来区分两个不同的事件处理函数)

Linter 插件

依赖项的 linter 会把组件范围内 useEvent 的返回值看作“稳定的”,所以它们在依赖项数组中是可选的。 (类似于现在判断 setState 的方式) 从父组件传来的 useEvent 需要被声明为依赖。 当你在一个 effect 里使用一个纯函数时,如果函数名以 onhandle 开头,linter 会“建议”使用 useEvent 而非 useCallback 包装。

在未来,如果 effect 的依赖项数组里有 handle*on* 函数,linter 可能会发出警告。 解决办法是在同一个组件里用 useEvent 包装它们。 这帮助你确保事件处理函数不会导致 effect 重复触发(因为它的标识永远相同),所以它不再需要被列入依赖项数组。

静态类型检查

最简单的类型声明是 useEvent 接收一个函数,然后返回同样类型的函数。 但也可能在类型系统层面添加更多限制,以在静态检查时避免类似渲染中操作 DOM 这样的错误。 我们打算在未来的提案中进行探索。

什么时候不应该使用 useEvent

在渲染过程中的函数调用依然使用 useCallback

有些函数需要记忆化,但却是在渲染时调用。useCallback 适用于这种情况:

function ThemedGrid() {
  const theme = useContext(ThemeContext);
  const renderItem = useCallback((item) => {
    // Called during rendering, so it's not an event.
    return <Row {...item} theme={theme} />;
  }, [theme]);
  return <Grid renderItem={renderItem} />
}

因为 useEvent 函数会在渲染中调用时报错,所以这不算大问题。

不是所有 effect 依赖项数组中的函数都是事件函数

在下面的例子中,createSocket 接受一个通过 context 传递的 createKeys 函数:

function Chat({ selectedRoom }) {
  const { createKeys } = useContext(EncryptionSettings);
  // ...
  useEffect(() => {
    const socket = createSocket('/chat/' + selectedRoom, createKeys());
    // ...
    socket.connect();
    return () => socket.disconnect();
  }, [selectedRoom, createKeys]); // ✅ Re-runs when room or createKeys changes
}

在这里,createKeys 不是一个事件函数,所以它应该写在依赖项数组中。 这确保了如果用户在聊天中更改了加密设置,createKeys 传来了一个不同的函数,它能使 API 重新连接。

不是所有从 effect 中提取的函数都是事件函数

这个例子里一些代码被错误的标记为事件函数:

function Chat({ selectedRoom, theme }) {
  // ...
  // 🔴 这不是个事件函数!
  const createSocket = useEvent(() => {
    const socket = createSocket('/chat/' + selectedRoom);
    socket.on('connected', async () => {
      await checkConnection(selectedRoom);
      onConnected(selectedRoom);
    });
    socket.on('message', onMessage);
    socket.connect();
    return () => socket.disconnect();
  });
  useEffect(() => {
    return createSocket();
  }, []);
}

这个代码有问题:因为 effect 不再依赖于 selectedRoom,切换房间不会使 scoket 重连。 问题在于把 createSocket 当作事件函数。

作为经验之谈,不管我们如何组织代码,事件都可以看作一个在特定时机发生的事情(“用户浏览了一个页面”、“连接到一个房间”、“收到一条消息”)。 如果一个函数的名称以 onhandle 开头,它就很可能是一个事件函数。 反过来说,事件不应该需要清理代码(因为它们在时间上是离散的)。

缺点

  • 它给 React 添加了新的概念。大家已经纠结于声明函数的最佳实践(“我应该总是使用 useCallback 吗?”),它又添加了一层选择。
    • 这是最大的问题。但是我们认为,在实际使用 React 时,这个概念是不可避免的,所以一个官方、有共识和一批最佳实践的API是有好处的。在#14099#16956中,获赞最多的问题之一就是 useCallback 失效。我们已经在 FAQ 里解释过,也是我们在发布 Hooks 时需要最先介绍的代码模式。即使编译器已经做了记忆化处理,我们还是需要区分重复触发的性能优化手段和语义上的保证。我们认为 useEvent 是 Hooks 编程模型缺失的基础部分,不需要像省略依赖那样容易出错的 hack 手段,它提供了修复 effect 过于频繁触发问题的正确解决方式。
  • 和一个纯粹的事件处理函数相比,使用 useEvent 看起来更混乱。
    • 然而,将它与现在人们用来解决同样问题的 useCallback 进行比较更有意义。许多(可能是大多数)useCallback 包装器用于在渲染期间从未调用的函数,因此可以用 useEvent 替换它们。与他们相比,useEvent 是一个符合人体工程学的改进(没有依赖列表、不会失效)。而且它是可选的,所以如果您愿意,可以保持代码原样。
  • useEvent 把“事件处理函数”的含义变得过于宽泛,超出了 DOM 事件处理函数的范围。
    • 也可以把它叫做别的,例如 useStableCallbackuseCommittedCallback。但是,关键在于鼓励将其用于事件处理函数。有一个简短的名称有帮助,当你想使用它时,“这是一个事件处理函数吗?”在大多情况下会是一个很好的经验法则。即使在 effect 方面,你希望将一部分逻辑提取到事件函数中的情况也对应于您希望表达“发生了一些事情!”(例如,你想要记录用户访问了一个页面)。从概念上讲,这些 “事件” 类似于函数式反应型编程的事件。但最重要的是,在 React 中,将任何 on* 回调 prop 作为“事件处理函数”已经很常见,而不管它是否与任何实际的 DOM 事件相对应(例如 onIntersectonFetchCompleteonAddTodo)。useEvent 也是同样的概念。
  • 相比于 useCallbackuseEvent 的实现在 commit 阶段添加了额外的工作。
    • 但是,实际上这种模式已经很普遍了。做这件事如果有一种官方的方法和一组最佳实践,总体上似乎比许多库和产品中存在的临时解决方案要好,因为这些解决方案受困于执行时机的缺陷。
  • 还有一些边缘场景,但我们觉得影响不大。
    • 卸载 layout effect 将使用事件回调的上一个版本,但卸载非 layout effect 将在切换后运行,因此它们将观察下一个版本。这类似于在卸载 layout effect 和非 layout effect 期间读取 ref 产生的不同的结果。
    • 事件处理函数中的值与调用该事件处理函数时的值对应。这意味着你不会得到真正的“活”绑定。例如,如果你在一个事件函数中使用了 async/await,并且在 await 之后读取了一些属性,那么这个值将与 await 之前的值相同。要再次获得“新鲜”值,您需要进入另一个事件函数。因此,事件函数通常不应该是异步的。最好是把他们当作“用后即抛”: “这就是刚刚发生的事情”
    • onSomething={cond ? handler1 : handler2} 这样的“条件性事件”。在这种情况下,如果你把 onSomething 用作一个 effect 的依赖,它会在 cond 改变时重复触发。你可以通过把 useEvent 包装挪到与调用 onSomething 的 effect 同组件下来“避免”这个问题。如果这个问题变得常见,我们会考虑添加更多运行时或 linter 警告。

替代方案

  • 现状:useCallback 过于频繁地失效,没有官方解决方案,effect 重复触发也同样没有官方解决方案。我们认为这用起来不舒服,需要一个解决方案。
  • useEvent 起别的名字,比如 useStableCallback。我们觉得这样在使用时更混乱,而且更长更复杂的名字用起来也不方便。
  • useCallback 改成 useEvent 这样。我们不想这样做,因为它们的语义完全不同。
  • 强制所有 React 事件处理函数都必须用 useEvent 声明,我们认为这还为时过早。
  • 添加一个 API 来读取任意值的“最新”版本。我们发现,这在实践中变得很杂乱,因为一段代码通常需要读取多个值。随着代码量的增长,标记整个代码块(函数)而不是单个值会更方便,并且以更通用的方式解决了同样的问题。
  • useEffect 添加一些特殊 API。我们认为这不够通用,因为记忆化事件处理函数有同样的问题,所以一个共用的解决方案会更好。
  • 类似提案,但是允许在渲染过程中调用事件函数。我们觉得它只会导致更多搬起石头砸自己的脚的情况。
  • 类似提案,但是不同的切换到“当前”版本的时机。这是一个待商议的问题。
  • 类似提案,但是不一样的 linter 行为或运行时警告。例如在事件函数被当作 effect 依赖时发出运行时警告、使用 lint 把事件函数从依赖项数组中移出。

采用策略

在一个小版本中发布。把 linter 现在对使用 useCallback 包装以 on*handle* 开头函数的建议改为使用 useEvent 包装。写一些新文档来教导常用的模式。

如果函数在渲染时使用,useCallback 依然有用。但它不再像现在这样常用,会逐渐淡化。

React 中没有生命周期或 Hook 可以在正确的时机切换 .current,所以 useEvent 没有完美的 polyfill。 虽然use-event-callback在很多场景下“非常接近”,但它不会在渲染时报错,切换时机也不太对。 在 React 发布内置 useEvent 的版本之前,我们不建议广泛采用这个模式。

如何教学

教人怎么用它包装函数很容易,教人怎么用它解决问题就难一些了。

我们可能在文档中useEffectmemo 之前介绍 useEvent,因为你不需要了解引用标识或依赖项数组就可以使用它。然后,当你学到 useEffectmemo 时,解决它们缺陷(阻碍记忆化、重复触发 effect)的方法基于一个你已经会用的 API。

待解决的问题

  • 切换“当前”函数的具体时机
  • 卸载 layout effect 时“查找”到旧的事件处理函数是否有影响。
  • 卸载非 layout effect 时“查找”到旧的事件处理函数是否有影响。
  • 在 effect 的清理函数中调用事件处理函数是不是个“反模式”,应该警告吗?
  • 具体如何修改 linter 建议。
  • 在后续提案中搞清楚整个类型问题会不会阻碍当前提案。