一、useReducer 简单介绍
const [state, dispatch] = useReducer(reducer, initialArg, init);
useState
的替代方案。它接收一个形如 (state, action) => newState
的 reducer
,并返回当前的 state
以及与其配套的 dispatch
方法。(如果你熟悉 Redux
的话,就已经知道它如何工作了。)
在某些场景下,useReducer
会比 useState
更适用,例如 state
逻辑较复杂且包含多个子值,或者下一个 state
依赖于之前的 state
等。并且,使用 useReducer
还能给那些会触发深更新的组件做性能优化,因为你可以向子组件传递 dispatch
而不是回调函数 。
以下是用 reducer
重写 useState
一节的计数器示例:
const initialState = {count: 0};
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
default:
throw new Error();
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
</>
);
}
注意
React
会确保dispatch
函数的标识是稳定的,并且不会在组件重新渲染时改变。这就是为什么可以安全地从useEffect
或useCallback
的依赖列表中省略dispatch
。
1.1 指定初始 state
有两种不同初始化 useReducer state
的方式,你可以根据使用场景选择其中的一种。将初始 state
作为第二个参数传入 useReducer
是最简单的方法:
const [state, dispatch] = useReducer(
reducer,
{count: initialCount}
);
注意
React
不使用state = initialState
这一由Redux
推广开来的参数约定。有时候初始值依赖于props
,因此需要在调用Hook
时指定。如果你特别喜欢上述的参数约定,可以通过调用useReducer(reducer, undefined, reducer)
来模拟Redux
的行为,但我们不鼓励你这么做。
1.2 惰性初始化
你可以选择惰性地创建初始 state
。为此,需要将 init
函数作为 useReducer
的第三个参数传入,这样初始 state
将被设置为 init(initialArg)
。
这么做可以将用于计算 state
的逻辑提取到 reducer
外部,这也为将来对重置 state
的 action
做处理提供了便利:
function init(initialCount) {
return {count: initialCount};
}
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
case 'reset':
return init(action.payload);
default:
throw new Error();
}
}
function Counter({initialCount}) {
const [state, dispatch] = useReducer(reducer, initialCount, init);
return (
<>
Count: {state.count}
<button
onClick={() => dispatch({type: 'reset', payload: initialCount})}>
Reset
</button>
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
</>
);
}
1.3 跳过 dispatch
如果 Reducer Hook
的返回值与当前 state
相同,React
将跳过子组件的渲染及副作用的执行。(React
使用 Object.is
比较算法 来比较 state
。)
需要注意的是,React
可能仍需要在跳过渲染前再次渲染该组件。不过由于 React
不会对组件树的“深层”节点进行不必要的渲染,所以大可不必担心。如果你在渲染期间执行了高开销的计算,则可以使用 useMemo
来进行优化。
二、useReducer的优势
举一个例子:
function Counter() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + step); // 依赖其他state来更新
}, 1000);
return () => clearInterval(id);
// 为了保证setCount中的step是最新的,
// 我们还需要在deps数组中指定step
}, [step]);
return (
<>
<h1>{count}</h1>
<input value={step} onChange={e => setStep(Number(e.target.value))} />
</>
);
}
这段代码能够正常工作,但是随着相互依赖的状态变多,setState
中的逻辑会变得很复杂,useEffect
的deps
数组也会变得更复杂,降低可读性的同时,useEffect
重新执行时机变得更加难以预料。
使用useReduce
r替代useState
以后:
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: 'tick' });
}, 1000);
return () => clearInterval(id);
}, []); // deps数组不需要包含step
return (
<>
<h1>{count}</h1>
<input value={step} onChange={e => setStep(Number(e.target.value))} />
</>
)
}
现在组件只需要发出action
,而无需知道如何更新状态。也就是将What to do
与How to do
解耦。彻底解耦的标志就是:useReducer
总是返回相同的dispatch
函数(发出action
的渠道),不管reducer
(状态更新的逻辑)如何变化。
另一方面,step
的更新不会造成useEffect
的失效、重执行。因为现在useEffect
依赖于dispatch
,而不依赖于状态值(得益于上面的解耦模式)。这是一个重要的模式,能用来避免useEffect、useMemo、useCallback
需要频繁重执行的问题。
以下是state
的定义,其中reducer
封装了“如何更新状态”的逻辑:
const initialState = {
count: 0,
step: 1,
};
function reducer(state, action) {
const { count, step } = state;
if (action.type === 'tick') {
return { count: count + step, step };
} else if (action.type === 'step') {
return { count, step: action.step };
} else {
throw new Error();
}
}
总结:
- 当状态更新逻辑比较复杂的时候,就应该考虑使用
useReducer
。因为:
(1)reducer
比setState
更加擅长描述“如何更新状态”。比如,reducer
能够读取相关的状态、同时更新多个状态;
(2)【组件负责发出action
,reducer
负责更新状态】的解耦模式,使得代码逻辑变得更加清晰,代码行为更加可预测(比如useEffect
的更新时机更加稳定)。
(3)简单来记,就是每当编写setState(prevState => newState)
的时候,就应该考虑是否值得将它换成useReducer
。 - 通过传递
useReducer
的dispatch
,可以减少状态值的传递。
(1)useReducer
总是返回相同的dispatch
函数,这是彻底解耦的标志:状态更新逻辑可以任意变化,而发起actions
的渠道始终不变;
(2) 得益于前面的解耦模式,useEffect
函数体、callback function
只需要使用dispatch
来发出action
,而无需直接依赖状态值。因此在useEffect
、useCallback
、useMemo
的deps
数组中无需包含状态值,也减少了它们更新的需要。不但能提高可读性,而且能提升性能(useCallback
、useMemo
的更新往往会造成子组件的刷新)。
三、高级用法:内联reducer
你可以将reducer
声明在组件内部,从而能够通过闭包访问props
、以及前面的hooks
结果:
const initialState = {
count: 0
};
function Counter() {
const [step, setStep] = useState(1);
console.log("before useReducer");
const [state, dispatch] = useReducer(reducer, initialState);
console.log("after useReducer", state);
function reducer(prevState, action) {
// reducer will read the value from the latest render
console.log("reducer", step);
const { count: prevCount } = prevState;
if (action.type === "add") {
return { count: prevCount + step };
} else if (action.type === "add-current-step") {
return { count: prevCount + action.step };
} else {
throw new Error();
}
}
return (
<div>
<h1>{state.count}</h1>
<div>
<button
onClick={() => {
// this two state updates will be batched
console.log("before dispatch");
dispatch({
type: "add"
});
console.log("after dispatch");
setStep((v) => v + 1);
}}
>
add latest step
</button>
<button
onClick={() => {
// this two state updates will be batched
console.log("before dispatch");
dispatch({
type: "add-current-step",
step
});
console.log("after dispatch");
setStep((v) => v + 1);
}}
>
add current step
</button>
</div>
</div>
);
}
这个能力可能会出乎很多人的意料。因为大部分人对reducer
的触发时机的理解是错误的(包括以前的我)。我以前理解的触发时机是这样:
- 某个
button
被用户点击,它的onClick
被调用,其中执行了dispatch({type:'add'})
,React
框架安排一次更新 - React框架处理刚才安排的更新,调用
reducer(prevState, {type:'add'})
,得到新的状态 (注意此时还没有发生重新渲染) - React框架用新的状态来重新渲染组件树,执行到
Counter
组件的useReducer`时,返回上一步得到的新状态即可
但是实际上,React会在下次渲染的时候,会同步地调用reducer
来处理队列中的action
:
- 某个
button
被用户点击,它的onClick
被调用,其中执行了dispatch({type:'add'})
,React
框架安排一次更新 React
框架处理刚才安排的更新,开始重渲染组件树 (注意此时还不知道最新的reducer
状态)React
框架重新渲染组件树,执行到Counter
组件的useReducer
时,调用reducer(prevState, {type:'add'})
,得到新的状态
重要的区别在于,reducer
是在重新渲染的时候被调用的,它的闭包捕获到了下次渲染的闭包(包括props
以及前面的hooks
结果)
如果按照上面的错误理解,reducer是在重新渲染之前被调用的,它的闭包捕获到上次渲染的props,那么点击“add latest step”按钮的结果应该是数字变成1。
事实上,上面的例子使用了console.log
来打印执行顺序,会发现reducer
会在新渲染执行useReducer
的时候被同步执行的:
console.log("before useReducer");
const [state, dispatch] = useReducer(reducer, initialState);
console.log("after useReducer", state);
调用点击按钮以后的输出包括:
before useReducer
reducer 2
after useReducer {count: 2}
证明reducer
确实被useReduce
r同步地调用来获取新的state
。
并且,如果按照上面所说的错误理解,在reducer
中打印的step
值应该是1。但实际打印的是2(本次渲染的step值),而不是1(上一次渲染的step值),说明它拿到了最新的闭包值。
四、参考文章:
- https://react.docschina.org/docs/hooks-reference.html#usereducer
- https://segmentfault.com/a/1190000023039945