从消息队列的角度来理解 Redux

在 React 中,组件之间是不能互相通信的,数据只能自上而下流动。所以必须把状态放在最高层的组件中。复杂一点的页面肯定会导致状态越提越高,所以最终还是需要一个单独的状态存储——redux。

Redux 的文档是非常差的。它只写了 What 和 How, 而没有写 Why. 他只说了自己是个状态存储,却不说为什么需要其他的东西,这就让初学者感到非常地迷惑,实际上它只要提到一下 “Event Bus” 这个词就非常容易理解了,非要上来就讲什么 Action/Store/Reducer/SingleSourceOfTruth 之类的。根据最高指示:代码是用来读的,不是用来装逼的,俗称 “码读不装”, 那么我们这里先来补上 redux 的 Why.

Why Redux?

Redux 本质上来说有很大一部分功能就是一个 Event Bus, 或者 Message Queue, 又或者 Pub/Sub. React 组件之间需要通信,如果让他们之间互相访问,那么就是一个 O(N^2) 的复杂度,而如果让他们通过一个中间组件,也就是 Redux 通信,那么复杂度就大大降低了,更重要的是,代码更清晰了。当然,Redux 不只是负责他们之间的通信,而且是把状态存储了下来。这两篇 文章 写得非常好了。

What is Redux?

Redux 里的概念特别多,不过知道了 EventBus, 就都非常好理解了,这里先列出来:

  • store, 存放状态的唯一容器,类似于消息队列的 broker.
  • reducer, 就是用来处理消息的函数,其实一般叫做 handler, 起个 reducer 的名字好像就函数式了,瞬间高大上加逼格高了起来。这个函数签名是 function(state, action) -> new_state.
  • action 和 dispatch, Action 其实就是消息总线里面的消息或者说事件,分发 Action, 其实不就是生产者么?
  • actionCreator, actionCreator 就是用来创建 Action 的。Action 不过就是一个 {type, value} 的字典罢了。function xxxCreator() -> action 就叫做一个 actionCreator
  • Middleware, 其实就是字面意思,会处理你的状态的一些中间件。
import {createStore} from "redux";

function reducer(state = {}, action) {
  switch (action.type) {
    case "XXX":
      return {
        ...state,
        XXX: action.value
      };
    case "YYY":
      return {

      }
    default:        
      return state;
  }
}

let store = createStore(reducer);

store.dispatch({type: "XXX", value: "XXX"})

console.log(store.getState())

以上就是一个非常基本的 redux 应用啦,如果要有多个 reducer 分开处理不同事件怎么办呢?使用 combineReducers. 需要注意的是,这样的话,每个 reducer 只会获得其中一部分数据了,比如说 userReducer 只会获得 user 部分,itemsReducer 只会处理 items 部分。

import {createStore, combineReducers} from "redux";

let reducers = combineReducers({
  user: userReducer, items: itemsReducer
})

let store = createStore(reducers)

React-Redux

在上面的例子中,实际上完全没有涉及到 react 的相关内容,redux 本身就是可以独立存在的一个库。但是,99.99% 的情况下,你还是为了管理 react 的状态采用 redux 的。要在 react 中使用 redux , 需要使用 react-redux 库。

这里需要注意的是,都 2020 年了,我们自然要用 hooks 了,所以网上好多还在讲 connect 的教程可以不用看了。几个常用的钩子:

  • useSelector, 顾名思义,用来从 store 中选取需要的状态,相当于消费者 consume
  • useDispatch, 用来 dispatch action, 相当于生产者 produce
  • useStore, 虽然有这个 hook, 但是文档里明确说了不推荐用
// yarn add react-redux
import {useSelector, useDispatch} from "react-redux";

// 这里需要注意的是,state 是整个状态树。
const result = useSelector((state) => selectedState)
// const result = useSelector((state) => state.counter)

const dispatch = useDispatch()
// 这里需要用一个匿名函数
<button onClick={() => dispatch({ type: 'increment-counter' })}>
  Increment counter
</button>

不使用 hooks 的方式

在没有 hooks 之前,需要使用 react-redux 库提供的 connect/mapState/mapDispatch 几个函数来实现 redux 和 react 组件之间的交互。

  • useSelector 对应以前的 mapState 函数
  • useDispatch 对应以前的 mapDispatch 函数

设计 redux state tree

我们知道 redux 的 state 是作为一个树存在的,设计这个树的形态是 redux 使用的重中之重了。

第一个问题,是否需要把所有的状态放进 redux 呢?不一定,对于一些组件内部的 UI 状态,比如是否隐藏某个按钮,是可以使用 useState 放在内部的。重点在于:你这个状态其他组件关心吗?一般来说,表单的状态时不需要放进 redux 的。

第二个问题,对于多个页面,比如说:一个列表页,一个详情页,一个用户页,要把所有的状态放在一个 store 中么?我倾向于把同一个组的页面的数据放在同一个 State 里面,这里的组大概就相当于 Django 的 App 或者是 Flask 的 Blueprint 的概念。当然,对于小型项目来说,用一个 store 就够了。

总的来说,分以下几步:

  1. 确定全局状态,比如用户登录状态,放在 redux 最顶级。
  2. 设计每个页面分区可视化树。根据页面布局,确定组件,以及每个组件的状态。
  3. 设计 reducers, 同样是一棵树状的组织。
  4. 实现 actions, 其中也包括加载数据的 action.
  5. 实现展示层,也就是组件们。

页面的布局图和状态树:


使用 normalized data

当我们使用 useSelector 的时候,需要从 state 出发,一层层地获取数据,所以数据的层级最好不要太多,避免出现 state.posts[0].comments[0] 这种冗长的表达式。另一方面,设计不好的状态树会导致好多状态重复存储,或者不好查询。为了解决这个问题,我们可以使用 Redux 官方推荐的解决方案。

不好的方案:

const blogPosts = [
  {
    id: 'post1',
    author: { username: 'user1', name: 'User 1' },
    body: '......',
    comments: [
      {
        id: 'comment1',
        author: { username: 'user2', name: 'User 2' },
        comment: '.....'
      },
      {
        id: 'comment2',
        author: { username: 'user3', name: 'User 3' },
        comment: '.....'
      }
    ]
  },
  {
    id: 'post2',
    author: { username: 'user2', name: 'User 2' },
    body: '......',
    comments: [
      {
        id: 'comment3',
        author: { username: 'user3', name: 'User 3' },
        comment: '.....'
      },
      {
        id: 'comment4',
        author: { username: 'user1', name: 'User 1' },
        comment: '.....'
      },
      {
        id: 'comment5',
        author: { username: 'user3', name: 'User 3' },
        comment: '.....'
      }
    ]
  }
  // and repeat many times
]

更好的组织方式

{
    posts : {
        byId : {
            "post1" : {
                id : "post1",
                author : "user1",
                body : "......",
                comments : ["comment1", "comment2"]
            },
            "post2" : {
                id : "post2",
                author : "user2",
                body : "......",
                comments : ["comment3", "comment4", "comment5"]
            }
        },
        allIds : ["post1", "post2"]
    },
    comments : {
        byId : {
            "comment1" : {
                id : "comment1",
                author : "user2",
                comment : ".....",
            },
            "comment2" : {
                id : "comment2",
                author : "user3",
                comment : ".....",
            },
            "comment3" : {
                id : "comment3",
                author : "user3",
                comment : ".....",
            },
            "comment4" : {
                id : "comment4",
                author : "user1",
                comment : ".....",
            },
            "comment5" : {
                id : "comment5",
                author : "user3",
                comment : ".....",
            },
        },
        allIds : ["comment1", "comment2", "comment3", "commment4", "comment5"]
    },
    users : {
        byId : {
            "user1" : {
                username : "user1",
                name : "User 1",
            },
            "user2" : {
                username : "user2",
                name : "User 2",
            },
            "user3" : {
                username : "user3",
                name : "User 3",
            }
        },
        allIds : ["user1", "user2", "user3"]
    }
}

而这些数据要放在 entities 中:

{
    simpleDomainData1: {....},
    simpleDomainData2: {....},
    entities : {
        entityType1 : {....},
        entityType2 : {....}
    },
    ui : {
        uiSection1 : {....},
        uiSection2 : {....}
    }
}

何时发送数据请求?

通过 side effects 和 thunk 与服务器通信

从上面的脚本我们可以看出,redux 完全是一个本地的消息处理,然而当我们在本地做出更改的时候,肯定需要在放到服务器啊,这种操作在 redux 中被称作 side effects(副作用), 可以使用 redux-thunk 来实现。

// 添加 thunk 支持,一般在 store.js 中
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";

const store = createStore(
  rootReducer,
  applyMiddleware(thunk)
);

如果是一个组件的内部数据,那么没必要在 redux 中保存状态,也就是使用 useState 就好了。获取数据直接在 useEffect 中 fetch 数据,然后 setState 就可以了。

在 useEffect 中获取数据之后触发 action, 还是通过触发一个 action 来获取数据?这个和私有状态是一样的考虑,如果还有其他的组件会触发这个操作更新数据,那么就使用 action 最好,如果这个数据是组件私有的,那么在 useEffect 中直接获取就好。比如说,对于页面组件,可能直接在组件中使用 useEffect(fn, []) 加载数据就好了,可以直接调用 fetch, 也可以触发一个 action.

对于需要获取数据的操作,一般需要三个 Action, 分别是 FETCH_XXX_BEGIN/SUCCESS/FAILURE. 如果获取数据成功,那么 SUCCESS 的 action 中就会包含数据。另外,还要有一个 RESET_XXX_DATA 的 action, 用来清除和重置。

一个典型的加载数据的 action:

export function fetchSearchData(args) {  
    return async (dispatch) => {    
    // Initiate loading state    
    dispatch({      
      type: FETCH_SEARCH_BEGIN    
    });
    try {      
      // Call the API      
      const result = await fetch(
        "api/search/?q=xxx",
        args.pageCount, 
        args.itemsPerPage
      );           
      // Update payload in reducer on success     
      dispatch({        
        type: FETCH_SEARCH_SUCCESS,        
        payload: result,        
        currentPage: args.pageCount      
      });    
    } catch (err) {     
      // Update error in reducer on failure           
      dispatch({        
        type: FETCH_SEARCH_FAILURE,        
        error: err      
      });    
    }  
  };
}

对应的 Reducer 可以这样:

const initialState = {  payload: [],  isLoading: false,  error: {}};

export function searchReducer( state=initialState, action ) {    
  switch(action.type) {    
    case FETCH_SEARCH_BEGIN:      
      return {        
        ...state,        
        isLoading: true    
      };        
    case FETCH_SEARCH_SUCCESS:      
      return {        
        ...state,        
        payload: action.payload,        
        isLoading: false      
      };        
    case FETCH_SEARCH_FAILURE:      
      return {        
        ...state,        
        error: action.error,        
        isLoading: false            
      };
    case RESET_SEARCH_DATA:      
      return {
        ...state,
        ...initialState
      };        
    default:
      return state;
  }
}

在 class-based React 中,一般是在 ComponentWill/DidMount 周期中调用加载数据的逻辑,我们现在自然是使用 useEffect 的这个钩子来实现。

redux 项目目录结构

只要分着放 actions 和 reducers 两个目录就可以了,其实没多大要求。我一般是这样放的:

App.js
state/
  actions/
  reducers/
  actionTypes.js
  rootReducer.js
  store.js

在 index.js 中还是需要

import {Provider} from 'react-redux';
import store from 'state/store';

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

和 React-router 一起使用

当我们和 react-router 一起使用的时候,会遇到一个问题,组件是从 URL 中读取状态,还是从 store 中读取状态呢?这两个状态之间怎么同步呢?redux 的作者 Dan 给出了答案:

不要在 redux 中保存 URL 中的状态,直接从 URL 中读取。可以把 URL 想象成另外一个单独的小的 Store.

遗留问题

  • 页面跳转/组件卸载时,是否要删除上一个页面的数据

参考

  1. Redux 的本质,Event bus
  2. https://stackoverflow.com/questions/50703862/react-redux-state-shape-for-multiple-pages
  3. https://stackoverflow.com/questions/37288070/designing-redux-state-tree
  4. https://stackoverflow.com/questions/33619775/redux-multiple-stores-why-not
  5. Redux 中文基础教程
  6. https://www.pluralsight.com/guides/how-to-structure-redux-components-and-containers
  7. https://www.pluralsight.com/guides/how-to-organize-your-react-+-redux-codebase
  8. 使用 hooks 编写 redux
  9. https://medium.com/@gaurav5430/async-await-with-redux-thunk-fff59d7be093
  10. https://medium.com/fullstack-academy/thunks-in-redux-the-basics-85e538a3fe60
  11. The best way to architect your Redux app
  12. Where and When to Fetch Data With Redux
  13. https://reacttraining.com/blog/useEffect-is-not-the-new-componentDidMount/
  14. How to sync Redux state and url query params
  15. https://redux.js.org/recipes/structuring-reducers/normalizing-state-shape

如何加载数据

  1. https://stackoverflow.com/questions/51113369/how-to-dispatch-fetched-data-from-api
  2. https://stackoverflow.com/questions/39419237/what-is-mapdispatchtoprops
  3. You have to include dispatch in useEffect because of lint rules
  4. https://stackoverflow.com/questions/38206477/react-router-redux-fetch-resources-if-needed-on-child-routes
  5. https://www.freecodecamp.org/news/loading-data-in-react-redux-thunk-redux-saga-suspense-hooks-666b21da1569/
  6. https://stackoverflow.com/questions/57097390/react-hooks-fetch-data-inside-useeffect-warning
  7. https://stackoverflow.com/questions/57925027/useeffect-goes-in-infinite-loop-when-combined-usedispatch-useselector-of-redux
  8. https://stackoverflow.com/questions/62167174/need-clarification-for-react-react-redux-hooks-middleware-thunk-fetching-ap

其他的状态管理

  1. https://github.com/jamiebuilds/unstated-next
  2. https://imweb.io/topic/5a453691a192c3b460fce36e
  3. https://news.ycombinator.com/item?id=12371248
  4. https://github.com/mobxjs/mobx/issues/199
  5. https://blog.rocketinsights.com/redux-vs-mobx/

及时获取更新,请关注公众号“爬虫技术学习”(spider-learn)

多年大厂求职&面试官经验,简历付费优化,¥ 500/次。

公众号“爬虫技术学习(spider-learn)”

About 逸飞

后端工程师

发表评论

邮箱地址不会被公开。 必填项已用*标注