跳到内容

b

React 与 GraphQL

我们接下来将实现一个React应用,它使用我们创建的GraphQL服务器。

服务器的当前代码可以在GitHub上找到,分支part8-3

理论上,我们可以使用GraphQL与HTTP POST请求。下面是一个使用Postman的例子。

fullstack content

通信的工作方式是向http://localhost:4000/graphql 发送HTTP POST请求。查询本身是一个字符串,作为键query的值发送。

我们可以通过使用Axios来处理React应用和GraphQL之间的通信。然而,在大多数情况下,这样做是不太明智的。使用一个能够抽象出不必要的通信细节的高阶库是一个更好的主意。

目前,有两个不错的选择。Facebook的RelayApollo Client,它是我们在上一节使用的同一个库的客户端。Apollo绝对是这两个中最受欢迎的,我们在这一节也会使用它。

Apollo client

让我们创建一个新的React-app。在写这篇文章的时候(2022年4月21日),Apollo客户端不能很好地与React 18版本一起工作。所以我们通过改变文件package.json将项目降级到以前的版本,如下所示。

{
  "dependencies": {
    "react": "^17.0.2",    "react-dom": "^17.0.2",    "react-scripts": "5.0.0",
    "web-vitals": "^2.1.4"
  },
  // ...
}

更改后,通过运行以下程序重新安装依赖关系

npm install

现在我们可以继续安装Apollo客户端所需的依赖项。

npm install @apollo/client graphql

我们将用以下代码开始我们的应用。

import ReactDOM from 'react-dom'
import App from './App'

import { ApolloClient, HttpLink, InMemoryCache, gql } from '@apollo/client'

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: new HttpLink({
    uri: 'http://localhost:4000',
  })
})

const query = gql`
query {
  allPersons {
    name,
    phone,
    address {
      street,
      city
    }
    id
  }
}
`

client.query({ query })
  .then((response) => {
    console.log(response.data)
  })

ReactDOM.render(<App />, document.getElementById('root'))

代码的开头创建了一个新的客户端对象,然后用它向服务器发送一个查询。

client.query({ query })
  .then((response) => {
    console.log(response.data)
  })

服务器的响应被打印到控制台。

fullstack content

应用可以使用client对象与GraphQL服务器通信。通过用ApolloProvider包装App组件,可以使应用的所有组件都能访问该客户端。

import ReactDOM from 'react-dom'
import App from './App'

import {
  ApolloClient, ApolloProvider, HttpLink, InMemoryCache} from '@apollo/client'

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: new HttpLink({
    uri: 'http://localhost:4000',
  })
})

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

Making queries

我们准备实现应用的主视图,它显示一个电话号码列表。

Apollo客户端提供了一些替代方案来进行查询

目前,使用钩子函数useQuery是最主要的做法。

查询是由App组件进行的,其代码如下。

import { gql, useQuery } from '@apollo/client'

const ALL_PERSONS = gql`
query {
  allPersons {
    name
    phone
    id
  }
}
`

const App = () => {
  const result = useQuery(ALL_PERSONS)

  if (result.loading) {
    return <div>loading...</div>
  }

  return (
    <div>
      {result.data.allPersons.map(p => p.name).join(', ')}
    </div>
  )
}

export default App

当被调用时,useQuery做出它作为参数收到的查询。

它返回一个有多个[字段]的对象(https://www.apollographql.com/docs/react/api/react/hooks/#result)。

如果查询还没有收到响应,字段loading为真。

然后下面的代码会被渲染。

if (result.loading) {
  return <div>loading...</div>
}

当收到响应时,可以从data字段中找到allPersons查询的结果,我们可以将名字的列表渲染在屏幕上。

<div>
  {result.data.allPersons.map(p => p.name).join(', ')}
</div>

让我们把显示人名列表分离成自己的组件。

const Persons = ({ persons }) => {
  return (
    <div>
      <h2>Persons</h2>
      {persons.map(p =>
        <div key={p.name}>
          {p.name} {p.phone}
        </div>
      )}
    </div>
  )
}

App组件仍然进行查询,并将结果传递给新的组件进行渲染。

const App = () => {
  const result = useQuery(ALL_PERSONS)

  if (result.loading)  {
    return <div>loading...</div>
  }

  return (
    <Persons persons={result.data.allPersons}/>
  )
}

Named queries and variables

我们来实现查看一个人的地址细节的功能。findPerson查询很适合于此。

我们在上一章所做的查询将参数硬编码到查询中。

query {
  findPerson(name: "Arto Hellas") {
    phone
    city
    street
    id
  }
}

当我们以编程方式进行查询时,我们必须能够动态地给他们参数。

GraphQL变量很适合于此。为了能够使用变量,我们也必须为我们的查询命名。

查询的一个好的格式是这样的。

query findPersonByName($nameToSearch: String!) {
  findPerson(name: $nameToSearch) {
    name
    phone
    address {
      street
      city
    }
  }
}

查询的名字是findPersonByName,它被赋予一个字符串$nameToSearch作为参数。

也可以用Apollo Explorer进行带参数的查询。参数在Variables中给出。

fullstack content

useQuery钩子非常适用于在组件渲染时进行查询的情况。 然而,我们现在想只在用户想看一个特定人的细节时才进行查询,所以查询只[按要求]进行(https://www.apollographql.com/docs/react/data/queries/#executing-queries-manually)。

这种情况的一个可能性是钩子函数useLazyQuery,它可以定义一个查询,当用户想看一个人的详细信息时才执行

然而,在我们的案例中,我们可以坚持使用useQuery,并使用选项skip,这使得只有在设定的条件为真时才可以进行查询。

解决方案如下。

import { useState } from 'react'
import { gql, useQuery } from '@apollo/client'

const FIND_PERSON = gql`
  query findPersonByName($nameToSearch: String!) {
    findPerson(name: $nameToSearch) {
      name
      phone
      id
      address {
        street
        city
      }
    }
  }
`

const Person = ({ person, onClose }) => {
  return (
    <div>
      <h2>{person.name}</h2>
      <div>
        {person.address.street} {person.address.city}
      </div>
      <div>{person.phone}</div>
      <button onClick={onClose}>close</button>
    </div>
  )
}

const Persons = ({ persons }) => {
  const [nameToSearch, setNameToSearch] = useState(null)  const result = useQuery(FIND_PERSON, {    variables: { nameToSearch },    skip: !nameToSearch,  })
  if (nameToSearch && result.data) {    return (      <Person        person={result.data.findPerson}        onClose={() => setNameToSearch(null)}      />    )  }
  return (
    <div>
      <h2>Persons</h2>
      {persons.map((p) => (
        <div key={p.name}>
          {p.name} {p.phone}
          <button onClick={() => setNameToSearch(p.name)}>            show address          </button>        </div>
      ))}
    </div>
  )
}

export default Persons

代码有相当大的变化,所有的变化并不完全明显。

当按下一个人的按钮显示地址时,这个人的名字被设置为状态nameToSearch

<button onClick={() => setNameToSearch(p.name)}>
  show address
</button>

这导致组件重新渲染。在渲染时,获取用户详细信息的查询FIND_PERSON被执行,如果变量nameToSearch有一个值。

const result = useQuery(FIND_PERSON, {
  variables: { nameToSearch },
  skip: !nameToSearch,})

当用户对看到任何一个人的详细信息不感兴趣时,状态变量nameToSearch为空,查询不被执行。

如果状态变量nameToSearch有一个值,并且查询结果已经准备好了,组件Person会显示一个人的详细信息。

if (nameToSearch && result.data) {
  return (
    <Person
      person={result.data.findPerson}
      onClose={() => setNameToSearch(null)}
    />
  )
}

一个单一的人的视图如下所示:

fullstack content

当用户想返回到人物列表时,nameToSearch状态被设置为null

该应用的当前代码可以在GitHub分支part8-1找到。

Cache

当我们进行多次查询时,例如查询Arto Hellas的详细地址,我们注意到一些有趣的事情:向后端查询只在第一次进行。在这之后,尽管代码再次进行相同的查询,但查询不会被发送到后端。

fullstack content

Apollo客户端将查询的响应保存到cache。为了优化性能,如果查询的响应已经在缓存中,查询根本不会被发送到服务器。

fullstack content

缓存显示了查询findPerson后Arto Hellas的详细信息。

fullstack content

Doing mutations

我们来实现添加新人物的功能。

在上一章中,我们对改变的参数进行了硬编码。现在,我们需要一个使用变量的addPerson改变的版本。

const CREATE_PERSON = gql`
mutation createPerson($name: String!, $street: String!, $city: String!, $phone: String) {
  addPerson(
    name: $name,
    street: $street,
    city: $city,
    phone: $phone
  ) {
    name
    phone
    id
    address {
      street
      city
    }
  }
}
`

钩子函数useMutation提供了制作改变的功能。

让我们创建一个新的组件,用于在目录中添加一个新的人。

import { useState } from 'react'
import { gql, useMutation } from '@apollo/client'

const CREATE_PERSON = gql`
  // ...
`

const PersonForm = () => {
  const [name, setName] = useState('')
  const [phone, setPhone] = useState('')
  const [street, setStreet] = useState('')
  const [city, setCity] = useState('')

  const [ createPerson ] = useMutation(CREATE_PERSON)
  const submit = (event) => {
    event.preventDefault()

    createPerson({  variables: { name, phone, street, city } })
    setName('')
    setPhone('')
    setStreet('')
    setCity('')
  }

  return (
    <div>
      <h2>create new</h2>
      <form onSubmit={submit}>
        <div>
          name <input value={name}
            onChange={({ target }) => setName(target.value)}
          />
        </div>
        <div>
          phone <input value={phone}
            onChange={({ target }) => setPhone(target.value)}
          />
        </div>
        <div>
          street <input value={street}
            onChange={({ target }) => setStreet(target.value)}
          />
        </div>
        <div>
          city <input value={city}
            onChange={({ target }) => setCity(target.value)}
          />
        </div>
        <button type='submit'>add!</button>
      </form>
    </div>
  )
}

export default PersonForm

这个表单的代码很简单,有趣的几行已经被强调了。

我们可以使用useMutation钩子来定义改变函数。

该钩子返回一个array,其中的第一个元素包含引起改变的函数。

const [ createPerson ] = useMutation(CREATE_PERSON)

查询变量在查询时接收数值。

createPerson({  variables: { name, phone, street, city } })

新的人员添加得很好,但屏幕没有更新。这是因为Apollo客户端不能自动更新应用的缓存,所以它仍然包含改变前的状态。

我们可以通过重新加载页面来更新屏幕,因为当页面被重新加载时,缓存被清空。然而,一定有更好的方法来做到这一点。

Updating the cache

对此有几个不同的解决方案。一种方法是对所有的人进行查询poll服务器,或者反复进行查询。

变化很小。让我们把查询设置为每两秒轮询一次。

const App = () => {
  const result = useQuery(ALL_PERSONS, {
    pollInterval: 2000  })

  if (result.loading)  {
    return <div>loading...</div>
  }

  return (
    <div>
      <Persons persons = {result.data.allPersons}/>
      <PersonForm />
    </div>
  )
}

export default App

解决方案很简单,每当一个用户添加一个新的人,它就会立即出现在所有用户的屏幕上。

这个解决方案的坏处是所有无意义的网络流量。

另一个保持缓存同步的简单方法是使用useMutation钩子的refetchQueries参数来定义,每当创建一个新的人,就重新进行获取所有人员的查询。

const ALL_PERSONS = gql`
  query  {
    allPersons  {
      name
      phone
      id
    }
  }
`

const PersonForm = (props) => {
  // ...

  const [ createPerson ] = useMutation(CREATE_PERSON, {
    refetchQueries: [ { query: ALL_PERSONS } ]  })

这个解决方案的优点和缺点几乎与前一个解决方案相反。没有额外的网络流量,因为查询不是为了以防万一而进行的。 然而,如果一个用户现在更新了服务器的状态,这些变化不会立即显示给其他用户。

如果你想做多个查询,你可以在refetchQueries里面传递多个对象。这将允许你同时更新你的应用的不同部分。下面是一个例子。

    const [ createPerson ] = useMutation(CREATE_PERSON, {
    refetchQueries: [ { query: ALL_PERSONS }, { query: OTHER_QUERY }, { query: ... } ] // pass as many queries as you need
  })

还有其他更新缓存的方法。在这一部分的后面会有更多的介绍。

目前,在我们的代码中,查询和组件被定义在同一个地方。

让我们把查询的定义分开到他们自己的文件queries.js

import { gql  } from '@apollo/client'

export const ALL_PERSONS = gql`
  query {
    // ...
  }
`
export const FIND_PERSON = gql`
  query findPersonByName($nameToSearch: String!) {
    // ...
  }
`

export const CREATE_PERSON = gql`
  mutation createPerson($name: String!, $street: String!, $city: String!, $phone: String) {
    // ...
  }
`

然后每个组件导入它需要的查询。

import { ALL_PERSONS } from './queries'

const App = () => {
  const result = useQuery(ALL_PERSONS)
  // ...
}

该应用的当前代码可以在GitHub分支part8-2上找到。

Handling mutation errors

试图用无效的数据创建一个人,会导致一个错误。

fullstack content

我们应该处理这个异常。我们可以使用useMutation钩子的onError选项向改变注册一个错误处理函数。

让我们给改变注册一个错误处理函数,该函数使用setError

它收到的函数作为参数来设置一个错误信息。

const PersonForm = ({ setError }) => {
  // ...

  const [ createPerson ] = useMutation(CREATE_PERSON, {
    refetchQueries: [  {query: ALL_PERSONS } ],
    onError: (error) => {      setError(error.graphQLErrors[0].message)    }  })

  // ...
}

然后我们就可以在屏幕上渲染必要的错误信息。

const App = () => {
  const [errorMessage, setErrorMessage] = useState(null)
  const result = useQuery(ALL_PERSONS)

  if (result.loading)  {
    return <div>loading...</div>
  }

  const notify = (message) => {    setErrorMessage(message)    setTimeout(() => {      setErrorMessage(null)    }, 10000)  }
  return (
    <div>
      <Notify errorMessage={errorMessage} />      <Persons persons = {result.data.allPersons} />
      <PersonForm setError={notify} />    </div>
  )
}

const Notify = ({errorMessage}) => {  if ( !errorMessage ) {    return null  }  return (    <div style={{color: 'red'}}>    {errorMessage}    </div>  )}

现在用户可以通过一个简单的通知来了解错误。

fullstack content

该应用的当前代码可以在GitHub分支part8-3上找到。

Updating a phone number

让我们在我们的应用中增加改变人的电话号码的可能性。这个解决方案与我们用于添加新人员的方案几乎相同。

同样,改变需要参数。

export const EDIT_NUMBER = gql`
  mutation editNumber($name: String!, $phone: String!) {
    editNumber(name: $name, phone: $phone) {
      name
      phone
      address {
        street
        city
      }
      id
    }
  }
`

负责更改的PhoneForm组件很简单。这个表单有个人姓名和新电话号码的字段,并调用changeNumber函数。这个函数是用useMutation钩子完成的。

代码中有趣的行已经被突出显示。

import { useState } from 'react'
import { useMutation } from '@apollo/client'

import { EDIT_NUMBER } from '../queries'

const PhoneForm = () => {
  const [name, setName] = useState('')
  const [phone, setPhone] = useState('')

  const [ changeNumber ] = useMutation(EDIT_NUMBER)
  const submit = (event) => {
    event.preventDefault()

    changeNumber({ variables: { name, phone } })
    setName('')
    setPhone('')
  }

  return (
    <div>
      <h2>change number</h2>

      <form onSubmit={submit}>
        <div>
          name <input
            value={name}
            onChange={({ target }) => setName(target.value)}
          />
        </div>
        <div>
          phone <input
            value={phone}
            onChange={({ target }) => setPhone(target.value)}
          />
        </div>
        <button type='submit'>change number</button>
      </form>
    </div>
  )
}

export default PhoneForm

它看起来很暗淡,但它能工作。

fullstack content

令人惊讶的是,当一个人的号码被改变时,新号码会自动出现在由Persons组件渲染的人员列表中。

发生这种情况是因为每个人都有一个ID类型的识别字段,所以当一个人的详细资料随着改变而改变时,保存在缓存中的资料会自动更新。

目前的应用代码可以在GitHub分支part8-4上找到。

我们的应用仍然有一个小缺陷。如果我们试图改变一个不存在的名字的电话号码,似乎什么都不会发生。

这种情况发生的原因是,如果找不到一个具有该名字的人。

改变的响应是null

fullstack content

对于GraphQL,这不是一个错误,所以注册一个onError错误处理程序是没有用的。

我们可以使用由useMutation钩子返回的result字段作为其第二个参数来生成一个错误信息。

const PhoneForm = ({ setError }) => {
  const [name, setName] = useState('')
  const [phone, setPhone] = useState('')

  const [ changeNumber, result ] = useMutation(EDIT_NUMBER)
  const submit = (event) => {
    // ...
  }

  useEffect(() => {    if (result.data && result.data.editNumber === null) {      setError('person not found')    }  }, [result.data])
  // ...
}

如果找不到一个人,或者result.data.editNumbernull,该组件使用它作为prop收到的回调函数来设置一个合适的错误信息。

我们希望只在改变的结果中设置错误信息

result.data发生变化,所以我们使用useEffect钩子来控制设置错误信息。

使用useEffect会导致一个ESLint警告。

fullstack content

这个警告毫无意义,最简单的解决办法是忽略该行的ESLint规则。

useEffect(() => {
  if (result.data && !result.data.editNumber) {
    setError('name not found')
  }
}, [result.data])  // eslint-disable-line

我们可以尝试通过在useEffect's第二参数数组中加入setError函数来摆脱这个警告。

useEffect(() => {
  if (result.data && !result.data.editNumber) {
    setError('name not found')
  }
}, [result.data, setError])

但是,如果notify函数没有被包裹到useCallback函数中,这个解决方案就不起作用。 如果不是这样,这将导致一个无休止的循环。当通知被删除后,App组件被重新渲染,一个新版本notify被创建,导致效果函数被执行,从而导致一个新的通知,等等,等等......

该应用的当前代码可以在GitHub分支part8-5上找到。

Apollo Client and the applications state

在我们的例子中,应用的状态管理主要由Apollo客户端负责。这是一个非常典型的GraphQL应用的解决方案。

我们的例子只使用React组件的状态来管理表单的状态和显示错误通知。因此,在使用GraphQL时,可能没有合理的理由使用Redux来管理应用的状态。

必要时,Apollo可以将应用的本地状态保存到Apollo cache