美文网首页
第四一章 逃生舱口-将事件与Effect分开

第四一章 逃生舱口-将事件与Effect分开

作者: 深圳都这么冷 | 来源:发表于2023-02-27 11:24 被阅读0次

将事件与Effect分开

事件处理程序仅在您再次执行相同的交互时重新运行。 与事件处理程序不同,如果 Effects 读取的某些值(如 prop 或状态变量)与上次渲染期间的值不同,则 Effects 会重新同步。 有时,您还需要两种行为的混合:一个 Effect 重新运行以响应某些值而不是其他值。 此页面将教您如何操作。

你将学习

  • 如何在事件处理程序和 Effect 之间进行选择
  • 为什么效果是反应性的,而事件处理程序不是
  • 当您希望 Effect 的一部分代码不响应时该怎么做
  • 什么是效果事件,以及如何从您的效果中提取它们
  • 如何使用 Effect Events 从 Effects 中读取最新的属性和状态

在事件处理程序和效果之间进行选择

首先,让我们回顾一下事件处理程序和 Effects 之间的区别。

假设您正在实现一个聊天室组件。 您的要求如下所示:

  1. 您的组件应该自动连接到选定的聊天室。
  2. 当您单击“发送”按钮时,它应该会向聊天室发送一条消息。

假设您已经为他们实现了代码,但不确定将代码放在哪里。 您应该使用事件处理程序还是 Effects? 每次需要回答这个问题的时候,想想为什么代码需要运行。

事件处理程序运行以响应特定的交互

从用户的角度来看,发送消息应该是因为点击了特定的“发送”按钮。 如果您在任何其他时间或出于任何其他原因向他们发送消息,用户会非常不高兴。 这就是为什么发送消息应该是一个事件处理程序。 事件处理程序可让您处理特定的交互,例如点击:

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');
  // ...
  function handleSendClick() {
    sendMessage(message);
  }
  // ...
  return (
    <>
      <input value={message} onChange={e => setMessage(e.target.value)} />
      <button onClick={handleSendClick}>Send</button>;
    </>
  );
}

使用事件处理程序,您可以确保 sendMessage(message) 仅在用户按下按钮时才会运行。

效果在需要同步时运行

回想一下,您还需要保持组件与聊天室的连接。 该代码去哪里了?

运行此代码的原因不是某些特定的交互。 用户为什么或如何导航到聊天室屏幕并不重要。 现在他们正在查看它并可以与之交互,该组件需要与选定的聊天服务器保持连接。 即使聊天室组件是您应用程序的初始屏幕,并且用户根本没有执行任何交互,您仍然需要连接。 这就是为什么它是一个效果:

function ChatRoom({ roomId }) {
  // ...
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
  // ...
}

使用此代码,您可以确保与当前所选聊天服务器的连接始终处于活动状态,而不管用户执行的特定交互如何。 无论用户只是打开了您的应用程序、选择了不同的房间,还是导航到另一个屏幕并返回,您的 Effect 都将确保该组件将与当前选择的房间保持同步,并在必要时重新连接。
chat.js

export function sendMessage(message) {
  console.log('🔵 You sent: ' + message);
}

export function createConnection(serverUrl, roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
    },
    disconnect() {
      console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
    }
  };
}

App.js

import { useState, useEffect } from 'react';
import { createConnection, sendMessage } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  function handleSendClick() {
    sendMessage(message);
  }

  return (
    <>
      <h1>Welcome to the {roomId} room!</h1>
      <input value={message} onChange={e => setMessage(e.target.value)} />
      <button onClick={handleSendClick}>Send</button>
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [show, setShow] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <button onClick={() => setShow(!show)}>
        {show ? 'Close chat' : 'Open chat'}
      </button>
      {show && <hr />}
      {show && <ChatRoom roomId={roomId} />}
    </>
  );
}

反应值和反应逻辑

直观地说,您可以说事件处理程序总是“手动”触发的,例如通过单击按钮。 另一方面,效果器是“自动”的:它们会根据需要经常运行和重新运行以保持同步。

有一种更精确的方法来考虑这个问题。

在组件体内声明的属性、状态和变量称为反应值。 在此示例中,serverUrl 不是反应值,但 roomId 和 message 是。 它们参与渲染数据流:

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  // ...
}

由于重新渲染,像这样的反应值可能会改变。 例如,用户可以编辑消息或在下拉列表中选择不同的 roomId。 事件处理程序和 Effects 在响应变化的方式上有所不同:

  • 事件处理程序中的逻辑不是反应性的。 它不会再次运行,除非用户再次执行相同的交互(例如,单击)。 事件处理程序可以读取响应值,但它们不会对其更改做出“反应”。
  • Effects 内部的逻辑是反应式的。 如果您的 Effect 读取一个反应值,您必须将其指定为依赖项。 然后,如果重新渲染导致该值发生变化,React 将使用新值再次重新运行 Effect 的逻辑。

让我们回顾一下前面的例子来说明这种差异。

事件处理程序内部的逻辑不是反应式的

看看这行代码。 这个逻辑是否应该是反应性的?

    // ...
    sendMessage(message);
    // ...

从用户的角度来看,对消息的更改并不意味着他们要发送消息。 它仅表示用户正在键入。 换句话说,发送消息的逻辑不应该是反应性的。 它不应该仅仅因为无功值已经改变而再次运行。 这就是将此逻辑放在事件处理程序中的原因:

  function handleSendClick() {
    sendMessage(message);
  }

事件处理程序不是反应式的,因此 sendMessage(message) 只会在用户单击“发送”按钮时运行。

Effects 内部的逻辑是反应式的

现在让我们回到这些行:

    // ...
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    // ...

从用户的角度来看,更改 roomId 确实意味着他们想要连接到不同的房间。 换句话说,连接到房间的逻辑应该是反应性的。 您希望这些代码行“跟上”反应值,并在该值不同时再次运行。 这就是将此逻辑放在 Effect 中的原因:

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect()
    };
  }, [roomId]);

效果是反应性的,因此 createConnection(serverUrl, roomId) 和 connection.connect() 将针对 roomId 的每个不同值运行。 您的 Effect 使聊天连接与当前选定的房间保持同步。

从 Effects 中提取非反应性逻辑

当您想将反应性逻辑与非反应性逻辑混合时,事情会变得更加棘手。

例如,假设您想在用户连接到聊天时显示通知。 您从属性中读取当前主题(深色或浅色),以便以正确的颜色显示通知:

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('Connected!', theme);
    });
    connection.connect();
    // ...

然而,theme 是一个反应值(它可以作为重新渲染的结果而改变),并且 Effect 读取的每个反应值都必须声明为它的依赖项。 所以现在你必须指定主题作为你的效果的依赖:

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('Connected!', theme);
    });
    connection.connect();
    return () => {
      connection.disconnect()
    };
  }, [roomId, theme]); // ✅ All dependencies declared
  // ...

玩一下这个例子,看看你是否能发现这个用户体验的问题:
notification.js

import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';

export function showNotification(message, theme) {
  Toastify({
    text: message,
    duration: 2000,
    gravity: 'top',
    position: 'right',
    style: {
      background: theme === 'dark' ? 'black' : 'white',
      color: theme === 'dark' ? 'white' : 'black',
    },
  }).showToast();
}

chat.js

export function createConnection(serverUrl, roomId) {
  // A real implementation would actually connect to the server
  let connectedCallback;
  let timeout;
  return {
    connect() {
      timeout = setTimeout(() => {
        if (connectedCallback) {
          connectedCallback();
        }
      }, 100);
    },
    on(event, callback) {
      if (connectedCallback) {
        throw Error('Cannot add the handler twice.');
      }
      if (event !== 'connected') {
        throw Error('Only "connected" event is supported.');
      }
      connectedCallback = callback;
    },
    disconnect() {
      clearTimeout(timeout);
    }
  };
}

App.js

import { useState, useEffect } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('Connected!', theme);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]);

  return <h1>Welcome to the {roomId} room!</h1>
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        Use dark theme
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}

当 roomId 更改时,聊天会如您所料重新连接。 但由于主题也是一个依赖项,每次在深色和浅色主题之间切换时,聊天也会重新连接。 那不是很好!

换句话说,您不希望此行是反应式的,即使它在 Effect 中(它是反应式的):

      // ...
      showNotification('Connected!', theme);
      // ...

您需要一种方法将这种非反应性逻辑与其周围的反应性 Effect 分开。

声明一个效果事件

构建中
本节描述了一个尚未添加到 React 中的实验性 API,因此您还不能使用它。

使用一个名为 useEffectEvent 的特殊 Hook 从 Effect 中提取这种非反应性逻辑:

import { useEffect, useEffectEvent } from 'react';

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });
  // ...

在这里,onConnected 被称为 Effect Event。 它是 Effect 逻辑的一部分,但它的行为更像一个事件处理程序。 它内部的逻辑不是反应式的,它总是“看到”你的属性和状态的最新值。

现在您可以从 Effect 内部调用 onConnected效果事件:

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      onConnected();
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ All dependencies declared
  // ...

这解决了问题。 请注意,您必须从 Effect 的依赖项列表中删除 onConnected。 效果事件不是反应性的,必须从依赖项中省略。 如果包含它们,linter 会出错。

验证新行为是否按预期工作:

import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      onConnected();
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return <h1>Welcome to the {roomId} room!</h1>
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        Use dark theme
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}

您可以将效果事件视为与事件处理程序非常相似。 主要区别在于事件处理程序运行以响应用户交互,而 Effect 事件由您从 Effects 触发。 Effect Events 可以让你“打破”Effects 的反应性和一些不应该是反应性的代码之间的链条。

使用 Effect Events 读取最新的属性和状态

构建中
本节描述了一个尚未添加到 React 中的实验性 API,因此您还不能使用它。

Effect Events 让您可以修复许多您可能想要抑制依赖性 linter 的模式。

例如,假设您有一个 Effect 来记录页面访问:

function Page() {
  useEffect(() => {
    logVisit();
  }, []);
  // ...
}

稍后,您将多条路线添加到您的站点。 现在您的 Page 组件收到一个带有当前路径的 url 属性。 您想将 url 作为 logVisit 调用的一部分传递,但依赖性 linter 抱怨:

function Page({ url }) {
  useEffect(() => {
    logVisit(url);
  }, []); // 🔴 React Hook useEffect has a missing dependency: 'url'
  // ...
}

想想你想让代码做什么。 您希望为不同的 URL 记录单独的访问,因为每个 URL 代表一个不同的页面。 换句话说,这个 logVisit 调用应该对 url 有反应。 这就是为什么在这种情况下,遵循依赖性 linter 并将 url 添加为依赖性是有意义的:

function Page({ url }) {
  useEffect(() => {
    logVisit(url);
  }, [url]); // ✅ All dependencies declared
  // ...
}

现在假设您想在每次页面访问时包括购物车中的商品数量:

function Page({ url }) {
  const { items } = useContext(ShoppingCartContext);
  const numberOfItems = items.length;

  useEffect(() => {
    logVisit(url, numberOfItems);
  }, [url]); // 🔴 React Hook useEffect has a missing dependency: 'numberOfItems'
  // ...
}

您在 Effect 中使用了 numberOfItems,因此 linter 要求您将其添加为依赖项。 但是,您不希望 logVisit 调用对 numberOfItems 有反应。 如果用户将东西放入购物车,并且 numberOfItems 发生变化,这并不意味着用户再次访问了该页面。 换句话说,访问该页面感觉类似于一个事件。 您想非常准确地说明事情发生的时间。

将代码分成两部分:

  const { items } = useContext(ShoppingCartContext);
  const numberOfItems = items.length;

  const onVisit = useEffectEvent(visitedUrl => {
    logVisit(visitedUrl, numberOfItems);
  });

  useEffect(() => {
    onVisit(url);
  }, [url]); // ✅ All dependencies declared
  // ...
}

在这里,onVisit 是一个 Effect Event。 它里面的代码不是反应性的。 这就是为什么您可以使用 numberOfItems(或任何其他响应值!)而不必担心它会导致周围的代码在更改时重新执行。

另一方面,Effect 本身仍然是反应性的。 Effect 中的代码使用 url 属性,因此 Effect 将在每次使用不同的 url 重新渲染后重新运行。 反过来,这将调用 onVisit 效果事件。

因此,您将针对 url 的每次更改调用 logVisit,并始终读取最新的 numberOfItems。 但是,如果 numberOfItems 自行更改,则不会导致任何代码重新运行。

注意

您可能想知道是否可以不带参数调用 onVisit() 并读取其中的 url:

  const onVisit = useEffectEvent(() => {
    logVisit(url, numberOfItems);
  });

  useEffect(() => {
    onVisit();
  }, [url]);

这会起作用,但最好将此 url 显式传递给 Effect Event。 通过将 url 作为参数传递给您的 Effect Event,您是说访问具有不同 url 的页面从用户的角度来看构成了一个单独的“事件”。 visitedUrl 是发生的“事件”的一部分:

  const onVisit = useEffectEvent(visitedUrl => {
    logVisit(visitedUrl, numberOfItems);
  });

  useEffect(() => {
    onVisit(url);
  }, [url]);

由于您的 Effect Event 明确“询问”visitedUrl,现在您不会不小心从 Effect 的依赖项中删除 url。 如果您删除 url 依赖项(导致不同的页面访问被计为一次),linter 将警告您。 您希望 onVisit 对 url 具有反应性,因此不是读取内部的 url(它不会是反应性的),而是从您的 Effect 中传递它。

如果 Effect 中有一些异步逻辑,这一点就变得尤为重要:

  const onVisit = useEffectEvent(visitedUrl => {
    logVisit(visitedUrl, numberOfItems);
  });

  useEffect(() => {
    setTimeout(() => {
      onVisit(url);
    }, 5000); // Delay logging visits
  }, [url]);

在此示例中,onVisit 中的 url 对应于最新的 url(可能已经更改),但 visitedUrl 对应于最初导致此 Effect(和此 onVisit 调用)运行的 url。

深度阅读:可以改为抑制依赖性 linter 吗?

在现有的代码库中,您有时可能会看到像这样抑制的 lint 规则:

function Page({ url }) {
  const { items } = useContext(ShoppingCartContext);
  const numberOfItems = items.length;

  useEffect(() => {
    logVisit(url, numberOfItems);
    // 🔴 Avoid suppressing the linter like this:
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [url]);
  // ...
}

在 useEffectEvent 成为 React 的稳定部分后,我们建议永远不要像这样抑制 linter。

抑制规则的第一个缺点是,当您的 Effect 需要对您引入代码的新反应依赖项“做出反应”时,React 将不再警告您。 例如,在前面的示例中,您将 url 添加到依赖项中,因为 React 会提醒您这样做。 如果您禁用 linter,您将不会再收到此类提醒,提醒您以后对该 Effect 进行任何编辑。 这会导致错误。

这是一个由抑制 linter 引起的令人困惑的错误示例。 在这个例子中,handleMove 函数应该读取当前的 canMove 状态变量值,以便决定点是否应该跟随光标。 但是,canMove 在 handleMove 中始终为真。 你能明白为什么吗?

import { useState, useEffect } from 'react';

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  function handleMove(e) {
    if (canMove) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  }

  useEffect(() => {
    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)}
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}

此代码的问题在于抑制依赖性 linter。 如果取消抑制,您会发现此 Effect 应依赖于 handleMove 函数。 这是有道理的:handleMove 是在组件体内声明的,这使它成为一个反应值。 每个反应值都必须指定为依赖项,否则它可能会随着时间的推移而变得陈旧!

原始代码的作者通过说 Effect 不依赖于 ([]) 任何反应值来对 React “撒谎”。 这就是为什么 React 在 canMove 发生变化(以及 handleMove 发生变化)后没有重新同步 Effect 的原因。 因为 React 没有重新同步 Effect,作为监听器附加的 handleMove 是在初始渲染期间创建的 handleMove 函数。 在初始渲染期间,canMove 为真,这就是初始渲染中的 handleMove 将永远看到该值的原因。

如果你从不抑制 linter,你将永远不会看到陈旧值的问题。

有了 useEffectEvent,就没有必要对 linter “说谎”了,代码会像你期望的那样工作:

import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  const onMove = useEffectEvent(e => {
    if (canMove) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  });

  useEffect(() => {
    window.addEventListener('pointermove', onMove);
    return () => window.removeEventListener('pointermove', onMove);
  }, []);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)}
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}

这并不意味着 useEffectEvent 始终是正确的解决方案。 您应该只将它应用于您不想响应的代码行。 例如,在上面的沙箱中,您不希望 Effect 的代码与 canMove 相关。 这就是为什么提取 Effect Event 是有意义的。

阅读 Removing Effect Dependencies 了解抑制 linter 的其他正确替代方法。

效果事件的限制

构建中
本节描述了一个尚未添加到 React 中的实验性 API,因此您还不能使用它。

效果事件的使用方式非常有限:

  • 只能从 Effects 内部调用它们。
  • 永远不要将它们传递给其他组件或 Hooks。

例如,不要像这样声明和传递 Effect Event:

function Timer() {
  const [count, setCount] = useState(0);

  const onTick = useEffectEvent(() => {
    setCount(count + 1);
  });

  useTimer(onTick, 1000); // 🔴 Avoid: Passing Effect Events

  return <h1>{count}</h1>
}

function useTimer(callback, delay) {
  useEffect(() => {
    const id = setInterval(() => {
      callback();
    }, delay);
    return () => {
      clearInterval(id);
    };
  }, [delay, callback]); // Need to specify "callback" in dependencies
}

相反,始终直接在使用它们的效果旁边声明效果事件:

function Timer() {
  const [count, setCount] = useState(0);
  useTimer(() => {
    setCount(count + 1);
  }, 1000);
  return <h1>{count}</h1>
}

function useTimer(callback, delay) {
  const onTick = useEffectEvent(() => {
    callback();
  });

  useEffect(() => {
    const id = setInterval(() => {
      onTick(); // ✅ Good: Only called locally inside an Effect
    }, delay);
    return () => {
      clearInterval(id);
    };
  }, [delay]); // No need to specify "onTick" (an Effect Event) as a dependency
}

效果事件是效果代码的非反应性“片段”。 它们应该在使用它们的效果旁边。

回顾

  • 事件处理程序运行以响应特定的交互。
  • 只要需要同步,效果就会运行。
  • 事件处理程序中的逻辑不是反应性的。
  • Effects 内部的逻辑是反应式的。
  • 您可以将非反应性逻辑从 Effects 移动到 Effect Events。
  • 仅从 Effects 内部调用 Effect Events。
  • 不要将 Effect Events 传递给其他组件或 Hooks。

相关文章

网友评论

      本文标题:第四一章 逃生舱口-将事件与Effect分开

      本文链接:https://www.haomeiwen.com/subject/jgznkdtx.html