理解Redux的实现原理

928 阅读7分钟

为什么要用Redux

React只是一个视图层的框架,负责把数据映射成DOM元素。但应用程序往往涉及到大量的数据交互和网络请求,修改数据的频率会很高,所以需要一种规范来约束对数据的更新,使得任何修改都可以被追踪,这样才不惧应用程序的复杂性,实现良好的调试性能和可扩展能力。Redux是一个数据流和数据容器管理工具。这篇文章中有一组生动的对比图看有无Redux时数据的处理方式。

通过TodoList的例子推导Redux

下面我们看一个简单的 todo-list 的例子。

//TodoApp.js
const [todos, setTodos] = useState([])
const addTodo = () => {}
const removeTodo = () => {}
const toggleTodo = () => {}

<AddTodo addTodo={addTodo} />
<TodoList removeTodo={removeTodo} toggleTodo={toggleTodo} todos={todos} />
//TodoList.js
{
    todos.map(todo => (
        <TodoItem removeTodo={removeTodo} toggleTodo={toggleTodo} />
    ))
}

通过上图我们知道,addTodo,removeTodo,toggleTodo这些功能函数都是通过setTodos对todos这个数据进行操作,对数据todos来说这些操作是不透明的。为了更透明的对todos进行操作,我们可以用这样的一种结构来描述每次对todos的操作

{
    type: 'add',
    payload: todo
}

我们称它为Action,每次对todos进行操作时都发出这样一个Action,就可以很清楚的看到在对todos进行了什么操作,这次操作携带的数据是什么。直接把这样一个Action对象丢给todos,todos是不知道该怎么办的,所以todos需要一个管家(dispatch)帮它处理然后把处理结果告诉它。下面让我们用代码来实现一下dispatch。

const dispatch = (action) => {
    const { type, payload } = action
    swicth(type) {
        case 'set':
            //set的逻辑
            break;
        case 'add':
            //add的逻辑
            break;
        case 'remove':
            //remove的逻辑
            break;
        case 'toggle':
            //toggle的逻辑
            break;
    }   
}

有了dispatch这个管家,现在处理addTodo的业务逻辑就很简单了,只需要

dispatch({
    type: 'add',
    payload: todo
})

因为每一次操作都是一个Action,而每一个Action都只有两个参数(type, payload),当操作频繁时每次都写上面的代码会很麻烦,所以我们考虑构建一个创造Action的函数actionCreator,这样我们就不用每次都手动生成Action了。因为有很多个Action,对应就会有很多个actionCreator,所以我们考虑把所有的actionCreator放在一个单独的文件actionCreators.js里

//actionCreators.js
export const add = payload => ({
    type: 'add',
    payload
})

export const remove = payload => ({
    type: 'remove',
    payload
})

export const toggle = payload => ({
    type: 'toggle',
    payload
})

然后把actionCreators.js引入到TodoList.js中,现在我们处理addTodo就只需要

//TodoList.js
import * as actionCreators from './actionCreators';
dispatch(actionCreators.add(payload))

仔细看看每次操作都需要dispatch来派发Action,我们可以考虑再封装一次,把dispatch也隐藏起来。

const addTodo = payload => dispatch(actionCreators.add(payload))

这样我们每次处理addTodo就只需要调用addTodo函数即可。这样的封装操作有很多个,我们可以批量实现一下,我们希望得到下面这样的结果

{
    addTodo: payload => dispatch(actionCreators.add(payload)),
    removeTodo: payload => dispatch(actionCreators.remove(payload)),
    toggleTodo: payload => dispatch(actionCreators.toggle(payload))
}

于是我们编写一个bindActionCreators函数来批量封装得到我们想要的结果

function bindActionCreators(actionCreators, dispatch) {
  const ret = {}

  for(let key in actionCreators) {
    ret[key] = function(...args) {
      const actionCreator = actionCreators[key]
      const action = actionCreator(...args)
      dispatch(action)
    }
  }

  return ret
}

现在我们可以这样实现一个addTodo的操作

const {
    add: addTodo, 
    remove: removeTodo,
    toggle: toggleTodo
} = bindActionCreators(actionCreators, dispatch)

addTodo(payload)

因为TodoList的逻辑很简单,所以我们这样改造完没有看到很明显的优势,所以让我们改造一下项目,让项目变得稍微复杂一点,我们新添加一个incrementCount变量,每次新添加一个todo,incrementCount就会加一。

const [todos, setTodos] = useState([])
const [incrementCount, setIncrementCount] = useState(0)

const dispatch = (action) => {
    const { type, payload } = action
    swicth(type) {
        case 'set':
            //set的逻辑
            setIncrementCount(c => c + 1)
            break;
        case 'add':
            //add的逻辑
            setIncrementCount(c => c + 1)
            break;
        case 'remove':
            //remove的逻辑
            break;
        case 'toggle':
            //toggle的逻辑
            break;
    }   
}

现在我们看代码可以发现多个Action有同样的逻辑,需要重复编码实现,这是因为我们是从Action的维度来执行的数据更新逻辑,但是这些Action操作都是为了更新数据,为了更加清晰,我们可以考虑从数据的维度来整理数据的更新逻辑,我们希望有这样的一个reducer,它接收state和action然后返回更新后的state数据,每一个数据有自己单独的reducer,然后返回合并后的多个reducer,现在让我们用代码来实现一下。

//reducers.js
const reducers = {
    todos(state, action) {
    const { type, payload } = action
    switch(type) {
        case 'set':
            return //set的逻辑
        case 'add':
            return //add的逻辑
        case 'remove':
            return //remove的逻辑
        case 'toggle':
            return //stoggle的逻辑
        }
        return state      
    },
  incrementCount(state, action) {
    const { type } = action
    switch(type) {
      case 'set':
        return state + 1
      case 'add':
        return state + 1
    }
    return state
  }
}

function combineReducers(reducers) {
  return function reducer(state, action) {
    const changed = {}

    for(let key in reducers) {
      changed[key] = reducers[key](state[key], action)
    }

    return {
      ...state,
      ...changed
    }
  }
}

export default combineReducers(reducers)

现在在TodoList.js中引入reducers.js,然后改写dispatch函数

const dispatch = (action) => {
    const state = {
        todo,
        incrementCount
    }

    const setters = {
        todos: setTodos,
        incrementCount: setIncrementCount
    }
    
    const newState = reducer(state, action)
    
    for(let key in newState) {
        setters[key](newState[key])
    }
}

reducer的意义在于能够从数据字段的维度来处理action。

上面说了这么多我们都是在处理同步的Action,现在让我们思考一下如何处理异步的Action。最直接的想法就是我们先处理异步的逻辑,异步结束后再派发一次Action,下面让我们用代码来实现一下

//异步的Action
export const add = text => (dispatch, state) => {
    setTimeout(() => {
      const { todos } = state
      
      if(!todos.find(todo => todo.text === text)) {
        dispatch({
            type: 'add',
            payload: {
                id: Date.now(),
                text,
                complete: false
            }
        })
      }
    }, 3000)
}

现在我们的dispatch只能处理对象,不能处理异步Action的函数,所以让我们改写一下dispatch让它可以支持对函数的处理

const dispatch = (action) => {
    const state = {
        todo,
        incrementCount
    }

    const setters = {
        todos: setTodos,
        incrementCount: setIncrementCount
    }
    
    if('function' === typeof action) {
        action(dispatch, state)
        return
    }
    
    const newState = reducer(state, action)
    
    for(let key in newState) {
        setters[key](newState[key])
    }
}

这样我们就实现了一个异步的Action,我们希望增加todo时先判断原有的todo列表中是否包含新添加todo的内容,如果是不再添加,如果不是再添加。这时我们进行调试如果在3s之前删掉了重复的Action,我们会发现3s后这个重复的Action还是被添加到了todo的列表中。这是因为add函数拿到的数据是3s前的数据,为了避免这种情况的出现,我们会考虑用函数动态的获取state里面的数据,例如

addTodo(dispatch, () => state)

但是state这个对象总是在异步action发起之前临时构成的,如果在3s内做了一些操作,那么数据其实已经发生改变,异步Action内获取到的还是旧的数据。在每次渲染周期state都会改变,所以我们可以在组件之外创建一个store来存储所有的state

let store = {
    todo: [],
    incrementCount: 0
}
//TodoList组件内同步数据
useEffect(() => {
    Object.assign(store, {
        todos,
        incrementCount
    })
}, [todos, incrementCount])

现在让我们改写dispatch

const dispatch = (action) => {
    const setters = {
        todos: setTodos,
        incrementCount: setIncrementCount
    }
    
    if('function' === typeof action) {
        action(dispatch, () => store)
        return
    }
    
    const newState = reducer(store, action)
    
    for(let key in newState) {
        setters[key](newState[key])
    }
}

改写异步Action

//异步的Action
export const add = text => (dispatch, getState) => {
    setTimeout(() => {
      const { todos } = getState()
      
      if(!todos.find(todo => todo.text === text)) {
        dispatch({
            type: 'add',
            payload: {
                id: Date.now(),
                text,
                complete: false
            }
        })
      }
    }, 3000)
}

总结

我们可以用actionCreators来生成一次操作的Action,用dispatch来派发这个Action,用reducer来更新数据,用bindActionCreators封装多个Action的派发操作,用combineReducers将多个reducer合并成一个。

实际上Redux也只有最基本的功能,它本身不具备对异步Action的处理,但是在Reudx的整个流程中,在Action被dispatch派发到达reducer之前可以经过多个中间件的处理,这些中间件可以增强dispatch的功能,比如Redux-thunk中间件就可以让dispatch具备处理异步Action的能力。如果想要对 Redux Store 进行更深层次的增强定制,就需要使用 Store Enhancer,利用 Store Enhancer 可以增强 Redux Store 的 各个 方面。

Action -> dispatch -> 各种中间件 -> reducer -> store

后记

我写文章比较少,所以逻辑可能不是很清晰,如果有问题欢迎大家在评论区中提出,我们一起学习讨论。本文是学习React劲爆新特性Hooks 重构去哪儿网火车票PWA这门课后,将老师讲的内容加上一点点自己的理解写成的。顺便安利一下这门课,老师讲的超级棒!!!再推荐一本书,程墨的《深入浅出React和Redux》,里面对Redux的原理也讲解的十分清晰。