美文网首页饥人谷技术博客
怎么写出好看的 redux 代码(上)

怎么写出好看的 redux 代码(上)

作者: 写代码的海怪 | 来源:发表于2020-08-30 18:22 被阅读0次

    前言

    目前 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 的推荐的写法,这个过程将会很爽,那下一篇文章见~

    (完)

    相关文章

      网友评论

        本文标题:怎么写出好看的 redux 代码(上)

        本文链接:https://www.haomeiwen.com/subject/oasssktx.html