React + TypeScript:将逻辑分离为事件和效果
在撰写本文时,“React Docs”(BETA)正在发布。其中一篇文章在将事件与效果分离是一篇详细的文章,解释了如何分离事件和效果的逻辑。
虽然这篇文章基本涵盖了文章的信息,但并不是日文翻译。我补充了缺失的部分,改变了解释的方式,删除了我认为不必要的描述。此外,与官网不同的是,示例代码分为模块,还引入了 TypeScript。
事件处理程序仅在执行预定交互时才会重新执行。另一方面,效果是在读取的值(例如属性和状态变量)与上次渲染期间不同时重新同步。在某些情况下,您可能希望将这两个动作结合起来。当您希望效果根据某些值重新运行而不响应其他值时。让我解释一下这个过程是如何工作的。
效果没有任何反应依赖
效果没有任何反应依赖 # 在事件处理程序和效果之间进行选择
首先,回顾一下事件处理程序和效果的不同之处。
假设您正在实现一个聊天室组件。有两个要求:
- 组件将自动连接到选定的聊天室。
- 单击发送按钮将消息发送到聊天室。
我想出了要实现的代码。但是应该放在哪里呢?添加事件处理程序或效果。当这个问题出现时,想想为什么你必须运行代码什么是效果,它与事件有何不同?“参考)。
事件处理程序被执行以响应定义的交互
从用户的角度来看,应该在单击标记为“发送”的按钮时发送消息。如果与其他时间或操作一起发送,用户会感到困惑。换句话说,发送消息应该是一个事件处理程序。事件处理程序处理预先确定的交互,例如点击。
src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId, serverUrl }) => { const [message, setMessage] = useState(''); const handleSendClick = () => { sendMessage(message); setMessage(''); }; // ... return ( <> {/* ... */} <input value={message} onChange={({ target: { value } }) => setMessage(value)} /> <button onClick={handleSendClick}>Send</button> </> ); };
使用事件处理程序,很明显
sendMessage(message)
仅在用户按下按钮时执行。需要同步时执行效果
该组件必须在聊天期间保持与房间的连接。我应该在哪里编写代码?
这段代码的执行不是基于固定的交互。用户如何或为何转换到聊天室屏幕并不重要。一旦打开屏幕进行查看和交互,该组件应保持与所选聊天服务器的连接。即使聊天室组件是应用程序的初始屏幕并且用户没有任何交互,仍然应该保持连接。在这种情况下使用效果。
src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId, serverUrl }) => { // ... useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId, serverUrl]); // ... };
此代码将始终连接到选定的聊天服务器。它不涉及用户交互。也许您刚刚打开了一个应用程序,移动到另一个房间,或者从您转换到的另一个屏幕返回。尽管如此,效果仍然与组件当前选择的房间保持同步,并根据需要重新连接(注意:React + TypeScript:React 18 在组件安装时运行 useEffect 两次)。此代码示例已作为示例 001 发布到 CodeSandbox。
示例 001 React + TypeScript:从效果中分离事件 01
反应式价值观和逻辑
很简单,事件处理程序和效果之间的区别如下。
- 事件处理程序:“手动”执行。
- 例如单击按钮。
- 效果:在“自动”上同步。
- 无论交互如何,都需要。
在组件主体中声明的属性和状态变量称为“反应性值”。在下面的代码示例中,
serverUrl
不再是反应值。roomId
和message
是响应式值,参与渲染数据流。src/ChatRoom.tsx
const serverUrl = 'https://localhost:1234'; // export const ChatRoom: FC<Props> = ({ roomId, serverUrl }) => { export const ChatRoom: FC<Props> = ({ roomId }) => { const [message, setMessage] = useState(''); // ... }
反应值可能会在重新渲染时发生变化。例如,用户编辑
message
。您还可以在下拉列表中选择不同的roomId
。事件处理程序和效果的不同之处在于它们对值更改的响应方式。- 写在事件处理程序中的逻辑不是反应式的.只有当用户重复相同的交互(如点击)时才会重新执行。事件处理程序可以读取反应值。但是,它对价值的变化不是“反应性的”。
- 写入内部效果的逻辑是反应式的.效果读取的反应性值必须添加到依赖项中(请参阅使效果对'反应性'值作出反应)。如果在重新渲染时该值发生了变化,React 将使用新值重新运行效果的逻辑。
保持反应值和逻辑分开。有时最好将组件主体中声明的“反应性值”的处理放在“非反应性逻辑”中。让我们再次看一下前面的代码示例。
事件处理程序中的逻辑不是反应式的
请参阅下面的代码。这个逻辑是被动的吗?
// ... sendMessage(message); // ...
从用户的角度来看,
message
重写不想发送值。它只是意味着用户正在输入。换句话说,发送消息的逻辑不应该是被动的。不要仅仅因为“反应值”发生了变化而重新运行。所以我把这个逻辑放在事件处理程序中。const handleSendClick = () => { sendMessage(message); // ... };
事件处理程序不是反应式的。因此
sendMessage(message)
中的逻辑仅在用户单击“发送”按钮时运行。逻辑内部效应是反应性的
考虑以下代码。
// ... const connection = createConnection(serverUrl, roomId); connection.connect(); // ...
从用户的角度来看,
roomId
的变化是因为你想连接到另一个房间是。因此,连接房间的逻辑应该是反应式的。这些代码应该“意识到”“反应性值”,并在值不同时重新执行。所以把这个逻辑放在一个效果里。useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId, serverUrl]);
效果是反应性的。因此代码
createConnection(serverUrl, roomId)
和connection.connect()
将针对每个不同的依赖值([roomId, serverUrl]
)执行。该效果会将聊天连接同步到当前选择的房间。此外,样品 001
serverUrl
不是响应式的,因为它是在组件App
之外声明的。但是,它作为属性传递给ChatRoom
。因此,它是子组件的反应值。从效果中去除非反应性逻辑
当你混合反应式和非反应式逻辑时,它变得棘手。
例如,假设您想在用户连接到聊天时显示通知。通知背景颜色的当前主题是从属性中读取的,可以是深色或浅色。以正确的颜色显示通知。
export const ChatRoom: FC<Props> = ({ theme, roomId, serverUrl }) => { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { showNotification('Connected!', theme); }); connection.connect(); // ... }, [roomId, serverUrl]); };
但是,
theme
是一个反应值(可能会随着重新渲染而改变)。然后在依赖声明中包含您的效果读取的任何反应值(请参阅验证 React 是否已将所有反应值包含在您的依赖项中)。因此theme
必须添加为依赖项。export const ChatRoom: FC<Props> = ({ theme, roomId, serverUrl }) => { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { showNotification('Connected!', theme); }); connection.connect(); return () => connection.disconnect(); }, [theme, roomId, serverUrl]); // ✅ すべての依存関係を宣言 // ... };
您可以在下面的示例 002 中看到此代码的运行情况。尝试一下,看看您在用户体验方面遇到了什么问题。
示例 002 React + TypeScript:从效果中分离事件 02
当
roomId
更改时,聊天会重新连接。此举按预期工作。但是,我还包括theme
作为依赖项。因此,即使您只是在深色和浅色主题之间切换,每次聊天都会重新连接。这是个问题。换句话说,即使此代码在效果(反应式逻辑)内,我也不希望它反应式运行。
// ... showNotification('Connected!', theme); // ...
我们需要一种方法来将这种非反应性逻辑与反应性效果分开。
声明一个事件函数(实验 API)
[注意] 本节描述的 API 是实验性的。它还没有在 React 的常规版本中提供。
useEvent
是一个特殊的钩子,可以将非反应性逻辑从效果中提取出来。import { useEffect, useEvent } from 'react'; export const ChatRoom: FC<Props> = ({ theme, roomId, serverUrl }) => { const onConnected = useEvent(() => { showNotification('Connected!', theme); }); // ... };
在这段代码中,我们将
onConnected
称为“事件函数”。甚至效果逻辑的一部分也表现得像一个事件处理程序。事件函数内部的逻辑不能是反应式的。属性和状态总是“看到”它们最近的值。这就是我们从效果中调用
onConnected
事件函数的方式。export const ChatRoom: FC<Props> = ({ theme, roomId, serverUrl }) => { const onConnected = useEvent(() => { showNotification('Connected!', theme); }); useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { onConnected(); }); connection.connect(); return () => connection.disconnect(); }, [roomId, serverUrl]); // ✅ すべての依存関係を宣言 // ... };
问题已经解决了。与从
useState
返回的 set 函数一样,事件函数不会浮动。重新渲染时它不会改变。从效果的依赖列表中排除事件函数加载的值。因为它不再是一个反应值。请参阅下面的示例 003,以查看修改后的代码是否按预期工作。
示例 003 React + TypeScript:从效果中分离事件 03
事件函数与事件处理程序非常相似。主要区别在于事件处理程序是响应用户交互而执行的,而事件函数是从效果中调用的。事件函数在你的效果的反应逻辑和不应该反应的代码之间“打破链条”。
使用事件函数读取最新的属性和状态(实验 API)
[注意] 本节描述的 API 是实验性的。它还没有在 React 的常规版本中提供。
有时你可能想要抑制依赖 linter。其中许多情况可以通过事件函数来修复。
例如,假设您有一个记录页面访问的效果。
export const Page: FC = () => { useEffect(() => { logVisit(); }, []); // ... };
后来,我向该站点添加了多条路线。所以我们给
Page
组件的接收属性是url
和当前路径。如果您尝试将此url
作为参数传递给logVisit
调用,则依赖项 linter 会警告您。React Hook useEffect 缺少依赖项:'url'
现在你必须考虑你想让你的代码做什么。您需要分别记录对不同 URL 的访问。因为每个 URL 代表一个不同的页面。也就是说,对
logVisit
的调用预计会响应url
。因此,根据 linter,您应该将url
添加到您的依赖项中。export const Page: FC<Props> = ({ url }) => { useEffect(() => { logVisit(url); // }, []); // ? 依存関係にurlが含まれていない }, [url]); // ✅ すべての依存関係を宣言 // ... };
此外,假设您想将购物车中的商品数量添加到页面访问日志中。将
numberOfItems
添加到logVisit
参数中,依赖linter 也会注意到。React Hook useEffect 缺少依赖项:'numberOfItems'
export const Page: FC<Props> = ({ url }) => { const { items } = useContext(ShoppingCartContext); const numberOfItems = items.length; useEffect(() => { // logVisit(url); logVisit(url, numberOfItems); }, [url]); // ? 依存関係にnumberOfItemsが含まれていない // ... };
因为在效果中使用了
numberOfItems
,所以 linter 要求我将值包含在依赖项中。但是,我不希望logVisit
调用对numberOfItems
产生反应。当用户将东西放入购物车时,numberOfItems
的值会发生变化。但这并不意味着用户重新访问了该页面。因此,对页面的访问更像是一个事件。你会想知道页面被访问的确切时间。为此,请使用
useEvent
将逻辑一分为二。export const Page: FC<Props> = ({ url }) => { const { items } = useContext(ShoppingCartContext); const numberOfItems = items.length; const onVisit = useEvent((visitedUrl: string) => { logVisit(visitedUrl, numberOfItems); }); useEffect(() => { onVisit(url); }, [url]); // ✅ すべての依存関係を宣言 // ... };
这段代码中的
onVisit
是事件函数。函数体中的逻辑不是反应式的。因此,如果您在代码中使用的numberOfItems
(或任何其他反应性值)发生更改,您的效果中的其他代码将不会重新运行。 linter 甚至不会要求您在依赖项中包含numberOfItems
。但是,效果仍然是被动的。效果内部的逻辑使用
url
属性,因此每次使用不同的url
重新渲染时都会重新运行。此时,事件函数onVisit
也被调用。因此,每次
url
更改时都会调用logVisit
,始终读取最新的numberOfItems
。但是,事件函数逻辑不会因为numberOfItems
本身发生变化而重新执行。传递给事件函数的参数
您可能认为
onVisit()
可以不带参数调用并从事件函数中读取url
。const onVisit = useEvent(() => { logVisit(url, numberOfItems); }); useEffect(() => { onVisit(); }, [url]);
即使这样也有效。但是,最好将
url
显式传递给事件函数。使用url
作为函数参数表明从用户的角度转换到不同的url
页面构成了另一个“事件”。.visitedUrl
是发生的“事件”的一部分。const onVisit = useEvent((visitedUrl: string) => { logVisit(visitedUrl, numberOfItems); }); useEffect(() => { onVisit(url); }, [url]);
事件函数 (
onVisit
) 明确“要求”visitedUrl
作为参数。因此,您不能再无意中从效果的依赖项中删除url
。如果您从依赖项中删除url
(因为即使您转换到另一个页面,它也不会识别值更改),linter 将发出警告。onVisit
应该对url
具有反应性。因此,我们不是直接从函数中读取url
(使其反应较少),而是通过效果传递值。如果您的效果包含异步逻辑,这一点尤其重要。
const onVisit = useEvent((visitedUrl: string) => { logVisit(visitedUrl, numberOfItems); }); useEffect(() => { setTimeout(() => { onVisit(url); }, 5000); // 訪問のログを遅らせる }, [url]);
在此代码示例中,从事件函数
onVisit
中引用的url
对应于最新的属性值(可能已经更改)。但是,当执行效果(以及onVisit
调用的异步处理)时,参数中传递的visitedUrl
将是url
的值。限制依赖linter的规则可以吗?
在现有代码库中,您可能会发现 lint 规则受到如下限制:
export const Page: FC<Props> = ({ url }) => { const { items } = useContext(ShoppingCartContext); const numberOfItems = items.length; // ? つぎのようなリンターの制限は避ける // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { logVisit(url, numberOfItems); }, [url]); // ... };
一旦
useEvent
被作为一个稳定的特性包含在 React 中,就像这样避免 linter 限制.规则限制的最大问题是 React 将不再警告您该效果的依赖关系。 React 不会告诉你你的效果是否必须对你编写的新的响应式依赖项做出“反应”。例如,在前面的代码示例中,我们在依赖项中包含了
url
。 React 应该会提示你这样做。如果稍后编辑,禁用 linter 的效果将不会收到警告。这会产生错误。以下代码是限制 linter 导致的错误示例。
handleMove
函数正在读取状态变量canMove
的当前布尔值,以确定Dot
组件是否会跟随光标。但是,在handleMove
的主体中,canMove
的值始终为true
。export default function App() { const [position, setPosition] = useState({ x: 0, y: 0 }); const [canMove, setCanMove] = useState(true); const handleMove = useCallback( ({ clientX, clientY }: PointerEvent) => { if (canMove) { setPosition({ x: clientX, y: clientY }); } }, [canMove] ); useEffect(() => { window.addEventListener('pointermove', handleMove); return () => window.removeEventListener('pointermove', handleMove); // ? リンターに制限を加える // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <div className="App"> {/* ... */} {canMove && <Dot position={position} />} </div> ); }
问题是您限制了依赖项的 linter。如果取消限制,效果将是
handleMove
根据功能,您将被告知。
handleMove
是一个反应值,因为它是在组件主体内声明的。所有的反应值都必须包含在依赖项中。否则,当值改变时,效果的处理不会用旧值更新。这个代码示例通过移除 linter 的限制“欺骗”了 React 效果没有反应性依赖 (
[]
)。这就是为什么当canMove
(和handleMove
)发生变化时,React 没有重新同步效果。 React 不会重新同步效果,因此添加到事件 (pointermove
) 的任何侦听器仍将是第一次渲染期间创建的handleMove
函数。此时canMove
的值为true
。结果,不管canMove
的值切换了多少,handleMove
继续看到true
的原始值。如果你不限制 linter,你就不会有过时值的问题.以下示例 004 删除了 linter 限制并正确定义了依赖项 (
[handleMove]
)。此外,限制 linter 的描述也被注释掉了。如果您有兴趣,请尝试一下。示例 004 React + TypeScript:从效果中分离事件 04
使用
useEvent
,您不必“愚弄”linter,并且您的代码可以按预期工作。示例 005 React + TypeScript:从效果中分离事件 05
useEvent
并不总能找到正确的解决方案。只有你不想反应的逻辑应该被切割成事件函数。例如,在上面的示例 005 中,我认为效果代码不应该对canMove
产生反应。所以我把它剪成一个事件函数。有关可以在不限制 lintering 的情况下正确定义效果依赖项的其他方式,请参阅移除效果依赖“请参阅。
事件函数的限制(实验 API)
[注意] 本节描述的 API 是实验性的。它还没有在 React 的常规版本中提供。
目前,事件函数的使用非常有限。
- 只能从效果内部调用。
- 不得传递给其他组件或挂钩。
例如,不要将声明的事件函数 (
onTick
) 传递给另一个钩子 (useTimer
),如下所示:src/Timer.tsx
export const Timer: FC = () => { const [count, setCount] = useState(0); const onTick = useEvent(() => { setCount(count + 1); }); useTimer(onTick, 1000); // ? NG: イベント関数を外に渡す return <h1>{count}</h1>; };
src/useTimer.ts
export const useTimer = (callback: () => void, delay: number) => { useEffect(() => { const id = setInterval(() => { callback(); }, delay); return () => { clearInterval(id); }; }, [delay, callback]); // 依存関係にcallbackを含めなければならない };
始终在与效果相同的位置声明事件函数,并从效果内调用它。各个模块的具体代码和动作请参见下面的示例006。
src/Timer.tsx
export const Timer: FC = () => { const [count, setCount] = useState(0); /* const onTick = useEvent(() => { setCount(count + 1); }); */ // useTimer(onTick, 1000); useTimer(() => { setCount(count + 1); }, 1000); return <h1>{count}</h1>; };
src/useTimer.ts
export const useTimer = (callback: () => void, delay: number) => { const onTick = useEvent(() => { callback(); }); useEffect(() => { const id = setInterval(() => { // callback(); onTick(); // ✅ OK: エフェクト内から内部的に呼び出す }, delay); return () => { clearInterval(id); }; // }, [delay, callback]); }, [delay]); // onTick(イベント関数)は依存関係に含めなくてよい };
示例 006 React + TypeScript:从效果中分离事件 06
将来,其中一些限制可能会被取消。但现在,最好将事件函数视为效果代码的非反应部分。因此,它应该与代码的效果密切相关。
概括
- 执行事件处理程序以响应某些交互。
- 只要需要同步,效果就会运行。
- 事件处理程序中的逻辑不是反应式的。
- 效果中的逻辑是被动的。
- 事件中的非反应性逻辑可以被切割成事件函数。
- 事件函数只能从效果中调用。
- 不要将事件函数传递给其他组件或挂钩。
- 事件处理程序:“手动”执行。
原创声明:本文系作者授权爱码网发表,未经许可,不得转载;
原文地址:https://www.likecs.com/show-308629346.html