React Hooks(一): From Redux to Hooks

现在的 react 的状态管理工具基本上分为 redux 和 mobx 两个流派,mobx 基本上你们都是使用官方的 mobx 库,可是对于 redux 却衍生数不胜数的 redux 框架。如redux-saga, dva, mirror, rematch等等,这么多 redux 的框架一方面说明 redux 是如此流行,另外一方面也代表 redux 自身的先天不足,笔者本人也是从最初的刀耕火种时代一路走来。html

最原始的 redux

// action_constant.js
// action_creator.js
// action.js
// reducer.js
// store.js
// 再加上一堆的middleware

复制代码

每次改一点业务动辄就须要改四五个文件,着实使人心累,并且不一样业务对 redux 文件的组织方式也不一样,用的按照组件进行组织,有的按照功能进行组织,每次看新的业务都得熟悉半天,对异步的支持也基本上就使用 redux-thunk、redux-promise 等,遇到复杂的异步处理,代码十分的晦涩难懂。react

redux duck

后来社区为了不每次修改都要修改一堆文件和制定文件规范,推出了 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

rematch | dva

duck modular proposal 虽然必定程度上减少了维护成本,但本质上并无减少每次开发业务的代码量,异步等问题仍然没有获得解决,所以开始衍生出了一大堆的基于 redux 的框架,重点在于解决简化样板代码量和复杂异步流程的处理。
样板代码简化的思路基本上是一致的。咱们发现绝大部分的业务 model 都知足以下性质typescript

const model = createModel({
  name: // 全局的key
  state:xxx, // 业务状态
  reducers:xxx, // 同步的action
  effects:xxxx, // 异步的action
  computed: xxx // state的衍生数据
}

复制代码

所以绝大部分框架的都采用了相似的定义,区别只在于语法和名称有所不一样redux

  • 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 里是如何处理异步流的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

Typescript 支持

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 例子。

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>

复制代码

这两种方式都存在必定的问题
对于高阶组件,存在不少须要注意的地方,如 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
    官方为了解决状态复用的问题,推出了 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 能够直接使用,如能够直接使用 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 里组织业务代码了

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 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…

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>; // 类型注入
}

复制代码
  • 使用注入的 service, 这里是类型安全的

相关文章
相关标签/搜索