redux 文档到底说了什么(上)

前言

最近又认真重读了一遍 redux 的文档,不出意料,还是一样的晦涩难懂。

虽然文档写得不怎么样,但是里面确实给了很多比较好的代码组织方式,推荐了很多很有用的工具和插件,也慢慢地理解为什么这么简单的一个状态中心可以搞出这么多概念和库。

redux 文档除了一些概念的介绍,主要包含了

  1. 怎么只用 redux 这个库来组织 redux 代码
  2. 怎么用 redux-toolkit 的 API 更智能地组织 redux 代码

redux 文档之所以难以看懂是因为它不按线性的思维来写,很多时候突然就冒出一个概念或者方法,而且总是将 reduxreact-reduxredux-toolkit 这三个玩意混在一起讲,搞得看的人是一脸蒙逼。

而这篇文章通过一步步的代码优化来呈现 redux 的最佳写法。(注:这里的最佳写法的范围仅限于 redux 文档,当然还有很多更好的写法这里不讨论)

这里给出文章的最终代码 https://github.com/learn-redux/learn-redux/tree/master/src/apps/ReactReduxTodo

好了,现在开始我们的探索 redux 之旅吧~


需求 - todo app

我们就以做一个 todo list 来作为我们的需求吧,主要涉及到 todo 的增,删,改,查的操作。对于复杂的页面也只是多个资源的增,删,改,查,所以 todo app 是一个非常好的样例。

app 参照如下

第一版 - 乞丐版的 todo app

乞丐版的意思是,我们只使用 redux 去本地测试里跑 todo app。先搞 reducer.tsstore.ts

// reducer.ts
const initTodos: TTodo[] = [
  {
    id: '1',
    text: '抽烟',
    state: 'done'
  },
  {
    id: '2',
    text: '喝酒',
    state: 'todo'
  },
  {
    id: '3',
    text: '烫头',
    state: 'todo'
  }
]

const initFilter: TFilter = 'all'

const initState = {
  todos: initTodos,
  filter: initFilter
}

const reducer = (state = initState , action: any) => {
  switch (action.type) {
    case 'addTodo':
      const newTodos = [...state.todos, action.payload]
      
      return {...state, todos: newTodos}
    case 'removeTodo':
      const newTodos = state.todos.filter(todo => todo.id !== action.payload)
      
      return { ...state, todos : newTodos }
    case 'toggleTodo':
      const newTodos = state.todos.map(todo =>
        todo.id === action.payload
          ? {...todo, state: todo.state === 'todo' ? 'done' : 'todo'}
          : todo
      )
      
      return { ...state, todos: newTodos }
    case 'setFilter':
      return { ...state filter: action.payload }
    case 'reset':
      return initState
    default:
      return state
  }
}

export default reducer
// store.ts
import {createStore} from "redux"
import reducer from "./reducer"

const store = createStore(reducer)

store.subscribe(() => console.log('update component'))

export default store

测试代码,因为篇幅问题,这里只展示一个用例。

// app.test.ts
it('可以添加一条 Todo', () => {
  const newTodo: TTodo = {
    id: '99',
    text: '吃好吃的',
    state: 'todo',
  }

  store.dispatch({type: 'addTodo', payload: newTodo})

  const todos = store.getState().todos
  expect(todos[todos.length - 1]).toEqual(newTodo)
})

这里测试会正常显示最后一个 todo 就是“吃好吃的”。

这里的 store 主要是 todo 列表和过滤器 filter,代码也很简单,无非就是添加 todo、删除 todo、toggle todo,reset 一些基本操作。

第二版:用 combineReducers 来做 slice

这里注意到在这个 redcuer 里其实包含了对 todos 和 filter 的操作,整个 reducer 看起来很冗长,因此我们会想将 todos 就搞 todosReducer 来管, filter 就用 filterReducer 来管,这种分开管理的子 store 被称为 "slice"

上面的 reducer 代码可以改写成:

const todosReducer = (todos: TTodo[] = initTodos, action: any) => {
  switch (action.type) {
    case 'addTodo':
      return [...todos, action.payload]
    case 'removeTodo':
      return todos.filter(todo => todo.id !== action.payload)
    case 'toggleTodo':
      return todos.map(todo =>
        todo.id === action.payload
          ? {...todo, state: todo.state === 'todo' ? 'done' : 'todo'}
          : todo
      )
    case 'reset':
      return initTodos
    default:
      return todos
  }
}

const filterReducer = (filter: TFilter = initFilter, action: any) => {
  switch (action.type) {
    case 'setFilter':
      return action.payload
    case 'reset':
      return initFilter
    default:
      return filter
  }
}

const reducer = (state = initState, action: any) => ({
  todos: todosReducer(state, action),
  filter: filterReducer(state, action)
})

redux 提供了一个 API 叫 combineReducers,上面的代理可以整理成这样:

const reducer = combineReducers({
  todos: todosReducer,
  filter: filterReducer
})

效果是一样的,只不过代码变好看了一点。

第三版:React + Redux

其实 redux 和 react 毛线关系都没有,真实让他们产生关系的是 react-redux 这个库。

$ yarn add react-redux

我初学 redux 的时候一直都不知道这俩的存在,一直以为 redux 就和 vuex 一样,是 react 的状态管理,其实 react-redux 才是。

读取

这里使用 Provider 组件全局注入 store 对象,使得所有人都可以访问 store。

// ReactReduxTodo
const ReactReduxTodo: FC = () => {
  return (
    <Provider store={store}>
      <TodoApp />
    </Provider>
  )
}

组件里读取数据可以使用 useSelector 来获取。

// TodoApp.tsx
const TodoApp: FC = () => {
  const todos = useSelector<TStore, TTodo[]>(state => {
    const todos = state.todos

    if (state.filter === 'all') {
      return todos
    }

    return todos.filter(todo => todo.state === state.filter)
  }
)
  ...
}

useSelector 的第一个参数是一个函数,返回值是想要的状态数据。这时候我们发现传入的函数很长,直接放在 useSelector 里不好看,而且如果别的组件也要获取 todos 那还要再写一遍,因此我们可以把这个函数提取出来,变成这样:

// selectors.ts
export const selectFilteredTodos = (state: TStore): TTodo[] => {
  const todos = Object.values(state.todos.entities)

  if (state.filter === 'all') {
    return todos
  }

  return todos.filter(todo => todo.state === state.filter)
}

// TodoApp.tsx
const TodoApp: FC = () => {
  const todos = useSelector<TStore, TTodo[]>(selectFilteredTodos)
  ...
}

这个提取出来的函数称为 selector,也是 hooks useSelector 名字的由来。

写数据

写数主要还是要 dispatch action,可以用 useDispatch 来获取 dispatch 函数。

const TodoApp: FC = () => {
  const dispatch = useDispatch()

  const onAddTodo = (text) => {
    dispatch({
      type: 'addTodo',
      payload: {
        id: new Date().toISOString(),
        text,
        state: 'todo'
      }
    })
    setTask('')
  }
  ...
}

我们发现这里的 'addTodo' 是硬编码,不是一个好习惯,因此我们要造一个变量来存放它,这些描述 action type 的变量一般放在 actionTypes.ts 里

// actionTypes.ts
export const ADD_TODO = 'addTodo'

// TodoApp.tsx
const TodoApp: FC = () => {
  const dispatch = useDispatch()

  const onAddTodo = (text) => {
    dispatch({
      type: ADD_TODO,
      payload: {
        id: new Date().toISOString(),
        text,
        state: 'todo'
      }
    })
    setTask('')
  }
  ...
}

而且,redux 的文档其实不是很推荐我们直接在组件里这么直接去写 action 的,应该用一个函数来生成 action,这种函数称为 action creator,代码改写成

// actionTypes.ts
export const ADD_TODO = 'addTodo'

// actionCreators.ts
export const addTodo = (text: string) => ({
  type: ADD_TODO,
  payload: {
        id: new Date().toISOString(),
        text,
        state: 'todo'
      }
    })
})

// TodoApp.tsx
const TodoApp: FC = () => {
  const dispatch = useDispatch(addTodo(text))

  const onAddTodo = (text) => {
    dispatch({
      type: ADD_TODO,
      payload: 
    setTask('')
  }
  ...
}

再来看我们的 reducer,这里要改的只是去掉硬编码就好了

// reducer.ts
const todosReducer = (todoState: TTodo = initTodos, action: any) => {
  switch (action.type) {
    case ADD_TODO:
      return [...todoState, action.payload]
    ...
  }
}

第四版:分类

目前我们不知不觉又多了 actionCreators.ts、 actionTypes.ts 和 selectors.ts 三个文件,但是这三个文件同时包含了 todos 和 filter 的 action creator、action type和 selector。

这时候我们页面要加个 loading 的 slice,每个文件里又多了 loading slice 的东西,所以最好按照上面讲到的 slice 来做个分类,因此我们可以有如下目录结构:

同时,我们还需要在 store.ts 去 comebine reducer

import {combineReducers, createStore} from "redux"
import todosReducer from "./todos/reducer"
import filterReducer from "./filter/reducer"
import loadingReducer from "./loading/reducer"

const reducer = combineReducers({
  todos: todosReducer,
  filter: filterReducer,
  loading: loadingReducer
})

const store = createStore(reducer)

export default store

是不是这样就感觉清爽了很多?每个 slice 相当于一个小 store,互不干扰。

第五版:表驱动优化 reducer

当操作变多后,会发现 action type 也变很多,reducer 的结构就变得很丑陋:

// todos/reducer.ts
const todosReducer = (todos: TTodo[] = initTodos, action: any) => {
  switch (action.type) {
    case SET_DODOS:
      return [...action.payload]
    case ADD_TODO:
      return [...todos, action.payload]
    case REMOVE_TODO:
      return todos.filter(todo => todo.id !== action.payload)
    case TOGGLE_TODO:
      return todos.map(todo =>
        todo.id === action.payload
          ? {...todo, state: todo.state === 'todo' ? 'done' : 'todo'}
          : todo
      )
    default:
      return todos
  }
}

所有的 switch-case 其实都可以用表驱动的方式来进行优化,这里也一样可以做,如:

// todos/reducer.ts
const todosReducer = (todos: TTodo[] = initTodos, action: any) => {
  const handlerMapper = {
    [SET_TODOS]: (todos, action) => {
      return [...action.payload]
    },
    [ADD_TODO]: (todos, action) => {
      return [...todos, action.payload]
    },
    [REMOVE_TODO]: (todos, action) => {
      return todos.filter(todo => todo.id !== action.payload)
    },
    [TOGGLE_TODO]: (todos, action) => {
      return todos.map(todo =>
        todo.id === action.payload
          ? {...todo, state: todo.state === 'todo' ? 'done' : 'todo'}
          : todo
      )
    }
  }
  
  const handler = handlerMapper[action.type]
  
  return handler ? handler(todos, action) : todos
}

上面就是使用表驱动的方式。但是,如果你在 TypeScript 里这么写是一定会报错的,主要是你没有定义好 handlerMapper 的类型,也没有定义 action 的类型。因此我们还要做类型的定义。

// todos/actionTypes.ts
export const ADD_TODO = 'addTodo'
export type ADD_TODO = typeof ADD_TODO
...

// todos/actionCreators.ts
export type TAddTodoAction = {
  type: ADD_TODO;
  payload: TTodo;
}
...

export type TTodoAction = TAddTodoAction | TToggleTodoAction...

// todos/reducer.ts
type THandler = (todoState: TTodoStore, action: TTodoAction) => TTodoStore
type THandlerMapper = {[key: string]: THandler}

const todosReducer = (todos: TTodo[] = initTodos, action: any) => {
  const handlerMapper: THandlerMapper = {
    ...
  }
  
  const handler = handlerMapper[action.type]
  
  return handler ? handler(todos, action) : todos
}

第六版:使用 immer 来优化 reducer

现在把目光放在 todosReducer 上,我们发现每次返回 state 都要用扩展运算符来返回 immutable 数组,如果 state 是对象,那就不可避免地要用到

return {
  ...prevState
  ...newState
}
// 或者
return Object.assign({}, prevState, newState)

如果 state 是数组,会这么写

return [...prevState, newItem]

一个还好,如果每个 handler 都要这么写就很恶心。redux 官方其实是推荐使用 immer 这个库来做 immutable 的。安装如下:

$ yarn add immer

这个库可以使得不再需要扩展运算符来造新对象、新数组,而是可以直接使用 mutable 的写法来构造新对象、新数组。如上面的 reducer 就可以改写成

import produce from 'immer'

// todos/reducer.ts
const todosReducer = (todos: TTodo[] = initTodos, action: any) => {
  const handlerMapper = {
    [SET_TODOS]: (todos, action) => {
      return [...action.payload]
    },
    [ADD_TODO]: (todos, action) => {
      return produce(todos, draftTodos => {
        draftTodos.push(action.payload)
      )}
    },
    [REMOVE_TODO]: (todos, action) => {
      return todos.filter(todo => todo.id !== action.payload)
    },
    [TOGGLE_TODO]: (todos, action) => {
      return produce(todos, draftTodos => {
        const draftTodo = draftTodos.find(t => t.id === action.payload)

        draftTodo.state = draftTodo.state === 'todo' ? 'done' : 'todo'
      })
    }
  }
  
  const handler = handlerMapper[action.type]
  
  return handler ? handler(todos, action) : todos
}

使用了 immer 之后,数组的 push 和直接赋值写法都可以直接用了,代码就感觉更好看一些。

第七版:Normalize 数据来优化 todosStore

从上面的 reducer 改造我们发现 TOGGLE_TODO 一个问题,因为传进来的参数必定是一个 id,所以每次 toggle 都要 draftTodos.find() 一下,然后再去改值。虽然这里数据不多,但是这不是一个特别好的习惯,最好可以用 O(1) 的时候直接获取 draftTodo。

O(1) 获取数据第一反应肯定 hash table,没错,我们可以将 Todo[] 数组变成:

todosStore = {
  ids: ['1', '2', ...]
  entities: {
    1: {
      id: '1',
      text: '抽烟',
      state: 'done'
    },
    ...
  }
}

将数组变成 {ids: ..., entities: ...} 的过程就叫做 Normalization。要做这种改动其实花费力气不小,因为 reducer.ts 的所有逻辑都要改,类型也要改。啊啊啊啊,好烦。改完后会变成这样:

// todos/reducer.ts
const todosReducer = (todoState: TTodoStore = initTodos, action: any) => {
  const handlerMapper: THandlerMapper = {
    [SET_TODOS]: (todoState, action) => {
      const {payload: todos} = action as TSetTodosAction

      const entities = produce<TTodoEntities>({}, draft => {
        todos.forEach(t => {
          draft[t.id] = t
        })
      })

      return {
        ids: todos.map(t => t.id),
        entities
      }
    },
    [UPDATE_TODO]: (todoState, action) => {
      return produce(todoState, draft => {
        const {payload: {id, text}} = action as TUpdateTodoAction

        draft.entities[id].text = text
      })
    },
    [TOGGLE_TODO]: (todoState, action) => {
      return produce(todoState, draft => {
        const {payload: id} = action as TToggleTodoAction

        const todo = draft.entities[id]

        todo.state = todo.state === 'todo' ? 'done' : 'todo'
      })
    },
    ...
  }

  const handler = handlerMapper[action.type]

  return handler ? handler(todoState, action) : todoState
}

其实改完之后就会变得很爽了,直接获取真香。

第八版:使用 thunk 处理异步

上面说的都是数据层面上的操作,一直没有说异步处理。redux 不推荐在 reducer 里写发请求的代码。这些代码应该都放在 action creator 里的。但是 action creator 除了返回 action 对象啥也干不了,所以就需要 redux-thunk 这个库了。

redux-thubk 是一个中间件,使用也很简单

// store.ts
import {applyMiddleware, createStore} from "redux"
import ReduxThunk from 'redux-thunk'

...

const store = createStore(reducer, applyMiddleware(ReduxThunk))

然后就可以快乐使用了,这里的使用只需要将 action creator 返回一个函数即可,返回的函数包含异步逻辑,参数为 dispatch 和 getState。

// todos/actionCreators.ts -> 异步代码的 action creator 返回函数
export const addTodo = (newTodo: TTodo) => async (dispatch: Dispatch) => {
  dispatch(setLoading({status: true, tip: '添加中...'}))

  const response: TTodo = await fetch('/addTodo', {data: newTodo})

  dispatch({ type: ADD_TODO, payload: response })

  dispatch(setLoading({status: false, tip: ''}))
}

// loading/actionCreators.ts -> 普通 action creator 返回 action 对象
export const setLoading = (loading: TLoading) => ({
  type: 'setLoading',
  payload: loading
})

// TodoApp.tsx
const onAddTodo = () => {
  dispatch(addTodo(newTodo))
}

第九版:使用 React.memo + useCallback 来提高性能

在 TodoApp 里我们可能需要展示 TodoList,可能会这么写

// TodoApp.tsx
const TodoApp: FC = () => {
  const dispatch = useDispatch()

  ...

  const onToggleTodo = (id: string) => {
    dispatch(toggleTodo(id))
  }

  return (
    <div className="app">
     <List>
     { todos.map(todo => <TodoItem todo={todo} onToggle={onToggleTodo} />) }
     <List>
    </div>
  )
}

// TodoItem.tsx
const TodoItem: FC<IProps> = (props) => {
  const {todo, onToggle} = props
  console.log('fuck')
  return (
    <li>
      {todo.text}
      <button onClick={() => onToggle(todo.id)}>Toggle</button>
    </li>
  )
}

假如现在有 3 个 todo,然后 toggle 其中一个 todo 后会发现会打出 3 个 'fuck'。这是因为在 TodoApp 里用了 useSelector,而我们的 selectFilteredTodos selector 每次都返回一个新的数组,TodoApp 就会重新渲染,父组件渲染了,子组件也要重新渲染,所以就渲染了 3 次,这明显不应该这么做。

为了只渲染那个改变了的子组件,别的不动,我们需要用到 React.memo,代码如下:

// TodoItem.tsx
const TodoItem: FC<IProps> = (props) => {
  const {todo, onToggle} = props
  console.log('fuck')
  return (
    <li>
      {todo.text}
      <button onClick={() => onToggle(todo.id)}>Toggle</button>
    </li>
  )
}

export default React.memo(TodoItem)

React.memo 传入组件,如果组件的 props 没变,那就不需要重新渲染,我们知道 todo 这个对象如果修改了状态是换成一个新的 todo 对象的,否则还是使用原来的 todo 对象,因此不应该触发渲染了。

但是我们往往容易忽略了 onToggle,这个函数的引用每次都会改变的,因此这里我们要使用 useCallback 来缓存函数的引用:

const onToggleTodo = useCallback((id: string) => {
  dispatch(toggleTodo(id))
}, [dispatch])

这里我们对 dispatch 做监听,因为 dispatch 一般是不会改的,因此可以对 onToggleTodo 函数进行缓存。

再次 toggle todo 后,我们发现只有一个 'fuck' 出现。

第十版:添加 dev tools

redux dev tools 是一个 Chrome 插件,可以方便地帮助我们追踪每次 store 的变化。

Chrome 插件商店安装地址

Github 地址

安装插件后,只需要在 store.ts 里配置一下就好:

import {applyMiddleware, combineReducers, createStore} from "redux"
import {composeWithDevTools} from 'redux-devtools-extension'

...

const enhancer = process.env.NODE_ENV === 'development' ? composeWithDevTools(
  applyMiddleware(ReduxThunk)
) :applyMiddleware(ReduxThunk)

const store = createStore(reducer, enhancer)

export default store

重新刷新页面在开发者工具里选中redux就可以看到 store 的情况了:

总结

可以看到,redux 其实是一个很简单的概念,就是怎么去管理好全局变量(状态)。

从上面的例子也可以看到,redux 的 API 就只用了

  • createStore
  • combineReducers
  • applyMiddleware

react-redux 的 API 只用了

  • Provide 组件
  • useSelector
  • useDispatch

那些什么 reducer, action creator, action type, selector 等,概念虽然多,但是都不是 API 层面的,只是换了个说法。

其实可以发现上面的最终版本感觉还可以,但是还不够智能,比如为什么要我自己去 normalize 数据?为什么要自己去写表驱动?为什么要我自己去用 React.memo 和 useCallback 来做优化?为什么要我自己去装 redux-thunk 和 immer?redux 你都提供了 comebineReducers 了不如再提供多一点 API 来做这些事情?

很多人觉得 redux 很让人头疼的点很多是因为:用 redux 管理状态要写的代码实在是太多了,像上面的selecor + actionCreator + actionType + reducer + slice 写出来能累死个人。因此,为了更简便去写这些“模板代码”诞生了很多 redux 的库,redux 官方也推出了 redux-toolkit 这个库来方便开发者组织代码。

下一篇文章将会说怎么将上面的代码都换成 redux-toolkit 的推荐的写法,这个过程将会很爽,那下一篇文章见~

(完)