Skip to content

effect 函数记录

使用 Effect 同步

Effect 在屏幕更新后的 提交阶段 运行。

不要随意在你的组件中使用 Effect。记住,Effect 通常用于暂时“跳出”React 代码并与一些 外部 系统进行同步。这包括浏览器 API、第三方小部件,以及网络等等。如果你想用 Effect 仅根据其他状态调整某些状态,那么 你可能不需要 Effect。

React 使用 Object.is 比较依赖项的值。

什么时候需要添加依赖项?

假设父组件有一个 state 表示 input 输入框的值,一个 state isPlaying 表示是否播放视频。

每次改变 input 的 state 触发重新渲染时,都会触发是否播放视频的 effect,

但我们想要的是改变 isPlaying 时才去执行是否播放视频的 effect。

为什么开发模式下,控制台会打印三条记录?

想象 ChatRoom 组件是一个大规模的 App 中许多界面中的一部分。用户切换到含有 ChatRoom 组件的页面上时,该组件被挂载,并调用 connection.connect() 方法连接服务器。然后想象用户此时突然导航到另一个页面,比如切换到“设置”页面。这时,ChatRoom 组件就被卸载了。接下来,用户在“设置”页面忙完后,单击“返回”,回到上一个页面,并再次挂载 ChatRoom。这将建立第二次连接,但是,第一次时创建的连接从未被销毁!当用户在应用程序中不断切换界面再返回时,与服务器的连接会不断堆积。

这样的错误很容易被遗漏。为了帮助你快速发现它们,在开发环境中,React 会在初始挂载组件后,立即再挂载一次。

最后你为了解决这个问题,应该在 Effect 中返回一个 清理(cleanup)函数。

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

在开发环境中,React 有意重复挂载你的组件,以查找像上面示例中的错误。正确的态度是“如何修复 Effect 以便它在重复挂载后能正常工作”,而不是“如何只运行一次 Effect”。

下面提供一些常用的 Effect 应用模式。

控制非 React 组件

在这种情况下不需要清理。在开发环境中,React 会调用 Effect 两次,但这两次挂载时依赖项 zoomLevel 都是相同的,所以会跳过执行第二次挂载时的 Effect。开发环境中它可能会稍微慢一些,但这问题不大,因为它在生产中不会进行不必要的重复挂载。

js
useEffect(() => {
  const map = mapRef.current;
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

某些 API 可能不允许连续调用两次。例如,内置的 <dialog> 元素的 showModal 方法在连续调用两次时会抛出异常,此时实现清理函数并使其关闭对话框:

js
useEffect(() => {
  const dialog = dialogRef.current;
  dialog.showModal();
  return () => dialog.close();
}, []);

在开发环境中,Effect 将调用 showModal(),然后立即调用 close(),然后再次调用 showModal()。这与调用只一次 showModal() 的效果相同。也正如在生产环境中看到的那样。

订阅事件

如果 Effect 订阅了某些事件,清理函数应该退订这些事件:

js
useEffect(() => {
  function handleScroll(e) {
    console.log(window.scrollX, window.scrollY);
  }
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

在开发环境中,Effect 会调用 addEventListener(),然后立即调用 removeEventListener(),然后再调用相同的 addEventListener(),这与只订阅一次事件的 Effect 等效;这也与用户在生产环境中只调用一次 addEventListener() 具有相同的感知效果。

触发动画

如果 Effect 对某些内容加入了动画,清理函数应将动画重置:

js
useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // 触发动画
  return () => {
    node.style.opacity = 0; // 重置为初始值
  };
}, []);

在开发环境中,透明度由 1 变为 0,再变为 1。这与在生产环境中,直接将其设置为 1 具有相同的感知效果,如果你使用支持过渡的第三方动画库,你的清理函数应将时间轴重置为其初始状态。

获取数据

effect 根据 id 获取对应的数据。假设一开始正在获取 123 的数据,这时 id 变为了 456,又去获取 456 的数据。

并且这时 456 的响应数据比 123 先返回,那么就导致先渲染 456,然后又渲染 123 的响应数据,但实际我们想渲染的是 456 的数据!

所以我们需要这样做:清理函数应当确保获取数据的过程以及获取到的结果不会继续影响程序运行。如果 userId 从 'Alice' 变为 'Bob',那么请确保 'Alice' 响应数据被忽略,即使它在 'Bob' 之后到达。

js
useEffect(() => {
  let ignore = false;

  async function startFetching() {
    const json = await fetchTodos(userId);
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

  return () => {
    ignore = true;
  };
}, [userId]);

初始化应用时不需要使用 Effect 的情形

某些逻辑应该只在应用程序启动时运行一次。比如,验证登陆状态和加载本地程序数据。你可以将其放在组件之外:

js
if (typeof window !== 'undefined') { // 检查是否在浏览器中运行
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ……
}

这保证了这种逻辑在浏览器加载页面后只运行一次。

不要在 Effect 中执行购买商品一类的操作

有时,即使编写了一个清理函数,也不能避免执行两次 Effect。例如,Effect 包含会发送 POST 请求以执行购买操作:

js
useEffect(() => {
  // 🔴 错误:此处的 Effect 会在开发环境中执行两次,这在代码中是有问题的。
  fetch('/api/buy', { method: 'POST' });
}, []);

“购买”的操作不应由组件的挂载、渲染引起的;它是由特定的交互作用引起的,它应该只在用户按下按钮时运行。因此,它不应该写在 Effect 中,应当把 /api/buy 请求操作移动到购买按钮事件处理程序中:

js
  function handleClick() {
    // ✅ 购买商品应当在事件中执行,因为这是由特定的操作引起的。
    fetch('/api/buy', { method: 'POST' });
  }

关于 Effect 易出错的代码?

计数器

下面的 Counter 组件显示一个计数器,应该每秒递增一次。在组件挂载时,它调用 setInterval。这会导致 onTick 每秒运行一次。onTick 函数会递增计数器。

然而,计数器不是每秒递增一次,而是两次。这是为什么呢?找出错误的原因并修复它。

js
import { useState, useEffect } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    function onTick() {
      setCount(c => c + 1);
    }

    setInterval(onTick, 1000);
  }, []);

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

在 严格模式 下,(本网站中的示例沙盒(sandbox)都已开启严格模式),React 在开发模式中,每个组件都会重复挂载一次。这也就导致计数器组件被挂载了两次。所以,计时器也被设立了两次,这就是为什么计数器每秒递增两次的原因。

然而,这并不是 React 本身的错:而是 Effect 代码中本身就存在问题。React 只不过把这个问题放大了。真正的错误原因是这样的 Effect 启动后,但没有提供清理函数,所以上一次的 Effect 残留就没有被除去。

要修复这个问题,保存 setInterval 返回的 interval ID,并使用 clearInterval 实现一个清理函数:

js
import { useState, useEffect } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    function onTick() {
      setCount(c => c + 1);
    }

    const intervalId = setInterval(onTick, 1000);
    return () => clearInterval(intervalId);
  }, []);

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

alic bob 问题

切换 id 获取对应数据对不上。

你可能不需要 Effect

移除不必要的 Effect 可以让你的代码更容易理解,运行得更快,并且更少出错。

根据 props 或 state 来更新 state

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

  // 🔴 避免:多余的 state 和不必要的 Effect
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}
js
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // ✅ 非常好:在渲染期间进行计算
  const fullName = firstName + ' ' + lastName;
  // ...
}

当 props 变化时重置所有 state

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

  // 🔴 避免:当 prop 变化时,在 Effect 中重置 state
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}
js
export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

function Profile({ userId }) {
  // ✅ 当 key 变化时,该组件内的 comment 或其他 state 会自动被重置
  const [comment, setComment] = useState('');
  // ...
}

当 prop 变化时调整部分 state

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

  // 🔴 避免:当 prop 变化时,在 Effect 中调整 state
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}
js
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // 好一些:在渲染期间调整 state
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}
js
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ 非常好:在渲染期间计算所需内容
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

订阅外部 store

有时候,你的组件可能需要订阅 React state 之外的一些数据。这些数据可能来自第三方库或内置浏览器 API。由于这些数据可能在 React 无法感知的情况下发变化,你需要在你的组件中手动订阅它们。这经常使用 Effect 来实现,例如:

js
function useOnlineStatus() {
  // 不理想:在 Effect 中手动订阅 store
  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();
  // ...
}

这个组件订阅了一个外部的 store 数据(在这里,是浏览器的 navigator.onLine API)。由于这个 API 在服务端不存在(因此不能用于初始的 HTML),因此 state 最初被设置为 true。每当浏览器 store 中的值发生变化时,组件都会更新它的 state。

尽管通常可以使用 Effect 来实现此功能,但 React 为此针对性地提供了一个 Hook 用于订阅外部 store。删除 Effect 并将其替换为调用 useSyncExternalStore:

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

function useOnlineStatus() {
  // ✅ 非常好:用内置的 Hook 订阅外部 store
  return useSyncExternalStore(
    subscribe, // 只要传递的是同一个函数,React 不会重新订阅
    () => navigator.onLine, // 如何在客户端获取值
    () => true // 如何在服务端获取值
  );
}

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

获取数据

许多应用使用 Effect 来发起数据获取请求。像这样在 Effect 中写一个数据获取请求是相当常见的:

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

  useEffect(() => {
    // 🔴 避免:没有清除逻辑的获取数据
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

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

你 不需要 把这个数据获取逻辑迁移到一个事件处理函数中。

这可能看起来与之前需要将逻辑放入事件处理函数中的示例相矛盾!但是,考虑到这并不是 键入事件,这是在这里获取数据的主要原因。搜索输入框的值经常从 URL 中预填充,用户可以在不关心输入框的情况下导航到后退和前进页面。

page 和 query 的来源其实并不重要。只要该组件可见,你就需要通过当前 page 和 query 的值,保持 results 和网络数据的 同步。这就是为什么这里是一个 Effect 的原因。

然而,上面的代码有一个问题。假设你快速地输入“hello”。那么 query 会从“h”变成“he”,“hel”,“hell”最后是“hello”。这会触发一连串不同的数据获取请求,但无法保证对应的返回顺序。例如,“hell”的响应可能在“hello”的响应 之后 返回。由于它的 setResults() 是在最后被调用的,你将会显示错误的搜索结果。这种情况被称为“竞态条件”:两个不同的请求“相互竞争”,并以与你预期不符的顺序返回。

为了修复这个问题,你需要添加一个 清理函数 来忽略较早的返回结果:

js
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);
  }
  // ...
}

这确保了当你在 Effect 中获取数据时,除了最后一次请求的所有返回结果都将被忽略。

如果你不使用框架(也不想开发自己的框架),但希望使从 Effect 中获取数据更符合人类直觉,请考虑像这个例子一样,将获取逻辑提取到一个自定义 Hook 中:

js
function SearchResults({ query }) {
  const [page, setPage] = useState(1);
  const params = new URLSearchParams({ query, page });
  const results = useData(`/api/search?${params}`);

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

function useData(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    let ignore = false;
    fetch(url)
      .then(response => response.json())
      .then(json => {
        if (!ignore) {
          setData(json);
        }
      });
    return () => {
      ignore = true;
    };
  }, [url]);
  return data;
}

Released under the MIT License.