[译]你可能不需要 effect

1,749 阅读10分钟

原文地址 - You Might Not Need an Effect

Effect 是 react 范式的一个逃生口。它让你跳出 react 并将你的组件与一些外部系统同步,如非 react 小部件、网络或浏览器 Dom 。如果不涉及外部系统,则不需要 effect 。删除不必要的 effect 将使您的代码运行更快且不易出错。

如何删除不必要的effect

有两种不需要effect的常见情况:

  • 不需要 effect 来转换数据以进行渲染。

  • 不需要 effect 来处理用户事件。比如:用户点击购买商品,用户切换表格页码等

根据 props 或 state 更新 state

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');

  // 🔴 Avoid: redundant state and unnecessary Effect
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

这个例子是低效的

// good case
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // ✅ Good: calculated during rendering
  const fullName = firstName + ' ' + lastName;
  // ...
}

缓存昂贵的计算

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');

  // 🔴 Avoid: redundant state and unnecessary Effect
  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);

  // ...
}

可以不适用任何 hooks 完成,如果 getFilteredTodos 十分缓慢,可以使用 useMemo 缓存结果

但是一般来说,除非你创建或循环数以千计的对象,否则它可能并不昂贵。(大部分前端的计算都是不昂贵的)

props 更改时重置所有状态

假设有一个 ProfilePage 组件,接受一个 userId,该页面内部维护了一个 state comment。有一天你发现,当你切换了 userId ,comment 的状态并不会被重置。因此很有可能会在另外一个 userId 下发送此前的 comment。也许你会这么解决这个问题:

export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');

  // 🔴 Avoid: Resetting state on prop change in an Effect
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

这是低效的,页面先按照旧的状态渲染之后执行 effect 更换 comment,然后再次渲染。你需要在每一个具有这种状态的组件中执行此类操作。

相反,你可以通过给他一个显示的 key ,告诉 react 需要重新渲染。

export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId} 
      key={userId}
    />
  );
}

function Profile({ userId }) {
  // ✅ This and any other state below will reset on key change automatically
  const [comment, setComment] = useState('');
  // ...
}

通常 react 会为同一个位置的渲染的同一个组件保留状态,但是设置 key 之后,react 会认为这是两个独立状态不共享的两个组件。通过这种方式初始化了 comment ,达到了清理状态的目的。

当 props 改变调整 state

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // 🔴 Avoid: Adjusting state on prop change in an Effect
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

这种情况也不理想,每次项目变更时,依旧是低效的两次更新,一次旧值,一次新值。

我们建议在渲染过程中直接调整状态

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // Better: Adjust the state while rendering
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

它相比在 effect 中更新相同的状态要更好。React 将在 List return 后,React 尚未更新 dom,而且 list 的子元素没有渲染,因此可以跳过一些旧值的渲染。

当你在更新过程中更新组件,react 会丢弃返回的 jsx,并立即重新尝试 render。为了减少非常缓慢的级联重试,react只允许你在render期间更新相同组件的状态。如果更新另外一个组件,你将看到一个错误。

但是其他的副作用,应该保留在事件处理或效果中。

虽然这个模式比 effect 更有效,但是大多数组件也不需要他。无论如何做,根据 props 或者 其他状态调整状态都会使你的数据流更加难以理解和调试。

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ Best: Calculate everything during rendering
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

现在没必要调整其他state。selection会在渲染的时候计算最新的状态。

在事件处理程序之间共享逻辑

假设您有一个包含两个按钮(buy 和 checkout)的页面,这两个按钮都允许你购买产品。您希望在用户将产品放入购物车时显示一个提醒。将 showToast() 调用添加到两个按钮的单机处理程序感觉是重复的,所以你可能会把这个逻辑放到一个 Effect 中:

function ProductPage({ product, addToCart }) {
  // 🔴 Avoid: Event-specific logic inside an Effect
  useEffect(() => {
    if (product.isInCart) {
      showToast(`Added ${product.name} to the shopping cart!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

  function handleCheckoutClick() {
    addToCart(product);
    navigateTo('/checkout');
  }
  // ...
}

这个 effect 是不必要的。它也很可能会导致错误。假设你的页面持久化储存了购物车的内容,当你切换了页面后,将会触发 toast 。这个行为其实是错误的。

当你不确定某些代码应该在 effect 中还是事件处理程序中时候,问问自己为什么需要运行这些代码。仅对页面展示时应该运行的代码使用 effect。在这个例子中,显示 toast 应该是用户触发了添加购物车的动作。

function ProductPage({ product, addToCart }) {
  // ✅ Good: Event-specific logic is called from event handlers
  function buyProduct() {
    addToCart(product);
    showToast(`Added ${product.name} to the shopping cart!`);    
  }

  function handleBuyClick() {
    buyProduct();
  }

  function handleCheckoutClick() {
    buyProduct();
    navigateTo('/checkout');
  }
  // ...
}

发送 post 请求

下面的组件发送两个 post 请求。第一个是 埋点事件,另外一个表单提交

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ Good: This logic should run because the component was displayed
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  // 🔴 Avoid: Event-specific logic inside an Effect
  const [jsonToSubmit, setJsonToSubmit] = useState(null);
  useEffect(() => {
    if (jsonToSubmit !== null) {
      post('/api/register', jsonToSubmit);
    }
  }, [jsonToSubmit]);

  function handleSubmit(e) {
    e.preventDefault();
    setJsonToSubmit({ firstName, lastName });
  }
  // ...
}

埋点分析的请求应该保留在 effect 中。这是因为发送埋点事件的原因是因为显示了表单。

但是,表单提交的请求并不是由表单显示引发的。你只希望在一个特定的时刻发送请求:当用户按下按钮时。它应该只发生在那个特定的交互中,删除第二个 effect 并把请求移动到事件处理程序中:

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ Good: This logic runs because the component was displayed
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    // ✅ Good: Event-specific logic is in the event handler
    post('/api/register', { firstName, lastName });
  }
  // ...
}

当你在选中将逻辑放入事件处理程序还是 effect 中时,你需要回答的问题是:从用户的角度看,他是什么样的逻辑。如果逻辑是由特定的交互引起的,则将其保留在事件处理程序中。如果他是由于用户在屏幕上看到组件造成的,那么将它保持在 effect 中。

初始化应用程序

有些逻辑应该只在应用程序加载时运行一次。你可以把它放在顶部组件的 effect 中:

function App() {
  // 🔴 Avoid: Effects with logic that should only ever run once
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}

然而很快你就会发现在开发中运行了两次(react 18 在开发环境严格模式下的“优化”)。这可能导致令牌失效等问题,尽管这不可能发生在生产环境。如果某些逻辑必须在每个应用程序加载时运行一次,而不是在每个组件加载时运行一次。那么可以添加一个全局变量跟踪它是否已经执行。

let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      // ✅ Only runs once per app load
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

或者

if (typeof window !== 'undefined') { // Check if we're running in the browser.
   // ✅ Only runs once per app load
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

不要过度使用此模式,将初始化的逻辑保留在根组件模块。

通知父组件状态更改

假设你正在编写一个具有内部 isOn 状态的 Toggle 组件,该状态可以为 true 或 false。 有几种不同的方式来切换它(通过点击或者拖动)。当 Toggle 内部状态发生改变时,你希望通知父组件,因此你传入了一个 onChange 事件,并通过一个 effect 调用它:

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  // 🔴 Avoid: The onChange handler runs too late
  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange])

  function handleClick() {
    setIsOn(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      setIsOn(true);
    } else {
      setIsOn(false);
    }
  }

  // ...
}

和之前一样,这并不理想。Toggle 组件首先更新其状态,react 更新屏幕,然后 react 运行 effect ,它调用 onChange 函数。出发另一次更新。

删除 effect,改为更新同一个时间处理程序中的两个组件的状态:

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  function updateToggle(nextIsOn) {
    // ✅ Good: Perform all updates during the event that caused them
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

  function handleClick() {
    updateToggle(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      updateToggle(true);
    } else {
      updateToggle(false);
    }
  }

  // ...
}

当然你也可以删除 isOn,从父组件接收 isOn:

// ✅ Also good: the component is fully controlled by its parent
function Toggle({ isOn, onChange }) {
  function handleClick() {
    onChange(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      onChange(true);
    } else {
      onChange(false);
    }
  }

  // ...
}

当你需要在两个不同的组件保持状态的同步,这是一个标记,尝试状态提升。

将数据传递给父级

假设有一个 Child 组件获取一些数据,然后通过 Effect 将其传递给一个 Parent 组件。

function Parent() {
  const [data, setData] = useState(null);
  // ...
  return <Child onFetched={setData} />;
}

function Child({ onFetched }) {
  const data = useSomeAPI();
  // 🔴 Avoid: Passing data to the parent in an Effect
  useEffect(() => {
    if (data) {
      onFetched(data);
    }
  }, [onFetched, data]);
  // ...
}

在 React 中。数据从父组件流向子组件。当子组件更新时却通过Effect 传递到父组件时,数据流变得非常难以跟踪。因为子组件和父组件都需要相同的数据,所以让父组件获取数据传递给子组件:

function Parent() {
  const data = useSomeAPI();
  // ...
  // ✅ Good: Passing data down to the child
  return <Child data={data} />;
}

function Child({ data }) {
  // ...
}

订阅外部存储

有时,你的组件需要订阅一些 React 状态之外的数据。比如一些第三方库和一些浏览器的API

function useOnlineStatus() {
  // Not ideal: Manual store subscription in an Effect
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function updateState() {
      setIsOnline(navigator.onLine);
    }

    updateState();

    window.addEventListener('online', updateState);
    window.addEventListener('offline', updateState);
    return () => {
      window.removeEventListener('online', updateState);
      window.removeEventListener('offline', updateState);
    };
  }, []);
  return isOnline;
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

建议使用 react 提供的专门负责的 hooks, useSyncExternalStore 负责订阅外部数据。

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function useOnlineStatus() {
  // ✅ Good: Subscribing to an external store with a built-in Hook
  return useSyncExternalStore(
    subscribe, // React won't resubscribe for as long as you pass the same function
    () => navigator.onLine, // How to get the value on the client
    () => true // How to get the value on the server
  );
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

获取数据

许多应用程序使用 effect 来获取数据

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    // 🔴 Avoid: Fetching without cleanup logic
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

你不需要将次操作移动到事件处理程序。

这似乎与前面的示例相矛盾。

但是,要考虑的是,提取的主要原因并不是输入事件。搜索输入通常是从 URL 预填充的,用户可以在不触发输入事件的情况下导航前进和后退,页面和查询来自哪里并不重要。

但是,上面的代码有一个 bug。想象一下你打“你好”很快。然后查询将从“ h”更改为“ he”、“ hel”、“ hell”和“ hello”。这将启动单独的获取,但是不能保证响应将按照什么顺序到达。例如,“ hell”响应可能在“ hello”响应之后到达。因为它会最后调用 setResults () ,所以您将显示错误的搜索结果。这被称为“竞争条件”: 两个不同的请求“竞争”彼此,并且以不同于您预期的顺序出现。

要修复竞态条件,您需要添加一个清理函数来忽略过时的响应:

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1); 
  useEffect(() => {
    let ignore = false;
    fetchResults(query, page).then(json => {
      if (!ignore) {
        setResults(json);
      }
    });
    return () => {
      ignore = true;
    };
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

处理竞态条件并不是实现数据获取的唯一问题。您可能还想考虑如何缓存响应(这样用户可以单击 Back 并立即看到前面的屏幕而不是 loading ) ,如何在服务器上获取它们(这样初始服务器呈现的 HTML 包含获取的内容而不是 loading ) ,以及如何避免网络瀑布(这样需要获取数据的子组件不必等到上面的每个父级完成获取数据之后才能启动)。这些问题适用于任何 UI 库,而不仅仅是 React。解决这些问题并非易事,这就是为什么现代框架提供了比直接在组件中编写 effect 更有效的内置数据获取机制的原因。

回顾一下

  • 如果你可以在渲染过程中计算一些东西,你就不需要效果
  • 要缓存复杂的计算,请添加useMemo 而不是useEffect
  • 若要重置整个组件树的状态,请传递一个不同的key
  • 因为 props 变化需要调整state,请在 render 函数中直接修改
  • 需要运行的代码,如果因为是展示组件应该在 effect 中,其余的应在事件中。
  • 如果需要更新多个组件的状态,最好在单个事件期间进行
  • 当您尝试同步不同组件中的状态变量时,请考虑提升状态
  • 您可以使用 effect 获取数据,但是需要实现清理以避免竞态条件

\