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

完整代码请看这里

上一篇文章主要介绍了 redux 文档里所用到的基本优化方案,但是很多都是手工实现的,不够自动化。这篇文章主要讲的是怎么用 redux-toolkit 组织 redux 代码。

先来回顾一下,我们所用到除 JS 之外的有:

  • react-redux
    • Provider 组件
    • useSelector
    • useDispatch'
  • redux
    • createStore
    • combineReducers
    • applyMiddleware
  • redux-thunk

最终得到的代码大概如下(因为篇幅有限,就只显示其中一部分,详细代码可以看这里

todos/store.ts

// todos/store.ts
import ...
const reducer = combineReducers({
  todos: todosReducer,
  filter: filterReducer,
  loading: loadingReducer
})

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

const store = createStore(reducer, enhancer)

export default store

todos/reducer.ts

// todos/reducer.ts
import ...

type THandlerMapper = {[key: string]: (todoState: TTodoStore, action: TTodoAction) => TTodoStore}

const initTodos: TTodoStore = {
  ids: [],
  entities: {}
}

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
      }
    },
  ...
  }

  const handler = handlerMapper[action.type]

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

export default todosReducer

todos/selectors.ts

// todos/selectors
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)
}
export const selectTodoNeeded = (state: TStore): number => {
  return Object.values(state.todos.entities).filter(todo => todo.state === 'todo').length
}

todos/actionCreators.ts

// todos/actionCreators.ts
export const fetchTodos = () => async (dispatch: Dispatch) => {
  dispatch(setLoading({status: true, tip: '加载中...'}))

  const response: TTodo = await fetch('/fetchTodos', () => dbTodos)

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

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

todos/actionTypes.ts

// todos/actionTypes.ts
export const SET_TODOS = 'setTodos'
export type SET_TODOS = typeof SET_TODOS

以前的做法

  • 手动配置常用中间件和 Chrome 的 dev tool
  • 手动将 slice 分类,并暴露 reducer
  • 手动 Normalization: 将 todos 数据结构变成 {ids: [], entities: {}} 结构
  • 使用 redux-thunk 来做异步,手动返回函数
  • 手动使用表驱动来替换 reducer 的 switch-case 模式
  • 手动将 selector 进行封装成函数
  • 手动引入 immer,并使用 mutable 写法

以前的写法理解起来真的不难,因为这种做法是非常纯粹的,基本就是 JavaScript 。不过,带来的问题就是每次都这么写,累不累?

因此这里隆重介绍 redux 一直在推荐的 redux-toolkit,这是官方提供的一揽子工具,这些工具并不能带来很多功能,只是将上面的手动档都变成自动档了。

安装:

$ yarn add @reduxjs/toolkit

configureStore

最重要的 API 就是 configureStore 了:

// store.ts
const reducer = combineReducers({
  todos: todosSlice.reducer,
  filter: filterSlice.reducer,
  loading: loadingSlice.reducer
})

const store = configureStore({
  reducer,
  devTools: true
})

可以和之前的 createStore 对比一下,configureStore 带来的好处是直接内置了 redux-thunk 和 redux-devtools-extension,这个 devtools 只要将 devTools: true 就可以直接使用。两个字:简洁。

createSlice

上面的代码我们看到是用 combineReducers 来组装大 reducer 的,前文也说过 todos, filter, loading 其实都是各自的 slice,redux-toolkit 提供了 createSlice 来更方便创建 reducer:

// todos/slice.ts
const todosSlice = createSlice({
  name: 'todos',
  initialState: initTodos,
  reducers: {
    [SET_TODOS]: (todoState, action) => {
      const {payload: todos} = action

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

      return {
        ids: todos.map(t => t.id),
        entities
      }
    }
    ...
  }
})

这里其实会发现 reducers 字段里面就是我们所用的表驱动呀。name 就相当于 namespace 了。

异步

之前我们用 redux-thunk 都是 action creator 返回函数的方式来写代码,redux-toolkit 提供一个 createAsyncThunk 直接可以创建 thunk(其实就是返回函数的 action creator,MD,不知道起这么多名字干啥),直接看代码

// todos/actionCreators.ts
import loadingSlice from '../loading/slice'

const {setLoading} = loadingSlice.actions

export const fetchTodos = createAsyncThunk<TTodo[]>(
  'todos/' + FETCH_TODOS,
  async (_, {dispatch}) => {
    dispatch(setLoading({status: true, tip: '加载中...'}))

    const response: TTodo[] = await fetch('/fetchTodos', () => dbTodos)

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

    return response
  }
)

可以发现使用 createSlice 的另一个好处就是可以直接获取 action,不再需要每次都引入常量,不得不说,使用字符串来 dispatch 真的太 low 了。

这其实还没完,我们再来看 todos/slice.ts 又变成什么样子:

// todos/slice.ts
const todosSlice = createSlice({
  name: 'todos',
  initialState: initTodos,
  reducers: {},
  extraReducers: {
    [fetchTodos.fulfilled.toString()]: (state, action) => {
      const {payload: todos} = action as TSetTodosAction

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

      state.ids = todos.map(t => t.id)
      state.entities = entities
    }
  }
})

这里我们发现,key 变成了 fetchTodos.fulfilled.toString() 了,这就不需要每次都要创建一堆常量。直接使用字符串来 dispatch 是非常容易出错的,而且对 TS 非常不友好。

注意:createSlice 里的 reducer 里可以直接写 mutable 语法,这里其实是内置了 immer。

我们再来看组件是怎么 dispatch 的:

// TodosApp.tsx
import {fetchTodos} from './store/todos/actionCreators'

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

  useEffect(() => {
    dispatch(fetchTodos())
  }, [dispatch])

  ...
}

其实还是和以前一样,直接 dispatch(actionCreator()) 函数完事。

builder

其实到这里我们对 [fetchTodos.fulfilled.toString()] 的写法还是不满意,为啥要搞个 toString() 出来?真丑。这里主要因为不 toString() 会报 TS 类型错误,官方的推荐写法是这样的:

// todos/slice.ts
const todosSlice = createSlice({
  name: 'todos',
  initialState: initTodos,
  reducers: {},
  extraReducers: builder => {
    builder.addCase(fetchTodos.fulfilled, (state, action) => {
      const {payload: todos} = action as TSetTodosAction

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

      state.ids = todos.map(t => t.id)
      state.entities = entities
    })

    builder.addCase...
})

使用 builder.addCase 来添加 extraReducer 的 case,这种做法仅仅是为了 TS 服务的,所以你喜欢之前的 toString 写法也是没问题的。

Normalization

之前我们使用的 Normalization 是需要我们自己去造 {ids: [], entities: {}} 的格式的,无论增,删,改,查,最终还是要变成这样的格式,这样的手工代码写得不好看,而且容易把自己累死,所以 redux-toolkit 提供了一个 createEntitiyAdapter 的函数来封装这个 Normalization 的思路。

// todos/slice.ts
const todosAdapter = createEntityAdapter<TTodo>({
  selectId: todo => todo.id,
  sortComparer: (aTodo, bTodo) => aTodo.id.localeCompare(bTodo.id), // 对 ids 数组排序
})

const todosSlice = createSlice({
  name: 'todos',
  initialState: todosAdapter.getInitialState(),
  reducers: {},
  extraReducers: builder => {
    builder.addCase(fetchTodos.fulfilled, (state, action: TSetTodosAction) => {
      todosAdapter.setAll(state, action.payload);
    })

    ...
     
    builder.addCase(toggleTodo.fulfilled, (state, action: TToggleTodoAction) => {
      const {payload: id} = action as TToggleTodoAction

      const todo = state.entities[id]

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

创建出来的 todosAdapter 就厉害了,它除了上面的 setAll 还有 updateOne, upsertOne, removeOne 等等的方法,这些 API 用起来就和用 Sequlize 这个库来操作数据库没什么区别,不足的地方是 payload 一定要按照它规定的格式,如 updateOne 的 payload 类型就得这样的

export declare type Update<T> = {
    id: EntityId;
    changes: Partial<T>;
};

这时 TS 的强大威力就体现出来了,只要你去看里面的 typing.d.ts,使用这些 API 就跟切菜一样简单,还要这个🐍皮 redux 文档有个🐔儿用。

createSelector

我们之前虽然封装好了 selector,但是只要别的地方更新使得组件被更新后,useSelector 就会被执行,而 todos.filter(...) 都会返回一个新的数组,如果有组件依赖 filteredTodos,则那个小组件也会被更新。

说白了,todos.filter(...) 这个 selector 其实就是依赖了 todos 和 filter 嘛,那能不能实现 useCallback 那样,只要 todos 和 filter 不变,那就不需要 todos.filter(..) 了,用回以前的数组,这个过程就是 Memorization

市面上也有这种库来做 Memorization,叫 Reselect。不过 redux-toolkit 提供了一个 createSelector,那还用个屁的 Reselect。

// todos/selectors.ts
export const selectFilteredTodos = createSelector<TStore, TTodo[], TFilter, TTodo[]>(
  selectTodos,
  selectFilter,
  (todos: TTodo[], filter: TFilter) => {
    if (filter === 'all') {
      return todos
    }

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

上面的 createSelector 第一个参数是获取 selectTodos 的 selector,selectFilter 返回 filter,然后第三个参数是函数,头两个参数就是所依赖的 todos 和 filter。这就完成了 memorization 了。

createReducer + createAction

其实 redux-toolkit 里面有挺多好的东西的,上面所说的 API 大概覆盖了 80% 了,剩下的还有 createReducer 和 createAction 没有说。没有说的原因是 createReducer + createAction 约等于 createSlice。

这里一定要注意:createAction 和 createReducer 是并列的,createSlice 类似于前两个的结合,createSlice 更强大一些。网上有些声音是讨论该用 createAction + createReducer 还是直接上 createSlice 的。如果分不清哪个好,那就用 createSlice

总结

到这里会发现真正我们用到的东西就是 redux + react-redux + redux-toolkit 就可以写一个最佳实践出来了。

市面上还有很多诸如 redux-action, redux-promise, reduce-reducers等等的 redux 衍生品(redux 都快变一个 IP 了)。这些东西要不就是更好规范 redux 代码,要不就是在dispatch(action) -> UI 更新 这个流程再多加流程,它们的最终目的都是为了更自动化地管理状态/数据,相信理解了这个思路再看那些 redux 衍生品就更容易上手了。