React + TypeScript:将逻辑分离为事件和效果

在撰写本文时,“React Docs”(BETA)正在发布。其中一篇文章在将事件与效果分离是一篇详细的文章,解释了如何分离事件和效果的逻辑。

虽然这篇文章基本涵盖了文章的信息,但并不是日文翻译。我补充了缺失的部分,改变了解释的方式,删除了我认为不必要的描述。此外,与官网不同的是,示例代码分为模块,还引入了 TypeScript。

事件处理程序仅在执行预定交互时才会重新执行。另一方面,效果是在读取的值(例如属性和状态变量)与上次渲染期间不同时重新同步。在某些情况下,您可能希望将这两个动作结合起来。当您希望效果根据某些值重新运行而不响应其他值时。让我解释一下这个过程是如何工作的。

效果没有任何反应依赖

效果没有任何反应依赖 # 在事件处理程序和效果之间进行选择

首先,回顾一下事件处理程序和效果的不同之处。

假设您正在实现一个聊天室组件。有两个要求:

  1. 组件将自动连接到选定的聊天室。
  2. 单击发送按钮将消息发送到聊天室。

    我想出了要实现的代码。但是应该放在哪里呢?添加事件处理程序或效果。当这个问题出现时,想想为什么你必须运行代码什么是效果,它与事件有何不同?“参考)。

    事件处理程序被执行以响应定义的交互

    从用户的角度来看,应该在单击标记为“发送”的按钮时发送消息。如果与其他时间或操作一起发送,用户会感到困惑。换句话说,发送消息应该是一个事件处理程序。事件处理程序处理预先确定的交互,例如点击。

    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 不再是反应值。 roomIdmessage 是响应式值,参与渲染数据流。

    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])执行。该效果会将聊天连接同步到当前选择的房间。

    此外,样品 001serverUrl 不是响应式的,因为它是在组件 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