useState 和 setState 有什么明显的区别?
useState 和 useReducer 的初始值如果是个执行函数返回值,执行函数是否会多次执行?
还原 useReducer 的初始值,为什么还原不回去了?
useEffect 如何模拟 componentDidMount、componentUpdate、componentWillUnmount 生命周期?
如何在 useEffect 中正确的为 DOM 设置事件监听?
useEffect、useCallback、useMemo 中取到的 state、props 中为什么会是旧值?
useEffect 为什么会出现无限执行的问题?
useEffect 中出现竞态如何解决?
如何在函数组件中保存一些属性,跟随组件进行创建和销毁?
当 useCallback 会频繁触发时,应该如何进行优化?
useCallback 和 useMemo 的使用场景有何区别?
先来看一下函数组件的运作方式:
Counter.js
function Counter() {
const [count, setCount] = useState(0);
return <p onClick={() => setCount(count + 1)}>count: {count}</p>;
}
每次点击 p
标签,count
都会 + 1,setCount
会触发函数组件的渲染。函数组件的重新渲染其实是当前函数的重新执行。
在函数组件的每一次渲染中,内部的 state
、函数以及传入的 props
都是独立的。
比如:
// 第一次渲染
function Counter() {
// 第一次渲染,count = 0
const [count, setCount] = useState(0);
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
// 点击 p 标签触发第二次渲染
function Counter() {
// 第二次渲染,count = 1
const [count, setCount] = useState(0);
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
// 点击 p 标签触发第三次渲染
function Counter() {
// 第三次渲染,count = 2
const [count, setCount] = useState(0);
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
// ...
在函数组件中声明的方法也是类似。因此,在函数组件渲染的每一帧对应这自己独立的
state
、function
、props
。
useState
/ useReducer
useState
VS setState
useState
只能作用在函数组件,setState
只能作用在类组件
useState
可以在函数组件中声明多个,而类组件中的状态值都必须声明在 this
的 state
对象中
一般的情况下,state
改变时:
useState
修改 state
时,同一个 useState
声明的值会被 覆盖处理,多个 useState
声明的值会触发 多次渲染
setState
修改 state
时,多次 setState
的对象会被 合并处理
useState
修改 state
时,设置相同的值,函数组件不会重新渲染,而继承 Component
的类组件,即便 setState
相同的值,也会触发渲染
useState
VS useReducer
useState
设置初始值时,如果初始值是个值,可以直接设置,如果是个函数返回值,建议使用回调函数的方式设置const initCount = c => {
console.log('initCount 执行');
return c * 2;
};
function Counter() {
const [count, setCount] = useState(initCount(0));
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
会发现即便 Counter
组件重新渲染时没有再给 count
重新赋初始值,但是 initCount
函数却会重复执行
修改成回调函数的方式:
const initCount = c => {
console.log('initCount 执行');
return c * 2;
};
function Counter() {
const [count, setCount] = useState(() => initCount(0));
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
这个时候,initCount
函数只会在 Counter
组件初始化的时候执行,之后无论组件如何渲染,initCount
函数都不会再执行
useReducer
设置初始值时,初始值只能是个值,不能使用回调函数的方式
useState
修改状态时,同一个 useState
声明的状态会被覆盖处理function Counter() {
const [count, setCount] = useState(0);
return (
<p
onClick={() => {
setCount(count + 1);
setCount(count + 2);
}}
>
clicked {count} times
</p>
);
}
当前界面中
count
的step
是 2
useReducer
修改状态时,多次 dispatch
会按顺序执行,依次对组件进行渲染function Counter() {
const [count, dispatch] = useReducer((x, payload) => x + payload, 0);
return (
<p
onClick={() => {
dispatch(1);
dispatch(2);
}}
>
clicked {count} times
</p>
);
}
当前界面中
count
的step
是 3
useReducer
的初始值,为什么还原不了比如下面这个例子:
const initPerson = { name: '小明' };
const reducer = function (state, action) {
switch (action.type) {
case 'CHANGE':
state.name = action.payload;
return { ...state };
case 'RESET':
return initPerson;
default:
return state;
}
};
function Counter() {
const [person, dispatch] = useReducer(reducer, initPerson);
const [value, setValue] = useState('小红');
const handleChange = useCallback(e => setValue(e.target.value), []);
const handleChangeClick = useCallback(() => dispatch({ type: 'CHANGE', payload: value }), [value]);
const handleResetClick = useCallback(() => dispatch({ type: 'RESET' }), []);
return (
<>
<p>name: {person.name}</p>
<input type="text" value={value} onChange={handleChange} />
<br />
<br />
<button onClick={handleChangeClick}>修改</button> |{' '}
<button onClick={handleResetClick}>重置</button>
</>
);
}
点击修改按钮,将对象的 name
改为 小红,点击重置按钮,还原为原始对象。但是我们看看效果:
可以看到 name
修改小红后,无论如何点击重置按钮,都无法还原。
这是因为在 initPerson
的时候,我们改变了 state
的属性,导致初始值 initPerson
发生了变化,所以之后 RESET
,即使返回了 initPerson``,但是name
值依然是小红。
所以我们在修改数据时,要注意,不要在原有数据上进行属性操作,重新创建新的对象进行操作即可。比如进行如下的修改:
// ...
const reducer = function (state, action) {
switch (action.type) {
case 'CHANGE':
// !修改后的代码
const newState = { ...state, name: action.payload }
return newState;
case 'RESET':
return initPerson;
default:
return state;
}
};
// ...
看看修改后的效果,可以正常的进行重置了:
useEffect
useEffect
基本用法:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('count: ', count);
});
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
每次点击 p
标签,Counter
组件都会重新渲染,都可以在控制台看到有 log
打印。
useEffect
模拟 componentDidMount
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('count: ', count);
// 设置依赖为一个空数组
}, []);
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
将 useEffect
的依赖设置为空数组,可以看到,只有在组件初次渲染时,控制台会打印输出。之后无论 count
如何更新,都不会再打印。
useEffect
模拟 componentDidUpdate
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
if (count !== 0) {
console.log('count: ', count);
}
}, [count]);
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
但是这样处理有个弊端,当有多个依赖项时,需要多次比较,因此可以选择使用下面这种方式。
useRef
设置一个初始值,进行比较function Counter() {
const [count, setCount] = useState(0);
const firstRender = useRef(true);
useEffect(() => {
if (firstRender.current) {
firstRender.current = false;
} else {
console.log('count: ', count);
}
}, [count]);
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
useEffect
模拟 componentWillUnmount
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('count: ', count);
return () => {
console.log('component will unmount')
}
}, []);
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
useEffect
中包裹函数中返回的函数,会在函数组件重新渲染时,清理上一帧数据时触发执行。因此这个函数可以做一些清理的工作。
如果 useEffect
给定的依赖项是一个空数组,那么返回函数被执行时,代表着组件真正被卸载了。
给
useEffect
设置 依赖项为空数组,并且 返回一个函数,那么这个返回的函数就相当于是componentWillUnmount
请注意,必须要设置依赖项为空数组。如果不是空数组,那么这个函数并不是在组件被卸载时触发,而是会在组件重新渲染,清理上一帧的数据时触发。
useEffect
正确的为 DOM
设置事件监听function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const handleClick = function() {
console.log('count: ', count);
}
window.addEventListener('click', handleClick, false)
return () => {
window.removeEventListener('click', handleClick, false)
};
}, [count]);
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
在 useEffect
中设置事件监听,在 return
的函数中对副作用进行清理,取消监听事件
useEffect、useCallback、useMemo
中获取到的 state、props
为什么是旧值正如我们刚才所说,函数组件的每一帧会有自己独立的 state、function、props
。而 useEffect、useCallback、useMemo
具有缓存功能。
因此,我们取的是当前对应函数作用域下的变量。如果没有正确的设置依赖项,那么 useEffect、useCallback、useMemo
就不会重新执行,其中使用的变量还是之前的值。
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const handleClick = function() {
console.log('count: ', count);
}
window.addEventListener('click', handleClick, false)
return () => {
window.removeEventListener('click', handleClick, false)
};
}, []);
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
还是上一个例子,如果此时给
useEffect
设置空数组为依赖项,那么无论count
改变了多少次,点击window
,打印出来的count
依然是 0
useEffect
中为什么会出现无限执行的情况useEffect
设置依赖项,并且在 useEffect
中更新 state
,会导致界面无限重复渲染function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
这种情况会导致界面无限重复渲染,因为没有设置依赖项,如果我们想在界面初次渲染时,给 count
设置新值,给依赖项设置空数组即可。
修改后:只会在初始化时设置 count
值
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
}, []);
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
上面这个例子是依赖项缺失的时候,会出现问题,那么在依赖项正常设置的情况下,也会出现问题。
count
增加的时候,我们需要进行翻页(page
+ 1),看看如何写:由于此时我们依赖 count
,依赖项中要包含 count
,而修改 page
时又需要依赖 page
,所以依赖项中也要包含 page
function Counter() {
const [count, setCount] = useState(0);
const [page, setPage] = useState(0);
useEffect(() => {
setPage(page + 1);
}, [count, page]);
return (
<>
<p onClick={() => setCount(count + 1)}>clicked {count} times</p>
<p>page: {page}</p>
</>
);
}
此时也会导致界面无限重复渲染的情况,那么此时修改 page
时改成函数的方式,并从依赖性中移除 page
即可
修改后:既能实现效果,又避免了重复渲染
function Counter() {
const [count, setCount] = useState(0);
const [page, setPage] = useState(0);
useEffect(() => {
setPage(p => p + 1);
}, [count]);
return (
<>
<p onClick={() => setCount(count + 1)}>clicked {count} times</p>
<p>page: {page}</p>
</>
);
}
执行更早但返回更晚的情况会错误的对状态值进行覆盖
在 useEffect
中,可能会有进行网络请求的场景,我们会根据父组件传入的 id
,去发起网络请求,id
变化时,会重新进行请求。
function App() {
const [id, setId] = useState(0);
useEffect(() => {
setId(10);
}, []);
// 传递 id 属性
return <Counter id={id} />;
}
// 模拟网络请求
const fetchData = id =>
new Promise(resolve => {
setTimeout(() => {
const result = `id 为${id} 的请求结果`;
resolve(result);
}, Math.random() * 1000 + 1000);
});
function Counter({ id }) {
const [data, setData] = useState('请求中。。。');
useEffect(() => {
// 发送网络请求,修改界面展示信息
const getData = async () => {
const result = await fetchData(id);
setData(result);
};
getData();
}, [id]);
return <p>result: {data}</p>;
}
展示结果:
上面的实例,多次刷新页面,可以看到最终结果有时展示的是 id 为 0 的请求结果
,有时是 id 为 10 的结果
。
正确的结果应该是 ‘id 为 10 的请求结果’。这个就是竞态带来的问题。
解决办法:
// 存储网络请求的 Map
const fetchMap = new Map();
// 模拟网络请求
const fetchData = id =>
new Promise(resolve => {
const timer = setTimeout(() => {
const result = `id 为${id} 的请求结果`;
// 请求结束移除对应的 id
fetchMap.delete(id);
resolve(result);
}, Math.random() * 1000 + 1000);
// 设置 id 到 fetchMap
fetchMap.set(id, timer);
});
// 取消 id 对应网络请求
const removeFetch = (id) => {
clearTimeout(fetchMap.get(id));
}
function Counter({ id }) {
const [data, setData] = useState('请求中。。。');
useEffect(() => {
const getData = async () => {
const result = await fetchData(id);
setData(result);
};
getData();
return () => {
// 取消对应网络请求
removeFetch(id)
}
}, [id]);
return <p>result: {data}</p>;
}
展示结果:
此时无论如何刷新页面,都只展示 id 为 10 的请求结果
。
// 模拟网络请求
const fetchData = id =>
new Promise(resolve => {
setTimeout(() => {
const result = `id 为${id} 的请求结果`;
resolve(result);
}, Math.random() * 1000 + 1000);
});
function Counter({ id }) {
const [data, setData] = useState('请求中。。。');
useEffect(() => {
let didCancel = false;
const getData = async () => {
const result = await fetchData(id);
if (didCancel) {
setData(result);
}
};
getData();
return () => {
didCancel = true;
};
}, [id]);
return <p>result: {data}</p>;
}
可以发现,此时无论如何刷新页面,也都只展示 id 为 10 的请求结果
。
state
、props
的值函数组件是没有 this
指向的,所以为了可以保存住组件实例的属性,可以使用 useRef
来进行操作
函数组件的 ref
具有可以 穿透闭包 的能力。通过将普通类型的值转换为一个带有 current
属性的对象引用,来保证每次访问到的属性值是最新的。
state
值是相同的useRef
的情况下,每一帧里的 state
值是如何打印的function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const handleClick = function() {
console.log('count: ', count);
}
window.addEventListener('click', handleClick, false)
});
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
先点击 p
标签 5 次,之后点击 window
对象,可以看到打印结果:
useRef
之后,每一帧里的 ref
值是如何打印的function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
// 将最新 state 设置给 countRef.current
countRef.current = count;
const handleClick = function () {
console.log('count: ', countRef.current);
};
window.addEventListener('click', handleClick, false);
});
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
和之前一样的操作,先点击 p
标签 5 次,之后点击 window
界面,可以看到打印结果
使用
useRef
即可以保证函数组件的每一帧里访问到的state
值是相同的。
函数组件是没有实例的,因此属性也无法挂载到 this
上。那如果我们想创建一个非 state
、props
变量,能够跟随函数组件进行创建销毁,该如何操作呢?
同样的,还是可以通过 useRef
,useRef
不仅可以作用在 DOM
上,还可以将普通变量转化成带有 current
属性的对象
比如,我们希望设置一个 Model
的实例,在组件创建时,生成 model
实例,组件销毁后,重新创建,会自动生成新的 model
实例
class Model {
constructor() {
console.log('创建 Model');
this.data = [];
}
}
function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(new Model());
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
按照这种写法,可以实现在函数组件创建时,生成 Model
的实例,挂载到 countRef
的 current
属性上。重新渲染时,不会再给 countRef
重新赋值。
也就意味着在组件卸载之前使用的都是同一个 Model
实例,在卸载之后,当前 model
实例也会随之销毁。
仔细观察控制台的输出,会发现虽然
countRef
没有被重新赋值,但是在组件在重新渲染时,Model
的构造函数却依然会多次执行
所以此时我们可以借用 useState
的特性,改写一下。
class Model {
constructor() {
console.log('创建 Model');
this.data = [];
}
}
function Counter() {
const [count, setCount] = useState(0);
const [model] = useState(() => new Model());
const countRef = useRef(model);
return <p onClick={() => setCount(count + 1)}>clicked {count} times</p>;
}
这样使用,可以在不修改 state
的情况下,使用 model
实例中的一些属性,可以使 flag
,可以是数据源,甚至可以作为 Mobx
的 store
进行使用。
useCallback
如题,当依赖频繁变更时,如何避免 useCallback
频繁执行呢?
function Counter() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(count + 1);
}, [count]);
return <p onClick={handleClick}>clicked {count} times</p>;
}
这里,我们把 click
事件提取出来,使用 useCallback
包裹,但其实并没有起到很好的效果。
因为 Counter
组件重新渲染目前只依赖 count
的变化,所以这里的 useCallback
用与不用没什么区别。
useReducer
替代 useState
可以使用 useReducer
进行替代。
function Counter() {
const [count, dispatch] = useReducer(x => x + 1, 0);
const handleClick = useCallback(() => {
dispatch();
}, []);
return <p onClick={handleClick}>clicked {count} times</p>;
}
useReducer
返回的 dispatch
函数是自带了 memoize
的,不会在多次渲染时改变。因此在 useCallback
中不需要将 dispatch
作为依赖项。
setState
中传递函数function Counter() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
return <p onClick={handleClick}>clicked {count} times</p>;
}
在 setCount
中使用函数作为参数时,接收到的值是最新的 state
值,因此可以通过这个值执行操作。
useRef
进行闭包穿透function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
const handleClick = useCallback(() => {
setCount(countRef.current + 1);
}, []);
return <p onClick={handleClick}>clicked {count} times</p>;
}
这种方式也可以实现同样的效果。但是不推荐使用,不仅要编写更多的代码,而且可能会产生出乎预料的问题。
useMemo
上面讲述了 useCallback
的一些问题和解决办法。下面看一看 useMemo
。
useMemo
和 React.memo
不同:
useMemo
是对组件内部的一些数据进行优化和缓存,惰性处理。React.memo
是对函数组件进行包裹,对组件内部的 state
、 props
进行浅比较,判断是否需要进行渲染。useMemo
和 useCallback
的区别
useMemo
的返回值是一个值,可以是属性,可以是函数(包括组件)useCallback
的返回值只能是函数因此,useMemo
一定程度上可以替代 useCallback
,等价条件:useCallback(fn, deps) => useMemo(() => fn, deps)
所以,上述关于 useCallback
一些优化点同样适用于 useMemo
。
useEffect 完整指南
React Hooks 第一期:聊聊 useCallback