在 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 就够了。
总的来说,分以下几步:
- 确定全局状态,比如用户登录状态,放在 redux 最顶级。
- 设计每个页面分区可视化树。根据页面布局,确定组件,以及每个组件的状态。
- 设计 reducers, 同样是一棵树状的组织。
- 实现 actions, 其中也包括加载数据的 action.
- 实现展示层,也就是组件们。
页面的布局图和状态树:
使用 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.
遗留问题
- 页面跳转/组件卸载时,是否要删除上一个页面的数据
参考
- Redux 的本质,Event bus
- https://stackoverflow.com/questions/50703862/react-redux-state-shape-for-multiple-pages
- https://stackoverflow.com/questions/37288070/designing-redux-state-tree
- https://stackoverflow.com/questions/33619775/redux-multiple-stores-why-not
- Redux 中文基础教程
- https://www.pluralsight.com/guides/how-to-structure-redux-components-and-containers
- https://www.pluralsight.com/guides/how-to-organize-your-react-+-redux-codebase
- 使用 hooks 编写 redux
- https://medium.com/@gaurav5430/async-await-with-redux-thunk-fff59d7be093
- https://medium.com/fullstack-academy/thunks-in-redux-the-basics-85e538a3fe60
- The best way to architect your Redux app
- Where and When to Fetch Data With Redux
- https://reacttraining.com/blog/useEffect-is-not-the-new-componentDidMount/
- How to sync Redux state and url query params
- https://redux.js.org/recipes/structuring-reducers/normalizing-state-shape
如何加载数据
- https://stackoverflow.com/questions/51113369/how-to-dispatch-fetched-data-from-api
- https://stackoverflow.com/questions/39419237/what-is-mapdispatchtoprops
- You have to include dispatch in useEffect because of lint rules
- https://stackoverflow.com/questions/38206477/react-router-redux-fetch-resources-if-needed-on-child-routes
- https://www.freecodecamp.org/news/loading-data-in-react-redux-thunk-redux-saga-suspense-hooks-666b21da1569/
- https://stackoverflow.com/questions/57097390/react-hooks-fetch-data-inside-useeffect-warning
- https://stackoverflow.com/questions/57925027/useeffect-goes-in-infinite-loop-when-combined-usedispatch-useselector-of-redux
- https://stackoverflow.com/questions/62167174/need-clarification-for-react-react-redux-hooks-middleware-thunk-fetching-ap
其他的状态管理
- https://github.com/jamiebuilds/unstated-next
- https://imweb.io/topic/5a453691a192c3b460fce36e
- https://news.ycombinator.com/item?id=12371248
- https://github.com/mobxjs/mobx/issues/199
- https://blog.rocketinsights.com/redux-vs-mobx/