2021年的React状态管理
前言
众所周知,React是一个专注于UI层的库,不同于Vue、Angular等框架,React的各种状态管理方案一直是在百花齐放/群魔乱舞。除了热门库Redux、Mobx、Recoil、Zustand等之外,React的正式版也来到了17,useState、useReducer、useContext等状态管理相关hook的概念和应用也逐渐深入人心。
2021年又快过去了,是时候来盘点一下React社区最新的状态管理方案现状。
这里不深入原理,只介绍各个方案的特性和在如今的优劣势。
React状态管理
React的状态管理主要分三类:Local state、Context、第三方库
。
其中,热门第三方库近两年的npm下载量和Github仓库情况如下:
Npm Downloads
Stats
(详见: npm trends)
可以看到,老大哥redux和他的小老弟(thunk、saga、observable)依然强势,mobx不温不火,后起之秀recoil势头凶猛,刚出来一年就已经有14k的stars。
Local State
React v16.8之后,functional component成为主流,local state的管理就是useState
和useReducer
的天下了。
useState - 更细粒度的状态
不同于Class Component将所有的state都放在一个对象中,useState的思想是将组件内的状态再拆分,以更细的粒度维护:
import {useCallback, useState} from 'react';
const Foo = () => {
const [stateA, setStateA] = useState(0);
const [stateB, setStateB] = useState(0);
const handleAdd = useCallback(
() => { setStateA(prev => prev + 1); },
[]
);
const handleSubtract = useCallback(
() => { setStateB(prev => prev - 1); },
[]
);
return (
<>
<Button onClick={handleAdd}>{stateA}</Button>
<Button onClick={handleSubtract}>{stateB}</Button>
</>
);
};
useReducer - 复杂逻辑抽象和复用
熟悉Redux的同学可以很容易地理解useReducer。使用useReducer可以认为是在组件内生成一个独立的redux store,并且这个reducer的逻辑可以在不同的组件中复用。
当state的计算逻辑比较复杂,或者派生状态的变化存在共性,或者reducer逻辑可以复用时,可以优先考虑用useReducer。
例如常见的列表分页逻辑封装:
import {useReducer} from 'react';
const initialState = { pageNum: 1, pageSize: 15 };
const reducer = (state, action) => {
switch (action.type) {
case 'next': // 下一页
return { ...state, pageNum: state.pageNum + 1 };
case 'prev': // 前一页
return { ...state, pageNum: state.pageNum - 1 };
case 'changePage': // 跳转到某页
return { ...state, pageNum: action.payload };
case 'changeSize': // 更改每页展示条目数
return { pageNum: 1, pageSize: action.payload };
default:
return state;
}
};
const Page = () => {
const [pager, dispatch] = useReducer(reducer, initialState);
return (
<Table
pageNum={pager.pageNum}
pageSize={pager.pageSize}
onGoNext={() => dispatch({ type: 'next' })}
onGoPrev={() => dispatch({ type: 'prev' })}
onPageNumChange={(num) => dispatch({ type: 'changePage', payload: num })}
onPageSizeChange={(size) => dispatch({ type: 'changeSize', payload: size })}
/>
);
};
使用useReducer还有一个优点是可以优化深层子组件需要触发更新时的应用性能。
假设我们在父组件定义了一个state,在子组件中有要更改父组件state的需求,以往惯用的做法是在父组件定义相关的callback,然后一层层地透传给子组件。
在组件层级特别深和callback特别多的时候,就会回想起被prop透传支配的恐惧,写子组件prop的类型也要脱半层皮。并且使用useCallback封装的方法有可能因为依赖的变量更新和返回新的引用,而导致透传途径的子组件都可能触发更新。
使用useReducer的话,可以结合useContext
,只把dispatch传到子组件中。并且dispatch生成之后引用恒定不变,不会触发context可能的force update。
import {createContext, useReducer, useContext} from 'react';
const ParentDispatch = createContext(null);
const Parent = () => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<ParentDispatch.Provider value={dispatch}>
<DeepTree parentState={state} />
</ParentDispatch.Provider>
);
};
// 深层子组件
const DeepChild = () => {
const dispatch = useContext(ParentDispatch);
const handleClick = () => {
dispatch({ type: 'add', payload: 'hello' });
};
return <button onClick={handleClick}>Add</button>;
};
详见How to avoid passing callbacks down?。
Context
在一个典型的 React 应用中,数据是通过 props 属性自上而下(由父及子)进行传递的,context提供了可以跨组件层级传递prop的API。React的context不是一个新东西,在这里对其该概念和用法不再过多赘述。
useContext
FC中结合createContext
和useContext使用,可见上文useReducer中的例子。
Context的问题
Context存在的问题也是老生常谈。
在react里,context是个反模式的东西,不同于redux等的细粒度响应式更新,context的值一旦变化,所有依赖该context的组件全部都会force update
,因为context API并不能细粒度地分析某个组件依赖了context里的哪个属性,并且它可以穿透React.memo
和shouldComponentUpdate
的对比,把所有涉事组件强制刷新。
React官方文档在 When to Use Context 一节中写道:
Context 设计目的是为了共享那些对于一个组件树而言是“全局”的数据,例如当前认证的用户、主题或首选语言。
综上,在系统中跟业务相关、会频繁变动的数据在共享时,应谨慎使用context。
如果决定使用context,可以在一些场景中,将多个子组件依赖的不同context属性提升到一个父组件中,由父组件订阅context并以prop的方式下发,这样可以使用子组件的memo、shouldComponentUpdate生效。
此外,官方文档还提到了另外一个坑,使用的时候也应该注意。
状态管理库
如今最火的React状态管理库莫过于Redux、Mobx、Recoil,其中Redux和Mobx都是老牌强手代表,Recoil则是这两年最火的后起之秀。
Redux
Redux想必大家都已经很熟悉:
React-redux推出useDispatch
、useSelector
等hook之后,减少了大量以前使用高阶组件(container)来连接(connect)store和view的代码,极大降低了获取state和封装action的成本,用法也更加灵活。
Redux本身很纯净,心智模型也不复杂,但实际使用还得搭配redux-thunk、redux-saga、redux-observable
这些中间件(middleware)和reselect、immer
这样的辅助工具才能达到真正可用的状态,加大了学习成本的同时,中间件也会引入各种副作用和中间态,真正的状态流并没有理想中那么美好。
Redux最令人诟病的是重复的模板代码太多,但redux团队并不是不知道这一点。Dan Abramov(redux作者)在推特上多次强调过,redux的设计是为以下原则服务的:要让状态的变化可追踪,可重复,可维护
,因此才会有 reducer, action, middleware 这些概念。为实现一个简单的状态更新操作,要改五六个文件写一整套的模板代码,这是成本浪费还是可维护性的代价,就见仁见智了。
模板代码多的这个问题,使用Redux Toolkit可以得到一些改善,它封装了reducer、action的写法,并附带了一些有用的工具包(但是学习成本好像又增加了呢)。
{
"dependencies": {
"immer": "^9.0.6",
"redux": "^4.1.0",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0"
},
}
Redux Middleware
Redux中间件的原理差不多,通过中间件的预处理,允许View(组件)中dispatch更灵活的action(可以是函数或者promise等),然后在中间件中处理各种副作用(接口请求等);或者是内置自定义的状态机(redux-saga)等。但最终都是将action转换为redux需要的plain object
格式,dispatch到redux store中。
来看看redux中间件三巨头redux-thunk、redux-saga、redux-observable这两年的使用趋势:
Npm Downloads
Stats
Redux-thunk依然是最常用的,并且在稳定增长,逐渐拉大与其他中间件的差距;redux-saga这两年的使用量也增加了近一倍。Thunk和saga的最新一个版本都在两三年前,已经达到了比较稳定的状态。Redux-observable还在更新,但使用量增长似乎已经停滞。
Redux-thunk
Redux-thunk允许应用在组件中dispatch一个function(这个function就被称为thunk),原本在组件中的异步代码被抽离到这个thunk中实现,从而在不同组件里复用。
Thunk
是一类函数的别名,这类函数的主要用途是将任务延迟执行,或者给另一个函数执行前后添加一些额外的操作。具体关于Thunk的模式介绍可以看What the heck is a 'thunk'?(或者译文)。
如下的原生redux写法:
// 原生Redux用法
import { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
const Demo = () => {
const dispatch = useDispatch();
const fetchUser = useCallback(
async () => {
const result = await getUserApi(params);
dispatch({
type: 'RECEIVE_USER_INFO',
payload: result,
});
},
[dispatch]
);
useEffect(
() => {
fetchUser();
},
[fetchUser]
);
const currentUser = useSelector(state => state?.context?.currentUser);
return <div>{currentUser?.name}</div>;
};
使用Redux-thunk的写法改造上面的例子:
// Redux Thunk Creator
const fetchUser = (params) => {
return async (dispatch, getState) => { // This is a Thunk
const result = await getUserApi(params);
dispatch({
type: 'RECEIVE_USER_INFO',
payload: result,
});
};
}
const Demo = () => {
const dispatch = useDispatch();
useEffect(
() => {
dispatch(fetchUser(params));
},
[dispatch]
);
const currentUser = useSelector(state => state?.context?.currentUser);
return <div>{currentUser?.name}</div>;
};
有没有觉得上面的代码很眼熟?Redux-thunk的设计其实非常超前,在六年前高阶组件满街跑的时候,redux-thunk的思路跟如今的hook如出一辙。
我们用hook+原生redux改造上述代码:
const useFetchUser = () => {
const dispatch = useDispatch();
const fetchUser = useCallback(
async (params) => {
const result = await getUserApi(params);
dispatch({
type: 'RECEIVE_USER_INFO',
payload: result,
});
},
[dispatch]
);
return fetchUser;
};
const Demo = () => {
const fetchUser = useFetchUser();
useEffect(
() => {
fetchUser(params);
},
[fetchUser]
);
const currentUser = useSelector(state => state?.context?.currentUser);
return <div>{currentUser?.name}</div>;
};
Redux-thunk由redux的作者Dan Abramov编写,源码只有14行,简单性感,看完即可一分钟精通redux中间件。
个人认为,有了hook之后,redux-thunk可能已经完成了它的历史使命,毕竟它能做的hook都能做。
Redux-saga
Redux-saga旨在使应用中的副作用更便于管理,基于ES generator特性实现。可以想像为,一个 saga 就像是应用程序中一个单独的线程,它独自负责处理副作用。
Saga高度封装了对Side effects的管理。我们在组件中dispatch的依然是redux的原生action(plain object)
,saga中监听action,并执行与action type相应的callback。这种写法相当于把组件内的异步代码都抽离到saga中管理。
Saga的一大优势是内置了race
、takeLatest
、takeEvery
等策略,可以很方便地实现action的竞态(Racing Effects)和并发(Concurrency)
场景下的支持。
同时saga由于自身的高度封装,概念和接口比较多,包括:
- 和组件、redux的交互(take、select、put)
- 阻塞调用和非阻塞调用(fork、call)
- 竞态、并发(race、takeLatest、takeEvery)
- ...
我们用saga实现一下上述thunk的例子:
// Demo.jsx
const Demo = () => {
const dispatch = useDispatch();
useEffect(
() => {
dispatch({ type: 'FETCH_USER_INFO', payload: {} }); // dispatch原生action
},
[dispatch]
);
const currentUser = useSelector(state => state?.context?.currentUser);
return <div>{currentUser?.name}</div>;
};
// sagas.js
import { call, put, takeEvery } from 'redux-saga/effects';
function *fetchUser(action) {
const result = yield call(getUserApi, action.payload); // 发起请求
yield put({ // 真正dispatch action到redux store
type: 'RECEIVE_USER_INFO',
payload: result,
});
}
function *mySaga() {
yield takeEvery('FETCH_USER_INFO', fetchUser); // 监听每一个 FETCH_USER_INFO ,并调用 fetchUser
}
export default mySaga;
上例中saga监听的action是FETCH_USER_INFO
,最后dispatch到redux store的action是RECEIVE_USER_INFO
,这是saga理念的充分体现:View只管触发,store只管更新,这中间的异步/副作用代码由saga来处理。
但是这一顿 call、put、take 下来,就问你新人看得懵不懵。另外saga也会写大量模板代码,加上redux本身的高重复度,对开发人员可以说是雪上加霜。
再来看一个官网的阻塞调用异步接口并支持中途取消的例子:
import { take, put, call, fork, cancel, cancelled, delay } from 'redux-saga/effects'
import { someApi, actions } from 'somewhere'
function* bgSync() {
try {
while (true) {
yield put(actions.requestStart())
const result = yield call(someApi)
yield put(actions.requestSuccess(result))
yield delay(5000)
}
} finally {
if (yield cancelled())
yield put(actions.requestFailure('Sync cancelled!'))
}
}
function* main() {
while ( yield take('START_BACKGROUND_SYNC') ) {
// starts the task in the background
const bgSyncTask = yield fork(bgSync)
// wait for the user stop action
yield take('STOP_BACKGROUND_SYNC')
// user clicked stop. cancel the background task
// this will cause the forked bgSync task to jump into its finally block
yield cancel(bgSyncTask)
}
}
简直就是Redux Pro Max 1T。
Saga的优势是对竞态和并发的支持更好,没接触过的同学只要记住这点就行了,等到有了需求场景,再去看看文档考虑使用也不迟,毕竟多接入一个saga中间件和原生的redux以及thunk都不冲突。
Mobx - 在React里写Vue
和redux一样,mobx本身是一个UI无关的纯粹的状态管理库,通过mobx-react
或更轻量的mobx-react-lite
和react建立连接。
实例
先来看一个用mobx实现计数器的简单例子:
import { useEffect } from 'react'
import { autorun } from 'mobx';
import { Observer, useLocalObservable } from 'mobx-react-lite';
const Counter = () => {
const counterStore = useLocalObservable(() => ({ // 创建一个局部的observable state,生命周期和组件一致
value: 0,
get doubleValue() { // computed value
return counterStore.value * 2;
},
increment() {
counterStore.value += 1;
},
decrement() {
counterStore.value -= 1;
},
}));
useEffect(
() => {
autorun(() => { // 收集依赖,并在依赖项变化时重新执行
console.log(counterStore.value);
});
},
[]
);
return (
/* Observer组件会收集dom节点对store的依赖项,并使渲染变成响应式 */
<Observer>
{() => (
<div>
<div>Value: {counterStore.value}</div>
<div>Double value: {counterStore.doubleValue}</div>
<button onClick={counterStore.increment}>Add</button>
<button onClick={counterStore.decrement}>Decrement</button>
</div>
)}
</Observer>
);
};
例子中,也可以将state抽成全局的:
import { useEffect } from 'react'
import { autorun, makeAutoObservable } from 'mobx';
import { useObserver } from 'mobx-react-lite';
// 全局state,可以在多个组件中订阅
const counterStore = makeAutoObservable({ // 把对象变成observable
value: 2,
get doubleValue() { // computed value
return counterStore.value * 2;
},
increment() {
counterStore.value += 1;
},
decrement() {
counterStore.value -= 1;
},
});
const Counter = () => {
return (
/* useObserver 的作用和 <Observer> 一样 */
useObserver(() => (
<div>
<div>Value: {counterStore.value}</div>
<div>Double value: {counterStore.doubleValue}</div>
<button onClick={counterStore.increment}>Add</button>
<button onClick={counterStore.decrement}>Decrement</button>
</div>
))
);
};
心智模型
Mobx的心智模型和react很像,它区分了应用程序的三个概念:
- State(状态)
- Actions(动作)
- Derivations(派生)
首先创建可观察的状态(Observable State),通过Action更新State,然后自动更新所有的派生(Derivations)。派生包括Computed value(类似useMemo或useSelector)、副作用函数(类似useEffect)和UI(render)。
Mobx虽然心智模型像react,但是实现却是完完全全的vue:mutable + proxy
(为了兼容性,proxy实际上使用Object.defineProperty实现)。
使用反react的数据流模式,注定会有成本:
-
Mobx的响应式脱离了react自身的生命周期,就不得不显式声明其派生的作用时机和范围。比如副作用触发需要在useEffect里再跑一个autorun/reaction,要给DOM render包一层useObserver/Observer,都加大了开发成本。
-
Mobx会在组件挂载时收集依赖,和state建立联系,这个方式在即将到来的react 18的并发模式(Concurrent Mode)中,可能无法平滑地迁移。为此,react专门开发了create-subscription方法用于在组件中订阅外部源,但是实际的应用效果还未可知。
尤大本人也盖过章:React + MobX 本质上就是一个更繁琐的Vue。
既然这样,直接用Vue不香吗(狗头)。
Mobx vs Redux
Mobx和Redux的对比,实际上可以归结为 面向对象 vs 函数式
和Mutable vs Immutable
。
-
相比于redux的广播遍历dispatch,然后遍历判断引用来决定组件是否更新,mobx基于proxy可以精确收集依赖、局部更新组件(类似vue),理论上会有更好的性能,但redux认为这可能不是一个问题(Won't calling “all my reducers” for each action be slow?)
-
Mobx因为数据只有一份引用,没有回溯能力,不像redux每次更新都相当于打了一个快照,调试时搭配redux-logger这样的中间件,可以很直观地看到数据流变化历史。
-
Mobx的学习成本更低,没有全家桶。
-
Mobx在更新state中深层嵌套属性时更方便,直接赋值就好了,redux则需要更新所有途经层级的引用(当然搭配immer也不麻烦)。
Recoil
Recoil是在React Europe 2020 Conference上facebook官方推出的专为react打造的状态管理库,动机是解决react状态共享模式的局限性:
- 以往只能将state提升到公共祖先来实现状态共享,并且一旦这么做了,基本就无法将组件树的顶层(state 必须存在的地方)与叶子组件 (使用 state 的地方) 进行代码分割
- Context 只能存储单一值,无法存储多个各自拥有消费者的值的集合
Recoil有以下特性:
- 状态原子化(atom),自由组合和订阅;并且状态定义是渐进式和分布式的,使代码分割成为可能
- 没有模板代码,天然是hook模式,让react尽量保持原来的样子
- 兼容并发模式(Concurrent Mode)
- 提供对状态流的快照(snapshot)支持,可以轻松回溯应用状态,甚至将snopshot编码放进url,让任何人打开应用都能进入到同样的状态
Recoil有很多优秀特性,是个值得关注的库,但目前仍然处于试验阶段,版本只发布到了0.4.1(截至2021-10-13),而且有大量的待解决issue,在正式项目中应谨慎使用。
实例
实现一个带筛选的列表:
源码:
import {atom, selector, useRecoilState, useRecoilValue} from 'recoil';
const listState = atom({ // 列表
key: 'listState',
default: [
{name: 'Tom', sex: 'male'},
{name: 'Allen', sex: 'male'},
{name: 'Lucy', sex: 'female'},
],
});
const filterState = atom({ // 筛选项的值
key: 'filterState',
default: 'all',
});
const filteredListState = selector({ // 筛选后的列表(selector定义派生状态)
key: 'filteredListState',
get: ({get}) => {
const list = get(listState); // 通过get方法获取其他state的值
const filter = get(filterState);
return filter === 'all' ? list : list.filter(item => item.sex === filter);
},
});
const List = () => {
const [filter, setFilter] = useRecoilState(filterState);
const filteredList = useRecoilValue(filteredListState);
return (
<div>
<Radio.Group value={filter} onChange={e => { setFilter(e.target.value) }}>
<Radio value="all">all</Radio>
<Radio value="male">male</Radio>
<Radio value="female">female</Radio>
</Radio.Group>
<ul className="list">
{filteredList.map(item => (
<li key={item.name}>{item.name}</li>
))}
</ul>
</div>
);
};
Recoil中定义状态的两个核心方法:
atom
: 定义原子状态,即组件的某个状态最小集,selector
: 定义派生状态,其实就是computed value
消费状态的方法有useRecoilState
、useRecoilValue
、useSetRecoilState
等,用法和react的useState类似,几乎没有上手成本。另外值得注意的是,recoil目前只支持FC的hook用法,Class组件想用的话可以通过HOC的方式获取状态并注入组件。
心智模型
Recoil的状态集是一个有向图 (directed graph),正交且天然连结于React组件树。状态的变化从该图的顶点(atom)开始,流经纯函数 (selector) 再传入组件。
正交:相互独立,相互间不可替代,并且可以组合起来实现其它功能
Snapshot
Recoil每一次状态变更都会生成一个不可变的快照,利用这个特性,可以快速实现应用导航相关的功能,例如状态回溯、跳转等。
来看一个上例中带筛选列表的拓展:增加一个按钮,点击时生成一个包含页面状态快照信息的url,其他人访问这个url时也能加载出相同状态的页面。
import {useRecoilSnapshot, useGotoRecoilSnapshot} from 'recoil';
const SharedList = () => {
const snapshot = useRecoilSnapshot(); // 获取当前全局状态的snapshot,每次变化都会更新
const gotoSnapshot = useGotoRecoilSnapshot();
const handleGenerateUrl = () => {
const url = mapSnapshotToUrl(snapshot); // 将snapshot信息编码进url
console.log(url);
};
useEffect(
() => {
// 组件加载时从url中获取snapshot信息并跳转状态
const snapshot = getSnapshotFromUrl();
snapshot && gotoSnapshot(snapshot);
},
[]
);
return (
<>
<List />
<button onClick={handleGenerateUrl}>Generate URL</button>
</>
);
};
注:mapSnapshotToUrl是自定义的状态编码方法,recoil未来会提供官方的helper实现。
总结
感谢看到这里,不知道大家现在更认同哪种方案?可以下结论的是,如今的react状态管理依然没有银弹,没有最好,只有最适合。
简单场景使用原生的useState、useReducer、useContext
就能满足;还可以用hox这样小而美的库将hook的状态直接拓展成持久化状态,几乎没有额外的心智负担。
复杂场景的应用,redux、mobx都是经受过千锤百炼的库,社区生态也很完备。
Redux高度模板化、分层化,职责划分清晰,塑造了其状态在可回溯、可维护性方面的优势;搭配thunk、saga这些中间件几乎是无所不能。
Mobx的优势是写法简单和高性能,但状态的可维护性不如redux,在并发模式中的兼容性也有待观察。
Recoil还在玩具阶段,应谨慎使用,但在复杂度一般的项目里用来替代redux还是能在开发体验上有不小的提升。
以react的尿性,或许根本不会有状态管理大一统的一天(一个UI库的自我修养),无关自身的都交给社区,在发展实践中逐渐收敛,然后又在react的版本换代中新旧更迭。
随着hook和有官方背景的recoil的出现,状态管理似乎在朝原子化、组件化
的方向发展,这也符合react的组件化哲学。Redux的暴力遍历和分发或许已经是逆潮流的解法。
最后附一份主流方案的多方面对比:
方案 | 学习成本 | 编码成本 | TS友好 | SSR | Code Split | 并发模式兼容 | 可调试性 | 生态繁荣 |
---|---|---|---|---|---|---|---|---|
Redux | 高 | 高 | 一般 | 支持 | 不支持 | 支持 | 好 | 高 |
Mobx | 中 | 中 | 好 | 支持 | 支持 | 未知 | 差 | 中 |
Recoil | 低 | 低 | 好 | 实践较少 | 支持 | 支持 | 好 | 低 |
完。
转载自:https://juejin.cn/post/7026232873233416223