react-refresh-webpack-plugin[1] 是 React 官方提供的一个 模块热替换(HMR)插件。
A Webpack plugin to enable "Fast Refresh" (also previously known as Hot Reloading) for React components.
在开发环境编辑代码时,react-refresh 可以保持组件当前状态,仅仅变更编辑的部分。在 umi[2] 中可以通过 fastRefresh: {}
快速开启该功能。
这张 gif 动图展示的是使用 react-refresh 特性的开发体验,可以看出,修改组件代码后,已经填写的用户名和密码保持不变,仅仅只有编辑的部分变更了。
对于 Class 类组件,react-refresh 会一律重新刷新(remount),已有的 state 会被重置。而对于函数组件,react-refresh 则会保留已有的 state。所以 react-refresh 对函数类组件体验会更好。本篇文章主要讲解 React Hooks 在 react-refresh 模式下的怪异行为,现在我来看下 react-refresh 对函数组件的工作机制。
useState
和 useRef
的值不会更新。useEffect
、useCallback
、useMemo
等会重新执行。When we update the code, we need to "clean up" the effects that hold onto past values (e.g. passed functions), and "setup" the new ones with updated values. Otherwise, the values used by your effect would be stale and "disagree" with value used in your rendering, which makes Fast Refresh much less useful and hurts the ability to have it work with chains of custom Hooks.
如上图所示,在文本修改之后,state
保持不变,useEffect
被重新执行了。
在上述工作机制下,会带来很多问题,接下来我会举几个具体的例子。
import React, { useEffect, useState } from 'react';
export default () => {
const [count, setState] = useState(0);
useEffect(() => {
setState(s => s + 1);
}, []);
return (
<div>
{count}
</div>
)
}
上面的代码很简单,在正常模式下,count
值最大为 1
。因为 useEffect
只会在初始化的时候执行一次。但在 react-refresh 模式下,每次热更新的时候,state
不变,但 useEffect
重新执行,就会导致 count
的值一直在递增。
如上图所示,count
随着每一次热更新在递增。
如果你使用了 ahooks[4] 或者 react-use[5] 的 useUpdateEffect
,在热更新模式下也会有不符合预期的行为。
import React, { useEffect } from 'react';
import useUpdateEffect from './useUpdateEffect';
export default () => {
useEffect(() => {
console.log('执行了 useEffect');
}, []);
useUpdateEffect(() => {
console.log('执行了 useUpdateEffect');
}, []);
return (
<div>
hello world
</div>
)
}
useUpdateEffect
与 useEffect
相比,它会忽略第一次执行,只有在 deps 变化时才会执行。以上代码的在正常模式下,useUpdateEffect
是永远不会执行的,因为 deps 是空数组,永远不会变化。但在 react-refresh 模式下,热更新时,useUpdateEffect
和 useEffect
同时执行了。
造成这个问题的原因,就是 useUpdateEffect
用 ref
来记录了当前是不是第一次执行,见下面的代码。
import { useEffect, useRef } from 'react';
const useUpdateEffect: typeof useEffect = (effect, deps) => {
const isMounted = useRef(false);
useEffect(() => {
if (!isMounted.current) {
isMounted.current = true;
} else {
return effect();
}
}, deps);
};
export default useUpdateEffect;
上面代码的关键在 isMounted
useEffect
执行,标记 isMounted
为 true
useEffect
重新执行了,此时 isMounted
为 true
,就往下执行了最初发现这个问题,是 ahooks 的 useRequest
在热更新后,loading
会一直为 true。经过分析,原因就是使用 isUnmount
ref 来标记组件是否卸载。
import React, { useEffect, useState } from 'react';
function getUsername() {
console.log('请求了')
return new Promise(resolve => {
setTimeout(() => {
resolve('test');
}, 1000);
});
}
export default function IndexPage() {
const isUnmount = React.useRef(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
getUsername().then(() => {
if (isUnmount.current === false) {
setLoading(false);
}
});
return () => {
isUnmount.current = true;
}
}, []);
return loading ? <div>loading</div> : <div>hello world</div>;
}
如上代码所示,在热更新时,isUnmount
变为了true,导致二次执行时,代码以为组件已经卸载了,不再响应异步操作。
第一个解决方案是从代码层面解决,也就是要求我们在写代码的时候,时时能想起来 react-refresh 模式下的怪异行为。比如 useUpdateEffect
我们就可以在初始化或者热替换时,将 isMounted
ref 初始化掉。如下:
import { useEffect, useRef } from 'react';
const useUpdateEffect: typeof useEffect = (effect, deps) => {
const isMounted = useRef(false);
+ useEffect(() => {
+ isMounted.current = false;
+ }, []);
useEffect(() => {
if (!isMounted.current) {
isMounted.current = true;
} else {
return effect();
}
}, deps);
};
export default useUpdateEffect;
这个方案对上面的问题二和三都是有效的。
根据官方文档[6],我们可以通过在文件中添加以下注释来解决这个问题。
/* @refresh reset */
添加这个问题后,每次热更新,都会 remount,也就是组件重新执行。useState
和 useRef
也会重置掉,也就不会出现上面的问题了。
本来 React Hooks 已经有蛮多潜规则了,在使用 react-refresh 时,还有潜规则要注意。但官方回复说这是预期行为,见该 issue[7]。
Effects are not exactly "mount"/"unmount" — they're more like "show"/"hide".
不管你晕没晕,反正我是晕了,╮(╯▽╰)╭。
[1]
react-refresh-webpack-plugin: https://github.com/pmmmwh/react-refresh-webpack-plugin
[2]
umi: https://umijs.org/zh-CN/docs/fast-refresh
[3]
为了解决某些问题: https://github.com/facebook/react/issues/21019#issuecomment-800650091
[4]
ahooks: https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useUpdateEffect/index.ts
[5]
react-use: https://github.com/streamich/react-use/blob/master/docs/useUpdateEffect.md
[6]
官方文档: https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/docs/API.md#reset
[7]
issue: https://github.com/facebook/react/issues/21019