怎麼寫出好看的 redux 代碼(下)

聲明:看這篇文章只需要看一看就好,不需要跟着敲,不要記,以有個印象爲主要目的。
完整代碼請看這裏

上一篇文章在不用使用太多的 API 情況下優化了十版代碼。

先來回顧一下,我們所用到除 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

以前的做法

  • Normalization: 將 todos 數據變成 {ids: [], entities: {}}
  • 做異步的時候,action creator 不返回 action 對象,而是返回一個 async 函數,裏面可以獲取 getState 和 dispatch 函數,並可以做異步操作
  • 使用表驅動來實現 reducer 的 switch-case 模式
  • 將 selector 進行封裝,但是這裏有個問題,每次別的地方更新使得組件被更新後,todos.filter(...) 都會返回一個新的數組,如果有組件依賴 filteredTodos,則那個小組件也會被更新
  • 雖然我們對 todos, filter, loading 分成了三個 slice,但是也只是 JS 層面上的分開,即只寫三個 reducer 而已
  • 每次開始這樣寫 redux 代碼,都要裝 redux-thunk, dev tools

以前的問題

以前的寫法其實如果JS基礎好的話,理解起來真的不難,因爲這種做法是非常純粹的,當然,帶來的問題就是每次都這麼寫,累不累?

因此這裏隆重介紹 redux 一直在推薦的 redux-toolkit,這是官方提供的一攬子工具,這些工具並不能帶來很多功能,只是封裝了上面的一些寫法,說白了寫得更順手了。

安裝:

$ yarn add redux-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 as TSetTodosAction

      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() 了,這就不需要每次都用 FETCH_TODOS 這種毫無意義的常量了。直接使用字符串來 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 函數完事。

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

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 用起來就和操作數據庫沒什麼區別,不足的地方是 payload 一定要按照它規定的格式,如 updateOne 的 payload 就是這樣的

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

這時候 TS 的強大威力就體現出來了,只要你去看裏面的 typing.d.ts,用這些 API 就跟切菜一樣簡單。

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 + react-redux + redux-toolkit 這個組合已經可以 cover 很多情況了。

有很多人其實還停留在 redux + redux-thunk + react-redux + redux-devtools-extension + immer + normalization + ... 這一層,所以這些人總會抱怨 redux 寫得好複雜,模板代碼好多呀。其實一個 redux-tookit 就頂完上面這些了。

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