目录
- 选择框架
- Vue Composition API
- 导入依赖
- 代码分割
- 懒加载
- 异步组件
- 分割API请求
- 有条件加载组件
- CSS
- 完善加载状态
- 取消API请求
- Stale While Revalidate
- 更新指示
- 总结
单页面应用(SPAs)当处理实时、异步数据时,可以提供丰富的、可交互的用户体验。但它们也可能很重,很臃肿,而且性能很差。在这篇文章中,我们将介绍一些前端优化技巧,以保持我们的Vue应用程序相对精简,并且只在需要的时候提供必需的JS。
注意:这里假设你对Vue和Composition API有一定的熟悉程度,但无论你选择哪种框架,都希望能有一些收获。
本文作者是一名前端开发工程师,职责是构建Windscope应用程序。下面介绍基于该程序所做的一系列优化。
选择框架
我们选择的JS框架是Vue,部分原因是它是我最熟悉的框架。以前,Vue与React相比,整体包规模较小。然而,自从最近的React更新以来,平衡似乎已经转移到React身上。这并不重要,因为我们将在本文中研究如何只导入我们需要的东西。这两个框架都有优秀的文档和庞大的开发者生态系统,这是另一个考虑因素。Svelte是另一个可能的选择,但由于不熟悉,它需要更陡峭的学习曲线,而且由于较新,它的生态系统不太发达。
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进行数据可视化,这是一个庞大的库,范围很广。然而,Windscope只需要使用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作为导航路由。与动态导入类似,我们可以懒加载我们的路由组件,这样就可以在用户导航到路由时,它们才会被导入(连同其相关的依赖关系)。
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,
},
];
当用户点击About链接并导航到路由时,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进行了一次请求。不幸地是,我们发现这会导致用户必须等待很长时间。
我们决定将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 };
}
}
该模式可以扩展到应用程序的任意地方,组件在用户交互后进行渲染。比如说,当用户点击Map标签时,加载map
组件以及相关依赖。
CSS
除了动态导入JS模块外,在组件的<style>
块中导入依赖也会懒加载CSS:
// In MapView.vue
<style>
@import "../../node_modules/leaflet/dist/leaflet.css";
.map-wrapper {
aspect-ratio: 16 / 9;
}
</style>
完善加载状态
在这一点上,我们的API请求是并行运行的,组件在不同时间被渲染。可能会注意到一件事,那就是页面看起来很糟糕,因为布局会有很大的变化。
一个让用户感觉更顺畅的快速方法,是在部件上设置一个与渲染的组件大致对应的长宽比,这样用户就不会看到那么大的布局变化。我们可以传入一个参数以考虑到不同的组件,并用一个默认值来回退。
// 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
函数中,我们创建一个新的controller
,并传递signal
到fetch
请求参数中:
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
函数,在组件被卸载之前中止请求:
onBeforeUnmount(() => controller.abort());
如果你运行该项目并在请求完成之前导航到另一个页面,你应该看到控制台中记录的错误,说明请求已经被中止。
Stale While Revalidate
目前为止,我们已经做了相当好的一部分优化。但是当用户前往下个页面后,然后返回上一页,所有的组件都会重新挂载,并返回自身的加载状态,我们又必须再次等待请求有所响应。
Stale-while-revalidate是一种HTTP缓存失效策略,浏览器决定是在内容仍然新鲜的情况下从缓存中提供响应,还是在响应过期的情况下"重新验证 "并从网络上提供响应。
除了在我们的HTTP响应中应用cache-control
头部(不在本文范围内,但可以阅读Web.dev的这篇文章以了解更多细节),我们可以使用SWRV库对我们的Vue组件状态应用类似的策略。
首先,我们必须从SWRV库中导入组合式内容:
import useSWRV from "swrv";
然后,我们可以在setup
函数使用它。我们把loadComponent
函数改名为fetchData
,因为它将只处理数据的获取。我们将不再在这个函数中导入我们的组件,因为我们将单独处理这个问题。
我们将把它作为第二个参数传入useSWRV
函数调用。只有当我们需要一个自定义函数来获取数据时,我们才需要这样做(也许我们需要更新一些其他的状态片段)。因为我们使用的是Abort Controller,所以我们要这样做;否则,第二个参数可以省略,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>
更新指示
如果我们能在我们的请求重新验证的时候向用户显示一个指示器,这样他们就知道应用程序正在检查新的数据,这可能会很有用。在这个例子中,我在组件的角落里添加了一个小的加载指示器,只有在已经有数据,但组件正在检查更新时才会显示。我还在组件上添加了一个简单的fade-in
过渡(使用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>
总结
在建立我们的网络应用程序时,优先考虑性能,可以提高用户体验,并有助于确保它们可以被尽可能多的人使用。我希望这篇文章提供了一些关于如何使你的应用程序尽可能高效的观点--无论你是选择全部还是部分地实施它们。
SPA可以工作得很好,但它们也可能成为性能瓶颈。所以,让我们试着把它们变得更好。