Vue处理异步组件加载错误

452 阅读19分钟

大家好,我是宝哥。

本周我会分享下面一系列的Vue教程,欢迎关注我。

  • Vue 如何处理异步组件加载错误(当前)
  • Vue、Nuxt 和 Vite 技巧、窍门和实践的集合
  • 我是如何发现没有在 Vue SPA 中将 EventListeners 放在挂载上的
  • 开源 Vueform 中的新 phone 元素
  • Vue DevTools Next v7.2 发布
  • 尤大谈 Vite & Vue 2024 状态

在工作了一段时间之后,我们构建了我所参与过的最复杂的Web应用程序。这是一个庞大的项目,有许多动态的部分,并且这是一次很棒的学习经历。我学到的其中一件事是如何处理错误,但它们以各种形式出现。我忽略的一个方面是处理异步组件加载时出现的错误的重要性,这就是本文要讨论的内容。

我们何时使用异步组件?

Vue的一个伟大之处在于它使将组件标记为“延迟加载”或“异步”(我将交替使用这些术语)变得多么容易。这允许我们有一个更高性能的应用程序,因为我们不需要在需要之前加载组件。当我们有一个大型应用程序和许多组件,我们想要减少初始加载时间时,这特别有用。

自从Vue 2时代以来,这在Vue中一直很容易做到,并且在Vue 3中仍然如此。您需要使用defineAsyncComponent函数来定义一个将被异步加载的组件。以下是一个例子:

import { defineAsyncComponent } from 'vue';
const AsyncComp = defineAsyncComponent(
  () => import('./components/MyComponent.vue'),
);

import()函数是一个动态导入,它返回一个承诺。当承诺解决时,组件被加载并可以使用。

大多数情况下,你会像这样明确地这样做,但异步组件的一个主要用例是当你用它来定义Vue Router中的路由组件时。以下是一个例子:

const router = createRouter({
  // ...
  routes: [
    {
      path: '/settings',
      component: () => import('./pages/UserSettings.vue'),
    },
  ],
});

这对于具有客户端路由或单页应用程序(SPA)的应用程序尤其重要,因为它们需要在访问路由时加载组件。否则,你将迫使用户一次性下载整个应用程序及其所有页面和子组件。不用说,这将使应用程序最初加载得更慢,并且可能无法响应。

某些框架和配置甚至默认在幕后为你这样做,因为它是一个如此关键的良好实践。例如,Nuxt.js和unplugin-vue-router

到目前为止,这听起来不错,而且在大多数情况下,你不需要担心我向你展示的之外的任何事情。但是当组件加载失败时会发生什么?有什么可能导致组件加载失败?你的应用程序在这种情况下会如何表现?

可能出错的地方?

延迟加载一个组件本质上是一个网络请求,获取组件需要渲染的资源。这意味着它将加载一个JS文件,也许还有一个CSS文件,或者一些其他资产。而且,因为它是一个网络请求,它可能会因为fetch调用可能失败的相同原因而失败:

  • 也许设备离线了。
  • 也许服务器宕机了。
  • 也许文件不存在。
    • 你的CI流水线可能没有正确构建应用程序,或者文件被删除了。
    • 应用程序可能试图加载文件的旧版本,而新部署已经重命名/删除了。

这些都是很好的原因,但它们是异常。很多事情需要出错才能让这些问题发生,这就是为什么许多开发人员不觉得有必要处理它们。我不会试图说服你,但下一个比你想象的更常见。

所以,在3年的构建和发布无数涉及大量延迟加载的功能中,我们可以为这个列表增加一个原因,广告拦截器。

广告拦截器已知会阻止包含某些关键字的请求,如“广告”“横幅”“弹出窗口”“对话框”或你能想到的大多数市场词汇。

在我们的例子中,它阻止了任何在其名称中包含“活动”一词的组件,并且最近一些广告拦截器也开始阻止“对话框”组件。

虽然这是你无法控制的,但你可以优雅地处理它,以一种信息性的方式告知用户。这就是错误用户体验的全部内容:如果你不能恢复,就通知

问题

现在你知道可能出错的地方,但当它发生时会发生什么?你的应用程序会发生什么?最好的情况下什么也不会发生。最坏的情况是应用程序崩溃。这两种都不是良好的用户体验。

为了使事情变得更困难,当上述任何一种情况发生时。它们都抛出完全相同的错误,你不能仅仅通过查看错误来知道为什么会发生。你只能通过错误上下文和一些浏览器API的帮助来猜测。

以下是一个错误在控制台中的样子的例子:

Failed to fetch dynamically imported module https://....

所以,你可以看到错误消息不是很有信息性,它没有告诉你为什么失败。所以试图告诉你的用户出了什么问题比你想象的要难。

让我们首先看看我们是否可以防止应用程序崩溃,或者当组件加载失败时我们可以做些什么。

onError和errorComponent

幸运的是,Vue的defineAsyncComponent有一些API可以用来处理错误。第一个是errorComponent,这是一个替代组件,当异步目标组件加载失败时会渲染,你可以使用异步组件的扩展对象定义来指定它。以下是一个例子:

// 你需要同步导入它,否则有什么意义呢?
import ErrorComponent from './ErrorComponent.vue';
const AsyncComp = defineAsyncComponent({
  // 加载器函数,我们之前有的任何东西
  loader: () => import('./Foo.vue'),
  // 如果`Foo.vue`由于某种原因加载失败,将渲染此组件
  errorComponent: ErrorComponent,
});

错误组件将接收导致异步组件加载失败的错误作为一个属性,所以你可以用它来告知用户出了什么问题。

以下是一个错误组件的快速定义:

<template>
  <div>
    {{ error.message }}
  </div>
</template>
<script setup>
import { onMounted } from 'vue';
const props = defineProps({
  error: Object,
});
onMounted(() => {
  // 用错误做些什么?
  console.error(props.error);
});
</script>

这里是一个完整的示例,我们在故意加载一个失败的组件:

image.png

这对于加载应用程序的内容部分很有用,但对于像对话框或模态框这样的覆盖组件来说并不是很好,因为存在定位的问题,但最重要的是你需要为你的应用程序中的不同类型的异步组件创建一个错误组件。

我们可以使用的另一个API是onError,这是一个回调,当异步组件加载失败时会被调用。这很有用,如果你想记录错误或将其发送到日志服务等。以下是一个例子:

const AsyncComp = defineAsyncComponent({
  // 加载器函数,我们之前有的任何东西
  loader: () => import('./Foo.vue'),
  // 当`Foo.vue`由于某种原因加载失败时,将调用此回调
  onError(error, retry, fail, attempts) {
    // 做一些事情...
  },
});

我们在这里有一些很酷的参数可以玩,让我来解释一下它们:

  • error:导致异步组件加载失败的错误。
  • retry:你可以调用的一个函数,用于重试加载组件。
  • fail:你可以调用的一个函数,用于将错误重新抛出到链上。
    • 如果有一个全局错误处理器或边界,它将捕获它。
    • 如果没有,应用程序将会崩溃,这正是没有这个API时发生的情况。
  • attempts:组件尝试加载的次数。

这里有一个简单的例子,它没有做太多,我们只是告诉用户我们未能完成一些事情,他们应该再试一次:

image.png

这里发生了一些比特的事情,让我尝试分解一下:

  • 我们有一个全局的<ErrorDialog>组件,我们将通过一个全局事件触发任何异步错误。
  • 我们使用DOM事件和CustomEvent对象来创建一个我们可以全局监听的自定义事件。
  • 我们使用onError回调在应用程序的任何地方触发事件,当异步组件加载失败时。

虽然这个例子有点缺乏,但这比前面的例子更灵活,给你更多的选项和对你想对错误做什么的控制。

即使这个例子有点欠缺,这个API允许你做的不仅仅是渲染一个错误组件。你可以重试加载组件,或者完全做其他事情。

为了改进这个,我们需要更深入地研究错误并尝试告知用户出了什么问题。

区分错误

错误本身并没有告诉我们太多,但我们可以回顾我们前面提到的原因,并尝试确认或排除每一个。让我们从用户连接开始。

注意

接下来的几个例子将为了简洁起见而简化,但你可以将它们与前面的例子结合起来,使它们更有用。

在文章的最后,我将向您展示一个更完整的例子。

网络

navigator对象有一个名为onLine的属性,可以告诉您用户是否在线。这不是一个完美的解决方案,但它是一个很好的开始。以下是如何使用它的方法:

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Foo.vue'),
  onError() {
    if (!navigator.onLine) {
      console.error('用户离线');
      return;
    }
  },
});

navigator.onLine根据用户的设备报告其值。所以,如果你从WiFi断开连接或打开飞行模式,它将返回false。然而,它在用户设备连接到网络,但网络本身宕机或不稳定的低保真情况下表现不佳。

你可以做的另一件事是尝试向服务器发送一个简单的请求,它需要是你知道你总是可以访问的东西。一个静态页面对此来说是完美的。以下是一个例子:

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Foo.vue'),
  async onError() {
    if (!navigator.onLine) {
      console.error('用户离线');
      return;
    }
    try {
      await fetch('/online.html');
    } catch (err) {
      console.error('用户离线或网络不佳');
      return;
    }
  },
});

这就是我喜欢fetch行为的地方,它只有在由于网络错误而从未发出请求时才会抛出。如果请求发出但服务器响应错误,它不会抛出。这非常适合我们的用例。

即使我们的静态页面宕机,请求仍将被发出,错误也不会被抛出。然而,如果用户的设备离线或用户有其他不稳定的网络问题,请求将不会被发出,错误将被抛出。

注意,我仍然在使用navigator.onLine检查用户是否离线,因为如果设备断开连接,那么发出请求就有点多余了。navigator.onLine在这种情况下是准确的,但如果用户连接到的网络不稳定或宕机,那么它就不那么可靠了。

你可以优化这个。我们不需要发出一个GET请求。相反,我们可以发出一个HEAD请求,这要快得多,而且不会下载整个页面。这确保我们注意到用户的带宽,并给我们我们所需要的。以下是如何做到这一点:

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Foo.vue'),
  async onError() {
    if (!navigator.onLine) {
      console.error('用户离线');
      return;
    }
    try {
      await fetch('/online.html'); 
      await fetch('/online.html', { method: 'HEAD' }); 
    } catch (err) {
      console.error('用户离线或网络不佳');
      return;
    }
  },
});

你可以进一步深入,尝试通过检查错误本身来弄清楚是什么样的网络错误。但这对我们来说已经足够了,以知道用户是否离线或有不良网络。

它的工作原理如下:在你尝试之前,请确保你离线,你可以通过打开开发工具,转到网络选项卡,并从节流下拉菜单中选择“离线”,或者只是断开你的设备连接。

image.png

在演示中,我们有机会通过等待用户重新上线来自动恢复。但通常,你不想在用户离线时阻塞他们的UI,重新尝试并不总是最好的解决方案。你可以让他们在通知他们之后再次执行操作。我只是包括这部分,以向你展示我们在这里有多少灵活性。

组件资产存在性

我们之前发出的请求并不能告诉我们组件的资产是否存在,但我们可以通过对我们组件的JS文件进行类似的fetch并检查响应状态码来确认。

  • 非200
    • 4xx:如果文件不存在,则为404,如果文件受保护,服务器可能会返回403或401。
    • 5xx:好的,哇,我们现在手头有一个严重的问题。
  • 200:文件存在并且可以下载,这是一个奇怪的案例。

这里的处理取决于你,但在我看来,4xx和5xx之间没有太大区别。我们在这里做的是检查文件的可下载性,在任何情况下,用户都无法下载文件,这才是重要的。

通常,告诉你的用户重新加载应用程序并重试是一个足够好的信息,你也可以向他们展示一个重试按钮。如果文件由于某种原因被限制,你无法从中恢复,但关键是你要将错误发送到你的日志服务,并提供确切的细节,以便你或你的团队以后可以调试。

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Foo.vue'),
  async onError(error, retry, _, attempts) {
    //...
    try {
      // 我们没有直接失败的URL,所以是正则表达式。
      // 一些浏览器可能不会在错误消息中给你URL。
      const url = error.message.match(/https?:\/\/[^ ]+/)?.[0];
      const response = await fetch(url, { method: 'HEAD' });
      // 嗯,得到了200。这很奇怪。
      if (response.ok && attempts < 2) {
        // 我们也可以重试导入,可能是网络上的一个短暂中断。
        // 如果成功了,那么它就成功了,用户不会注意到。
        retry();
        return;
      }
      // 非200状态码,我们有问题。
      dispatchAsyncError({
        message:
        '无法下载组件,看起来像404',
        retry,
      });
      // 将此发送到你的日志服务,这是至关重要的。
      logError({
        message: '无法加载异步组件',
        error,
        status: response.status,
      });
    } catch (error) {
      // 获取错误意味着他们的网络很差,4xx和5xx不会在这里被捕获。
      // 与之前相同的处理...
    }
  },
});

这有点简单,我们在这里进行了某种自动恢复,通过检查文件是否可下载,如果是,我们就当场重试,用户不会意识到。然而,如果失败,我们通知用户并记录错误。

这里有一个组件404的例子:

这种情况有点罕见,如果你试图加载一个你没有的组件,你的打包器可能会抱怨。我知道vite会这样对我大叫,如果我这样做。

这更多是一些奇怪的情况,可能文件名大小写敏感性在起作用,或者你的CI流水线用新部署替换了文件,或者一些其他奇怪的情况。对我们来说,在我们修复我们的部署流水线以保持所有旧资产用于长用户会话之前,这是太常见的情况。这里的主要事情是,如果发生了什么事情,你会确切知道出了什么问题,这让你走上了修复它的道路。

广告拦截器

好的,我们都使用广告拦截器,对吧?但,一些客户有如此激进的广告拦截器,它阻止了任何在其名称中包含“活动”一词的组件。活动是我们应用程序的重要组成部分,所以这对我们来说是一个很大的问题。

我们考虑在构建期间对组件名称进行混淆,但如果与该组件相关的错误发生,这会反过来咬我们一口,因为我们将无法说出是哪一个。但如果这对你有用,那就去做吧!

仍然没有办法知道广告拦截器可能决定阻止什么以及它们未来如何发展以继续这样做。所以我们需要优雅地处理这个问题,告诉用户“如果他们有广告拦截器,他们应该禁用它”在这里就足够了。我们只需要检测它。

我们可以从检查onError回调中给出的错误消息或错误名称开始:

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Ads.vue'),
  async onError(error) {
    console.log(error.message, error.name);
  },
});

但记住我在文章开始时说了什么?错误总是...

Failed to fetch dynamically imported module: http://....

所以它不是很有帮助,实际上我们只是用它来获取文件URL,这在这里将很有用。我们可以尝试使用我们的HEAD方法获取文件并检查响应,就像我们之前对非200响应所做的那样。在这种情况下,你将没有响应,因为错误被抛出就像网络错误一样。

const AsyncComp = defineAsyncComponent({
  loader: () => import('./components/Ads.vue'),
  async onError(error) {
    try {
      const url = error.message.match(/https?:\/\/[^ ]+/)?.[0];
      const response = await fetch(url, { method: 'HEAD' });
    } catch (err) {
      // 请求没有通过,这要么是网络错误,要么是广告拦截器。
    }
  },
});

这使得区分网络错误和广告拦截器有点困难,但记住我们已经检查了网络错误。所以如果请求失败,那么很可能是广告拦截器。我们可以通知用户。

这里有一个例子,如果你使用广告拦截器,将以下内容添加到你的黑名单中:

/BigAd.vue

我们可以将这与我们之前做的可下载性检查结合起来,以避免两次发出请求。

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Foo.vue'),
  async onError(error, retry, fail, attempts) {
    //...
    try {
      const url = error.message.match(/https?:\/\/[^ ]+/)?.[0];
      const response = await fetch(url, { method: 'HEAD' });
      if (response.ok && attempts < 2) {
        retry();
        return;
      }
      // 非200状态码,我们有问题。
      dispatchAsyncError({
        message:
        '无法下载组件,看起来像404',
        retry,
      });
      // 记录错误到你的日志服务
      // ...
    } catch (error) {
      dispatchAsyncError({

        message: '看起来你启用了广告拦截器', 
        retry, 
        fail, 
      });
    }
  },
});

何时调用fail

所以你可能觉得我们没有在前面的例子中使用fail函数。

然而,如果你查看我们之前使用的ErrorDialog.vue组件代码,你会发现它在用户在没有解决问题的情况下关闭对话框时调用它。

function onExitErrorHandling() {
  dialogEl.value?.close();
  // 你应该使用`fail`告诉Vue错误处理器未能成功恢复
  callbackProps.value.fail?.();
}

这不是必需的,在我看来,但这似乎是一个好习惯,没有多少资源在这方面,所以我自己也不确定。

我测试了一些有或没有调用它的场景,看起来Vue假设如果你没有调用它,组件错误就已经解决了。

所以从语义上讲,如果用户关闭对话框,问题就没有得到解决,我会调用它。这意味着你需要从你的日志服务中忽略这个错误弹出,因为你已经有了更好的处理方式,这是一个胜利。

现在全部放在一起

把一切都放在一起并清理,会给我们一个不错的异步组件错误处理系统。

不过,这一切都是一团糟,我们可以通过将逻辑拆分成较小的函数,并创建一个包含这些错误处理策略的defineAsyncComponent包装器来清理它。这样,我们就可以在整个应用程序中使用它,而不会重复自己。

它全部都在运行,尝试更改异步组件的名称为以下任何名称:

  • /BigAd.vue如果你想测试广告拦截器检测,请确保将其添加到你的黑名单中。
  • /AnythingWeird.vue如果你想测试404和非200响应。
  • 如果你想测试离线检测,请在点击按钮之前断开连接或模拟离线连接。

如果你想重置示例,请重新加载本文。

utils.ts是魔法发生的地方,但总的来说,我们现在有了一个处理这些类型错误的可靠策略。你可以通过添加更多检查或更多的错误处理策略来扩展它。

其他想法和探索

我考虑过其他处理这些错误的方法,遗憾的是你从错误对象本身得不到太多信息。因为每当这个错误被抛出时,你只会得到一个模糊的TypeError,但是引起它的fetch错误不会被抛出,通常也不会与这个事件联系起来。这是我们在这里遇到的主要问题。

所以,你可以探索的一件事可能是使用服务工作者将fetch错误本身与错误事件联系起来。但我发现这有点太高级了,不适合在这里涵盖。

另一个限制是,你目前不能同时使用errorComponentonError,你只能使用它们中的一个。这有点限制性,因为我可以想象你想要执行一些操作,然后渲染一个错误组件。一个例子是你有一个标签/手风琴系统,它延迟加载其内容,你想要运行我们的错误处理逻辑,并在未能加载的标签/手风琴中渲染一个组件。实现这一点的一种方法是将我们在这里编写的一些逻辑移动到错误组件本身,但记住你无法访问retryfailattempts计数。

在我结束之前还有一件事。你可能想为用户选择更好的消息副本,我在这里使用了简单的副本,以便你知道发生了什么,但你的用户可能会欣赏不同的语调和措辞。

结论

这可能看起来有点小众或过度,但这将使在用户设备要么阻止文件要么他们的网络不稳定时,更容易不浪费时间调试这些问题,并让我们专注于真正的问题,即使一切看起来都很好,但组件却没有加载,即使在这种情况下,我们也拥有更多的信息可供使用。

我相信这是Vue.js中最少被探索的API之一,有了这篇文章,我希望你可以让你的应用程序更加健壮和用户友好,并在需要时发展这个API。


往期文章推荐:


最后,如果你觉得宝哥的分享还算实在,就给我点个赞,关注一波。分享出去,也许你的转发能给别人带来一点启发。

关注我,明天见!