easy-peasy: 理想的redux流派状态管理工具了
hardfist opened this issue · 0 comments
如今的react的状态管理工具基本上分为redux和mobx两个流派,mobx基本上大家都是使用官方的mobx库,但是对于redux却衍生数不胜数的redux框架。如redux-saga
, dva
, mirror
, rematch
等等,这么多redux的框架一方面说明redux是如此流行,另一方面也表明 redux自身的先天不足,笔者本人也是从最初的刀耕火种时代一路走来。
最原始的redux
// action_constant.js
// action_creator.js
// action.js
// reducer.js
// store.js
// 再加上一堆的middleware
每次改一点业务动辄就需要改四五个文件,着实令人心累,而且不同业务对redux文件的组织方式也不同,用的按照组件进行组织,有的按照功能进行组织,每次看新的业务都得熟悉半天,对异步的支持也基本上就使用redux-thunk、redux-promise等,遇到复杂的异步处理,代码十分的晦涩难懂。
redux duck
后来社区为了避免每次修改都要修改一堆文件和制定文件规范,推出了ducks-modular-redux规范,将每个子module的文件都放置到一个文件里,这样大大简化了日常开发中一些冗余工作。
// widgets.js
// Actions
const LOAD = 'my-app/widgets/LOAD';
const CREATE = 'my-app/widgets/CREATE';
const UPDATE = 'my-app/widgets/UPDATE';
const REMOVE = 'my-app/widgets/REMOVE';
// Reducer
export default function reducer(state = {}, action = {}) {
switch (action.type) {
// do reducer stuff
default: return state;
}
}
// Action Creators
export function loadWidgets() {
return { type: LOAD };
}
export function createWidget(widget) {
return { type: CREATE, widget };
}
export function updateWidget(widget) {
return { type: UPDATE, widget };
}
export function removeWidget(widget) {
return { type: REMOVE, widget };
}
// side effects, only as applicable
// e.g. thunks, epics, etc
export function getWidget () {
return dispatch => get('/widget').then(widget => dispatch(updateWidget(widget)))
}
笔者的之前维护的一个老项目至今仍然采用这种方式。
rematch | dva
duck modular proposal虽然一定程度上减小了维护成本,但本质上并没有减小每次开发业务的代码量,异步等问题仍然没有得到解决,因此开始衍生出了一大堆的基于redux的框架,重点在于解决简化样板代码量和复杂异步流程的处理。
样板代码简化的思路基本上是一致的。我们发现绝大部分的业务model都满足如下性质
const model = createModel({
name: // 全局的key
state:xxx, // 业务状态
reducers:xxx, // 同步的action
effects:xxxx, // 异步的action
computed: xxx // state的衍生数据
}
因此绝大部分框架的都采用了类似的定义,区别只在于语法和名称有所不同
- dva
// dva.js
export default {
namespace: 'products',
state: [],
reducers: {
'delete'(state, { payload: id }) {
return state.filter(item => item.id !== id);
},
},
effects: {
*add(action, { call, put }) {
yield call(delay, 1000);
yield put({ type: 'minus' });
}
}
};
- rematch
export const count = {
state: 0, // initial state
reducers: {
// handle state changes with pure functions
increment(state, payload) {
return state + payload
}
},
effects: (dispatch) => ({
// handle state changes with impure functions.
// use async/await for async actions
async incrementAsync(payload, rootState) {
await new Promise(resolve => setTimeout(resolve, 1000))
dispatch.count.increment(payload)
}
})
}
两者的区别主要在于对异步的处理,dva选择了用generator,而rematch选择了用async/await。
首先我们回顾一下redux-thunk里是如何处理异步流的
const fetch_data = url => (dispatch, getState) =>{
dispatch({
type: 'loading',
payload: true
})
fetch(url).then((response) => {
dispatch({
type: 'data',
payload: response
})
dispatch({
type: 'loading',
payload: false
})
}).catch((err) => {
dispatch({
type: 'error',
payload: err.message
})
dispatch({
type: 'loading',
payload: false
})
})
}
一个简单的拉取数据的逻辑就显得如此繁杂,更别提如何将多个异步action组合起来构成更加复杂的业务逻辑了(我已经不知道咋写了)
async/await 和generator的最大优点在于1. 其可以使用看似同步的方式组织异步流程 2.各个异步流程能够很容易的组合到一起。具体使用哪一个全看个人喜好了。
如上面同样的逻辑在rematch里的写法如下
const todo = createModel({
effects: ({todo}) => ({
async fetch_data(url) {
todo.setLoading(true);
try {
const response = fetch(url);
todo.setLoading(false);
}catch(err){
todo.setLoading(false);
todo.setError(err.message)
}
},
async serial_fetch_data_list(url_list){
const result = []
for(const url of url_list){
const resp = await todo.fetch_data(url);
result.push(resp);
}
return result;
}
})
})
得益于async/await的支持,现在无论是异步action本身的编写还是多个异步action的组合现在都不是问题了。
我们现在的绝大部分新业务,基本上都还是采用rematch,相比之前纯redux的开发体验,得到了很大的改善,但是仍然不是尽善尽美,仍然存在如下一些问题。
Typescript支持
9102年了,Typescript已经大大普及,稍微上点规模的业务,Typescript的使用已经是大势所趋,Typescript的好处就不多赘述,我们基本上所有的业务都是使用Typescript进行开发,在日常开发过程中基本上碰到的最大问题就是库的支持。
俗话所说,Typescript坑不太多(其实也多),库的坑不太多,但是Typescript和库结合者使用,坑就很多了。很不幸Dva和Rematch等都缺乏对Typescript的良好支持,对日常业务开发造成了不小的影响,笔者就曾经针对如何修复Rematch的类型问题,写过一篇文章https://zhuanlan.zhihu.com/p/78741920 ,但是这仍然是个hack的办法,dva的ts支持就更差了,generator的类型安全在ts3.6版本才得以充分支持(还有不少bug),至今也没看到一个能较完美支持ts的dva例子。
Batteries Included
redux可以说是Batteries Included的标准反例了,为了保证自己的纯粹,一方面把异步处理这个脏活,全部交给了中间件,这导致搞出了一堆的第三方的异步处理方案,另一方面其不愿做更高的抽象,导致需要编写一堆的boilerplate code还导致了各种写法。因此对于日常的业务开发来讲,一个Batteries Included库就足够重要了,即保证了编码规范,也简化了业务方的使用。
Computed State和 immutable就是日常开发中非常重要的feature,但是rematch把两个功能都交给插件去完成,导致日常使用不够方便和第三方插件的TS支持也不尽如人意。
仅支持对redux状态的管理
如今react的状态和业务逻辑基本上存在于三种形态
- redux: 存放业务领域的状态,同时存放一些业务更新逻辑
- context: 主要存放一些全局配置的信息,较少变动或者不变如,主题、语言等信息
- local: 多存放UI相关的状态,如模态框的展示状态,loading状态等等。在class组件里存放于this.state中,在hook组件中存放于useState里
rematch对redux的状态管理方式基本上做到了最简,但是其仅仅只能用于redux状态的管理,对于local state的管理却无可奈何。
local state的管理
对于大部分的简单业务,local state的管理并不麻烦,基本上就是控制一些弹窗的展示,loading的展示,在用class组件来控制业务逻辑时,处理方式也较为简单
class App extends React.Component {
state = {
loading: false,
data: null,
err: null
}
async componentDidMount() {
this.setState({loading: true})
try {
const result = await service.fetch_data()
this.setState({
loading:false
})
}catch(err){
this.setState({loading: false, error: err.message})
}
}
render(){
if(this.state.loading){
return <div>loading....</div>
}else{
return <div>{this.sstate.data}</div>
}
}
}
这里的组件其实同时扮演了三个角色
- 状态容器
state = {
loading: false,
data: null,
err: null
}
- 状态处理
async componentDidMount() {
this.setState({loading: true})
try {
const result = await service.fetch_data()
this.setState({
loading:false
})
}catch(err){
this.setState({loading: false, error: err.message})
}
}
- view
render(){
if(this.state.loading){
return <div>loading....</div>
}else{
return <div>{this.sstate.data}</div>
}
}
这种做法有利有弊,好处在于其足够的locality,因为状态,状态处理,渲染这几部分是紧密关联的,将它们放在一起,阅读代码的看到这段代码,很自然的就能看懂
但是一个组件放置了太多的功能就导致其复用很困难。
因此衍生出了不同的复用方式
容器组件和视图组件分离:视图复用
第一种复用方式就是通过状态容器组件和视图组件将状态&&状态处理与view的逻辑进行分离,
容器组件只负责处理状态&&状态处理,视图组件只负责展示的逻辑,这样做法的最大好处在于视图组件的复用极为方便。
UI组件库可谓是这方面的极致了,我们将一些常用视图组件提取出来构成组件库,大部分的UI组件,没有状态,或者一些非受控的组件有一些内部状态。这种组件库极大的简化了日常的UI开发。上面的组件可以重构如下
// 视图组件
class Loading extends React.Component {
render(){
if(this.props.loading){
return <div>loading....</div>
}else{
return <div>{this.props.data}</div>
}
}
}
// 容器组件
class LoadingContainer extends React.Component {
state = {
loading: false,
data: null,
err: null
}
async componentDidMount() {
this.setState({loading: true})
try {
const result = await service.fetch_data()
this.setState({
loading:false
})
}catch(err){
this.setState({loading: false, error: err.message})
}
}
render(){
return <Loading {...this.state} /> // 渲染逻辑交给视图组件
}
}
// app.js
<LoadingContainer>
HOC && renderProps && Hooks: 业务复用
视图组件的复用非常方便,但是容器组件的复用就没那么简单了。社区中衍生出了HOC和renderProps来解决状态&&状态操作的复用
- HOC
// Loading.js
class Loading extends React.Component {
render(){
if(this.props.loading){
return <div>loading....</div>
}else{
return <div>{this.props.data}</div>
}
}
}
export default withLoading(Loading);
// app.js
<Loading />
- renderProps
<WithLoading>
{(props) => {
<Loading {...props} />
}}
</WithLoading>
这两种方式都存在一定的问题
对于高阶组件,存在很多需要注意的地方,如https://zh-hans.reactjs.org/docs/higher-order-components.html#caveats ,带来不小的心智负担,对于新手并不友好,另一个问题在于HOC对于Typescript的支持并不友好,实现一个TS友好的HOC组件有相当大的难度可参考https://www.zhihu.com/question/279911703/answer/410372076,在日常使用第三方的支持高阶组件库也经常会碰到各种TS的问题。
而renderProps虽然一定程度上拜托了HOC存在的问题,但是其会造成render props callback hell,当我们需要同时使用多个renderprops的时候,就会编写出如下代码
这种代码无论是对代码的阅读者,还是调试element结构的时候,都会带来不小的影响。
- Hooks
官方为了解决状态复用的问题,推出了react hooks,且解决了renderProps和HOC带来的问题,上面组件用hooks重写如下
// hooks.js
function useLoading(){
const [loading, setLoading] = useState(false);
const [ error, setError] = useState(null);
const [ data,setData] = useState(null);
useEffect(() => {
setLoading(true);
fetch_data().then(resp => {
setLoading(false);
setData(resp);
}).catch(err => {
setLoading(false);
setError(err.message)
})
})
}
// Loading.js
function Loading(){
const [loading, error, data ] = useLoading();
if(loading){
return <div>loading....</div>
}else{
return <div>{data}</div>
}
}
hooks的复用性特别强,事实上社区上已经积攒了很多的hook可以直接使用,如可以直接使用https://github.com/alex-cory/use-http这个hooks来简化代码
function Loading(){
const { error, loading, data} = useHttp(url);
if(loading){
return <div>loading....</div>
}else{
return <div>{data}</div>
}
}
hooks几乎完美解决了状态复用的问题,但是hooks本身也带来了一些问题,
hooks的心智负担并不比HOC要少,https://zh-hans.reactjs.org/docs/hooks-faq.html FAQ的长度可见一斑,另一个问题是hook只能使用在function里,这意味着我们需要在function里组织业务代码了
Function && Class 谁更适合业务逻辑
刚刚从class组件转移到hook组件时,大部分人最先碰到的问题就是如何组织业务逻辑
class里的method天然的帮我们做好了业务隔离
import React from 'react';
class App extends React.Component {
biz1 = () =>{
}
biz2= () =>{
this.biz3()
}
biz3= () =>{
}
render(){
return (
<div>
<button onClick={() => this.biz1()}>dobiz1</button>
<button onClick={() => this.biz2()}>dobiz2</button>
</div>
)
}
}
但是到了function里,已经缺乏method的这个抽象来帮我们做业务隔离了,很有可能写成如下这种代码
function App (){
const [state1, setState] = useState();
function biz1(){
}
biz1();
const [state2, setState2] = useState();
const biz2 = useCallback(() => {
biz3();
},[state1,state2])
biz2();
return (
<div>
<button onClick={() => biz1()}>dobiz1</button>
<button onClick={() => biz2()}>dobiz2</button>
</div>
)
function biz3(){
}
}
基本上是你想怎么来就怎么来,可以有无数种写法,自己写的还好,其他读代码的人就是一头雾水了,想理清一段业务逻辑,就得反复横跳了。
当然也可以指定一些编写hook的规范如
function APP(){
// 这里放各种hook
// 同步的业务逻辑
// render逻辑
// 业务逻辑定义
}
按照这种规范,上述代码如下
function App (){
const [state1, setState] = useState();
const [state2, setState2] = useState();
biz0();
return (
<div>
<button onClick={() => biz2()}>dobiz1</button>
<button onClick={() => biz2()}>dobiz2</button>
</div>
)
function biz0(){
// 同步代码
}
function biz1(){
// 异步代码
}
function biz2(){
// 异步代码
biz3()
}
function biz3(){
// utilty
}
}
这样组织代码的可读性就好很多,但是这只是认为约定,也没有对应的eslint做保证,而且biz的定义也没法使用useCallback等工具了,仍然存在问题。
编写local state存在的问题
上面的讨论我们可以看出,尽管hooks解决了状态复用的问题,但是其代码的组织和维护存在较多问题,如何解决hooks代码的维护问题就成了个问题
状态全放在rematch里
rematch的状态管理比较规整,我们因此可以考虑将local state的状态管理页存放到全局的redux里,但这样会带来一些问题
- 有些状态本身不太适合放在全局,如A页面的一些UI状态切换到B页面时,我们期望丢弃掉A页面的状态,如果状态放置到A的组件里,随着A组件的卸载,状态自然而然丢弃掉,而如果放置到全局,则需要手动的进行清理
- 全局状态的泛滥:将一些局部状态放置到全局会造成全局状态的泛滥,导致难以辨别核心的业务逻辑
- 违反了局部性的原则:业务逻辑放在全局,导致阅读组件代码时,需要频繁的在组件和全局状态内进行切换
model和view的分离
我们虽然不能将状态放在全局,我们仍然可以效仿rematch的方式,将组件拆分为view和model,view负责纯渲染,model里存放业务逻辑,借助于hooks,比较容易实现该效果,大致代码结构如下
// models.ts
const model = {
state:{
data: null,
err: null,
loading: false
},
setState: action((state,new_state) => {
Object.assign(state,new_state)
}),
fetch_data: effects(async (actions) => {
const { setState } = actions;
setState({loading: true});
try {
const resp = await fetch();
setState({
loading: false,
data:resp
})
}catch(err){
setState({
loading: false,
err: err.mssage
})
}
})
}
// hooks.ts
import model from './model';
export const useLoading = createLocalStore(model);
// loading/ index.ts
import {useLoading} from './hooks';
export default () => {
const [state, actions] = useLoading();
return (<Loading {...state} {...actions} />)
}
const Loading = ({
err,
data,
loading,
fetch_data
}) => {
if(loading) return (<div>loading...</div)
if(err) return (<div>error:{err}</div>)
return <div onClick={fetch_data}>data:{data}</div>
}
代码主要有三部分组成
model: 业务逻辑(状态及状态变化)
hooks: 根据model生成useLoding hooks,实际控制的是从何处去获取状态
view: 使用根据useLoading hooks的返回的state和action进行渲染
这样我们的代码组织就比较清晰,不太可能出现之前hook出现的混乱的情况了
重要的是model而非local或者全局
我们发现至此我们组件无论是local state还是全局state,写法几乎一致了,都是划分为了modle和view,区别只在于状态是存在全局还是local,如果我们全局和local的model定义完全一致,那么将很容易实现状态全局和local的切换,这实际上在业务中也比较常见,尤其是在spa里,刚开始某个页面里的状态是local的,但是后来新加了个页面,需要和这个页面共享状态,我们就需要将这个状态和新页面共享,这里可以先将状态提升至两个页面的公共父页面里(通过Context),或者直接提取到全局。所以此时对于组件,差别仅仅在于我们的状态从何读取而已。
我们通过hook就隔离了这种区别,当我们需要将状态切换至全局或者context或者local时并不需要修改model,仅仅需修改读取的hook即可
// hook.ts
import model from './model';
const useLocaleLoading = createLocaleStore(model); // 从locale读取状态
const useConextLoading = createContext(model); // 从context读取状态
const useGlobalLoading = createStore(model); // 从redux里读取状态
// loading.ts
export default () => {
const [state, actions] = useLocaleLoading(); // 这里可以选用从何处读取状态
return <Loading {...state} {...actions} />
}
此时我们的组件无论是状态复用、UI复用、还是代码组织上都达到了比较合理的水平,mobx里实际上已经采用了类似做法
依赖注入
我们在编写model的过程中,effects里不可避免的需要调用service来获取数据,这导致了我们的model直接依赖了service,这一般不会出现问题,但是当我们做同构时就会出现问题。
因为浏览器端和服务端的service差别很大,如浏览器端的service通常是http请求,而服务端的service则有可能是rpc服务,且调用过程中需要打日志和一些trace信息。这导致了如果model直接依赖于service将无法构建通用于服务端和浏览器端的model,更好的处理方式应该是将service通过依赖注入的方式注入到model,在创建strore的时候将service实际的进行注入
上面说的这些问题包括Typescript支持、Batteries Included、localStore的支持、依赖注入的支持等,rematch| dva等库受限于历史原因,都不太可能支持,很幸运的是https://github.com/ctrlplusb/easy-peasy 对上述均做了很好的支持。具体例子可参考 https://github.com/hardfist/hardfist_tools/tree/master/packages/spa/src/components/counter
easy-peasy 简介
disclaimer: 我和这库没啥关系,只是发现很符合我的需求,所以推荐一下
easy-peasy的使用方式和rematch相似,但区别于rematch缺乏对hook的内置支持(虽然也能支持react-redux的hook用法),且需要兼容react-redux的写法,
easy-peasy内置了对hook的支持且并不依赖react-redux,而仅仅是对react-redux的用法做简单兼容,导致了其可以摆脱rematch现存的种种问题。
typescript的first class支持
9102年了,对typescript的支持对于一个库应该成了基本需求,easy-peasy很好的做到了这一点,其专门为TS设计了一套API,用于解决TS的支持问题(内部使用了ts-boolbelt来解决类型推断问题),简单的使用TS定义一个model如下
export interface TodosModel {
todo_list: Item[]; // state
filter: FILTER_TYPE; // 同上
init: Action<TodosModel, Item[]>; // 同步action
addTodo: Action<TodosModel, string>; // 同上
setFilter: Action<TodosModel, FILTER_TYPE>; // 同上
toggleTodo: Action<TodosModel, number>;
addTodoAsync: Thunk<TodosModel, string>; // 异步
fetchTodo: Thunk<TodosModel, undefined, Injections>; // 异步并进行service的依赖注入
visible_todo: Computed<TodosModel, Item[]>; // computed state
}
定义好model的结构后,我们在编写model时借助于contextual typing可以享受到自动补全和类型检查的功能了
业务中使用model也不再是通过HOC的方式通过connect来读取state和action,而是直接通过内置的hook来解决状态读取问题,避免了对connect的类型兼容问题(rematch对这里的兼容很坑爹),且保证了类型安全
内置computed和immer
区别于rematch,easy-peasy通过immer实现了对immutable的支持,同时内置了对computed state的支持,简化了我们业务的编写
export const todo: TodosModel = {
todo_list: [
{
text: 'learn easy',
id: nextTodoId++,
completed: false
}
],
filter: 'SHOW_ALL' as FILTER_TYPE,
init: action((state, init) => {
state.todo_list = init;
}),
addTodo: action((state, text) => {
// 看似mutable,实际是immutable,通过immer实现了通过mutable的写法,来实现了immutable结构
state.todo_list.push({
text,
id: nextTodoId++,
completed: false
});
}),
setFilter: action((state, filter) => {
state.filter = filter;
}),
toggleTodo: action((state, id) => {
const item = state.todo_list.filter(x => x.id === id)[0];
item.completed = !item.completed;
}),
addTodoAsync: thunk(async (actions, text) => {
await delay(1000);
actions.addTodo(text);
}),
fetchTodo: thunk(async function test(actions, payload, { injections }) {
const { get_todo_list } = injections;
const {
data: { todo_list }
} = await get_todo_list();
actions.init(todo_list);
}),
// 内置对computed的支持
visible_todo: computed(({ todo_list, filter }) => {
return todo_list.filter(x => {
if (filter === 'SHOW_ALL') {
return true;
} else if (filter === 'SHOW_COMPLETED') {
return x.completed;
} else {
return !x.completed;
}
});
})
};
同样的方式编写local和全局的state
easy peasy的model定义不仅适用于全局,也适用于context和local,只需要通过hook进行切换即可
export const ContextCounter = () => {
const [state, actions] = useContextCounter();
return renderCounter(state, actions);
};
export const LocalCounter = () => {
const [state, actions] = useLocalCounter();
return renderCounter(state, actions);
};
export const ReduxCounter = () => {
const [state, actions] = useReduxCounter();
return renderCounter(state, actions);
};
依赖注入支持
easy peasy同时通过thunk实现了依赖注入,且保证了依赖注入的类型安全
- 构造store时注入service
// src/store/index.ts
import {get_todo_list } from 'service'
export interface Injections {
get_todo_list: typeof get_todo_list;
} //定义注入的类型,供后续使用
export const store = createStore(models, {
injections: { // 注入service
get_todo_list
}
});
- 定义model时,声明要注入的类型
import { Injections } from '../store';
// 导入需要注入的类型
export interface TodosModel {
items: string[];
addTodo: Action<TodosModel, string>;
saveTodo: Thunk<TodosModel, string, Injections>; // 类型注入
}