
Redux 初步学习
了解 Redux 的基本概念和使用流程,以及如何在 React 项目中使用 Redux。
由于我是学 Vue 出身的,了解到 React 中的这款状态管理工具的时候很自然的就类比到我经常接触的 Vue 中的状态管理工具——Pinia。在稍稍的了解一遍过后,我发现两者的使用方式其实相差不大,只不过 Pinia 更开箱即用一点,而 Redux 需要比较多的“流水线”操作来初始化、取数据、调用方法改数据,每一步都需要调用固定的 Hook 来处理。
Redux 是一个用于 JavaScript 应用的状态管理工具,它可以与 React 以及其他 JavaScript 库或框架一起使用。Redux 的设计思想非常简单,主要基于以下几个核心概念:
- 单一数据源(Single Source of Truth)
Redux 使用单一的全局状态树(即一个对象)来存储整个应用的状态,这意味着应用的状态被集中到一个地方管理,这样可以更容易地追踪状态的变化和维护状态的一致性。 - 状态是只读的(State is Read-Only)
唯一改变状态的方法是触发一个动作(Action),动作是一个描述发生了什么的普通 JavaScript 对象。这样确保了状态的变更是可预测的和可追踪的。 - 使用纯函数来执行修改(Changes are Made with Pure Functions)
为了描述动作如何改变状态树,你需要编写 reducers。Reducers 是纯函数,它接收当前的状态和一个动作作为参数,返回一个新的状态。由于 reducers 是纯函数,它们不会修改原始状态,而是返回一个新的状态对象。
初始化
如果要在你的 React 项目中使用 Redux,需要安装固定的 npm 包并将其在项目中初始化。
npm i @reduxjs/toolkit react-redux
在你的 react 项目的 src 目录下面新建目录 store ,并在该目录下新建文件 index.js 和目录 modules 。
index.js是用来集中导出modules中定义的模块的,这样在其他组件导入 store 的时候只用引入该文件。modules目录是 redux 定义不同方面模块的目录。比如关于用户的集中状态管理,就新建userStore.js,关于文章列表的状态,就新建articlesStore.js,并在其中编写相应的代码。
然后在 src/index.js 中,引入 store 和 Provider,把根组件 App 放到 Provider 组件里面,实现 store 可以在 App 内部的所有地方都可以使用:
// src/index.jsx
import React from 'react'
import { createRoot } from 'react-dom/client'
// 导入store,提供组件Provider
import { Provider } from 'react-redux'
import App from './App'
// 导入store
import store from './store'
const root = createRoot(document.getElementById('root'))
root.render(
// 使用Provider组件包裹App组件,传入store
<Provider store={store}>
<App />
</Provider>
)
Provider 组件是 React Redux 库中的一个重要组件,它的作用是将 Redux 的 store 连接到你的 React 应用中。通过使用 Provider 组件包裹你的 React 应用(或者应用的一部分),你可以在应用的任何组件中访问到 Redux 的 store。这是通过 React 的上下文(Context)特性来实现的,此处不详细展开。
编写并注册 redux 模块
一个 redux 模块主要包含了以下三个方面:
- state: 一个对象,存放着我们管理的数据
- action: 一个对象,用来描述你想怎么改数据
- reducer: 一个函数,根据 action 的描述更新 state
我们编写一个 redux 模块,也需要遵循如下的步骤:
- 导入所需的模块(createSlice)
- 定义模块名称
- 初始化模块状态
- 定义模块方法, 基本是用于修改状态中某个属性的值的方法
- 导出上面定义的修改属性的方法
- 导出这个模块的 reducer
举个例子,比如我要实现一个加减器,点击加号就可以触发模块中定义的 increase 方法使 redux 中名为 count 的状态+1,反之点击减号就-1,如果传入固定的某个数就把 count 改成那个数。我们可以试着写一下代码:
import { createSlice } from '@reduxjs/toolkit'
/*
1. 指定store的名字
2. 指定store的初始状态,需要哪些属性、属性的类型、属性的默认值
3. 指定修改数据的同步方法,对应的方法会直接注册到counterStore的actions中
4. 解构/导出需要的方法
*/
const counterStore = createSlice({
name: 'counter', // name指定每个store的名字
// initialState指定store的初始状态
initialState: {
count: 1,
},
// reducers指定修改数据的同步方法,对应的方法会直接注册到counterStore的actions中
reducers: {
// 此处的state指的是当前store的状态
increment: (state) => {
state.count += 1
},
decrement: (state) => {
state.count -= 1
},
// 此处的state指的是当前store的状态,action指的是dispatch提交的action对象
addToNum(state, action) {
state.count = action.payload
},
},
})
// 解构出increment和decrement方法
const { increment, decrement, addToNum } = counterStore.actions
// 获取reducer函数
const counterReducer = counterStore.reducer
// 导出
export { addToNum, decrement, increment }
export default counterReducer
可见,我们的步骤都是严格遵循着 redux 模块的定义来的。
- 引入 createSlice 方法,这个函数用于定义 Redux 模块,是每次初始化模块必须的。
- 调用该函数,使用一个常量去接,这个常量的名称往往和这个模块的文件名一致。
- 在 createSlice 中写具体配置:
- name:这个模块的名称。
- initialState:这个模块的初始状态,定义了状态名和对应的初值。
- reducers:内部就是各种 action 函数,用于修改 state 中对应的状态的值。里面定义的每个函数都可以接收到两个参数:state 和 action。
state 就是当前这个 redux 模块的状态,是一个对象。
action 是其他组件在使用这个模块的时候通过 dispatch 提交的 action 对象,往往是用于接收传参的。如果有传参,那么参数会放在 action.payload 里面,需要的时候自取。 - 解构出所有在 reducers 中定义好的方法:
xxx.actions;获取到这个模块的 ruducer 函数:xxx.reducer,之后将函数分别进行导出、将 reducer 作为默认导出。
编写模块完成后,需要在 store 根目录下的 index.js 中完成注册。
- 引入
@reduxjs/toolkit库中的configureStore方法,需要用到这个东西来注册在模块中导出的 reducer。 - 引入想要注册的 modules 目录下的模块。
- 使用
configureStore方法,传入 reducer 对象,内部将引入的模块分别按<module-name>: <reducer-name>的形式进行挂载即可。
import { configureStore } from '@reduxjs/toolkit'
import channelReducer from './modules/channelStore'
import counterReducer from './modules/counterStore'
export default configureStore({
reducer: {
// 注册子模块,主要是子模块的reducer
counter: counterReducer,
channel: channelReducer,
},
})
使用 redux 模块
完成注册后就是在组件中进行使用。
在组件中使用就两个方面:
- 获取 redux 模块的数据。
- 调用 redux 模块中的方法来改 redux 模块中的数据。
如果要获取 redux 模块的数据,需要使用 react-redux 中提供的 useSelector hook 来实现;
如果需要调用 redux 模块中的方法来改 redux 模块中的数据,需要使用 react-redux 中提供的 useDispatch 方法和在各个模块中导出的方法结合来实现。
结合上方的加减器模块使用的具体例子 ↓
import { useDispatch, useSelector } from 'react-redux'
import { addToNum, decrement, increment } from './store/modules/counterStore'
function Conter() {
// useSelector返回store的状态,state.counter指的是counterStore的名字
const { count } = useSelector(state => state.counter)
// useDispatch返回一个dispatch函数,用于提交action对象
const dispatch = useDispatch()
return (
<div className="App">
{/* 调用dispatch提交action对象 */}
<button onClick={() => dispatch(decrement())}>-</button>
<span>{count}</span>
<button onClick={() => dispatch(increment())}>+</button>
<button onClick={() => dispatch(addToNum(10))}>add to 10</button>
<button onClick={() => dispatch(addToNum(20))}>add to 20</button>
</div>
)
}
export default Conter
获取数据使用的是 useSelector 这个 hook。它允许你在函数组件中直接访问 Redux store 的状态。这个 Hook 的工作方式是通过 React 的 Context API 从最近的 <Provider> 组件中提取 Redux 的 store,然后让你能够选择(select)store 中的某部分状态并使用它。以下是它的工作流程:
- 访问 Context:
useSelector首先使用 Context API 来访问由<Provider>提供的 Reduxstore。这个过程是自动的,前提是你的组件树中在上层有<Provider>包裹,并且传入了store作为其value。 - 选择状态:当你调用
useSelector时,你需要传给它一个函数,这个函数被称为“选择器”(selector)。选择器函数接收 Reduxstore的整个状态作为其唯一参数,并返回你感兴趣的状态的某个部分。这样,你可以精确地指定哪些状态值你的组件需要订阅。 - 响应状态变化:
useSelector会订阅 Reduxstore,并在选择的状态部分发生变化时自动重新渲染使用了该 Hook 的组件。为了优化性能,useSelector默认进行了浅比较(shallow comparison)来决定是否需要重新渲染。如果你的选择逻辑更复杂,需要深层比较,你可能需要使用自定义的比较逻辑或者重构你的状态结构以避免不必要的重新渲染。
在上面获取数据的代码中,使用 useSelector 来订阅 Redux store 中的状态。这里的选择器函数 (state) => state.counter 从 store 的整个状态树中选择了 counter 部分的状态,然后解构出 count 值,使其可以在组件中直接使用。这样,每当 counter 状态更新时,Conter 组件就会重新渲染以反映最新的 count 值。
useDispatch 是另一个来自 React Redux 的 Hook,它用于在函数组件中派发(dispatch)动作(actions)以更新 Redux store 的状态。 useDispatch Hook 的调用返回 Redux store 的 dispatch 函数,这个函数允许你派发动作对象到 Redux store,触发状态更新。
动作(actions)是带有 type 属性的普通 JavaScript 对象,它描述了发生了什么。根据动作的类型和携带的数据,reducers 会决定如何更新状态。
<button onClick={() => dispatch(decrement())}>-</button>
<button onClick={() => dispatch(increment())}>+</button>
<button onClick={() => dispatch(addToNum(10))}>add to 10</button>
<button onClick={() => dispatch(addToNum(20))}>add to 20</button>
decrement和increment动作:当用户点击 "-" 或 "+" 按钮时,dispatch函数被调用,派发decrement()或increment()动作。这些动作是由./store/modules/counterStore导出的动作创建函数生成的,它们通常不带有额外的数据(除非这些函数被设计为接受参数并将其包含在动作对象中)。addToNum动作:"add to 10" 和 "add to 20" 按钮通过调用dispatch并传递addToNum(10)或addToNum(20)动作对象,来请求增加count状态值 10 或 20。addToNum是一个动作创建函数,接受一个数字作为参数,并返回一个动作对象,该对象包含要执行的操作类型和附加的数据(在这个例子中,是增加的数量)。
派发到 store 的动作会被对应的 reducer 函数处理。Reducer 函数根据动作的类型来决定如何更新状态,然后返回新的状态。整个过程是同步的,确保了状态的更新是可预测和一致的。
通过使用 useDispatch,你可以在 React 函数组件中轻松派发动作来更新 Redux store 的状态。这个过程涉及到创建动作对象(可能通过动作创建函数),并使用 dispatch 函数将其发送到 store。然后,store 使用 reducers 来处理这些动作并更新状态,最终影响到应用的 UI。
在 pinia 里面是可以直接通过 store.xxx = 'xxx' 来直接赋值修改定义的数据的,而这点在 react 当中几乎是不可行的。在 react 当中,一切的响应式数据,也可以叫状态数据,几乎是不可以被直接赋值的。要么是通过 useState 来定义状态及其对应的修改方法,要么是如 redux 中定义特定的 reducer 来触发 action,来间接的修改状态。
总而言之,在 react 当中,不仅仅数据要定义,修改数据本身的方法也得定义——是不是有点 java-bean 的感觉了?其实这也是从 vue 入坑 react 有点比较难以接受的一点:react 中的 hooks 用不太顺手,觉得有点麻烦。
这个定义 store 目录的结构和 pinia 其实也是共通的,目录也可以仿照其来定义,hook 也可以仿照 redux 来封装。