掘金 后端 ( ) • 2024-04-07 13:54

使用 Apollo 客户端进行数据变更:使用 useMutation 钩子

学习了如何使用 Apollo 客户端从后端查询数据后,下一步自然是学习如何使用变更来修改后端数据。

本文展示了如何使用 useMutation 钩子向 GraphQL 服务器发送更新。您还将学习在执行变更后如何更新 Apollo 客户端缓存,以及如何跟踪加载和错误状态。

要跟随下面的示例,请打开我们的入门项目和 CodeSandbox 上的示例 GraphQL 服务器。您可以在这里查看应用的完整版本。

先决条件

本文假设您熟悉构建基本的 GraphQL 变更。如果您需要复习,请阅读这个指南。

本文还假设您已经设置了 Apollo 客户端,并已将您的 React 应用包装在 ApolloProvider 组件中。获取开始的帮助,请参见这里。

执行一个变更 (Executing a mutation)

useMutation React 钩子是在 Apollo 应用程序中执行变更的主要 API。

要执行一个变更,首先在 React 组件中调用 useMutation,并传递您要执行的变更,如下所示:

my-component.jsx
import { gql, useMutation } from '@apollo/client';

// 定义变更
const INCREMENT_COUNTER = gql`
  # 增加一个后端计数器并获取其结果值
  mutation IncrementCounter {
    currentValue
  }
`;

function MyComponent() {
  // 将变更传递给 useMutation
  const [mutateFunction, { data, loading, error }] = useMutation(INCREMENT_COUNTER);
}

如上所示,您使用 gql 函数将变更字符串解析成 GraphQL 文档,然后传递给 useMutation。

当组件渲染时,useMutation 返回一个包括:

  • 一个可以随时调用以执行变更的 mutate 函数 与 useQuery 不同,useMutation 不会在渲染时自动执行其操作。相反,您调用这个 mutate 函数。
  • 一个表示变更执行当前状态的对象(data, loading 等) 该对象类似于 useQuery 钩子返回的对象。有关详细信息,请参见 Result。

示例

假设我们正在创建一个待办事项列表应用程序,并且我们希望用户能够向他们的列表中添加项目。首先,我们将创建一个名为 ADD_TODO 的对应 GraphQL 变更。记得将 GraphQL 字符串包裹在 gql 函数中,以将它们解析成查询文档:

jsxCopy code
add-todo.jsx
import { gql, useMutation } from '@apollo/client';

const ADD_TODO = gql`
  mutation AddTodo($type: String!) {
    addTodo(type: $type) {
      id
      type
    }
  }
`;

接下来,我们将创建一个名为 AddTodo 的组件,它代表待办事项列表的提交表单。在其中,我们将我们的 ADD_TODO 变更传递给 useMutation 钩子:

jsxCopy code
add-todo.jsx
function AddTodo() {
  let input;
  const [addTodo, { data, loading, error }] = useMutation(ADD_TODO);

  if (loading) return '提交中...';
  if (error) return `提交错误!${error.message}`;

  return (
    <div>
      <form
        onSubmit={e => {
          e.preventDefault();
          addTodo({ variables: { type: input.value } });
          input.value = '';
        }}
      >
        <input
          ref={node => {
            input = node;
          }}
        />
        <button type="submit">添加待办事项</button>
      </form>
    </div>
  );
}

在此示例中,我们的表单 onSubmit 处理程序调用 useMutation 钩子返回的 mutate 函数(命名为 addTodo)。这告诉 Apollo 客户端通过将其发送到我们的 GraphQL 服务器来执行变更。

请注意,这与 useQuery 的行为不同,useQuery 在其组件渲染后立即执行其操作。这是因为变更通常是响应用户操作而执行的(例如在这种情况下提交表单)。

提供选项(Providing options)

useMutation 钩子接受一个选项对象作为其第二个参数。这是一个为 GraphQL 变量提供一些默认值的示例:

const [addTodo, { data, loading, error }] = useMutation(ADD_TODO, {
  variables: {
    type: "占位符",
    someOtherVariable: 1234,
  },
});

所有支持的选项都列在 Options 中。

您也可以将选项直接提供给您的 mutate 函数,如上面示例中所示:

addTodo({
  variables: {
    type: input.value,
  },
});

在这里,我们使用 variables 选项为我们的变更所需的任何 GraphQL 变量提供值(特别是创建的待办事项的类型)。

选项优先级

如果您同时向 useMutation 和 mutate 函数提供了相同的选项,mutate 函数的值优先。在变量选项的具体情况下,两个对象被浅合并,这意味着仅提供给 useMutation 的任何变量都保留在结果对象中。这有助于为变量设置默认值。

在上面的示例片段中,input.value 会覆盖 "占位符" 作为 type 变量的值。someOtherVariable(1234)的值将被保留。

跟踪变更状态(Tracking mutation status)

除了 mutate 函数之外,useMutation 钩子还返回一个表示变更执行当前状态的对象。该对象的字段(在 Result 中列出)包括表示 mutate 函数是否已调用,以及变更结果是否当前正在加载的布尔值。

上面的示例解构了此对象的 loading 和 error 字段,根据变更的当前状态以不同的方式渲染 AddTodo 组件:

if (loading) return '提交中...';
if (error) return `提交错误!${error.message}`;

useMutation 钩子还支持 onCompleted 和 onError 选项,如果您更喜欢使用回调,请参阅 API 参考。

重置变更状态 (Resetting mutation status)

useMutation 返回的变更结果对象包括一个 reset 函数:

const [login, { data, loading, error, reset }] = useMutation(LOGIN_MUTATION);

调用 reset 可将变更结果重置为其初始状态(即,在调用 mutate 函数之前)。您可以使用此功能来使用户在 UI 中忽略变更结果数据或错误。

调用 reset 不会删除变更执行返回的任何缓存数据。它只影响与 useMutation 钩子相关的状态,导致相应的组件重新渲染。

jsxCopy code
function LoginPage () {
  const [login, { error, reset }] = useMutation(LOGIN_MUTATION);

  return (
    <>
      <form>
        <input class="login"/>
        <input class="password"/>
        <button onclick={login}>登录</button>
      </form>
      {
        error &&
        <LoginFailedMessageWindow
          message={error.message}
          onDismiss={() => reset()}
        />
      }
    </>
  );
}

更新本地数据

执行变更时,您会修改后端数据。通常,您会希望更新您的本地缓存数据以反映后端的修改。例如,如果您执行一个变更以向您的待办事项列表中添加一个项目,您也希望该项目出现在您的缓存副本中。

支持的方法

更新本地数据最直接的方式是重新获取可能受变更影响的任何查询。但是,这种方法需要额外的网络请求。

如果您的变更返回了它修改的所有对象和字段,您可以直接更新您的缓存,而不需要进行任何后续网络请求。但是,随着您的变更变得更加复杂,这种方法的复杂度也会增加。

如果您刚开始使用 Apollo 客户端,我们建议重新获取查询来更新您的缓存数据。在您解决这个问题之后,您可以通过直接更新缓存来提高应用程序的响应性。

重新获取查询

如果您知道您的应用程序通常需要在特定变更后重新获取某些查询,您可以在该变更的选项中包含一个 refetchQueries 数组:

jsxCopy code
// 变更完成后重新获取两个查询
const [addTodo, { data, loading, error }] = useMutation(ADD_TODO, {
  refetchQueries: [
    GET_POST, // 用 gql 解析的 DocumentNode 对象
    'GetComments' // 查询名称
  ],
});

您只能重新获取活动查询。活动查询是当前页面上的组件使用的那些查询。如果您想更新的数据没有被当前页面上的组件获取,则最好直接更新您的缓存。

refetchQueries 数组中的每个元素是以下之一:

  • 用 gql 函数解析的 DocumentNode 对象
  • 您之前执行的查询的名称,作为字符串(例如,GetComments) 要通过名称引用查询,请确保您的应用中的每个查询都有一个唯一的名称。 每个包含的查询都将使用其最近提供的一组变量来执行。

您可以将 refetchQueries 选项提供给 useMutation 或 mutate 函数。有关详细信息,请参见选项优先级。

请注意,在一个拥有数十个或数百个不同查询的应用中,确定在特定变更后需要重新获取哪些查询可能是具有挑战性的。

直接更新缓存(Updating the cache directly)

包含变更响应中的修改对象

在大多数情况下,变更响应应该包括变更修改的任何对象。这使得 Apollo 客户端能够根据其 __typename 和 id 字段(默认情况下)对这些对象进行规范化并缓存它们。

在上面的示例中,我们的 ADD_TODO 变更可能返回一个 Todo 对象,具有以下结构:

jsonCopy code
{
  "__typename": "Todo",
  "id": "5",
  "type": "groceries"
}

Apollo 客户端默认会自动向您的查询和变更中的每个对象添加 __typename 字段。

收到此响应对象后,Apollo 客户端使用键 Todo:5 缓存它。如果缓存中已经存在带有此键的对象,则 Apollo 客户端会覆盖在变更响应中也包含的任何现有字段(其他现有字段被保留)。

像这样返回修改后的对象是将您的缓存与后端同步的有用的第一步。但是,这并不总是足够的。例如,一个新缓存的对象不会自动添加到现在应该包括该对象的任何列表字段中。为了做到这一点,您可以定义一个 update 函数。

update 函数

当变更的响应不足以更新缓存中的所有修改字段(如某些列表字段)时,您可以定义一个 update 函数在变更后对缓存数据进行手动更改。

您可以向 useMutation 提供一个 update 函数,如下所示:

jsxCopy code
const GET_TODOS = gql`
  query GetTodos {
    todos {
      id
    }
  }
`;

function AddTodo() {
  let input;
  const [addTodo] = useMutation(ADD_TODO, {
    update(cache, { data: { addTodo } }) {
      cache.modify({
        fields: {
          todos(existingTodos = []) {
            const newTodoRef = cache.writeFragment({
              data: addTodo,
              fragment: gql`
                fragment NewTodo on Todo {
                  id
                  type
                }
              `
            });
            return [...existingTodos, newTodoRef];
          }
        }
      });
    }
  });

  return (
    <div>
      <form
        onSubmit={e => {
          e.preventDefault();
          addTodo({ variables: { type: input.value } });
          input.value = "";
        }}
      >
        <input
          ref={node => {
            input = node;
          }}
        />
        <button type="submit">添加待办事项</button>
      </form>
    </div>
  );
}

如上所示,update 函数被传递了一个表示 Apollo 客户端缓存的 cache 对象。该对象提供对 cache API 方法的访问,如 readQuery/writeQuery、readFragment/writeFragment、modify 和 evict。这些方法使您能够像与 GraphQL 服务器交互一样对缓存执行 GraphQL 操作。

在与缓存数据交互中了解更多关于支持的缓存函数。

update 函数还被传递了一个包含变更结果的 data 属性的对象。您可以使用此值使用 cache.writeQuery、cache.writeFragment 或 cache.modify 更新缓存。

如果您的变更指定了一个乐观响应,您的 update 函数将被调用两次:一次使用乐观结果,再次使用变更实际返回的结果。

在上面的示例中,当 ADD_TODO 变更执行时,新添加并返回的 addTodo 对象在 update 函数运行之前自动保存到缓存中。但是,ROOT_QUERY.todos 的缓存列表(由 GET_TODOS 查询观察)没有自动更新。这意味着 GET_TODOS 查询没有被通知新的 Todo 对象,这反过来意味着查询没有更新以显示新项目。

为了解决这个问题,我们使用 cache.modify 来手术般地从缓存中插入或删除项目,通过运行 "修改器" 函数。在上面的示例中,我们知道 GET_TODOS 查询的结果存储在缓存中的 ROOT_QUERY.todos 数组中,所以我们使用一个 todos 修改器函数来更新缓存数组,以包含对新添加的 Todo 的引用。借助 cache.writeFragment,我们得到了对添加的 Todo 的内部引用,然后将该引用附加到 ROOT_QUERY.todos 数组中。

您在 update 函数内对缓存数据所做的任何更改都会自动广播到正在监听该数据更改的查询。因此,您的应用程序的 UI 将更新以反映这些更新的缓存值。

在更新后重新获取

update 函数尝试在客户端的本地缓存中复制变更的后端修改。这些缓存修改被广播到所有受影响的活动查询,自动更新您的 UI。如果 update 函数正确地做到了这一点,您的用户将立即看到最新的数据,而无需等待另一轮网络往返。

但是,update 函数可能通过错误地设置一个缓存值来弄错这种复制。您可以通过重新获取受影响的活动查询来“双重检查”您的 update 函数的修改。为此,您首先为您的 mutate 函数提供一个 onQueryUpdated 回调函数:

addTodo({
  variables: { type: input.value },
  update(cache, result) {
    // 以缓存更新作为服务器端变更效果的近似
  },
  onQueryUpdated(observableQuery) {
    // 定义是否重新获取的自定义逻辑
    if (shouldRefetchQuery(observableQuery)) {
      return observableQuery.refetch();
    }
  },
})

在您的 update 函数完成后,Apollo 客户端将为每个具有更新的缓存字段的活动查询调用 onQueryUpdated 一次。在 onQueryUpdated 中,您可以使用任何自定义逻辑来确定是否想要重新获取关联的查询。

要从 onQueryUpdated 重新获取查询,请调用 return observableQuery.refetch(),如上所示。否则,不需要返回值。如果重新获取的查询的响应与您的 update 函数的修改不同,则您的缓存和 UI 都会再次自动更新。否则,您的用户不会看到任何变化。

有时,让您的 update 函数更新所有相关查询可能会很困难。并非每个变更都返回足够的信息,以便 update 函数有效地完成其工作。为了绝对确保某个查询被包括在内,您可以将 onQueryUpdated 与 refetchQueries: [...] 结合使用:

jsxCopy code
addTodo({
  variables: { type: input.value },
  update(cache, result) {
    // 以缓存更新作为服务器端变更效果的近似。
  },
  // 强制 ReallyImportantQuery 传递给 onQueryUpdated。
  refetchQueries: ["ReallyImportantQuery"],
  onQueryUpdated(observableQuery) {
    // 如果 ReallyImportantQuery 是活动的,它将传递给 onQueryUpdated。
    // 如果没有该名称的活动查询,则将记录一个警告。
  },
})

如果 ReallyImportantQuery 已经因为您的 update 函数而要被传递给 onQueryUpdated,那么它只会被传递一次。使用 refetchQueries: ["ReallyImportantQuery"] 只是保证了查询将被包括。

如果您发现已经包含了比您预期更多的查询,则可以在检查 ObservableQuery 后确定它不需要重新获取,从 onQueryUpdated 返回 false 来跳过或忽略一个查询。从 onQueryUpdated 返回 Promise 会导致最终的 Promise<FetchResult> 等待来自 onQueryUpdated 的任何 promise,消除了对传统 awaitRefetchQueries: true 选项的需求。

要在不执行变更的情况下使用 onQueryUpdated API,请尝试 client.refetchQueries 方法。在独立的 client.refetchQueries API 中,refetchQueries: [...] 变更选项称为 include: [...],update 函数称为 updateCache 以便清晰。否则,相同的内部系统支持 client.refetchQueries 和变更后重新获取查询。