跳到内容

d

connect方法

在本章结束,我们将会了解几种管理应用状态的不同方式。

我们继续回到 note 应用。这次我们将关注与服务器的通信。我们从头开始打造应用,它的第一版如下:

const App = () => {
  const addNote = async (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.note.value = ''
    console.log(content)
  }

  const toggleImportance = (note) => {
    console.log('toggle importance of', note.id)
  }

  const notes = []

  return(
    <div>
      <h2>Notes app</h2>
      <form onSubmit={addNote}>
        <input name="note" />
        <button type="submit">add</button>
      </form>
      {notes.map(note =>
        <li key={note.id} onClick={() => toggleImportance(note)}>
          {note.content} 
          <strong> {note.important ? 'important' : ''}</strong>
        </li>
      )}
    </div>
  )
}

export default App

初始代码可以在 GitHub 仓库 https://github.com/fullstack-hy2020/query-notespart6-0 的分支中找到.

利用 React Query 管理服务器端数据

我们现在将用 React Query 存储并管理从服务器检索的数据。

用以下命令安装 React Query 库:

npm install react-query

index.js 中需要增加一些内容,以便将这个库中的函数传递给整个应用。

import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from 'react-query'
import App from './App'

const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')).render(
  <QueryClientProvider client={queryClient}>    <App />
  </QueryClientProvider>)

我们现在就可以从 App 组件中获取笔记了。相关代码如下:

import { useQuery } from 'react-query'import axios from 'axios'
const App = () => {
  // ...

  const result = useQuery(    'notes',    () => axios.get('http://localhost:3001/notes').then(res => res.data)  )  console.log(result)
  if ( result.isLoading ) {    return <div>loading data...</div>  }
  const notes = result.data
  return (
    // ...
  )
}

从服务器中获取数据的方式和 Axios 的 get 方法类似。然而,Axios 的调用方法现在被包装在一个用 useQuery 函数形成的 query 查询中。在这个函数调用中,第一个参数(字符串 "notes" )是已定义查询的 key,即笔记列表。

useQuery 函数的返回值是一个包含查询状态的对象。控制台中的输出展现了这个情境:

browser devtools showing success status

当组件第一次被渲染时,查询仍处于加载状态,即,相关的 HTTP 请求仍在等待中。在这个阶段,只有如下元素会被渲染:

<div>loading data...</div>

然而, HTTP 请求在瞬息之内完成,甚至最敏锐的人也无法看到这个文本。当请求完成后,这个组件会被重新渲染。在第二次渲染中,查询的状态为成功,而且,查询对象的 data 字段中包含了请求返回的数据,即,屏幕上显示的笔记列表。

因此,这个应用可以从服务器中获取数据并将其渲染到屏幕上,而完全不使用我们在第 2 章至第 5 章谈及的 React 钩子—— useStateuseEffect。服务器中的数据现在完全在 React Query 库的管理下,应用程序完全不需要用 React 的 useState 钩子定义状态!

让我们将发出实际 HTTP 请求的函数,移动到单独的 requests.js 文件中。

import axios from 'axios'

export const getNotes = () =>
  axios.get('http://localhost:3001/notes').then(res => res.data)

现在,APP 组件变得稍微简洁了。

import { useQuery } from 'react-query' 
import { getNotes } from './requests'
const App = () => {
  // ...

  const result = useQuery('notes', getNotes)
  // ...
}

The current code for the application is in GitHub in the branch part6-1.

当前应用的代码可以在 GitHubpart6-1 的分支中找到。

使用 React Query 将数据同步至服务器

数据已经成功地从服务器中检索出来。接下来,我们将确保对数据的新增和修改也会存储到服务器中。让我们从新增笔记开始。

让我们在 requests.js 中构建一个 createNote 函数,用以存储新笔记:

import axios from 'axios'

const baseUrl = 'http://localhost:3001/notes'

export const getNotes = () =>
  axios.get(baseUrl).then(res => res.data)

export const createNote = newNote =>  axios.post(baseUrl, newNote).then(res => res.data)

App 组件相应做出如下更新:

import { useQuery, useMutation } from 'react-query'import { getNotes, createNote } from './requests'
const App = () => {
  const newNoteMutation = useMutation(createNote)
  const addNote = async (event) => {
    event.preventDefault()
    const content = event.target.note.value
    event.target.note.value = ''
    newNoteMutation.mutate({ content, important: true })  }

  // 

}

为了新增一条笔记,我们需要用 useMutation 创建一个 mutation(突变)

const newNoteMutation = useMutation(createNote)

useMutation 的参数即是我们在 requests.js 中添加的函数——它使用 Axios 向服务器发送一条新笔记。

事件处理器 addNote 在调用 mutation 对象中的 mutate 函数时,将新笔记作为参数传入,以执行 mutation :

newNoteMutation.mutate({ content, important: true })

我们的解决方案挺不错,但仍有改进空间。新的笔记虽然存储在了服务器上,但并没有在屏幕上更新。

为了能渲染出新的笔记,我们需要告诉 React Query,应该使 key 为 notes 的旧查询结果 invalidated(无效)

幸运的是,无效化很容易,它可以通过为 mutation 定义适当的 onSuccess 回调函数来完成:

import { useQuery, useMutation, useQueryClient } from 'react-query'import { getNotes, createNote } from './requests'

const App = () => {
  const queryClient = useQueryClient()
  const newNoteMutation = useMutation(createNote, {
    onSuccess: () => {      queryClient.invalidateQueries('notes')    },
  })

  // ...
}

在 mutation 已经成功执行后,一个函数被调用:

queryClient.invalidateQueries('notes')

这让 React Query 通过从服务器上获取笔记。自动更新 key 为 notes 的查询。因此,应用渲染了服务器上最新的状态,包括刚刚新增的笔记。

让我们加入更改笔记重要性的功能。更新笔记的函数被加入到文件 requests.js 中:

export const updateNote = updatedNote =>
  axios.put(`${baseUrl}/${updatedNote.id}`, updatedNote).then(res => res.data)

更新笔记同样通过 mutation 来完成。App 组件扩展为如下:

import { useQuery, useMutation, useQueryClient } from 'react-query' 
import { getNotes, createNote, updateNote } from './requests'
const App = () => {
  // ...

  const updateNoteMutation = useMutation(updateNote, {
    onSuccess: () => {
      queryClient.invalidateQueries('notes')
    },
  })

  const toggleImportance = (note) => {
    updateNoteMutation.mutate({...note, important: !note.important })
  }

  // ...
}

一个能够无效化查询的 mutation 被再次创建,更新后的笔记也可以正常渲染。使用 mutation 很轻松,mutate 方法接收一个笔记作为参数,这个笔记的重要性已变为旧值的反义。

当前应用的代码可以在 GitHubpart6-2 的分支中找到。

优化性能

应用目前运转良好,代码也相对简单。对笔记列表的更改也异常轻松。例如,当我们改变了笔记的重要性,使 key 为 notes 的查询无效即可更新应用中的数据。

  const updateNoteMutation = useMutation(updateNote, {
    onSuccess: () => {
      queryClient.invalidateQueries('notes')    },
  })

但这样的话,应用会在一个导致笔记更新的 PUT 请求后,创建一个 GET 请求向服务器获取数据。

devtools network tab with highlight over 3 and notes requests

如果应用从服务器中获取的数据量不大,这样的更新流程无关紧要。毕竟,从浏览器功能的角度来看,额外的一个 HTTP 请求并不重要,但在某些情况下,这可能会给服务器带来压力。

必要情况下,也可以通过 手动更新 React Query 所维护的查询状态,以实现性能优化。

对新增笔记的 mutation,做出如下更改:

const App = () => {
  const queryClient =  useQueryClient() 

  const newNoteMutation = useMutation(createNote, {
    onSuccess: (newNote) => {
      const notes = queryClient.getQueryData('notes')      queryClient.setQueryData('notes', notes.concat(newNote))    }
  })
  // ...
}

onSuccess 的回调函数中,queryClient 对象首先读取已经存在的笔记状态,并加入在回调函数参数中获取到的新增笔记以实现更新。回调函数参数的值,即为在 requests.js 中定义的 createNote 函数所返回的值:

export const createNote = newNote =>
  axios.post(baseUrl, newNote).then(res => res.data)

用类似的方法去更新笔记的重要性也相对简单,但我们把这留作一个可选练习。

如果我们仔细观察浏览器的网络面板,我们会注意到:当我们将光标移动至输入框时,React Query 会立即去获取全部笔记。

dev tools notes app with input text field highlighted and arrow on network over notes request as 200

发生了什么?通过阅读 文档 ,我们注意到 React Query 查询的默认功能是:当窗口焦点,即应用中用户界面的活动元素,发生变化时,查询(其状态为 stale)会被更新。如果我们希望,我们可以按以下方式创建查询,以关闭这个功能:

const App = () => {
  // ...
  const result = useQuery('notes', getNotes, {
    refetchOnWindowFocus: false  })

  // ...
}

如果你在代码中放入 console.log,就会在浏览器的控制台中发现: React Query 引发的应用重复渲染是多么频繁。经验法则是,应在有需要的时候(即在查询状态发生变化时),才进行重新渲染。你可以在 这里 了解更多。

当前应用的代码可以在 GitHubpart6-3 的分支中找到。

React Query 一个多功能的库,根据我们已看到的情况,它简化了应用。那么,React Query 是否让更复杂的状态管理解决方案,如 Redux,变得无足轻重了呢?并非如此,在某些情况下,React Query 可以部分替代应用程序的状态,但是正如 文档 所说:

  • React Query 是 服务器状态的库,负责管理服务器和客户端之间的异步操作。
  • Redux 等则是客户端状态的库,可以用来存储异步数据,尽管效率不如 React Query 这样的工具。

因此,React Query 是一个在前端维护服务器状态的库,即作为服务器存储内容的缓存。React Query 简化了对服务器数据的处理,在某些情况下,可以消除将服务器数据存储在前端的需求。

大多数 React 应用不仅需要一种临时存储服务器数据的方法,还需要一些处理其他前端状态(例如表单和通知的状态)的解决方案。

useReducer

即使应用使用了 React query,通常还需要某种解决方案以管理前端的其他状态(例如,表单状态)。通常,利用 useState 创建的状态足以应对这种状况。使用 Redux 当然也没问题,但是我们还有其他选择。

让我们看一个简单的计数应用。这个应用显示计数器的值,并提供三个按钮以更新计数器的状态:

browser showing + - 0 buttons and 7 above

现在,我们利用 React 内置的 useReducer 钩子来进行状态管理,useReducer 钩子具有类似 Redux 的状态管理机制。代码如下:

import { useReducer } from 'react'

const counterReducer = (state, action) => {
  switch (action.type) {
    case "INC":
        return state + 1
    case "DEC":
        return state - 1
    case "ZERO":
        return 0
    default:
        return state
  }
}

const App = () => {
  const [counter, counterDispatch] = useReducer(counterReducer, 0)

  return (
    <div>
      <div>{counter}</div>
      <div>
        <button onClick={() => counterDispatch({ type: "INC"})}>+</button>
        <button onClick={() => counterDispatch({ type: "DEC"})}>-</button>
        <button onClick={() => counterDispatch({ type: "ZERO"})}>0</button>
      </div>
    </div>
  )
}

export default App

useReducer 钩子提供了为应用创建状态的机制。创建一个状态所需的参数有:处理状态变化的 reducer 函数,以及状态的初始值:

const [counter, counterDispatch] = useReducer(counterReducer, 0)

处理状态变化的 reducer 函数和 Redux 中的 reducers 类似,即,用该函数获得当前状态和改变此状态的 action 作为参数。该函数根据 action 的类型和其中的内容而返回更新后的状态。

const counterReducer = (state, action) => {
  switch (action.type) {
    case "INC":
        return state + 1
    case "DEC":
        return state - 1
    case "ZERO":
        return 0
    default:
        return state
  }
}

在我们的例子中,action 只有类型这一个字段。如果动作的类型是 INC,它就会将计数器的值增加 1,其他也类似。正如 Redux 的 reducers,actions 也可以包含任意的数据,这些数据通常都被放在 payload 字段中。

useReducer 函数返回一个数组,该数组包含一个可以访问当前状态值的元素(数组的第一个元素),以及一个用于改变状态的 dispatch 函数(数组的第二个元素):

const App = () => {
  const [counter, counterDispatch] = useReducer(counterReducer, 0)
  return (
    <div>
      <div>{counter}</div>      <div>
        <button onClick={() => counterDispatch({ type: "INC" })}>+</button>        <button onClick={() => counterDispatch({ type: "DEC" })}>-</button>
        <button onClick={() => counterDispatch({ type: "ZERO" })}>0</button>
      </div>
    </div>
  )
}

我们对状态的更改顺利完成,正如利用 Redux 一样。恰当的状态改变类型被传入 dispatch 函数作为参数:

counterDispatch({ type: "INC" })

当前应用的代码可以在 GitHubpart6-1 的分支中找到。

使用 context 传递组件的状态

如果我们希望将应用拆分成多个组件,我们必须将计数器的值和用于管理它的 dispatch 函数也传递给其他组件。一个解决方案是将计数器的值和 dispatch 函数作为参数传递:

const Display = ({ counter }) => {
  return <div>{counter}</div>
}

const Button = ({ dispatch, type, label }) => {
  return (
    <button onClick={() => dispatch({ type })}>
      {label}
    </button>
  )
}

const App = () => {
  const [counter, counterDispatch] = useReducer(counterReducer, 0)

  return (
    <div>
      <Display counter={counter}/>      <div>
        <Button dispatch={counterDispatch} type='INC' label='+' />        <Button dispatch={counterDispatch} type='DEC' label='-' />        <Button dispatch={counterDispatch} type='ZERO' label='0' />      </div>
    </div>
  )
}

这个解决方案是可行的,但并不是最优的。如果组件的结构变得更复杂,例如,需要经过多个组件才能将 dispatch 函数转发到真正需要它的组件,即使处于组件树中的其他组件都不需要它。这种现象被称为 prop drilling.

React 内置的 Context API 为我们提供了一个解决方案。React 的 context 类似应用的全局状态,应用中的组件均可以直接访问它。

现在,让我们在应用中创建一个 context,用以存储计数器的状态。

使用 React 的 createContext 钩子创建 context。让我们在文件 CounterContext.js 中创建 context:

import { createContext } from 'react'

const CounterContext = createContext()

export default CounterContext

App 组件现在可以通过如下的方式,向子组件提供 context:

import CounterContext from './CounterContext'
const App = () => {
  const [counter, counterDispatch] = useReducer(counterReducer, 0)

  return (
    <CounterContext.Provider value={[counter, counterDispatch]}>      <Display counter={counter}/>
      <div>
        <Button type='INC' label='+' />
        <Button type='DEC' label='-' />
        <Button type='ZERO' label='0' />
      </div>
    </CounterContext.Provider>  )
}

可以看到,我们通过将子组件包裹在 CounterContext.Provider 组件中,并为 context 设置合适的值,以传递 context。

context 的值现在被设置为一个包含了计数器的值和 dispatch 函数的数组。

其他的组件现在可以通过使用 useContext 钩子来访问 context。

import { useContext } from 'react'import CounterContext from './CounterContext'

const Display = () => {
  const [counter, dispatch] = useContext(CounterContext)  return <div>
    {counter}
  </div>
}

const Button = ({ type, label }) => {
  const [counter, dispatch] = useContext(CounterContext)  return (
    <button onClick={() => dispatch({ type })}>
      {label}
    </button>
  )
}

当前应用的代码可以在 GitHubpart6-2 的分支中找到。

在单独的文件中定义计数器的 context

我们的应用有个令人讨厌的特点:计数器一部分状态管理的功能,是在 APP 组件中定义的。现在,让我们将和计数器有关的内容,都移动到 CounterContext.js

import { createContext, useReducer } from 'react'

const counterReducer = (state, action) => {
  switch (action.type) {
    case "INC":
        return state + 1
    case "DEC":
        return state - 1
    case "ZERO":
        return 0
    default:
        return state
  }
}

const CounterContext = createContext()

export const CounterContextProvider = (props) => {
  const [counter, counterDispatch] = useReducer(counterReducer, 0)

  return (
    <CounterContext.Provider value={[counter, counterDispatch] }>
      {props.children}
    </CounterContext.Provider>
  )
}

export default CounterContext

这个文件除了导出和 context 对应的 CounterContext 对象外,还导出了CounterContextProvider 组件,这个组件实际上是一个 context 提供方,它的值包括一个计数器和一个用于其状态管理的调度器。

让我们更新 index.js ,以启用 context 提供方:

import ReactDOM from 'react-dom/client'
import App from './App'
import { CounterContextProvider } from './CounterContext'
ReactDOM.createRoot(document.getElementById('root')).render(
  <CounterContextProvider>    <App />
  </CounterContextProvider>)

现在,定义了计数器的值和功能的 context,可以被应用中的所有组件使用。

App 组件则被简化成如下的形式:

import Display from './components/Display'
import Button from './components/Button'

const App = () => {
  return (
    <div>
      <Display />
      <div>
        <Button type='INC' label='+' />
        <Button type='DEC' label='-' />
        <Button type='ZERO' label='0' />
      </div>
    </div>
  )
}

export default App

对 context 的使用仍然遵循先前的相同方法,例如, Button 组件可以通过如下的方式定义:

import { useContext } from 'react'
import CounterContext from '../CounterContext'

const Button = ({ type, label }) => {
  const [counter, dispatch] = useContext(CounterContext)
  return (
    <button onClick={() => dispatch({ type })}>
      {label}
    </button>
  )
}

export default Button

Button 组件仅需要计数器的 dispatch 函数,但是它也可以通过 useContext 从 context 中获取计数器的值:

  const [counter, dispatch] = useContext(CounterContext)

这不是个大问题,但是我们可以通过在 CounterContext 文件中编写一些辅助函数,使我们的代码更加优雅和清晰:

import { createContext, useReducer, useContext } from 'react'
const CounterContext = createContext()

// ...

export const useCounterValue = () => {
  const counterAndDispatch = useContext(CounterContext)
  return counterAndDispatch[0]
}

export const useCounterDispatch = () => {
  const counterAndDispatch = useContext(CounterContext)
  return counterAndDispatch[1]
}

// ...

有了辅助函数的帮助,组件使用 context 就可以只获取它们所需要的那部分。Display 组件的更新如下:

import { useCounterValue } from '../CounterContext'
const Display = () => {
  const counter = useCounterValue()  return <div>
    {counter}
  </div>
}


export default Display

Button 组件更新为:

import { useCounterDispatch } from '../CounterContext'
const Button = ({ type, label }) => {
  const dispatch = useCounterDispatch()  return (
    <button onClick={() => dispatch({ type })}>
      {label}
    </button>
  )
}

export default Button

这个解决方案非常优雅。整个应用的状态,即,计数器的值和管理值的代码,已经独立放置于 CounterContext 文件中,这个文件提供了命名良好和易于使用的辅助函数来管理状态。

当前应用的代码可以在 GitHubpart6-3 的分支中找到。

作为一个技术细节,应当注意到辅助函数——useCounterValueuseCounterDispatch,是 自定义钩子(custom hooks),因为只能通过 React 组件或自定义钩子调用钩子函数——useContext。此外,自定义钩子是必须以 use 作为名称开头的 JavaScript 函数。我们将在这门课程的 part 7 更深入地探讨自定义钩子。

###

应该选择哪一个状态管理方案?

在 1 至 5 章中,应用的所有状态管理都通过 React 的钩子 —— useState 来处理. 偶尔,对后端的异步调用还需要用上 useEffect。通常情况下,我们就不再需要额外的东西了。

在使用 useState 作为状态管理解决方案时,存在一个微妙的问题:如果应用某部分状态被多个组件需要,那么该状态和对应的操作状态的函数,必须通过 props 在所有处理状态的组件中层层传递。有时,props 需要在多个组件中传递,虽然这些过程中的组件并不需要该状态。这种有些令人不快的现象叫做 prop drilling

过去几年中,一些针对 Rect 应用状态管理的替代方案开始显露头角,它们可用于解决棘手的状况(例如:prop drilling)。然而,目前还不存在一个终极方案,当下所有的方案都有其自己的优势和劣势,而且新的解决方案还在层出不穷。

这种状况可能让初学者、甚至经验丰富的网页开发者感到无所适从——究竟应该使用哪一种方案?

对于简单的应用,useState 是个很好的起点。如果应用需要和服务器进行通信的话,这样的通信可以用与 1 - 5 章中相同的方式处理——即利用应用本身的状态。然而最近,利用 React Query (或类似的库)去处理全部,或至少一部分,通信和相关的状态管理,已变得越来越普遍。如果你对 useState 及相应的 prop drilling 抱有疑虑,context 可能会是一个好的选择。在一些情境下,利用 useState 管理部分状态,同时使用 context 管理其他部分的状态,也会是合理的。

Redux 是其中最全面和强大的状态管理方案,它是实现所谓 Flux 架构的一种方式。Redux 比本章介绍的方案更有历史。Redux 过去的僵化成为了当前很多新状态管理工具的开发动力,例如 React 的 useReducer 。但在有了 Redux Toolkit 后,对 Redux 僵化的批评已经消散。

过去几年中,类似 Redux 的状态管理库层出不穷,比如新晋的 Recoil 和略老一些的 MobX。然而,根据 Npm 趋势,Redux 仍旧是主宰,而且甚至扩大了领先优势。

graph showing redux growing in popularity over past 5 years

此外,Redux 不需要应用于整个应用。例如,当一个表单状态完全不影响应用的其他状态时,不使用 Reudx 去管理表单状态也是合理的。另外,在一个应用中,同时使用 Redux 和 React Query 也是完全可以接受的。

该选择哪一个状态管理方案?这个问题并不容易回答,也无法给出一个单一的正确答案。当应用增长到一定程度,此前的状态管理方案也可能成为一个次优的选择,即使该应用已经投入了生产使用。