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

前言

目前 react + redux 用的也算熟,裏面的基本概念,如 action, reducer 等也很清楚,但是看到 redux 還是會有點頭皮發麻的感覺,所以又回頭看了一篇文檔。

然而時隔2年,文檔幾乎沒怎麼變,具體體現在看文檔猶如強行喂💩,而且是喫完吐出來,再喫下去再吐出來,再喫回去如此循環。

雖然文檔寫得不怎麼樣,但是裏面確實給了很多比較好的代碼組織方式,推薦了很多很有用的工具和插件。過了一篇還是有點收穫,因此就寫篇文章總結一下。

目的

這篇文章的目的是通過一步步的代碼優化來呈現 redux 的最佳寫法。(注:這裏只使用 redux 提供的 API,不涉及任何 redux 周邊,除了必要的 redux-thunk 以及 Redux DevTools 調試工具)

文章裏的代碼需要你去理解,但是不需要你去一個一個字地敲然後運行,有個印象就好了,等真正用到的時候再去找對應文檔位置。

這裏給出文章的最終代碼 https://github.com/learn-redux/learn-redux/tree/master/src/apps/ReactReduxTodo

好了,現在開始我們的探索 redux 之旅吧~


需求 - todo app

我們就以做一個 todo list 來作爲我們的需求吧,主要涉及到 todo 的增,刪,改,查的操作。對於複雜的頁面也只是多個資源的增,刪,改,查,所以 todo app 是一個非常好的樣例。

app 參照如下

基本概念

redux 是一個全局狀態管理庫,說白了就是存放全局變量的東西,畢竟 window.xxx = 1 這種存放全局變量是要被打的。

OK,那我們再思考下一步:全局變量就需要 getter 和 setter,所以引出了存放取出兩個操作。對於前端來說,我們當然希望存了變量後可以通知到組件來更新,因此需要在存放這裏做個監聽。那取出操作好像沒我們前端什麼事,那就直接取就好了。

用一個按鈕點擊的僞代碼來表示上面的設計:

// 瀏覽器監聽按鈕的 style.left 是否改變,如果改變則更新頁面
browser.on('button -> style.left', () => updatePage())

// 定義監聽函數
function onClick(name, value) {
  switch (name) {
    case 'style.left':
      button.style.left = value + 'px'
      return button
    ...
  }
}

// 綁定監聽事件
button.on('click', (event) => onClick(event.name, event.value))

// 點擊按鈕後,觸發事件
browser.button.triggerEvent({name: 'style.left', value: 1})

然後做下面的轉化:

  • 將 button 換成 store
  • 將 triggerEvent 換成 dispatch
  • 將 event 換成 action
  • 將 event.name 換成 action.type
  • 將 event.value 換成 action.payload
  • 將 onClick 換成 reducer
  • browser.on('button -> style.left', () => updatePage()) 換成 subscribe

如果你覺得這不就是事件管理?那麼恭喜你,你已經掌握了 redux 的基本概念。store 是全局狀態數據中心,更新數據時需要 dispatch 一個 action,action 帶有 type 表示是到底是哪個 action,並帶上 payload 來更新 store。當完成 dispatch 後,進入 reducer 函數根據 action.type 判斷目前是哪個 action 在搞事,然後修改 store 的數據,修改數據後,因爲之前有做 subscirbe,因此此是會執行 subscribe 的回調函數。

第一版 - 乞丐版的 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

測試代碼,因爲篇幅問題,這裏只展示一個用例。

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

上面只是展示瞭如何創建 store,但是我們畢竟要用在 React 上,所以我們還要裝一個叫 react-redux 的庫。

$ yarn add react-redux

爲啥要裝這個庫呢?因爲 redux 默認不能在 react 組件裏直接使用 store.getState()store.dispatch 的,返回就是組件不能直接 improt store from xxx。爲了去訪問 store 裏的 state 和 dispatch,只能裝 react-redux 來讀和寫數據。

讀取

首先 store 說白了就是一對象,和我們的組件沒什麼關係,組件要訪問那肯定要和 store 建立關係,因此我們需要在最頂部的組件注入 store,這裏使用 Provider 組件。

// 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

是不是這樣就感覺清爽了很多?

第五版:表驅動優化 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 的類型。因此我們還要做類型的定義。這裏只以 addTodo 爲例子,別的都是一樣的

// 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 處理異步

現在我們要在Todo的時候顯示 loading,等添加 Todo 請求結束後再關掉 loading,爲了實現這個效果,可能會寫這種漢堡的代碼:

// TodoApp.tsx
const onAddTodo = async () => {
  dispatch(setLoading({state: false, tip: '加載中...'}))
  await fetch('/addTodo', {data: newTodo})
  dispatch(addTodo(newTodo))
  dispatch(setLoading({state: false, tip: ''}))
}

這代碼也太醜了,如果在獲取 todo list,修改 todo,刪除 todo 都寫這樣的代碼那會多難看呀。因此我們希望可以將設置 loading 代碼放在 action creator addTodo 裏,但是 actionCreator 只能返回 action,也很難拿到 dispatch,所以用 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 用於直接操作 store。

// 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 裏我們可能有這樣的結構

// 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 就會重新渲染,React 規定父組件渲染了,子組件也要重新渲染。但是我們這裏其實只改變3個todo裏的1個todo,應該只渲染那個就好了。

這時候我們就需要用到 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 全都是 JS 的知識,就算我不告訴你是什麼,你就照抄你也會使用,那些只是名字而已,所以不要將 redux 想像成洪水猛獸。

當然,上面的代碼只是展示了一小部分,你是沒辦法去運行的,所以這裏提供一個完整最終版的 todo app 代碼:https://github.com/learn-redux/learn-redux/tree/master/src/apps/ReactReduxTodo

有興趣可以看看,但是不要參照太多,因爲 redux 真正牛逼的地方不在於 redux 本身,而在於 redux-toolkit 以及周邊的一些工具。

其實你可以看到上面的最終版本雖然感覺上代碼還可以,但是還不夠智能,比如爲什麼要我自己去 normalize 數據?爲什麼要自己去寫表驅動?爲什麼要我自己去用 React.memo 和 useCallback 來做優化?爲什麼要我自己去裝 redux-thunk 和 immer?redux 你都提供了 comebineReducers 了不如再提供多一點 API 來做這些事情?

其實 redux 也是有提供上面的功能的,只是放到了 redux-toolkit 這個庫裏了,下一篇文章將會說怎麼將上面的代碼都換成 redux-toolkit 的推薦的寫法,這個過程將會很爽,那下一篇文章見~

(完)

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