useEffect 最佳实践

移除不必要的 useEffect

  • 对于渲染所需的数据,如果可以用组件内状态(propsstate)转换而来,转换操作避免放在Effect中,而应该直接放在 FC 函数体中。
    如果转换计算的消耗比较大,可以用useMemo进行缓存。
  • 对于一些用户行为引起数据变化,其后续的逻辑不应该放在Effect中,而是在事件处理函数中执行逻辑即可

当 props 变化时重置所有 state

比如,一个组件需要在用户切换的时候,重置所有的 state ,我们平常会这么做:

export default function ProfilePage({ userId }) {
	const [comment, setComment] = useState("");
 
	// 🔴 避免:当 prop 变化时,在 Effect 中重置 state
	useEffect(() => {
		setComment("");
	}, [userId]);
	// ...
}

我们可以利用组件**key不同将会完全重新渲染**的特点解决这个问题,只需要在父组件中给这个组件传递一个与props同步的key值即可:

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

订阅外部 store

我们曾经常用的做法是在Effect中编写事件监听的逻辑:

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

这里可以换成useSyncExternalStore这个 hook

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(); // …
}

在 useEffect 中请求数据

竞态问题

useEffect 中请求数据要面临的第一个问题是「需要解决竞态问题」**。
这里有个开发阶段很难复现的bug —— 如果userID变化足够快,会发起多个不同的用户请求。

而最终展示哪个用户的数据,取决于哪个请求先返回。这就是「请求的竞态问题」**。

可以通过增加一个 ignore 标识,来保证只有最后一次的回调是有效的

function SearchResults({ query }) {
	const [results, setResults] = useState([]);
	const [page, setPage] = useState(1);
	useEffect(() => {
		// 说白了用一个ignore变量来控制这个Effect回调的"有效性",
		let ignore = false;
		fetchResults(query, page).then((json) => {
			if (!ignore) {
				setResults(json);
			}
		});
		return () => {
			ignore = true;
		};
	}, [query, page]);
 
	function handleNextPageClick() {
		setPage(page + 1);
	} // …
}

点击返回按钮后重新请求数据

如果用户跳转到新的页面后,又通过浏览器回退按钮回到当前页面,并不能立刻看到他跳转前的页面。

相反,看到的可能是个白屏 —— 因为还需要重新执行useEffect获取初始数据。

这个问题的本质原因是:没有初始数据的缓存。

CSR 时的白屏时间

CSR(Client-Side Rendering,客户端渲染)时在 useEffect中请求数据,在数据返回前页面都是白屏状态。

瀑布问题

如果父子组件都依赖useEffect获取初始数据渲染,那么整个渲染流程如下:

  1. 父组件mount
  2. 父组件useEffect执行,请求数据
  3. 数据返回后重新渲染父组件
  4. 子组件mount
  5. 子组件useEffect执行,请求数据
  6. 数据返回后重新渲染子组件

可见,当父组件数据请求成功后子组件甚至还没开始首屏渲染。
这就是渲染中的瀑布问题 —— 数据像瀑布一样一级一级向下流动,流到的组件才开始渲染,很低效。

解决方式

其他参考