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 衍生品就更容易上手了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章