FullStackOpen: Redux

当应用变得庞大时,状态管理应当与 React 组件进行解耦。在这一章节,我们会引入 Redux 库,也是目前 React 应用生态中最流行的状态管理解决方案。

教程:FullStackOpen2022/Part 6

本文以 unicafe-redux 与 redux-anecdotes 应用为例。对应练习 6.1-6.18,6.21。

1. Flux 架构

Facebook 开发了 Flux 架构,提供了一种标准的方式来保存应用程序的状态以及如何修改它,使状态管理更加容易。

在 Flux 架构中,状态完全从 React-components 分离到自己的存储中。存储中的状态不会直接更改,而是使用不同的 actions进行更改。当一个操作改变了存储的状态时,视图会被重新渲染。

2. 在 React 中使用 Redux

Redux 使用与 Flux 相同的原理,但是更简单一些。

安装 Redux:

npm install redux

定义 reducer 处理 state 与 action

在 Redux 中,状态 state 存储在 store 中。存储的状态 state 通过 action 改变,action 对状态 state 的影响通过使用一个 reducer 来定义。

实际上,reducer 是一个函数,它以当前状态 state 和 action 为参数,返回一个新的状态 state。

为应用 unicafe-redux 定义一个 reducer。reducer.js:

const initialState = {
  good: 0,
  ok: 0,
  bad: 0,
}

const counterReducer = (state = initialState, action) => {
  console.log(action)
  switch (action.type) {
    case 'GOOD':
      return { ...state, good: state.good + 1 }
    case 'OK':
      return { ...state, ok: state.ok + 1 }
    case 'BAD':
      return { ...state, bad: state.bad + 1 }
    case 'ZERO':
      return initialState
    default:
      return state
  }
}

export default counterReducer

Flux

Reducer 不应该直接从应用程序中调用。Reducer 只在创建 store 时,作为 createStore 的一个参数给出。

index.js:

import { createStore } from 'redux'
import reducer from './reducer'

const store = createStore(reducer)

//...

store 现在使用 reducer 来处理 action。这些 action 通过 dispatch 方法 被分派/发送到 store 中。

index.js:

//...

const App = () => {
  const good = () => {
    store.dispatch({
      type: 'GOOD',
    })
  }

  const ok = () => {
    store.dispatch({
      type: 'OK',
    })
  }

  //...

  return (
    <div>
      <button onClick={good}>good</button>
      <button onClick={ok}>ok</button>
      //...
    </div>
  )
}

可以使用方法 getState 查找存储的状态 state。

index.js:

//...

const App = () => {
  //...
  return (
    <div>
      //...
      <div>good {store.getState().good}</div>
      <div>ok {store.getState().ok}</div>
      <div>bad {store.getState().bad}</div>
    </div>
  )
}

store 拥有的另一个重要方法是 subscribe,它用于在 store 状态改变时创建调用的回调函数。

当 store 中的状态发生更改时,React 无法自动重新运行应用程序。因此,需注册一个函数 renderApp 渲染应用程序,并用 store.subscribe 方法监听 store 中的更改。

注意,必须立即调用 renderApp 方法。没有这个调用,应用程序的第一次渲染将永远不会发生。

index.js:

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

renderApp()
store.subscribe(renderApp)

Reducer 为纯函数,不可变

Redux 的 reducer 必须是纯函数,当使用相同的参数调用时,必须始终返回相同的结果。Reducer 状态必须由不可变 immutable 对象组成。如果状态发生了更改,则不会更改旧对象,而是将其替换为新的、已更改的对象。

添加 deep-freeze 库 ,它可以用来确保 reducer 被正确定义为不可变函数。

npm install --save-dev deep-freeze

在文件 reducer.test.js 中定义测试。deepFreeze(state) 命令确保该 reducer 不会更改作为参数提供给它的存储的状态。

reducer.test.js:

import deepFreeze from 'deep-freeze'
import counterReducer from './reducer'

describe('unicafe reducer', () => {
  const initialState = {
    good: 0,
    ok: 0,
    bad: 0,
  }

  test('should return a proper initial state when called with undefined state', () => {
    const state = {}
    const action = {
      type: 'DO_NOTHING',
    }

    const newState = counterReducer(undefined, action)
    expect(newState).toEqual(initialState)
  })

  test('good is incremented', () => {
    const action = {
      type: 'GOOD',
    }
    const state = initialState

    deepFreeze(state)
    const newState = counterReducer(state, action)
    expect(newState).toEqual({
      good: 1,
      ok: 0,
      bad: 0,
    })
  })
})

3. 共享 Redux store 到多个组件

有多种方法可以与 React 组件共享 redux-store。最简单的方法是使用 react-redux 的 hooks API 。

使用 react-redux

安装 react-redux:

npm install react-redux

接下来,让 App 组件成为 redux 库提供的 Provider 组件的子组件。应用的存储作为 store 属性提供给 Provider。

index.js:

import React from 'react'
import ReactDOM from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import reducer from './reducers/reducer'
import App from './App'

const store = createStore(reducer)

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

使用 Action Creator

Redux 组件并不需要知道 Redux 操作的类型和形式。

将创建 action 的行为分离到它们自己的功能中,该创建 action 的函数称为 Action Creator。App 组件不再需要知道任何关于 action 的内部表示,只需要调用 Creator 函数就可以执行正确的操作。

reducers/anecdoteReducer.js:

const anecdoteReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'CREATE':
      //...
    case 'VOTE':
      //...
    default:
      return state
  }
}

export const voteAnecdote = (id) => {
  return { type: 'VOTE', data: { id } }
}

export const createAnecdote = (anecdote) => {
  return {
    type: 'CREATE',
    data: {
      content: anecdote,
      id: getId(),
      votes: 0,
    },
  }
}

export default anecdoteReducer

在此之前,代码通过调用 redux-store 的 dispatch 方法来分派操作;现在,它使用 useDispatch hook 中的 dispatch 函数来完成,允许所有组件对 redux-store 的状态进行更改。

另外,可以通过 react-redux 库的 useSelector hook 访问存储在 store 中的 state。useSelector 接收一个函数作为参数,该函数可以搜索或选择来自 redux-store 的数据。

components/AnecdoteList.js:

import { useSelector, useDispatch } from 'react-redux'
import { voteAnecdote } from '../reducers/anecdoteReducer'

const AnecdoteList = () => {
  const anecdotes = useSelector((state) =>
    state.anecdotes.filter((anecdote) =>
      anecdote.content.includes(state.filter)
    )
  )
  const dispatch = useDispatch()

  const vote = (anecdote) => {
    console.log('vote', anecdote.id)
    dispatch(voteAnecdote(anecdote.id))
  }

  return (
    <>
      {anecdotes
        .sort((a, b) => b.votes - a.votes)
        .map((anecdote) => (
          <div key={anecdote.id}>
            <div>{anecdote.content}</div>
            <div>
              has {anecdote.votes}
              <button onClick={() => vote(anecdote)}>vote</button>
            </div>
          </div>
        ))}
    </>
  )
}

export default AnecdoteList

非受控表单

不将表单字段的状态绑定到 App 组件状态的形式为不受控(Uncontrolled)。

Uncontrolled forms have certain limitations (for example, dynamic error messages or disabling the submit button based on input are not possible). However, they are suitable for our current needs.

可以直接从 form 对象中获取表单内容,表单字段有 name 属性,可以通过事件对象 event.target.[name].value 访问内容。

components/AnecdoteForm.js:

//...
const AnecdoteForm = () => {
  //...

  const create = (e) => {
    e.preventDefault()
    dispatch(createAnecdote(e.target.anecdote.value))
    e.target.anecdote.value = ''
  }

  return (
    <form onSubmit={create}>
      <div>
        <input name="anecdote" type="text" />
      </div>
      <button>create</button>
    </form>
  )
}
//...

4. 使用 Redux DevTools

库 redux-devtools,一个可以安装在 Chrome 上的 Redux DevTools 扩展 ,用于监视 Redux store 的状态 state 和改变它的 action。

npm install --save @redux-devtools/extension

在 store 中添加扩展:

import { createStore, combineReducers } from 'redux'
import { composeWithDevTools } from '@redux-devtools/extension'

const reducer = combineReducers({
  //...
})

const store = createStore(reducer, composeWithDevTools())

export default store

5. 使用复合 reducer 处理多种储存

可以为应用创建多个不同的 reducer,并通过 combineReducers 函数结合多个 reducer。

store.js:

import { createStore, combineReducers } from 'redux'
import { composeWithDevTools } from '@redux-devtools/extension'

import anecdoteReducer from './reducers/anecdoteReducer'
import notificationReduer from './reducers/notificationReducer'
import filterReducer from './reducers/filterReducer'

const reducer = combineReducers({
  anecdotes: anecdoteReducer,
  notification: notificationReduer,
  filter: filterReducer,
})

const store = createStore(reducer, composeWithDevTools())

export default store

上面由 reducer 定义的存储状态是一个具有多个属性的对象。anecdotes 属性的值由 anecdoteReducer 定义,不必处理状态的其他属性。 类似地,其他属性由不同的 reducer 管理。复合 reducer 的工作方式使得每个 action 在复合 reducer 的每个 部分都得到处理。

components/AnecdoteList.js:

import { useSelector, useDispatch } from 'react-redux'
import { voteAnecdote } from '../reducers/anecdoteReducer'
import {
  setNotification,
  resetNotification,
} from '../reducers/notificationReducer'

const AnecdoteList = () => {
  const anecdotes = useSelector((state) =>
    state.anecdotes.filter((anecdote) =>
      anecdote.content.includes(state.filter)
    )
  )
  const dispatch = useDispatch()

  const vote = (anecdote) => {
    console.log('vote', anecdote.id)
    dispatch(voteAnecdote(anecdote.id))
    dispatch(setNotification('you voted anecdote: ' + anecdote.content))
    setTimeout(() => {
      dispatch(resetNotification())
    }, 5000)
  }

  //...
}

export default AnecdoteList

6. 使用异步 action 与后端通信

安装 redux-thunk 库,它允许我们创建 asynchronous actions。

npm install redux-thunk

Redux-thunk-库 是所谓的redux-中间件,它必须在store的初始化过程中初始化。

store.js:

import { createStore, combineReducers, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import { composeWithDevTools } from '@redux-devtools/extension'

const reducer = combineReducers({
  //...
})

const store = createStore(reducer, composeWithDevTools(applyMiddleware(thunk)))

export default store

redux-thunk 可以定义 action creators,返回一个参数是 redux-store 的 dispatch 方法的函数,首先等待某个 action 完成,再分派真正的 action,以此创建异步action创建器。

reducers/anecdoteReducer.js:

import anecdoteService from '../services/anecdotes'

//...

export const voteAnecdote = (id) => {
  return async (dispatch) => {
    const anecdote = await anecdoteService.get(id)
    await anecdoteService.update(id, { ...anecdote, votes: anecdote.votes + 1 })
    dispatch({ type: 'VOTE', data: { id } })
  }
}

export const createAnecdote = (anecdote) => {
  return async (dispatch) => {
    const newAnecdote = await anecdoteService.createNew(anecdote)
    dispatch({
      type: 'CREATE',
      data: newAnecdote,
    })
  }
}

export const initAnecdotes = () => {
  return async (dispatch) => {
    const anecdotes = await anecdoteService.getAll()
    dispatch({ type: 'INIT', data: anecdotes })
  }
}

7. 使用 React Context 替代 Redux

现在,通过使用 React Context API 和 useReducer hook,可以不需要 redux 就实现类似 redux 的状态管理。

详见:React Hooks vs. Redux: Do Hooks and Context replace Redux?