在React UI中自动处理Apollo客户端错误

502 阅读12分钟

在React UI中自动处理Apollo客户端错误

当你的客户端与GraphQL服务器进行通信时,在运行时有许多事情会出错。首先是网络问题的可能性,然后是到达服务器但格式不正确或不匹配模式的查询问题。接下来,一旦你的服务器能够成功地解析一个查询,你的解析器就会出现各种问题,因为它们试图从上游来源获取数据,并将其转换成可以发回给客户端的形式。

除了奇怪的网络问题外,这些错误都不是用户的错。相反,它们通常代表了你这个开发者的错误。这个错误可能是一个小的代码缺陷,一个主要的用户界面设计失败,或者是介于两者之间的东西。不管是什么情况,用户通常很少能做到这一点。因此,当你的应用程序出现问题时,能为你的用户做的最好的事情往往是尽快让他们知道,也许建议他们以后再试一下,以防这是一个间歇性的问题,并鼓励他们在问题持续时让你知道。

在这篇文章中,我将演示如何建立一个React UI,以便在Apollo客户端遇到任何错误时都能通知用户。然后我将展示这一技术如何适应与部分返回的数据集的工作,其中一些类型的错误被认为是可以接受的。最后,对于那些担心全局错误处理程序会使其难以捕捉到需要特殊处理的错误的人,我将论证,如果你能控制你的GraphQL模式,你可能会更好地将错误信息编入模式中,或者改进你的用户界面,将错误发生的机会降到最低。

入门

让我们从一个简单的Apollo客户端设置开始,没有任何错误处理。

import ReactDOM from "react-dom";
import App from "./App";
import {
  ApolloClient,
  ApolloLink,
  ApolloProvider,
  HttpLink,
  InMemoryCache
} from "@apollo/client";

const apolloClient = new ApolloClient({
  cache: new InMemoryCache(),
  link: ApolloLink.from([
    new HttpLink({ uri: "http://localhost:4000" })
  ]),
 });
  
ReactDOM.render(
  <ApolloProvider client={apolloClient}>
    <App />
  </ApolloProvider>,
  document.getElementById("root")
);

这将我们的整个App 组件包裹在一个ApolloProvider ,这意味着App 组件层次结构中的任何东西都可以使用Apollo React钩子

每次你执行这些钩子时,你可以选择处理(或忽略)任何你认为合适的错误。如果你不想处处这样做,而希望总是将错误记录到浏览器控制台,你可以设置一个Apollo客户端错误链接来做。

...
import { onError } from "@apollo/client/link/error";

const apolloClient = new ApolloClient({
  cache: new InMemoryCache(),
  link: ApolloLink.from([
    new HttpLink({ uri: "http://localhost:4000" }),
    onError(({ graphQLErrors, networkError }) => {
      if (networkError) {
        console.log(`[Network error]: ${networkError}`);
      }

      if (graphQLErrors) {
        graphQLErrors.forEach(({ message, locations, path }) =>
          console.log(
            `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
          )
        );
      }
    })
  ]),
 });
 ...

但是这对用户来说没有什么用处,他们甚至可能不知道什么浏览器控制台,更不用说他们应该在什么时候打开它来检查是否发生了错误。相反,如果问题发生在一个钩子上,而该调用站点没有进行具体的错误处理,那么用户很可能只是从页面上的一个空白部分开始,或者是一个无限期旋转的活动指示灯。

然而,我们的代码目前的结构并不能让错误链接在问题发生时触发我们的用户界面,因为在我们开始渲染用户界面之前,ApolloClient 实例已经被创建。幸运的是,这并不意味着必须如此。

一个天真的方法

让我们从定义一个(非常)简单的组件开始,这个组件可以用来在用户要求时向其显示一个(非常)简单的错误信息。在这种情况下,该组件将使用函数为子的模式,将一个回调传递给它的孩子。然后,子代可以使用这个回调来触发显示错误信息,只要他们想。该组件的代码将看起来像这样。

import { useState } from "react"
...
function ErrorNotifier({ children }) {
  const [doShowError, setDoShowError] = useState(false);
  return (
    <>
      {doShowError ? (
        <>
          <h1 role="alert">Error!</h1>
          <button onClick={() => setDoShowError(false)}>Dismiss</button>
        </>
      ) : null}
      {children(() => setDoShowError(true))}
    </>
  );
}

我们还包括一个 "解散 "按钮,点击后将隐藏错误信息。

请注意,在现实世界的使用中,错误通知可能会比这个方便用户。例如,它可以是一个弹出的对话框,或者是一个敬酒通知。我们也可以通过React Context让子函数访问触发器,而不是把它传给子函数。然而,为了保持这篇文章的简短,我们将采用最简单的东西,可能会有效果。

为了使用这个组件,我们首先把它包在ApolloProvider

ReactDOM.render(
  <ErrorNotifier>
    {(showError) => (
      <ApolloProvider client={apolloClient}>
        <App />
      </ApolloProvider>
    )}
  </ErrorNotifier>,
  document.getElementById("root")
);

尽管这仍然没有什么帮助,因为showError 的回调还没有被调用。我们真正想要的是让传入ApolloClient 实例的错误链接能够调用showError 。为了达到这个目的,我们要做一些非常规的事情,将实例的创建推迟到showError 之后。

...
ReactDOM.render(
  <ErrorNotifier>
    {(showError) => (
      <ApolloProvider client={new ApolloClient({
        cache: new InMemoryCache(),
        link: ApolloLink.from([
          onError(({ graphQLErrors, networkError }) => {
            if (networkError) {
              ...
              showError();
            }

            if (graphQLErrors) {
              ...
              showError();
            }
          }),
          new HttpLink({ uri: "http://localhost:3000" }),
        ]),
      })}>
        <App />
      </ApolloProvider>
    )}
  </ErrorNotifier>,
  document.getElementById("root")
);

所以现在,只要Apollo客户端检测到一个错误,它就会把它记录到控制台通知用户发生了问题。问题解决了,对吗?

不完全是

任何一个经常使用Apollo客户端的人在这一点上可能会被吓到,因为我们在这里所做的意味着每次 ErrorNotifier 渲染时都会创建一个新的ApolloClient 的实例,也就是每次错误发生时。这又意味着之前的ApolloClient 实例所保留的内存缓存将被扔掉,并创建一个新的缓存。这不是一件好事。

幸运的是,我们可以用一些React的功夫来阻止这种情况的发生。让我们先把创建ApolloClient 实例和渲染ApolloProvider 的逻辑移到自己组件中,称为MyApolloProvider 。然后我们会让MyApolloProvider 使用 [useMemo](https://reactjs.org/docs/hooks-reference.html#usememo)钩子来记忆ApolloClient 实例。

import { useMemo } from "react"
...
function MyApolloProvider({ showError }) {
  const apolloClient = useMemo(
    () => new ApolloClient({
      ... // Same configuration as before
    }), 
    [showError]
  )

  return (
    <ApolloProvider client={apolloClient}>
      {children}
    </ApolloProvider>
  );
}
...

请注意我们是如何将showError 函数作为一个道具传递给组件的,并使这个道具值成为useMemo 调用的依赖。这是必要的,因为如果showError 函数改变了,那么严格来说,我们需要创建一个新的ApolloClient 实例,使用新版本的函数。

现在我们可以切换我们的代码来使用MyApolloProvider

...
ReactDOM.render(
  <ErrorNotifier>
    {(showError) => (
      <MyApolloProvider showError={showError}>
        <App />
      </MyApolloProvider>
    )}
  </ErrorNotifier>,
  document.getElementById("root")
);

但我们还没有走出困境。ErrorNotifier 组件在每次渲染时都会给它的孩子们提供一个新的showError 函数实例。因为这个实例被传递到MyApolloProvider ,所以每当错误发生时,useMemo 调用仍然会丢弃它所拥有的ApolloClient 实例并创建一个新的实例。

好消息是,每次发生错误时,showError 函数不必 改变。这意味着我们可以使用一个 [useCallback](https://reactjs.org/docs/hooks-reference.html#usecallback)``ErrorNotifier 钩子来确保只有一个showError 函数的实例被使用。

function ErrorNotifier({ children }) {
  const [doShowError, setDoShowError] = useState(false);
  const showError = useCallback(() => setDoShowError(true), []);

  return (
    <>
      {doShowError ? (
        <>
          <h1 role="alert">Error!</h1>
          <button onClick={() => setDoShowError(false)}>Dismiss</button>
        </>
      ) : null}
      {children(showError)}
    </>
  );
}

现在,如果我们的应用程序发生错误,用户将被通知,但我们现有的ApolloClient 实例将被保留。只有重新挂载整个ErrorNotifier 才会触发重新创建,而这不会经常发生(如果有的话),因为通知者在组件树中的位置很高。

useMemo 文档指出,"你可以将useMemo 作为一种性能优化,而不是作为一种语义保证" 对于useCallback ,也可以说是如此。在这种情况下,我认为性能优化是指我们尽可能长时间地保留一个单一的ApolloClient 实例。如果没有这种优化,应用程序将继续工作,但所有的数据将在错误发生后从后端重新加载。

与不同的Apollo客户端GraphQL错误策略合作

默认情况下,如果Apollo客户端它从GraphQL服务器收到的响应中发现任何错误,它将忽略该响应中的数据。它提供给onError 错误链接的graphQLErrors 属性将被填充错误,但数据将是undefined

这并不总是理想的行为。有时客户对响应中的部分数据感兴趣,即使一些错误也被报告。

为了处理这种情况,Apollo客户端支持不同的错误策略。错误策略可以全局设置,也可以为单个操作设置。要全局设置,你可以提供一个 [defaultOptions](https://www.apollographql.com/docs/react/api/core/ApolloClient/#defaultoptions)对象给ApolloClient 构造函数。因此,如果我们想保留部分数据,即使任何操作发生错误,我们会像这样设置我们的ApolloClient 实例。

...
function MyApolloProvider({ showError }) {
  const apolloClient = useMemo(
    () => {
      const baseOptions = { errorPolicy: "all" }

      return new ApolloClient({
        defaultOptions: {
          watchQuery: baseOptions,
          query: baseOptions,
          mutate: baseOptions,
        },
        ... // Same configuration as before
      }), 
    }, 
    [showError]
  )
  ...
}      


请注意, 3.1.0 之前的Apollo客户端版本要么根本不支持defaultOptions ,要么在实现方式上错误。如果你想使用defaultOptions ,我建议使用Apollo客户端3.1.0或更高版本。

然而,我们在这里的工作还没有完成。如果我们希望我们的客户端能够处理部分数据,我们也许应该停止在每次从GraphQL服务器得到的响应中出现错误时显示错误信息。但这引出了一个问题:在什么情况下我们应该显示错误信息?

我发现一个好的标准是,只有在完全没有收到数据的时候才通知用户。一般来说,如果出现GraphQL语法错误、验证错误,或者错误已经传播到请求中的所有根字段,就会出现这种情况。在任何这些情况下,GraphQL响应中的data 属性要么是null ,要么是完全丢失。因此,我们将在弹出错误信息之前添加这个作为一个额外的检查。

...
function MyApolloProvider({ showError }) {
  const apolloClient = useMemo(
    () => 
      new ApolloClient({
        defaultOptions: {
          query: {
            errorPolicy: "all",
          },
          mutate: {
            errorPolicy: "all",
          },
        },
        link: ApolloLink.from([
          onError(({ graphQLErrors, networkError, response }) => {
            if (networkError) {
              console.log(`[Network error]: ${networkError}`);
              showError();
            }

            if (graphQLErrors) {
              graphQLErrors.forEach(({ message, locations, path }) =>
                console.log(
                  `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
                )
              );
              // Only notify the user if absolutely no data came back
              if (!response || !response.data) {
                showError();
              }
            }
          })
        ...
      }), 
    [showError]
  )
  ...
}      

现在我们只在GraphQL出错 响应中没有数据返回的情况下通知用户有问题。请注意,用户仍将始终被通知网络错误,因为它们永远不会包括任何数据。

对于我想自己处理的GraphQL错误怎么办?

有时,你可能希望能够自己检测和处理特定的GraphQL错误,而不是让默认的错误处理程序捕捉它们,并向用户显示一个通用的信息。例如,你可能想抓取某些服务器端的验证错误,并将其报告给用户。

如果是这种情况,我们在这里提出的解决方案可能不适合你。然而,如果你控制了客户端使用的GraphQL模式,我们认为在客户端检测和处理特定的GraphQL错误没有什么好的理由,而更倾向于在原地放置一个全局错误处理器。这有几个原因。

检测具体的GraphQL错误是很难做到的

首先,检查GraphQL响应中的errors 属性是一个本身很容易出错的过程。GraphQL规范为这个字段指定了一个结构,包括一个叫做extensions 的属性,其中包含关于每个错误的任意附加信息的地图。然而,该规范让实现者决定extensions 应该有哪些键和值。因为结构不能被正式指定,你不能利用代码生成器来生成类型,让你知道客户端或服务器是否对它的结构做了错误的假设。这意味着,如果他们中的任何一方弄错了,那么你直到运行时才会发现。

相反,我们发现,如果你真的需要能够在客户端处理特定的错误情况,使用Sasha Solomon在她的博文"200 OK!GraphQL中的错误处理 "中所倡导的方法要安全得多。这种技术利用GraphQL联合类型来封装GraphQL模式中特定错误情况的信息。因为这些信息是在模式中编码的,所以它是良好的类型。这意味着它与代码生成工具一起工作,可以在运行前警告你潜在的问题。

它(可以说)标志着用户界面中的一个故障

我们避免在客户端检测具体的GraphQL服务器错误的第二个原因是,让用户达到一个服务器端错误发生的点,可以说是标志着用户界面的故障,而不是用户。我们想不出有多少服务器端的验证错误是不能被客户端提前发现的,无论是简单的验证,如最大数字值或字符串长度,还是更复杂的验证,如唯一性检查。

这并不意味着你根本不应该在服务器上进行检查,因为服务器应该始终是验证输入的最后一道防线。然而,它不一定是唯一的防线,特别是如果结果是一个次优的用户体验。

相反,把用户界面看作是第一道防线,而服务器是第二道防线。如果一个检查从第一道防线溜走,而只被第二道防线捡到,这总比完全不被捡到要好,但你也许应该在问题再次发生之前,通过改进用户界面来修补这个漏洞。

如果你采用这种理念,那么全局错误处理程序是很有用的,因为它可以向你提示,有什么东西已经进入了你的服务器,但可能不应该这样做。如果你担心这会给你的终端用户带来太多噪音,你可以在生产中关闭它,但在开发和测试中保留它。

让我们把它总结一下

在这篇文章中,我介绍了一种技术,当Apollo客户端遇到任何网络或GraphQL错误时,可以通知用户。我还演示了这种技术如何用于包含部分数据的响应。最后,我提出了这样的观点:如果你能控制你的模式,GraphQL错误并不是直接向用户传达具体错误信息的一个特别好的机制。相反,如果你想为用户提供更多关于特定错误情况的通用信息,你应该考虑将错误信息编入GraphQL模式中,或者改进你的用户界面以减少错误发生的机会。然后,全局错误处理程序可以作为最后的防御措施,以标记你事先没有想到的错误情况。