Redux必须手册,第2部分:Redux应用程序结构

709 阅读17分钟

介绍

第1部分:Redux概述和概念中,我们研究了Redux为何有用,用于描述Redux代码不同部分的术语和概念以及数据如何通过Redux应用程序流动。

现在,让我们看一个实际的示例,看看这些部分如何组合在一起。

创建Redux Store

打开app/store.js,里面的代码应该长这个样子:

import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '../features/counter/counterSlice'

export default configureStore({
  reducer: {
    counter: counterReducer
  }
})

Redux存储是使用Redux Toolkit中的configureStore函数创建的。configureStore要求我们传入一个reducer参数。

我们的App可能由许多不同的功能组成,并且这些功能中的每一个都可能具有自己的reducer功能。当我们调用configureStore时,我们可以在一个对象中传递所有不同的reducer。对象中的键名称将定义最终状态值中的键。

我们有一个名为features/counter/counterSlice.js的文件,该文件为计数器逻辑导出一个reducer函数。我们可以在此处导入该counterReducer函数,并在创建Store时将其包括在内。

当我们传入{counter:counterReducer}之类的对象时,这表示我们想要Redux store对象有一个state.counter,并且我们希望每当action被dispatch时,counterReducer函数负责确定是否、以及如何更新state.counter部分。

Redux允许使用不同类型的插件(“中间件”和“增强器”)自定义Store设置。configureStore默认情况下会自动向Store设置中添加一些中间件,以提供良好的开发人员体验,并且还会设置Store,以便Redux DevTools Extension可以检查其内容。

Redux Slices

“slice”是Redux reducer逻辑和针对App中单个功能的操作的集合,通常在单个文件中一起定义。名称来自将根Redux store对象拆分为多个状态“切片”。

例如,在博客应用中,我们的store设置可能类似于:

import { configureStore } from '@reduxjs/toolkit'
import usersReducer from '../features/users/usersSlice'
import postsReducer from '../features/posts/postsSlice'
import commentsReducer from '../features/comments/commentsSlice'

export default configureStore({
  reducer: {
    users: usersReducer,
    posts: postsReducer,
    comments: commentsReducer
  }
})

在该示例中,state.usersstate.postsstate.comments分别是Redux state的单独“slice”。 由于usersReducer负责更新state.users这个slice,因此我们将其称为“slice reducer”函数。

创建slice reducers和action

由于我们知道counterReducer函数来自features/counter/counterSlice.js,因此,让我们逐段查看该文件中的内容。

import { createSlice } from '@reduxjs/toolkit'

export const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0
  },
  reducers: {
    increment: state => {
      // Redux Toolkit允许我们在reducers中编写“mutating”逻辑。
      // 它实际上不会改变state,因为它使用了immer库,
      // 该库可检测到“draft state”的更改并根据这些更改生成全新的不可变状态
      state.value += 1
    },
    decrement: state => {
      state.value -= 1
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload
    }
  }
})

export const { increment, decrement, incrementByAmount } = counterSlice.actions

export default counterSlice.reducer

之前,我们看到单击UI中的不同按钮会dispatch三种不同的Redux action类型:

  • {type: "counter/increment"}
  • {type: "counter/decrement"}
  • {type: "counter/incrementByAmount"}

我们知道action是带有type字段的普通对象,type字段始终是字符串,并且我们通常具有创建和返回action对象的“action creator”函数。那么这些action对象,type字符串和action creator在哪里定义呢?

我们可以每次都用手写的方式写这些东西。但是,那将是乏味的。此外,在Redux中真正重要的是reducer函数,以及它们用于计算新状态的逻辑。

Redux Toolkit有一个名为createSlice的函数,该函数负责生成action type字符串,action creator函数和action对象。你要做的就是为此slice定义一个名称,编写一个包含一些reducer函数的对象,然后它会自动生成相应的action代码。name选项中的字符串用作每种action type的第一部分,而每个reducer函数的键名用作第二部分。因此,"counter"名称 + "increment" reduce函数生成了{type:"counter/increment"}的action type。(毕竟,如果计算机可以帮助我们,那么为什么要手写此内容!)

除了name字段外,createSlice还需要我们传递reducer的初始state值,以便在首次调用时存在一个state。在这种情况下,我们为对象提供了一个从0开始的value字段。

我们可以在此处看到三个reducer函数,它们对应于通过单击不同的按钮而dispatch的三种不同的action type。

createSlice自动生成与我们编写的reducer函数同名的action creator。我们可以通过调用其中之一并查看返回值来进行检查:

console.log(counterSlice.actions.increment())
// {type: "counter/increment"}

它还生成slice reducer函数,该函数知道如何响应所有这些action type:

const newState = counterSlice.reducer(
  { value: 10 },
  counterSlice.actions.increment()
)
console.log(newState)
// {value: 11}

Reducer的规则

前面我们说过,reducer必须始终遵循一些特殊规则:

  • 它们应该仅根据stateaction参数来计算新的状态值
  • 不允许它们修改现有state。相反,它们必须通过复制现有state,并对复制的值进行更改来进行不可变的更新。
  • 他们不得执行任何异步逻辑或其他“副作用”

但是,为什么这些规则很重要? 有几个不同的原因:

  • Redux的目标之一是使你的代码可预测。当仅从输入参数计算函数的输出时,更容易理解该代码的工作方式并对其进行测试。
  • 另一方面,如果函数依赖于自身外部的变量或随机运行,则你永远不知道运行该函数会发生什么。
  • 如果函数修改了其他值(包括其参数),则可能会更改应用程序的异常工作方式。这可能是常见的错误来源,例如“我更新了state,但是现在我的UI并没有在适当的时候更新!”
  • Redux DevTools的某些功能取决于reducer正确遵循这些规则

关于“不变更新”的规则特别重要,值得进一步讨论。

Reducer和不变更新

之前,我们讨论了“mutation”(修改现有对象/数组值)和“不变性”(将值视为无法更改的值)。

在Redux中,绝对不允许我们的reducer更改原始/当前state值!

// ❌ Illegal - by default, this will mutate the state!
state.value = 123

你不能在Redux中更改state的原因有几个:

  • 它会导致错误,例如UI无法正确更新以显示最新值
  • 这使得很难理解为什么更新state,以及如何更新state
  • 这使得编写测试变得更加困难
  • 它破坏了正确使用“时间旅行调试”的能力
  • 它违反了Redux的预期精神和使用模式

因此,如果我们不能更改原始state,我们如何返回更新后的state?

提示 reducer只能复制原始值,然后才可以对副本进行变更。

// ✅ 这是安全的,因为我们制作了一份拷贝
return {
  ...state,
  value: 123
}

我们已经看到,可以使用JavaScript的数组/对象解构运算符和其他返回原始值副本的函数来手工编写不可变的更新。但是,如果你认为“用这种方式手动编写不可变的更新看起来很难记住并正确执行”——是的,您是对的! :)

手动编写不可变的更新逻辑很困难,而在reducer中意外地改变state是Redux用户犯下的最常见错误。

这就是为什么Redux Toolkit的createSlice函数可以让你编写更简单的不可变更新的原因!

createSlice在内部使用一个名为Immer的库。Immer使用称为Proxy的特殊JS工具包装你提供的数据,并允许你编写对包装的数据进行“变更”的代码。但是,Immer会跟踪你尝试进行的所有更改,然后使用该更改列表返回安全不变的更新值, 就好像你已经手工编写了所有不变更新的逻辑一样。

因此,想要代替以下代码:

function handwrittenReducer(state, action) {
  return {
    ...state,
    first: {
      ...state.first,
      second: {
        ...state.first.second,
        [action.someId]: {
          ...state.first.second[action.someId],
          fourth: action.someValue
        }
      }
    }
  }
}

你可以像这样编写代码:

function reducerWithImmer(state, action) {
  state.first.second[action.someId].fourth = action.someValue
}

这更容易阅读!

但是,这里要记住一些非常重要的事情:

警告 你只能在Redux Toolkit的createSlicecreateReducer中编写“变更”逻辑,因为它们在内部使用Immer!如果你在不使用Immer的reducer中编写变更逻辑,它将改变state并导致错误!

考虑到这一点,让我们回过头来看看计数器slice中的实际reducer。

export const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0
  },
  reducers: {
    increment: state => {
      // Redux Toolkit允许我们在reducers中编写“mutating”逻辑。
      // 它实际上不会改变state,因为它使用了immer库,
      // 该库可检测到“draft state”的更改并根据这些更改生成全新的不可变状态
      state.value += 1
    },
    decrement: state => {
      state.value -= 1
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload
    }
  }
})

我们可以看到,increment reducer将始终向state.value加1。 因为Immer知道我们已经对draft state对象进行了更改,所以我们实际上不必在此处返回任何内容。以同样的方式,decrement reducer减去1。

在这两个reducer中,我们实际上不需要让我们的代码查看action对象。它会以任何方式传递,但是由于我们不需要它,因此我们可以跳过将action声明为reducer的参数。

另一方面,incrementByAmount reducer确实需要知道一些知识:应该将多少增加到计数器值上。因此,我们将reducer声明为同时具有stateaction参数。在这种情况下,我们知道我们在文本框中键入的金额已放入action.payload字段中,因此我们可以将其添加到state.value中。

用Thunk编写异步逻辑

到目前为止,我们应用程序中的所有逻辑都是同步的。dispatch一个action,store运行reducer并计算新state,然后dispatch函数完成。但是,JavaScript语言有很多方式可以编写异步代码,而且我们的应用程序通常具有异步逻辑来处理诸如从API提取数据之类的事情。我们需要在Redux App中有放置异步逻辑的地方。

thunk是Redux函数的一种特殊类型,可以包含异步逻辑。Thunk是使用两个函数编写的:

  • 内部thunk函数,该函数获取dispatchgetState作为参数
  • 外部creator函数,该函数创建并返回thunk函数

counterSlice导出的下一个函数是一个thunk action creator的示例:

// 下面的函数称为thunk,它使我们能够执行异步逻辑。
// 它可以像常规action一样被调度:`dispatch(incrementAsync(10))`。
// 这将使用`dispatch`函数作为第一个参数来调用thunk。然后可以执行异步代码,并可以调度其他action
export const incrementAsync = amount => dispatch => {
  setTimeout(() => {
    dispatch(incrementByAmount(amount))
  }, 1000)
}

我们可以像使用典型的Redux action creator一样使用它们:

store.dispatch(incrementAsync(5))

但是,使用thunk需要在创建Redux store时将redux-thunk中间件(Redux的一种插件)添加到Redux store中。幸运的是,Redux Toolkit的configureStore函数已经为我们自动设置了,因此我们可以继续在这里使用thunk。

当你需要进行AJAX调用以从服务器获取数据时,可以将该调用放入thunk中。这是一个写了更长的示例,因此你可以看到它的定义方式:

// the outside "thunk creator" function
const fetchUserById = userId => {
  // the inside "thunk function"
  return async (dispatch, getState) => {
    try {
      // make an async call in the thunk
      const user = await userAPI.fetchById(userId)
      // dispatch an action when we get the response back
      dispatch(userLoaded(user))
    } catch (err) {
      // If something went wrong, handle it here
    }
  }
}

我们将在第5部分:异步逻辑和数据获取中看到使用thunk。

详细说明:Thunk和异步逻辑

我们知道,我们不允许在reducer中放置任何类型的异步逻辑。但是,这种逻辑必须存在于某个地方。

如果我们有权访问Redux store,则可以编写一些异步代码并在完成后调用store.dispatch()

const store = configureStore({ reducer: counterReducer })

setTimeout(() => {
  store.dispatch(increment())
}, 250)

但是,在真正的Redux App中,我们不允许将store导入其他文件,尤其是在我们的React组件中,因为这会使该代码更难测试和重用。

另外,我们经常需要编写一些异步逻辑,这些逻辑最终会与某个store一起使用,但是我们不知道哪个store。

可以使用“中间件”扩展Redux store,“中间件”是一种可以添加额外功能的附加组件或插件。使用中间件的最常见原因是让你编写可以具有异步逻辑但仍同时与store对话的代码。它们还可以修改store,以便我们可以调用dispatch()并传递不简单的action对象值,例如函数或Promises。

Redux Thunk中间件修改了store,使你可以将函数传递给dispatch。实际上,它足够短,我们可以在此处粘贴它:

const thunkMiddleware = ({ dispatch, getState }) => next => action => {
  if (typeof action === 'function') {
    return action(dispatch, getState, extraArgument)
  }

  return next(action)
}

它看起来是判断传递给dispatch的“action”是否是一个函数,而不是一个普通的action对象。如果它实际上是一个函数,它将调用该函数并返回结果。否则,由于它必须是一个action对象,因此它将action转发给store。

这为我们提供了一种编写所需的同步或异步代码的方法,同时仍然可以访问dispatchgetState

该文件中还有一个功能,我们将在稍后查看<Counter> UI组件时讨论。

React计数器组件

之前,我们看到了一个独立的React <Counter>组件的外观。我们的React + Redux应用程序具有类似的<Counter>组件,但在某些方面有不同之处。

我们将从查看Counter.js组件文件开始:

import React, { useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import {
  decrement,
  increment,
  incrementByAmount,
  incrementAsync,
  selectCount
} from './counterSlice'
import styles from './Counter.module.css'

export function Counter() {
  const count = useSelector(selectCount)
  const dispatch = useDispatch()
  const [incrementAmount, setIncrementAmount] = useState('2')

  return (
    <div>
      <div className={styles.row}>
        <button
          className={styles.button}
          aria-label="Increment value"
          onClick={() => dispatch(increment())}
        >
          +
        </button>
        <span className={styles.value}>{count}</span>
        <button
          className={styles.button}
          aria-label="Decrement value"
          onClick={() => dispatch(decrement())}
        >
          -
        </button>
      </div>
      {/* omit additional rendering output here */}
    </div>
  )
}

与之前的简单React示例一样,我们有一个称为Counter的函数组件,该组件将一些数据存储在useState hook中。

但是,在我们的组件中,看起来好像我们没有将实际的当前计数器值存储为state。有一个名为count的变量,但它不是来自useState hook。

尽管React包括几个内置的hook,如useStateuseEffect,但其他库可以创建自己的自定义hook,这些自定义hook使用React的hook来构建自定义逻辑。

React-Redux库具有一组自定义的hook,这些hook允许你的React组件与Redux store进行交互

使用useSelector读取数据

首先,useSelector hook使我们的组件能够从Redux store state中提取所需的任何数据。

之前,我们看到可以编写selector函数,该函数将state作为参数并返回state值的某些部分。

我们的counterSlice.js在底部具有此selector函数:

// The function below is called a selector and allows us to select a value from
// the state. Selectors can also be defined inline where they're used instead of
// in the slice file. For example: `useSelector((state) => state.counter.value)`
export const selectCount = state => state.counter.value

如果我们可以访问Redux store,则可以将当前计数器值检索为:

const count = selectCount(store.getState())
console.log(count)
// 0

我们的组件无法直接与Redux store对话,因为不允许我们将其导入到组件文件中。但是,useSelector会为我们与幕后的Redux store进行交谈。如果传入selector函数,它将为我们调用someSelector(store.getState())并返回结果。

因此,我们可以通过执行以下操作来获取当前store的计数器值:

const count = useSelector(selectCount)

我们也不必只使用已经导出的selector。例如,我们可以将selector函数编写为useSelector的内联参数:

const countPlusTwo = useSelector(state => state.counter.value + 2)

每当action被dispatch并更新Redux store时,useSelector都会重新运行selector函数。如果selector返回的值与上次不同,则useSelector将确保我们的组件使用新值重新呈现。

使用useDispatch dispatch action

同样,我们知道如果可以访问Redux store,则可以使用action creator(例如store.dispatch(increment()))来dispatch action。由于我们无权访问store本身,因此我们需要某种方式来仅访问dispatch方法。

useDispatch hook为我们完成了该任务,并为我们提供了Redux store中实际的dispatch方法:

const dispatch = useDispatch()

从那里,我们可以在用户执行诸如单击按钮之类的操作时dispatch action:

<button
  className={styles.button}
  aria-label="Increment value"
  onClick={() => dispatch(increment())}
>
  +
</button>

组件状态和形式

现在,你可能会想:“我是否总是需要将我所有应用程序的状态都放入Redux store中?”

答案是应用程序所需的全局状态应在Redux store中。仅在一个地方需要的状态应保持在组件中。

在此示例中,我们有一个输入文本框,用户可以在其中输入要添加到计数器的下一个数字:

const [incrementAmount, setIncrementAmount] = useState('2')

// later
return (
  <div className={styles.row}>
    <input
      className={styles.textbox}
      aria-label="Set increment amount"
      value={incrementAmount}
      onChange={e => setIncrementAmount(e.target.value)}
    />
    <button
      className={styles.button}
      onClick={() => dispatch(incrementByAmount(Number(incrementAmount) || 0))}
    >
      Add Amount
    </button>
    <button
      className={styles.asyncButton}
      onClick={() => dispatch(incrementAsync(Number(incrementAmount) || 0))}
    >
      Add Async
    </button>
  </div>
)

通过在输入的onChange处理程序中dispatch一个action并将其保留在我们的reducer中,我们可以将当前数字字符串保留在Redux store中。但是,这并没有给我们带来任何好处。此处使用文本字符串的唯一位置是<Counter>组件。(当然,此示例中只有一个其他组件:<App>。但是,即使我们有一个包含许多组件的大型应用程序,也只有<Counter>关心此输入值。)

因此,将值保留在<Counter>组件中的useState hook中是最好的。

同样,如果我们有一个名为isDropdownOpen的布尔值标志,应用程序中没有其他组件会关心它——那么它实际上应该保持在该组件中。

React + Redux应用程序中,你的全局状态应在Redux store中,而本地state应保留在React组件中。

如果你不确定在哪里放置东西,可以使用以下经验法则来确定应将哪种数据放入Redux:

  • 应用程序的其他部分是否关心此数据?
  • 你是否需要能够基于此原始数据创建其他派生数据?
  • 是否使用相同的数据来驱动多个组件?
  • 能够将这种状态恢复到给定的时间点(例如,时间旅行调试)对你有价值吗?
  • 你是否要缓存数据(即,使用已存在的state而不是重新请求)?
  • 你是否想在热重载UI组件时保持这些数据的一致性(交换时可能会丢失其内部state)?

这也是一般情况下,思考是否将表单放在Redux中的一个例子。大多数表单状态可能不应该保留在Redux中。 相反,在编辑数据时将其保留在表单组件中,然后在用户完成操作后dispatch Redux action以更新store。

在继续之前要注意的另一件事:还记得来自counterSlice.jscrementAsync thunk吗?我们在此组件中使用它。请注意,我们使用它的方式与dispatch其他常规action creator的方式相同。这个组件不在乎我们是要dispatch正常action还是启动一些异步逻辑。它只知道当你单击该按钮时,它会dispatch一些东西。

提供store

我们已经看到,我们的组件可以使用useSelectoruseDispatch hook与Redux store进行通信。但是,由于我们没有导入store,所以这些hook如何知道要与Redux store交谈?

现在,我们已经了解了该应用程序的所有不同部分,现在该回到该应用程序的起点,看看拼图的最后部分是如何组合在一起的。

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import store from './app/store'
import { Provider } from 'react-redux'
import * as serviceWorker from './serviceWorker'

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

我们总是必须调用ReactDOM.render(<App />)来告诉React开始渲染我们的根<App>组件。为了使像useSelector这样的hook正常工作,我们需要使用一个名为<Provider>的组件将Redux store在后台传递下来,以便它们可以访问它。

我们已经在app/store.js中创建了store,因此我们可以在此处将其导入。然后,将<Provider>组件放在整个<App>周围,并传入store:<Provider store={store}>

现在,任何调用useSelectoruseDispatch的React组件都将与我们提供给<Provider>的Redux store进行交谈。