优化 Vue 应用程序

78 阅读11分钟

这是一篇英文翻译,文章介绍了一些较实用的方式来优化Vue应用程序的性能。原文链接:www.smashingmagazine.com/2022/11/opt…

简要总结:在构建 Web 应用程序时优先考虑性能可以改善用户体验,并有助于确保它们可以被尽可能多的人使用。在本文中,Michelle Barker 将引导您了解一些前端优化技巧,以保持我们的 Vue 应用程序尽可能高效。

单页应用程序 (SPA)在处理实时动态数据时可以提供丰富的交互式用户体验。但它们也可能很重、臃肿并且性能不佳。在本文中,我们将介绍一些前端优化技巧,以保持我们的 Vue 应用程序相对精简,并且仅在需要时交付我们需要的 JS。

注意:假设您对Vue和 Composition API 有一定的了解,无论您选择什么框架,希望能获得一些有用的收获。

作为Ada Mode的前端开发人员,我的工作涉及构建 Windscope,这是一款供风电场运营商管理和维护其涡轮机群的 Web 应用程序。由于需要实时接收数据并且需要高水平的交互性,该项目选择了SPA架构。我们的 Web 应用程序依赖于一些繁重的 JS 库,但我们希望通过尽可能快速高效地获取数据和渲染来为最终用户提供最佳体验。

选择一个框架

我们选择的 JS 框架是 Vue,部分选择它是因为它是我最熟悉的框架。此前,与 React 相比,Vue 的整体包大小更小。然而,自从最近的 React 更新以来,平衡似乎已经向有利于 React 的方向转变。这并不一定重要,因为我们将在本文中了解如何仅导入我们需要的内容。这两个框架都有优秀的文档和庞大的开发者生态系统,这是另一个考虑因素。Svelte是另一种可能的选择,但由于不熟悉,它需要更陡峭的学习曲线,而且它较新,生态系统不太发达。

作为演示各种优化的示例,我构建了一个简单的 Vue 应用程序,它从 API 获取数据并使用D3.js渲染一些图表。

注意:请参阅示例 GitHub 存储库以获取完整代码。

我们使用Parcel(一个最小配置构建工具)来打包我们的应用程序,但我们将在此处介绍的所有优化都适用于您选择的任何打包工具。

树摇动、压缩和使用构建工具进行缩小

只发布您需要的代码是最好的,并且是开箱即用的。Parcel 在构建过程中删除未使用的 Javascript 代码(tree shake)。它还会压缩结果,并且可以配置为使用 Gzip 或 Brotli 压缩输出。

除了压缩之外,Parcel 还采用范围提升作为其生产过程的一部分,这可以帮助提高压缩效率。范围提升的深入指南超出了本文的范围(看看我在那里做了什么?)。尽管如此,如果我们使用--no-optimize和--no-scope-hoist标志在示例应用程序上运行 Parcel 的构建过程,我们可以看到生成的包大小为 510kB —比优化和压缩版本高出大约 5 倍。因此,无论您使用哪个打包工具,可以公平地说您可能希望确保它执行尽可能多的优化。

但工作并没有就此结束。即使我们总体上交付的包较小,浏览器仍然需要时间来解析和编译我们的 JS,这可能会导致用户体验变慢。Calibre 的这篇有关捆绑包大小优化的文章解释了大型 JS 捆绑包如何影响性能指标。

让我们看看我们还可以做些什么来减少浏览器要做的工作量。

Vue Composition API

Vue 3 引入了Composition API,这是一组新的 API,作为 Options API 的替代方案。通过专门使用 Composition API,我们可以只导入我们需要的 Vue 函数,而不是整个包。它还使我们能够使用可组合项编写更多可复用的代码。使用 Composition API 编写的代码更适合压缩,并且整个应用程序更容易受到 tree-shaking 的影响。

注意:如果您使用旧版本的 Vue,您仍然可以使用 Composition API:它已向后移植到 Vue 2.7,并且有一个适用于旧版本的官方插件

导入依赖项 (按需引用)

一个关键目标是减少客户端下载的初始 JS 包的大小。Windscope 广泛使用 D3 进行数据可视化,而D3是一个大型库,范围广泛。然而,Windscope 仅需要其中的一部分(D3 库中有整个模块,我们根本不需要)。如果我们检查Bundlephobia上的整个 D3 包,我们可以看到我们的应用程序使用了不到一半的可用模块,甚至可能没有使用这些模块中的所有功能。

保持包大小尽可能小的最简单方法之一就是仅导入我们需要的模块。

我们来看看D3的selectAll函数。我们可以从模块中导入我们需要的函数,而不是使用默认导入d3-selection:

// Previous:
import * as d3 from 'd3'

// Instead:
import { selectAll } from 'd3-selection'

使用动态导入进行代码分割

Windscope 中的许多地方都会使用某些三方库,例如 AWS Amplify 身份验证库,特别是该Auth方法。这是一个很大的依赖项,对我们的 JS 包大小有很大影响。动态导入允许我们将模块准确地导入到代码中需要的位置,而不是在文件顶部静态导入模块。

代替:

import { Auth } from '@aws-amplify/auth'

const user = Auth.currentAuthenticatedUser()

当我们想要使用它时,我们可以导入该模块:

import('@aws-amplify/auth').then(({ Auth }) => {
    const user = Auth.currentAuthenticatedUser()
})

这意味着该模块将被分割成一个单独的 JS 包(或“块”),只有在需要时浏览器才会下载该包。此外,浏览器可以缓存这些依赖项,这些依赖项的更改频率可能低于应用程序其余部分的代码。

使用 Vue Router 延迟加载路由

我们的应用程序使用Vue Router进行导航。与动态导入类似,我们可以延迟加载路由组件,因此只有当用户导航到该路由时才会导入它们(及其关联的依赖项)。

在我们的index/router.js文件中:

// Previously:
import Home from "../routes/Home.vue";
import About = "../routes/About.vue";

// Lazyload the route components instead:
const Home = () => import("../routes/Home.vue");
const About = () => import("../routes/About.vue");

const routes = [
  {
    name: "home",
    path: "/",
    component: Home,
  },
  {
    name: "about",
    path: "/about",
    component: About,
  },
];

仅当用户单击“关于”链接并导航到该路线时,才会加载“关于”路线的代码。

异步组件

除了延迟加载每个路由之外,我们还可以使用 Vue 的defineAsyncComponent方法延迟加载单个组件。

const KPIComponent = defineAsyncComponent(() => 
	import('../components/KPI.vue)
)

这意味着 KPI 组件的代码将被动态导入,正如我们在路由器示例中看到的那样。我们还可以提供一些在加载或错误状态时显示的组件(如果我们正在加载特别大的文件,则很有用)。

const KPIComponent = defineAsyncComponent({
  loader: () => import('../components/KPI.vue),
  loadingComponent: Loader,
  errorComponent: Error,
  delay: 200,
  timeout: 5000,
});

拆分 API 请求

我们的应用程序主要涉及数据可视化,并且严重依赖于从服务器获取大量数据。其中一些请求可能非常慢,因为服务器必须对数据执行大量计算。在我们最初的原型中,我们对每个路由的 REST API 发出一个请求。不幸的是,我们发现这导致用户必须等待很长时间(有时长达 10 秒),在应用程序成功接收数据并开始渲染可视化之前观看loading图。

我们决定将 API 拆分为多个端点,并对每个小部件发出请求。虽然这可能会增加总体响应时间,但这意味着应用程序应该可以更快地使用,因为用户在等待其他页面时会看到页面的部分内容。此外,可能发生的任何错误都将被本地化,而页面的其余部分仍然可用。

您可以在这里看到差异:

在右侧的示例中,用户可以与某些组件交互,而其他组件仍在请求数据。左边的页面必须等待大数据响应才能渲染并变得可交互。

有条件地加载组件

现在我们可以将其与异步组件结合起来,以便仅在收到服务器的成功响应时加载组件。这里我们获取数据,然后在fetch函数成功返回时导入组件:

<template>
  <div>
    <component :is="KPIComponent" :data="data"></component>
  </div>
</template>

<script>
import {
  defineComponent,
  ref,
  defineAsyncComponent,
} from "vue";
import Loader from "./Loader";
import Error from "./Error";

export default defineComponent({
    components: { Loader, Error },

    setup() {
        const data = ref(null);

        const loadComponent = () => {
          return fetch('https://api.npoint.io/ec46e59905dc0011b7f4')
            .then((response) => response.json())
            .then((response) => (data.value = response))
            .then(() => import("../components/KPI.vue") // Import the component
            .catch((e) => console.error(e));
        };

        const KPIComponent = defineAsyncComponent({
          loader: loadComponent,
          loadingComponent: Loader,
          errorComponent: Error,
          delay: 200,
          timeout: 5000,
        });

        return { data, KPIComponent };
    }
}

为了处理每个组件的此过程,我们创建了一个名为 的更高阶组件WidgetLoader,您可以在存储库中看到它。

此模式可以扩展到应用程序中在用户交互时呈现组件的任何位置。例如,在 Windscope 中,仅当用户单击“地图”选项卡时,我们才加载地图组件(及其依赖项),这称为交互导入

CSS

如果运行示例代码,您将看到单击“位置”导航链接会加载地图组件。除了动态导入 JS 模块之外,在组件块中导入依赖项也会延迟加载 CSS:

// In MapView.vue
<style>
@import "../../node_modules/leaflet/dist/leaflet.css";

.map-wrapper {
  aspect-ratio: 16 / 9;
}
</style>

优化加载状态

此时,我们的 API 请求并行运行,组件在不同时间呈现。我们可能会注意到的一件事是页面看起来很卡顿,因为布局会发生很大的变化。

让用户感觉更流畅的一种快速方法是在小部件上设置大致对应于渲染组件的纵横比,这样用户就不会看到那么大的布局变化。我们可以传入一个 prop 来解释不同的组件,并使用默认值。

// WidgetLoader.vue
<template>
  <div class="widget" :style="{ 'aspect-ratio': loading ? aspectRatio : '' }">
    <component :is="AsyncComponent" :data="data"></component>
  </div>
</template>

<script>
import { defineComponent, ref, onBeforeMount, onBeforeUnmount } from "vue";
import Loader from "./Loader";
import Error from "./Error";

export default defineComponent({
  components: { Loader, Error },

  props: {
    aspectRatio: {
      type: String,
      default: "5 / 3", // define a default value
    },
    url: String,
    importFunction: Function,
  },

  setup(props) {
      const data = ref(null);
      const loading = ref(true);

        const loadComponent = () => {
          return fetch(url)
            .then((response) => response.json())
            .then((response) => (data.value = response))
            .then(importFunction
            .catch((e) => console.error(e))
            .finally(() => (loading.value = false)); // Set the loading state to false
        };

    /* ...Rest of the component code */

    return { data, aspectRatio, loading };
  },
});
</script>

中止 API 请求

在一个有大量 API 请求的页面上,如果用户在所有请求完成之前就离开了,会发生什么情况?我们可能不希望这些请求继续在后台运行,从而降低用户体验。

我们可以使用AbortController接口,它使我们能够根据需要中止 API 请求。

在我们的setup函数中,我们创建一个新控制器并将其信号传递到我们的获取请求参数中:

setup(props) {
  // AbortController 接口表示一个控制器对象,允许你根据需要中止一个或多个 Web 请求。
  const controller = new AbortController();

  const loadComponent = () => {
    return fetch(url, { signal: controller.signal })
      .then((response) => response.json())
      .then((response) => (data.value = response))
      .then(importFunction)
      .catch((e) => console.error(e))
      .finally(() => (loading.value = false));
      };
}

然后我们在组件卸载之前中止请求,使用 Vue 的onBeforeUnmount函数:

onBeforeUnmount(() => controller.abort());

如果您在请求完成之前运行项目并导航到另一个页面,您应该会看到控制台中记录的错误,表明请求已中止。

重新验证时失效

到目前为止,我们已经很好地优化了我们的应用程序。但是,当用户导航到第二个视图然后返回前一个视图时,所有组件都会重新安装并返回到加载状态,我们必须再次等待请求响应。

Stale-while-revalidate是一种 HTTP 缓存失效策略,其中浏览器确定是否在内容仍然新鲜时从缓存提供响应,或者在响应过时时“重新验证”并从网络提供响应。

除了将缓存控制标头应用于我们的 HTTP 响应(超出了本文的范围,但请阅读Web.dev 上的这篇文章以了解更多详细信息)之外,我们还可以使用SWRV库对 Vue 组件状态应用类似的策略。

首先,我们必须从 SWRV 库导入可组合项:

import useSWRV from "swrv";

然后我们就可以在我们的setup函数中使用它了。我们将loadComponent函数重命名为fetchData,因为它只处理数据获取。我们将不再在此函数中导入我们的组件,因为我们将单独处理它。

我们将把它作为useSWRV第二个参数传递给函数调用。仅当我们需要自定义函数来获取数据时,我们才需要这样做(也许我们需要更新其他一些状态)。当我们使用中止控制器时,我们将这样做;否则,可以省略第二个参数,SWRV 将使用Fetch API

// In setup()
const { url, importFunction } = props;

const controller = new AbortController();

const fetchData = () => {
  return fetch(url, { signal: controller.signal })
    .then((response) => response.json())
    .then((response) => (data.value = response))
    .catch((e) => (error.value = e));
};

const { data, isValidating, error } = useSWRV(url, fetchData);

然后,我们将从异步组件定义中删除loadingComponent和errorComponent选项,因为我们将使用 SWRV 来处理错误和加载状态。

// In setup()
const AsyncComponent = defineAsyncComponent({
  loader: importFunction,
  delay: 200,
  timeout: 5000,
});

这意味着我们需要在模板中包含Loader和Error组件,并根据状态显示和隐藏它们。返回isValidating值告诉我们是否发生请求或重新验证。

<template>
  <div>
    <Loader v-if="isValidating && !data"></Loader>
    <Error v-else-if="error" :errorMessage="error.message"></Error>
    <component :is="AsyncComponent" :data="data" v-else></component>
  </div>
</template>

<script>
import {
  defineComponent,
  defineAsyncComponent,
} from "vue";
import useSWRV from "swrv";

export default defineComponent({
  components: {
    Error,
    Loader,
  },

  props: {
    url: String,
    importFunction: Function,
  },

  setup(props) {
    const { url, importFunction } = props;

    const controller = new AbortController();

    const fetchData = () => {
      return fetch(url, { signal: controller.signal })
        .then((response) => response.json())
        .then((response) => (data.value = response))
        .catch((e) => (error.value = e));
    };

    const { data, isValidating, error } = useSWRV(url, fetchData);

    const AsyncComponent = defineAsyncComponent({
      loader: importFunction,
      delay: 200,
      timeout: 5000,
    });

    onBeforeUnmount(() => controller.abort());

    return {
      AsyncComponent,
      isValidating,
      data,
      error,
    };
  },
});
</script>

我们可以将其重构为自己的可组合项,使我们的代码更加简洁,并使我们能够在任何地方使用它。

// composables/lazyFetch.js
import { onBeforeUnmount } from "vue";
import useSWRV from "swrv";

export function useLazyFetch(url) {
  const controller = new AbortController();

  const fetchData = () => {
    return fetch(url, { signal: controller.signal })
      .then((response) => response.json())
      .then((response) => (data.value = response))
      .catch((e) => (error.value = e));
  };

  const { data, isValidating, error } = useSWRV(url, fetchData);

  onBeforeUnmount(() => controller.abort());

  return {
    isValidating,
    data,
    error,
  };
}
// WidgetLoader.vue
<script>
import { defineComponent, defineAsyncComponent, computed } from "vue";
import Loader from "./Loader";
import Error from "./Error";
import { useLazyFetch } from "../composables/lazyFetch";

export default defineComponent({
  components: {
    Error,
    Loader,
  },

  props: {
    aspectRatio: {
      type: String,
      default: "5 / 3",
    },
    url: String,
    importFunction: Function,
  },

  setup(props) {
    const { aspectRatio, url, importFunction } = props;
    const { data, isValidating, error } = useLazyFetch(url);

    const AsyncComponent = defineAsyncComponent({
      loader: importFunction,
      delay: 200,
      timeout: 5000,
    });

    return {
      aspectRatio,
      AsyncComponent,
      isValidating,
      data,
      error,
    };
  },
});
</script>

更新指标

如果我们可以在重新验证请求时向用户显示一个指示器,以便他们知道应用程序正在检查新数据,这可能会很有用。在示例中,我在组件的一角添加了一个小的加载指示器,只有在已有数据但组件正在检查更新时才会显示该指示器。我还在组件上添加了一个简单的淡入过渡(使用 Vue 的内置Transition组件),因此在渲染组件时不会出现如此突然的跳转。

<template>
  <div
    class="widget"
    :style="{ 'aspect-ratio': isValidating && !data ? aspectRatio : '' }"
  >
    <Loader v-if="isValidating && !data"></Loader>
    <Error v-else-if="error" :errorMessage="error.message"></Error>
    <Transition>
        <component :is="AsyncComponent" :data="data" v-else></component>
    </Transition>

    <!--Indicator if data is updating-->
    <Loader
      v-if="isValidating && data"
      text=""
    ></Loader>
  </div>
</template>

结论

在构建 Web 应用程序时优先考虑性能可以改善用户体验,并有助于确保它们可以被尽可能多的人使用。我们已成功在 Ada 模式中使用上述技术来提高我们的应用程序速度。我希望本文能够提供一些关于如何使您的应用程序尽可能高效的指导 - 无论您选择全部还是部分实现它们。

SPA 可以很好地工作,但它们也可能成为性能瓶颈。因此,让我们尝试将它们构建得更好。