文章源地址:Optimizing A Vue App
在构建我们的 Web 应用程序时优先考虑性能可以改善用户体验,有助于确保尽可能多的人可以使用它们。在本文中,Michelle Barker 将向您介绍一些前端优化技巧,以保持我们的 Vue 应用程序尽可能高效。
单页应用程序 (SPA) 可以在处理实时动态数据时提供丰富的交互式用户体验。但它们也可能很重、臃肿而且表现不佳(或者说速度很慢?)。在本文中,我们将介绍一些前端优化技巧,以保持我们的 Vue 应用程序相对精简,并且按需打包我们需要的 JS。
注意: 虽然假设你对Vue和 Composition API 有一定的了解,但是希望这篇文章对你来说你选择什么框架都能有所收获
作为Ada Mode的前端开发人员,我的工作涉及维护Windscope,这是一个供风电场运营商管理和维护其涡轮机组的网络应用程序。由于需要实时接收数据以及所需的高度交互性,因此为该项目选择了SPA架构。我们的 Web 应用程序依赖于一些庞大的 JS 库,但我们希望通过尽可能快速高效地获取数据和渲染来为最终用户提供最佳体验。
选择框架
我们选择的JS框架是Vue,部分原因是它是我最熟悉的框架。以前,与 React 相比,Vue 的整体捆绑包大小更小。然而,自从最近的 React 更新以来,React在这方面似乎做得更好。这不一定重要,因为在本文中,我们将介绍如何仅导入所需的内容。这两个框架都有优秀的文档和庞大的开发人员生态系统(社区环境),这是另一个考虑因素。Svelte是另一种可能的选择,但由于不熟悉,它需要更陡峭的学习曲线,而且由于较新,它的生态系统不太发达。
作为演示各种优化的示例,我构建了一个简单的 Vue 应用程序,该应用程序从 API 获取数据并使用D3.js 渲染一些图表。
注意:完整代码请参考示例 GitHub 仓库。
我们正在使用Parcel(一种最小配置构建工具)来打包我们的应用,但我们在此处介绍的所有优化都适用于您选择的任何打包工具。
使用构建工具摇树、压缩和最小化
最好只打包您需要的代码,并且开箱即用,Parcel 会在构建过程中删除未使用的 Javascript 代码(摇树)。它还可以最小化代码,并且可以配置使用 Gzip 或 Brotli 进行压缩输出。
除了缩小,Parcel 还采用范围提升作为其生产过程的一部分,这有助于提高缩小效率。范围提升的深入指南不在本文的范围之内(看看我在那里做了什么?尽管如此,如果在我们的示例应用程序上带 --no-optimize --no-scope-hoist
运行 Parcel 的构建过程,我们可以看到生成的捆绑包为 510kB - 大约是优化和缩小版本的5 倍。因此,无论您使用哪种打包工具,都可以公平地说,您可能希望确保它执行尽可能多的优化。
但工作并没有就此结束。即使我们总体上发布了一个较小的捆绑包,浏览器仍然需要时间来解析和编译我们的 JS,这可能会导致用户体验变慢。这篇关于 Calibre 的打包大小优化的文章解释了大型 JS 包如何影响性能指标。
让我们看看我们还能做些什么来减少浏览器必须做的工作量。
这里我还是更推荐使用vite去作为打包工具,能大幅提高打包效率和拥护更大的插件生态
Vue 组合 API
Vue 3 引入了 Composition API作为选项API 的替代方案,这是一组新的 API用于编写组件。通过专门使用 Composition API,我们可以只导入我们需要的 Vue 函数,而不是整个包。它还使我们能够使用可组合代码编写更多可复用代码。使用 Composition API 编写的代码小,并且整个应用程序更容易进行摇树优化。
注意:如果你使用的是旧版本的 Vue,你仍然可以使用 Composition API:它被向后移植到 Vue 2.7,并且有一个旧版本的官方插件。
导入依赖项
一个关键目标是减少客户端下载的初始 JS 包的大小。Windscope广泛使用D3进行数据可视化,这是一个大型库,范围广泛。但是,Windscope只需要其中的一部分(D3库中有我们根本不需要的整个模块)。如果我们在 Bundlephobia 上检查整个 D3 包,我们可以看到我们的应用程序使用的可用模块不到一半,甚至可能不是这些模块中的所有功能(方法/函数)。
保持我们的捆绑包大小尽可能小的最简单方法之一是仅导入我们需要的模块。
让我们以 D3 的函数为例。我们可以使用模块导入所需的函数,而不是使用默认导入
1 2 3 4 5 6 7
| import * as d3 from 'd3'
import { selectAll } from 'd3-selection'
|
使用动态导入进行代码拆分
在整个Windscope的很多地方都使用了某些软件包,例如AWS Amplify身份验证库,特别是 Auth
(身份校验)方法。这是一个很大的依赖关系,对我们的 JS 包大小影响非常大。动态导入不是在文件顶部静态导入模块,而是在代码中需要它的时候允许我们精准的导入模块。
而不是:
1 2 3 4 5
| import { Auth } from '@aws-amplify/auth'
const user = Auth.currentAuthenticatedUser()
|
当我们想要使用它时,我们可以导入它:
1 2 3 4 5
| import('@aws-amplify/auth').then(({ Auth }) => { const user = Auth.currentAuthenticatedUser() })
|
这意味着该模块将被拆分为一个单独的JS包(或“块”),浏览器将在需要时下载该包。此外,浏览器可以缓存这些依赖项,这些依赖项的更改频率可能低于我们应用程序其余部分的代码。
使用 Vue 路由器的延迟加载路由
我们的应用程序使用 vue-router
进行导航。与动态导入类似,我们可以延迟加载路由组件,因此只有在用户导航到该路由时才会导入它们(以及它们关联的依赖项)。
在我们 index/router.js
的代码中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import Home from "../routes/Home.vue"; import About from "../routes/About.vue";
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
方法延迟加载单个组件。
1
| const KPIComponent = defineAsyncComponent(() => import('../components/KPI.vue))
|
这意味着 KPI 组件的代码将被动态导入,正如我们在路由器示例中所看到的那样。我们还可以提供一些组件,以便在它处于加载或错误状态时显示(如果我们加载一个特别大的文件,这很有用)。
1 2 3 4 5 6 7
| const KPIComponent = defineAsyncComponent({ loader: () => import('../components/KPI.vue'), loadingComponent: Loader, errorComponent: Error, delay: 200, timeout: 5000, });
|
拆分 API 请求
我们的应用程序主要关注数据可视化,严重依赖从服务器获取大量数据。其中一些请求可能非常慢,因为服务器必须对数据执行大量计算。在我们的初始原型中,我们向每个路由的 REST API 发出了一个请求。不幸的是,我们发现这导致用户不得不等待很长时间 - 有时长达 10 秒,在应用程序成功接收数据并开始渲染可视化之前观看加载微调器。
我们决定将 API 拆分为多个端点,并为每个小部件发出请求。虽然这可能会增加整体响应时间,但这意味着应用程序应该更快地可用,因为用户将在等待其他页面时看到页面的一部分呈现。此外,可能发生的任何错误都将本地化,而页面的其余部分仍然可用。
您可以看到此处说明的差异:
在右侧的示例中,用户可以与某些组件进行交互,而其他组件仍在请求数据。左侧的页面必须等待大型数据响应,然后才能呈现并具有交互性。
有条件地加载组件
现在我们可以将其与异步组件结合使用,以便仅在收到来自服务器的成功响应时加载组件。在这里,我们获取数据,然后在 fetch
函数成功返回时导入组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| <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") .catch((e) => console.error(e)); };
const KPIComponent = defineAsyncComponent({ loader: loadComponent, loadingComponent: Loader, errorComponent: Error, delay: 200, timeout: 5000, });
return { data, KPIComponent }; } } ) </script>
|
为了处理每个组件的此过程,我们创建了一个称为 WidgetLoader
的高阶组件,您可以在仓库中看到该组件。
此模式可以扩展到应用中在用户交互时呈现组件的任何位置。例如,在Windscope中,我们仅在用户单击“地图”选项卡时才加载地图组件(及其依赖项)。这称为交互时导入。
CSS
如果运行示例代码,您将看到单击“位置”导航链接会加载地图组件。除了动态导入JS模块外,在组件的 <style>
块中导入依赖项也会延迟加载CSS:
1 2 3 4 5 6 7 8
| // In MapView.vue <style>@import "../../node_modules/leaflet/dist/leaflet.css";
.map-wrapper { aspect-ratio: 16 / 9; }
</style>
|
优化加载状态
此时,我们的 API 请求并行运行,组件在不同的时间呈现。我们可能会注意到的一件事是页面看起来很卡顿,因为布局会有很多变化。
使用户感觉更流畅的一种快速方法是在小部件上设置一个大致对应于渲染组件的纵横比,这样用户就不会看到那么大的布局偏移。我们可以为此传递一个 prop 来解释不同的组件,并回退到默认值。
WidgetLoader.vue1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| <template> <div class="widget" :style="{ 'aspect-ratio': loading ? aspectRatio : '' }" > <component :is="AsyncComponent" :data="data" /> </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", }, 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)); };
return { data, aspectRatio, loading }; }, }); </script>
|
中止 API 请求
在包含大量 API 请求的页面上,如果用户在所有请求完成之前导航离开,会发生什么情况?我们可能不希望这些请求继续在后台运行,从而减慢用户体验。
我们可以使用AbortController接口,它使我们能够根据需要中止 API 请求。
在我们的 setup
函数中,我们创建一个新的控制器并将其信号传递到我们的获取请求参数中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| setup(props) { 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
函数:
1
| onBeforeUnmount(() => controller.abort());
|
如果在请求完成之前运行项目并导航到另一个页面,则应在控制台中看到错误,指出请求已中止。
使用 stale-while-revalidate 保持新鲜度
Stale While Revalidate,说实话不知道怎么翻译了。。。
到目前为止,我们在优化应用程序方面做得很好。但是,当用户跳转到第二个路由,然后返回到前一个路由时,所有组件都会重新挂载并返回到其加载状态,我们必须重新等待请求响应。
stale-while-revalidate
是一种 HTTP 缓存失效策略,其中浏览器确定是提供来自缓存的响应(如果该内容仍然是最新的),还是“重新验证”并在响应过时时从网络提供。
除了将缓存控制标头应用于我们的 HTTP 响应(超出了本文的范围,但请阅读Web.dev 本文以获取更多详细信息),我们还可以使用SWRV库将类似的策略应用于我们的 Vue 组件状态。
首先,我们必须从 SWRV 库中导入组合式API:
1
| import useSWRV from "swrv";
|
然后我们可以在我们的 setup
函数中使用它。我们将函数 loadComponent
重命名为 fetchData
,因为它只处理数据获取。我们将不再在此函数中导入我们的组件,因为我们将单独处理。
我们将它作为第二个参数传递给 useSWRV
函数调用。只有当我们需要一个自定义函数来获取数据时,我们才需要这样做(也许我们需要更新一些其他状态)。当我们使用 Abort Controller 时,我们将执行此操作;否则,可以省略第二个参数,SWRV 将使用Fetch API:
setup函数1 2 3 4 5 6 7 8 9 10 11 12
| 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 来处理错误和加载状态。
setup函数1 2 3 4 5
| const AsyncComponent = defineAsyncComponent({ loader: importFunction, delay: 200, timeout: 5000, });
|
这意味着我们需要在模板中使用Loader和Error组件,并根据状态显示和隐藏它们。返回的isValidating告诉我们是否有请求或重新验证发生。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| <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.js1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| 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.vue1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| 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, }; }, });
|
更新指示器
如果我们可以在请求重新验证时向用户显示指示器,以便他们知道应用程序正在查询新数据,这可能会很有用。在示例中,我在组件的一角添加了一个小的加载指示器,仅当已经有数据但组件正在检查更新时,才会显示该指示器。我还在组件上添加了一个简单的淡入过渡(使用 Vue 内置的 Transition
组件),因此在渲染组件时不会有如此突然的跳跃。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <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>
<Loader v-if="isValidating && data" text="" ></Loader> </div> </template>
|
结论
在构建我们的 Web 应用程序时优先考虑性能可以改善用户体验,并有助于确保尽可能多的人可以使用它们。我们已经在 Ada Mode 中成功使用了上述技术来加快我们的应用程序。我希望本文提供了一些关于如何使您的应用程序尽可能高效的指示 - 无论您选择全部还是部分实现它们。
SPA 可以做的很好,但它们也可能是性能瓶颈。所以,让我们尝试更好地编写SPA应用吧。