react-redux实践总结

标题

作者:赵玮龙 先后就职于面包旅行,阿里体育,现在就职于美团。涉及技术范围React, AngularJS, gulp, grunt, webpack, redux, canvas, node等,现在专注于前端react周边技术栈研究

特此声明:本篇文章都是围绕react技术栈展开的如果你要跟我辩解是React,Vue,AngularJS之间区别,网络上文章数不胜数,可以移步别处.我假设你已经对react,redux有一些实战经验,基本的东西不会涉及

文章专著于如何尽量做到react-redux最佳实践

redux的必要性:

Eric在Medium上发文喷setState
这件事情很早就引起热议
其实无非就是setState的对于新手不友好以及文档的晦涩导致的
React抽象来说,就是一个公式
UI=f(state)
我们把最终绘制出来的UI当做一个函数f运行的结果,f就是React和我们基于React写得代码,而f的输入参数就是state。
作为React管理state的一个重要方法,setState肯定非常重要,如果只是简单用法,也不会有任何问题,但是如果用得深,就会发现很……尴尬。
我刚开始接触React的时候,就意识到React相当于一个jQuery的替代品,但是就像单独依靠jQuery难以管理大型项目,所以也需要给配合使用的MVC框架找一个替代品,我选择的替代品是Redux,我很早就将React和Redux配合使用;现在,回过头来看看React的setState,发现坑真的不少,不禁感叹自己还是挺走运的。
对setState用得深了,就容易犯错,所以我们开门见山先把理解setState的关键点列出来。
  • setState不会立刻改变React组件中state的值;
  • setState通过引发一次组件的更新过程来引发重新绘制;
  • 多次setState函数调用产生的效果会合并。
    这几个关键点其实是相互关联的
setState不会立刻改变React组件中state的值(他是异步触发的,也就是考虑到列队处理的必要性)
在React中,一个组件中要读取当前状态用是访问this.state,但是更新状态却是用this.setState
如果需要同步我们还是更需要
函数式的setState用法
如果传递给this.setState的参数不是一个对象而是一个函数,那游戏规则就变了。
这个函数会接收到两个参数,第一个是当前的state值,第二个是当前的props,这个函数应该返回一个对象,这个对象代表想要对this.state的更改,换句话说,之前你想给this.setState传递什么对象参数,在这种函数里就返回什么对象,不过,计算这个对象的方法有些改变,不再依赖于this.state,而是依赖于输入参数state。
可以这么写一个函数。
function increment(state, props) {
  return {count: state.count + 1};
}复制代码
可以看到,同样是把状态中的count加1,但是状态的来源不是this.state,而是输入参数state。
对应incrementMultiple的函数就是这么写。
function incrementMultiple() {
  this.setState(increment);
  this.setState(increment);
  this.setState(increment);
}复制代码
对于多次调用函数式setState的情况,React会保证调用每次increment时,state都已经合并了之前的状态修改结果。
简单说,加入当前this.state.count的值是0,第一次调用this.setState(increment),传给increment的state参数是0,第二调用时,state参数是1,第三次调用是,参数是2,最终incrementMultiple的效果,真的就是让this.state.count变成了3,这个函数incrementMultiple终于实至名归。
值得一提的是,在increment函数被调用时,this.state并没有被改变,依然,要等到render函数被重新执行时(或者shouldComponentUpdate函数返回false之后)才被改变。
让setState接受一个函数的API设计很棒!因为这符合函数式编程的思想,让开发者写出没有副作用的函数,我们的increment函数并不去修改组件状态,只是把“希望的状态改变”返回给React,维护状态这些苦力活完全交给React去做。
正因为流程的控制权交给了React,所以React才能协调多个setState调用的关系。
让我们再往前推进一步,试着如果把两种setState的用法混用,那会有什么效果?
我们把incrementMultiple改成这样。
function incrementMultiple() {
  this.setState(increment);
  this.setState(increment);
  this.setState({count: this.state.count + 1});
  this.setState(increment);
}复制代码

在几个函数式setState调用中插入一个传统式setState调用(嗯,我们姑且这么称呼以前的setState使用方式),最后得到的结果是让this.state.count增加了2,而不是增加4。

原因也很简单,因为React会依次合并所有setState产生的效果,虽然前两个函数式setState调用产生的效果是count加2,但是半路杀出一个传统式setState调用,一下子强行把积攒的效果清空,用count加1取代。

这么看来,传统式setState的存在,会把函数式setState拖下水啊!只要有一个传统式的setState调用,就把其他函数式setState调用给害了。

如果说setState这儿API将来如何改进,也许就该完全采用函数为参数的调用方法,废止对象为参数的调用方法。

当然,React近期肯定不会有这样的惊世骇俗的改变,但是大家可以先尝试函数式setState用法,这才是setState的未来。

当然这还不是问题所在这里

关注react的人都知道,facebook提出的flux单向数据流控制,业界也出现了很多类似的flux数据流实现方式

他们其实起到的作用无非是如何去在全局状态下不再让你的组建分而治之,而是具有统一管理state的能力

现在业界比较火的是Mobx和Redux

先对比下两个库的实现方式:

那么具体到这两种模型,又有一些特定的优缺点呈现出来

先谈谈 Redux 的优势:

  • 数据流流动很自然,因为任何 dispatch 都会导致广播,需要依据对象引用是否变化来控制更新粒度。
  • 如果充分利用时间回溯的特征,可以增强业务的可预测性与错误定位能力。
  • 时间回溯代价很高,因为每次都要更新引用,除非增加代码复杂度,或使用 immutable。
  • 时间回溯的另一个代价是 action 与 reducer 完全脱节,数据流过程需要自行脑补。原因是可回溯必然不能保证引用关系。
  • 引入中间件,其实主要为了解决异步带来的副作用,业务逻辑或多或少参杂着 magic。
  • 但是灵活利用中间件,可以通过约定完成许多复杂的工作。
  • 对 typescript 支持困难。

Mobx:

  • 数据流流动不自然,只有用到的数据才会引发绑定,局部精确更新,但免去了粒度控制烦恼。
  • 没有时间回溯能力,因为数据只有一份引用。
  • 自始至终一份引用,不需要 immutable,也没有复制对象的额外开销。
  • 没有这样的烦恼,数据流动由函数调用一气呵成,便于调试。
  • 业务开发不是脑力活,而是体力活,少一些 magic,多一些效率。
  • 由于没有 magic,所以没有中间件机制,没法通过 magic 加快工作效率(这里 magic 是指 action 分发到 reducer 的过程)。
  • 完美支持 typescript。

如何来确认这两个库的适用场景,实际中如果你的数据结构足够复杂那么还是redux带来的灵活性以及数据管理模式更加自然,

mobx上手会更快,如果数据结构一般则比较建议这种方式

那么当我们确认redux符合复杂业务场景后(后台业务一般都是复杂业务场景的必发处)

如何善于利用redux为我们带来更好的开发体验和可维护性高的代码是这次探讨的重点

大体上,Redux 的数据流是这样的:

界面 => action => reducer => store => react => virtual dom => 界面

每一步都很纯净,看起来很美好对吧?对于一些小小的尝试性质的 DEMO #### 来说确实很美好。但其实当应用变得越来越大的时候,这其中存在诸多问题:

如何优雅地写异步代码?(从简单的数据请求到复杂的异步逻辑)

状态树的结构应该怎么设计?

状态树中的状态越来越多,结构越来越复杂的时候,和 react #### 的组件映射如何避免混乱?

每次状态的细微变化都会生成全新的 state #### 对象,其中大部分无变化的数据是不用重新克隆的,这里如何提高性能?

如何拆分reducer?

state如何解耦选择数据分片呢?

我只能从业务中一一示范,当然并不是上面的问题都解决了,而且以一个更好的方式解决了,这里只做到抛砖引玉的作用

官方文档里介绍了一种很朴素的异步控制中间件 redux-thunk(如果你还不了解中间件的话请看 Middleware | Redux 中文文档,事实上 redux-thunk 的代码很简单,简单到只有几行代码:

function createThunkMiddleware(extraArgument) {
    return ({ dispatch, getState }) => next => action => {
        if (typeof action === 'function') {
            return action(dispatch, getState, extraArgument);
        }
        return next(action);
    };
}

//普通action
function foo(){
    return {
        type: 'foo',
        data: 123
    }
}

//异步action
function fooAsync(){
    return dispatch => {
        setTimeout(_ => dispatch(123), 3000);
    }
}复制代码

但这种简单的异步解决方法在应用变得复杂的时候,并不能满足需求,反而会使 action 变得十分混乱。

举个简单的例子

用普通的 redux-thunk 是这样写的:

function upload(data){
    return dispatch => {
        // view
        dispatch({ type: 'SHOW_WAITING_MODAL' });
        // upload
        api.upload(data)
            .then(res => {
            // 成功
            dispatch({ type: 'PRELOAD_IMAGES', data: res.images });
            dispatch({ type: 'HIDE_WAITING_MODAL' });
            })
        .catch(err => {
            // 错误
            dispatch({ type: 'SHOW_ERROR', data: err });
            dispatch({ type: 'HIDE_WAITING_MODAL' });
            setTimeout(_ => dispatch({ type: 'HIDE_ERROR' }), 2000);
        })
    }
}复制代码

这里的问题在于,一个异步的 upload action 执行过程中会产生好几个新的 action,更可怕的是这些新的 action 也是包含逻辑的(比如要判断是否错误),这直接导致异步代码中到处都是 dispatch(action),是很不可控的情况。如果还要进一步考虑取消、超时、队列的情况,就更加混乱了。

下面我们来看看如果换成 redux-saga 的话会怎么样:

import { take, put, call, delay } from 'redux-saga/effects'
// 上传的异步流
function *uploadFlow(action) {
    // 显示出加载效果
      yield put({ type: 'SHOW_WAITING_MODAL' });
      // 简单的 try-catch
      try{
          const response = yield call(api.upload, action.data);
        yield put({ type: 'PRELOAD_IMAGES', data: response.images });
        yield put({ type: 'HIDE_WAITING_MODAL' });
      }catch(err){
          yield put({ type: 'SHOW_ERROR', data: err });
        yield put({ type: 'HIDE_WAITING_MODAL' });
        yield delay(2000);
          yield put({ type: 'HIDE_ERROR' });
      }     
}

function* watchUpload() {
  yield* takeEvery('BEGIN_REQUEST', uploadFlow)
}复制代码

是不是规整很多呢?redux-saga 允许我们使用简单的 try-catch 来进行错误处理,更神奇的是竟然可以直接使用 delay 来替代 setTimeout 这种会造成回调和嵌套的不优雅的方法。

本质上讲,redux-sage 提供了一系列的『副作用(side-effects)方法』,比如以下几个

  • put(产生一个 action)
  • call(阻塞地调用一个函数)
  • fork(非阻塞地调用一个函数)
  • take(监听且只监听一次 action)
  • delay(延迟)
  • race(只处理最先完成的任务)

并且通过 Generator 实现对于这些副作用的管理,让我们可以用同步的逻辑写一个逻辑复杂的异步流。

下面这个例子出自于官方文档,实现了一个对于请求的队列,即让程序同一时刻只会进行一个请求,其它请求则排队等待,直到前一个请求结束:

import { buffers } from 'redux-saga';
import { take, actionChannel, call, ... } from 'redux-saga/effects';

function* watchRequests() {
  // 1- 创建一个针对请求事件的 channel
  const requestChan = yield actionChannel('REQUEST');
  while (true) {
    // 2- 从 channel 中拿出一个事件
    const {payload} = yield take(requestChan);
    // 3- 注意这里我们使用的是阻塞的函数调用
    yield call(handleRequest, payload);
  }
}复制代码

但是我在项目中并没有适用redux-saga一个是因为会增加组员的学习成本,一个是代码迭代过快造成的落差

所以我在代码中把请求异步处理封装成一个简单的只有开始,成功,和错误处理的机制

import 'whatwg-fetch'
import handleError from './handleError'

// 设定一个symbol类型做为唯一的属性名
export const CALL_API = Symbol('call_api')

const API_HOST = process.env.API_HOST || 'http://localhost:8080/pc'

export default store => next => action => {
  const callApi = action[CALL_API]
  if (typeof callApi === 'undefined') {
    return next(action)
  }

  // 获取action中参数
  let { endpoint,
        types: [requestType, successType, failureType],
        method,
        body,
        ...options
      } = callApi
  let finalBody = body

  if (method) {
    options.method = method.toUpperCase()
  }
  if (typeof body === 'function') {
    finalBody = body(store.getState())
  }
  if (finalBody) {
    options.body = JSON.stringify(finalBody)
    options.headers = { 'content-type': 'application/json', 'agent': 'pc' }
  } else {
    options.headers = { 'cache-control': 'no-cache', 'agent': 'pc' }
  }
  // 替换action标记方法
  const actionWith = data => {
    const finalAction = Object.assign({}, action, data)
    delete finalAction[CALL_API]
    return finalAction
  }

  next(actionWith({ type:requestType }))

  return fetch(`${API_HOST}${endpoint}`,{
    credentials: 'include',
    ...options,
  })
  .then(response => {
    if (response.status === 204) {
      return { response }
    }
    const type = response.headers.get('content-type')
    if (type && type.split(';')[0] === 'application/json') {
      return response.json().then(json => ({ json, response }))
    }
    return response.text().then(text => ({ text, response }))
  })
  .then(({ json, text, response }) => {
    if (response.ok) {
      if (json) {
        if (json.status === 200 && json.data) {
          next(actionWith({ type: successType, payload: json.data }))
        } else if (json.status === 500) {
          next(actionWith({ type: successType, payload: json.msg }))
        } else {
          next(actionWith({ type: successType }))
        }
      }
    } else {
      if (json) {
        let error = { status: response.status }
        if (typeof json === 'object') {
          error = { ...error, ...json }
        } else {
          error.msg = json
        }
        throw error
      }
      const error = {
        name: 'FETCH_ERROR',
        status: response.status,
        text,
      }
      throw error
    }
  })
  .catch((error) => {
    next(actionWith({ type: failureType, error }))
    handleError(error)
  })
}复制代码

我们可以利用symbol定一个我们需要处理的机制然后去处理每次返回的结果,只是用到了redux-thunk 作为一个thunk函数去返回有副作用的请求

结构状态state应该如何去设计呢?

我们考虑到官方给出的建议用entities去维护我们所需要的数据,因为业务中表单居多,并且表单复杂,

考虑到适用场景我们会根据reducer的概念去讲解

reducer就是实现(state, action) => newState的纯函数,也就是真正处理state的地方。值得注意的是,Redux并不希望你修改老的state,而且通过直接返回新state的方式去修改。

在讲如何设计reducer之前,先介绍几个术语:

reducer:实现(state, action) -> newState的纯函数,可以根据场景分为以下好几种

  • root reducer:根reducer,作为createStore的第一个参数
  • slice reducer:分片reducer,相对根reducer来说的。用来操作state的一部分数据。多个分片reducer可以合并成一个根reducer
  • higher-order reducer:高阶reducer,接受reducer作为参数的函数/返回reducer作为返回值的函数。
  • case function:功能函数,接受指定action后的更新逻辑,可以是简单的reducer函数,也可以接受其他参数。

reducer的最佳实践主要分为以下几个部分

  • 抽离工具函数,以便复用。
  • 抽离功能函数(case function),精简reducer声明部分的代码。
  • 根据数据类别拆分,维护多个独立的slice reducer。
  • 合并slice reducer。
  • 通过crossReducer在多个slice reducer中共享数据。
  • 减少reducer的模板代码。

接下来,我们详细的介绍每个部分

如何抽离工具函数?

抽离工具函数,几乎在任何一个项目中都需要。要抽离的函数需要满足以下条件:

纯净,和业务逻辑不耦合
功能单一,一个函数只实现一个功能

由于reducer都是对state的增删改查,所以会有较多的重复的基础逻辑,针对reducer来抽离工具函数,简直恰到好处。

// 比如对象更新,浅拷贝
export const updateObject = (oldObj, newObj) => {
    return assign({}, oldObj, newObj);
}
// 比如对象更新,深拷贝
export const deepUpdateObject = (oldObj, newObj) => {
    return deepAssign({}, oldObj, newObj);
}复制代码

工具函数抽离出来,建议放到单独的文件中保存。

如何抽离 case function 功能函数?

不要被什么case function吓到,直接给你看看代码你就清楚了,也是体力活,目的是为了让reducer的分支判断更清晰

// 抽离前,所有代码都揉到slice reducer中,不够清晰
function appreducer(state = initialState, action) {
    switch (action.type) {
        case 'ADD_TODO':
            ...
            ...
            return newState;
        case 'TOGGLE_TODO':
            ...
            ...
            return newState;
        default:
            return state;
    }
}

// 抽离后,将所有的state处理逻辑放到单独的函数中,reducer的逻辑格外清楚
function addTodo(state, action) {
    ...
    ...
    return newState;
}
function toggleTodo(state, action) {
    ...
    ...
    return newState;
}
function appreducer(state = initialState, action) {
    switch (action.type) {
        case 'ADD_TODO':
            return addTodo(state, action);
        case 'TOGGLE_TODO':
            return toggleTodo(state, action);
        default:
            return state;
    }
}复制代码

case function就是指定action的处理函数,是最小粒度的reducer。

抽离case function,可以让slice reducer的代码保持结构上的精简。

#### 如何设计slice reducer?

我们需要对state进行拆分处理,然后用对应的slice reducer去处理对应的数据,比如article相关的数据用articlesReducer去处理,paper相关的数据用papersReducer去处理。

这样可以保证数据之间解耦,并且让每个slice reducer保持代码清晰并且相对独立。

比如业务中有shopInfo和bankInfol两个类别的数据,我们拆分state并扁平化改造

export default (state = initialState, action) => {
  switch (action.type) {
    case allTypes.SHOPINFO_REQ:
      return {
        ...state,
        isloading: true,
      }
    case allTypes.SHOPINFO_SUCCESS:
      return {
        ...state,
        isloading: false,
        productId: action.payload.productId,
      }
    default:
      return state
}复制代码

注意一下这里的解构对于shopinfo来说他并不感知到state的存在对于他来说他就是shop

那么这里的select对于要渲染的组建来讲是一个道理,我们不敢知如何在组建中渲染,只是选择我们这个分片中的数据

由于我们的state进行了扁平化改造,所以我们需要在case function中进行normalizr化。

根据state的拆分,设计出对应的slice reducer,让他们对自己的数据分别管理,这样后代码更便于维护,但也引出了两个问题。

拆分多个slice reducer,但createStore只能接受一个reducer作为参数,所以我们怎么合并这些slice reducer呢?

每个slice reducer只负责管理自身的数据,对state并不知情。那么shop怎么去改变state.entities的数据呢?

这两个问题,分别引出了两部分内容,分别是:slice reducer合并、slice reducer数据共享。

如何合并多个slice reducer?

redux提供了combineReducer方法,可以用来合并多个slice reducer,返回root reducer传递给createStore使用。直接上代码,非常简单。

combineReducers({
    entities: entitiesreducer,

    // 对于shopReducer来说,他接受(state, action) => newState,
    // 其中的state,是shop,也就是state.shopinfo
    // 它并不能获取到state的数据,更不能获取到state.papers的数据
    shopinfo: shopinfoReducer,
    bankinfo: bankinfoReducer
})复制代码

传递给combineReducer的是key-value 键值对,其中键表示传递到对应reducer的数据,也就是说:slice reducer中的state并不是全局state,而是state.articles/state.papers等数据。

结语

如果解决多个slice reducer间共享数据的问题?

slice reducer本质上是为了实现专门数据专门管理,让数据管理更清晰。那么slice reducer间如何共享数据呢?

如何在一个回传数据中拿到另一个共享数据的的数据呢?透传给一个reducer吗?当然一点都不优雅。。。。预计下次再讲

最后,团队为了招聘方便,整了个公众号,主要是一些招聘信息,团队信息,所有的技术文章在公众号里也可以看到,对了,如果你想去美团其他团队,我们也可以帮你内推哦 ~

二维码

你可能感兴趣的:(json,数据结构与算法,ui)