现在的 react 的状态管理工具基本上分为 redux 和 mobx 两个流派,mobx 基本上你们都是使用官方的 mobx 库,可是对于 redux 却衍生数不胜数的 redux 框架。如redux-saga
, dva
, mirror
, rematch
等等,这么多 redux 的框架一方面说明 redux 是如此流行,另外一方面也代表 redux 自身的先天不足,笔者本人也是从最初的刀耕火种时代一路走来。html
// action_constant.js
// action_creator.js
// action.js
// reducer.js
// store.js
// 再加上一堆的middleware
复制代码
每次改一点业务动辄就须要改四五个文件,着实使人心累,并且不一样业务对 redux 文件的组织方式也不一样,用的按照组件进行组织,有的按照功能进行组织,每次看新的业务都得熟悉半天,对异步的支持也基本上就使用 redux-thunk、redux-promise 等,遇到复杂的异步处理,代码十分的晦涩难懂。react
后来社区为了不每次修改都要修改一堆文件和制定文件规范,推出了 ducks-modular-redux 规范,将每一个子 module 的文件都放置到一个文件里,这样大大简化了平常开发中一些冗余工做。git
// 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)))
}
复制代码
笔者的以前维护的一个老项目至今仍然采用这种方式。github
duck modular proposal 虽然必定程度上减少了维护成本,但本质上并无减少每次开发业务的代码量,异步等问题仍然没有获得解决,所以开始衍生出了一大堆的基于 redux 的框架,重点在于解决简化样板代码量和复杂异步流程的处理。
样板代码简化的思路基本上是一致的。咱们发现绝大部分的业务 model 都知足以下性质typescript
const model = createModel({
name: // 全局的key
state:xxx, // 业务状态
reducers:xxx, // 同步的action
effects:xxxx, // 异步的action
computed: xxx // state的衍生数据
}
复制代码
所以绝大部分框架的都采用了相似的定义,区别只在于语法和名称有所不一样redux
// 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' });
}
}
};
复制代码
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 里是如何处理异步流的promise
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 的开发体验,获得了很大的改善,可是仍然不是尽善尽美,仍然存在以下一些问题。markdown
9102 年了,Typescript 已经大大普及,稍微上点规模的业务,Typescript 的使用已是大势所趋,Typescript 的好处就很少赘述,咱们基本上全部的业务都是使用 Typescript 进行开发,在平常开发过程当中基本上碰到的最大问题就是库的支持。
俗话所说,Typescript 坑不太多(其实也多),库的坑不太多,可是 Typescript 和库结合者使用,坑就不少了。很不幸 Dva 和 Rematch 等都缺少对 Typescript 的良好支持,对平常业务开发形成了不小的影响,笔者就曾经针对如何修复 Rematch 的类型问题,写过一篇文章 zhuanlan.zhihu.com/p/78741920 ,可是这仍然是个 hack 的办法,dva 的 ts 支持就更差了,generator 的类型安全在 ts3.6 版本才得以充分支持(还有很多 bug),至今也没看到一个能较完美支持 ts 的 dva 例子。
redux 能够说是 Batteries Included 的标准反例了,为了保证本身的纯粹,一方面把异步处理这个脏活,所有交给了中间件,这致使搞出了一堆的第三方的异步处理方案,另外一方面其不肯作更高的抽象,致使须要编写一堆的 boilerplate code 还致使了各类写法。所以对于平常的业务开发来说,一个 Batteries Included 库就足够重要了,即保证了编码规范,也简化了业务方的使用。
Computed State 和 immutable 就是平常开发中很是重要的 feature,可是 rematch 把两个功能都交给插件去完成,致使平常使用不够方便和第三方插件的 TS 支持也不尽如人意。
现在 react 的状态和业务逻辑基本上存在于三种形态
rematch 对 redux 的状态管理方式基本上作到了最简,可是其仅仅只能用于 redux 状态的管理,对于 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})
}
}
复制代码
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 来解决状态 && 状态操做的复用
// 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 />
复制代码
<WithLoading>
{(props) => {
<Loading {...props} />
}}
</WithLoading>
复制代码
这两种方式都存在必定的问题
对于高阶组件,存在不少须要注意的地方,如 zh-hans.reactjs.org/docs/higher… ,带来不小的心智负担,对于新手并不友好,另外一个问题在于 HOC 对于 Typescript 的支持并不友好,实现一个 TS 友好的 HOC 组件有至关大的难度可参考 www.zhihu.com/question/27… 在平常使用第三方的支持高阶组件库也常常会碰到各类 TS 的问题。
而 renderProps 虽然必定程度上拜托了 HOC 存在的问题,可是其会形成 render props callback hell, 当咱们须要同时使用多个 renderprops 的时候, 就会编写出以下代码
这种代码不管是对代码的阅读者,仍是调试 element 结构的时候,都会带来不小的影响。
// 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 能够直接使用,如能够直接使用 github.com/alex-cory/u… 这个 hooks 来简化代码
function Loading(){
const { error, loading, data} = useHttp(url);
if(loading){
return <div>loading....</div>
}else{
return <div>{data}</div>
}
}
复制代码
hooks 几乎完美解决了状态复用的问题,可是 hooks 自己也带来了一些问题,
hooks 的心智负担并不比 HOC 要少,zh-hans.reactjs.org/docs/hooks-… FAQ 的长度可见一斑,另外一个问题是 hook 只能使用在 function 里,这意味着咱们须要在 function 里组织业务代码了
刚刚从 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 等工具了,仍然存在问题。
上面的讨论咱们能够看出,尽管 hooks 解决了状态复用的问题,可是其代码的组织和维护存在较多问题,如何解决 hooks 代码的维护问题就成了个问题
rematch 的状态管理比较规整,咱们所以能够考虑将 local state 的状态管理页存放到全局的 redux 里,但这样会带来一些问题
咱们虽然不能将状态放在全局,咱们仍然能够效仿 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 出现的混乱的状况了
咱们发现至此咱们组件不管是 local state 仍是全局 state,写法几乎一致了,都是划分为了 modle 和 view,区别只在于状态是存在全局仍是 local,若是咱们全局和 local 的 model 定义彻底一致,那么将很容易实现状态全局和 local 的切换,这实际上在业务中也比较常见,尤为是在 spa 里,刚开始某个页面里的状态是 local 的,可是后来新加了个页面,须要和这个页面共享状态,咱们就须要将这个状态和新页面共享,这里能够先将状态提高至两个页面的公共父页面里(经过 Context), 或者直接提取到全局。因此此时对于组件,差异仅仅在于咱们的状态从何读取而已。
咱们经过 hook 就隔离了这种区别,当咱们须要将状态切换至全局或者 context 或者 local 时并不须要修改 model,仅仅需修改读取的 hook 便可
// hook.ts
import model from './model';
const useLocalLoading = createLocalStore(model); // 从local读取状态
const useConextLoading = createContextStore(model); // 从context读取状态
const useGlobalLoading = createStore(model); // 从redux里读取状态
// loading.ts
export default () => {
const [state, actions] = useLocalLoading(); // 这里能够选用从何处读取状态
return <Loading {...state} {...actions} />
}
复制代码
此时咱们的组件不管是状态复用、UI 复用、仍是代码组织上都达到了比较合理的水平,mobx 里实际上已经采用了相似作法
咱们在编写 model 的过程当中,effects 里不可避免的须要调用 service 来获取数据,这致使了咱们的 model 直接依赖了 service,这通常不会出现问题,可是当咱们作同构或者时就会出现问题。
由于浏览器端和服务端以及测试端的 service 差异很大,如浏览器端的 service 一般是 http 请求,而服务端的 service 则有多是 rpc 服务,且调用过程当中须要打日志和一些 trace 信息而测试端多是一些 mock 的 http 服务。这致使了若是 model 直接依赖于 service 将没法构建通用于服务端和浏览器端的 model,更好的处理方式应该是将 service 经过依赖注入的方式注入到 model,在建立 strore 的时候将 service 实际的进行注入
上面说的这些问题包括 Typescript 支持、Batteries Included、localStore 的支持、依赖注入的支持等,rematch| dva 等库受限于历史缘由,都不太可能支持,很幸运的是 github.com/ctrlplusb/e… 对上述均作了很好的支持。具体例子可参考 github.com/hardfist/ha…
disclaimer: 我和这库没啥关系,只是发现很符合个人需求,因此推荐一下
easy-peasy 的使用方式和 rematch 类似,但区别于 rematch 缺少对 hook 的内置支持(虽然也能支持 react-redux 的 hook 用法),且须要兼容 react-redux 的写法,
easy-peasy 内置了对 hook 的支持且并不依赖 react-redux,而仅仅是对 react-redux 的用法作简单兼容,致使了其能够摆脱 rematch 现存的种种问题。
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 对这里的兼容很坑爹), 且保证了类型安全
区别于 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;
}
});
})
};
复制代码
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 实现了依赖注入,且保证了依赖注入的类型安全
// 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
}
});
复制代码
import { Injections } from '../store';
// 导入须要注入的类型
export interface TodosModel {
items: string[];
addTodo: Action<TodosModel, string>;
saveTodo: Thunk<TodosModel, string, Injections>; // 类型注入
}
复制代码