Vue 性能优化:如何实现延迟加载和代码拆分?

阅读数:6203 2019 年 3 月 5 日

移动优先方法已经成为一种标准,但不确定的网络条件导致应用程序快速加载变得越来越困难。在本系列文章中,我将深入探讨我们在 Storefront 应用程序中所使用的 Vue 性能优化技术,你们也可以在自己的 Vue 应用程序中使用它们来实现快速加载。

Webpack 捆绑的工作原理

本系列文章中的大多数技巧都与如何使 JS 包变得更小有关。不过,我们首先需要了解 Webpack 是如何捆绑文件的。

在捆绑文件时,Webpack 会创建一个叫作依赖图的东西。它是一种图,链接所有导入的文件。假设 Webpack 配置中有一个叫作 main.js 的文件被指定为入口点,那么它就是依赖图的根。这个文件要导入的每个 JS 模块都将成为图的叶子,而这些叶子中导入的每个模块都将成为叶子的叶子。

Webpack 使用这个依赖图来决定应该在输出包中包含哪些文件。输出包是一个 JavaScript 文件,包含了依赖图中指定的所有模块。

这个过程就像这样:

在知道了捆绑的工作原理之后,我们就可以得出一个结论,即随着项目的增长,初始 JavaScript 捆绑包也会随着增大,下载和解析捆绑包所需的时间也会越长,用户等待的时间也会变长,他们离开网站的可能性也就越大。

简单地说,更大的捆绑包 = 更少的用户,至少在大多数情况下是这样的。

延迟加载

那么,在添加新功能和改进应用程序的同时,我们如何减小捆绑包的大小?答案很简单——延迟加载和代码拆分。

顾名思义,延迟加载就是延迟加载应用程序的部分内容。换句话说——只在真正需要它们时加载它们。代码拆分是指将应用程序拆分成可以延迟加载的块。

在大多数情况下,你不需要在用户访问网站后立即使用 JavaScript 包中的所有代码。假设应用程序中有三个不同的路由,无论用户最终要访问哪个更难,总是要下载、解析和执行所有这些路由,即使他们只需要其中的一个路由。多么浪费时间和精力!

延迟加载允许我们拆分捆绑包,并只提供必要的部分,这样用户就不会浪费时间下载和解析无用的代码。

要想知道网站实际使用了多少 JavaScript 代码,我们可以转到 devtools -> cmd + shift + p -> type coverage -> 单击“record”,然后应该能够看到实际使用了多少下载的代码。

标记为红色的都是当前路由不需要的东西,可以延迟加载。如果你使用了源映射,可以单击列表中的任意一个文件,看看是哪些部分没有被调用到。可以看到,即使是 vuejs.org 也还有很大的改进空间。

通过延迟加载适当的组件和库,我们将 Storefront 的捆绑包大小减少了 60%!

接下来,让我们来看看如何在 Vue 应用程序中使用延迟加载。

动态导入

我们可以使用 Webpack动态导入来加载应用程序的某些部分。让我们看看它们的工作原理以及它们与常规导入的区别。

标准的 JS 模块导入:

复制代码
// main.js
import ModuleA from './module_a.js'
ModuleA.doStuff()

它将作为 main.js 的叶子被添加到依赖图中,并被捆绑到捆绑包中。

但是,如果我们仅在某些情况下需要 ModuleA 呢?将这个模块与初始捆绑包捆绑在一起不是一个好主意,因为可能根本就不需要它。我们需要一种方法来告诉应用程序应该在什么时候下载这段代码。

这个时候可以使用动态导入!来看一下这个例子:

复制代码
//main.js
const getModuleA = () => import('./module_a.js')
// invoked as a response to some user interaction
getModuleA()
.then({ doStuff } => doStuff())

我们来看看这里都发生了什么:

我们创建了一个返回 import() 函数的函数,而不是直接导入 module_a.js。现在 Webpack 会将动态导入模块的内容捆绑到一个单独的文件中,除非调用了这个函数,否则 import() 也不会被调用,也就不会下载这个文件。在后面的代码中,我们下载了这个可选的代码块,作为对某些用户交互的响应。

通过使用动态导入,我们基本上隔离了将被添加到依赖图中的叶子(在这里是 module_a),并在需要时下载它(这意味着我们也切断了在 module_a.js 中导入的模块)。

让我们看另一个可以更好地说明这种机制的例子。

假设我们有 4 个文件:main.js、module_a.js、module_b.js 和 module_c.js。要了解动态导入的原理,我们只需要 main 和 module_a 的源代码:

复制代码
//main.js
import ModuleB from './mobile_b.js'
const getModuleA = () => import('./module_a.js')
getModuleA()
.then({ doStuff } => doStuff()
)
//module_a.js
import ModuleC from './module_c.js'

通过让 module_a 成为一个动态导入的模块,可以让 module_a 及其所有子文件从依赖图中分离。当 module_a 被动态导入时,其中导入的所有子模块也会被加载。

换句话说,我们为依赖图创建了一个新的入口点。

延迟加载 Vue 组件

我们已经知道了什么是延迟加载以及为什么需要它,现在是时候看看如何在 Vue 应用程序中使用它了。

好消息是它非常简单,我们可以延迟加载整个 SFC 以及它的 css 和 html,语法和之前一样!

复制代码
const lazyComponent = () => import('Component.vue')

现在只会在请求组件时才会下载它。以下是调用 Vue 组件动态加载的最常用方法:

  • 调用带有 import 语句的函数:
复制代码
const lazyComponent = () => import('Component.vue')
lazyComponent()
  • 请求渲染组件:
复制代码
<template>
<div>
<lazy-component />
</div>
</template>
<script>
const lazyComponent = () => import('Component.vue')
export default {
components: { lazyComponent }
}
</script>

请注意,只有当请求在模板中渲染组件时,才会调用 lazyComponent 函数。

例如这段代码:

复制代码
<lazy-component v-if="false" />

就不会动态导入组件,因为它没有被添加到 DOM(但一旦值变为 true 就会导入,这是一种条件延迟加载 Vue 组件的好方法)。

应用程序增长

vue-router 是一个可用于将 Web 应用程序拆分为单独页面的库。每个页面都变成与某个特定 URL 路径相关联的路由。

假设我们有一个简单的组合应用程序,具有以下结构:

你可能已经注意到,根据我们访问的路由的不同,可能不需要 Home.vue 或 About.vue,但它们都在相同的 app.js 捆绑包中,无论用户访问哪个路由,它们都会被下载。这真是浪费下载和解析时间!

只是额外下载一个路由这并不是什么大问题,但想象一下,当这个应用程序越来越大,任何新添加的内容都意味着在首次访问时需要下载更大的捆绑包。

用户有可能在 1 秒钟之内就会离开我们的网站,所以这是不可接受的!

使用 vue-router 进行基于路由的代码拆分

为了避免让应用程序变得更糟,我们只需要使用动态导入语法为每个路由创建单独的包。

与 Vue 中的其他东西一样——它非常简单。我们不需要直接将组件导入到 route 对象中,只需要传入一个动态导入函数。只有在解析给定的路由时,才会下载路由组件。

所以不要像这样静态导入路径组件:

复制代码
import RouteComponent form './RouteComponent.vue'
const routes = [{ path: /foo', component: RouteComponent }]

我们需要动态导入它,这将创建一个新的捆绑包,并将这个路由作为入口点:

复制代码
const routes = [
{ path: /foo', component: () => import('./RouteComponent.vue') }
]

使用动态导入的捆绑和路由是这个样子的:

Webpack 将创建三个包:

  • app.js——主捆绑包,包含应用程序入口点(main.js)和每个路由所需的库或组件;

  • home.js——包含主页的捆绑包,当用户输入 / 路径时才会加载;

  • about.js——包含关于页面的捆绑包,当用户输入 /about 路径时才会加载。

这项技术几乎适用于所有应用程序,并且可以提供非常好的结果。

在很多情况下,基于路由的代码拆分将解决所有的性能问题,并且可以在几分钟内应用于几乎任何一个应用程序上!

Vue 生态系统中的代码拆分

你可能正在使用 Nuxt 或 vue-cli 来创建应用程序。如果是这样,你就应该知道,它们都有一些与代码拆分有关的自定义行为:

  • 在 vue-cli 3 中,默认情况下将预取所有延迟加载的块。
  • 在 Nuxt 中,如果我们使用了 Nuxt 路由系统,所有页面路由默认都是经过代码拆分的。

现在让我们来看看一些常见的反模式,它会减小基于路由的代码拆分所起到的作用。

第三方捆绑反模式

第三方捆绑通常被用在单独 JS 文件包含 node_modules 模块的上下文中。

虽然把所有东西放在一个地方并缓存它们可能很诱人,但这种方法也引入了我们将所有路由捆绑在一起时遇到的问题:

看到了问题吗?即使我们只在一个路由中使用 lodash,它也会与所有其他依赖项一起被捆绑在 vendor.js 中,因此它总是会被加载。

将所有依赖项捆绑在一个文件中看起来很诱人,但这样会导致应用程序加载时间变长。但我们可以做得更好!

让应用程序使用基于路由的代码拆分就足以确保只下载必要的代码,只是这样会导致一些重复代码。

假设 Home.vue 也需要 lodash。

在这种情况下,从 /about(About.vue)导航到 /(Home.vue)需要下载 lodash 两次。

不过这仍然比下载大量的冗余代码要好,但既然已经有了同样的依赖项,就应该重用它,不是吗?

这个时候可以使用 splitChunksPlugin

只需在 Webpack 配置中添加几行代码,就可以将公共依赖项分组到一个单独的包中,并共享它们。

复制代码
// webpack.config.js
optimization: {
splitChunks: {
chunks: 'all'
}
}

我们通过 chunks 属性告诉 Webpack 应该优化哪些代码块。这里设置为 all,这意味着它应该优化所有的代码。

Vuex 模块的两种类型

在进一步了解如何延迟加载 Vuex 模块之前,你需要了解有哪些方法可用来注册 Vuex 模块,以及它们的优缺点。

静态 Vuex 模块在 Store 初始化期间声明。以下是显式创建静态模块的示例:

复制代码
// store.js
import { userAccountModule } from './modules/userAccount'
const store = new Vuex.Store({
modules: {
user: userAccountModule
}
})

上面的代码将创建一个带有静态模块 userAccountModule 的 Vuex Store。静态模块不能取消注册,并且在 Store 初始化后不能更改它们的结构。

虽然这种限制对于大多数模块来说都不是问题,并且在一个地方声明所有这些限制确实有助于将所有与数据相关的内容放在一个地方,但这种方法也有一些缺点。

假设我们的应用程序中有一个带有专用 Vuex 模块的 Admin Dashboard。

复制代码
// store.js
import { userAccountModule } from './modules/userAccount'
import { adminModule } from './modules/admin'
const store = new Vuex.Store({
modules: {
user: userAccountModule,
admin: adminModule
}
})

你可以想象这样的模块可能非常庞大。尽管仪表盘只会被一小部分用户使用,但由于静态 Vuex 模块的集中注册,它的所有代码都将被包含在主捆绑包中。

这肯定不是我们想要的结果。我们需要一种方法,只为 /admin 路由加载这个模块。你可能已经猜到静态模块无法满足我们的需求。所有静态模块都需要在创建 Vuex Store 时注册,所以不能到了后面再进行注册。

这个时候可以使用动态模块!

动态模块可以在创建 Vuex Store 后进行注册。这个功能意味着我们不需要在应用程序初始化时下载动态模块,并且可以将其捆绑在不同的代码块中,或者在需要时延迟加载。

首先让我们来看一下之前的代码如果使用了动态注册的 admin 模块将会是什么样子。

复制代码
// store.js
import { userAccountModule } from './modules/userAccount'
import { adminModule } from './modules/admin'
const store = new Vuex.Store({
modules: {
user: userAccountModule,
}
})
store.registerModule('admin', adminModule)

我们没有将 adminModule 对象直接传给 Store 的 modules 属性,而是使用 registerModule 方法在 Store 创建后注册它。

动态注册不需要在模块内部进行任何更改,因此可以静态或动态注册任意的 Vuex 模块。

Vuex 模块的代码拆分

让我们回到我们的问题。既然我们知道如何动态注册 admin 模块,当然可以尝试将它的代码放入 /admin 路由捆绑包中。

让我们先暂停一下,先简要了解一下我们的应用程序。

复制代码
// router.js
import VueRouter from 'vue-router'
const Home = () => import('./Home.vue')
const Admin = () => import('./Admin.vue')
const routes = [
{ path: '/', component: Home },
{ path: '/admin', component: Admin }
]
export const router = new VueRouter({ routes })

在 router.js 中,我们有两个延迟加载并经过代码拆分的路由。admin Vuex 模块仍然在主 app.js 捆绑包中,因为它是在 store.js 中静态导入的。

让我们修复这个问题,只将这个模块发送给访问 /admin 路由的用户,这样其他用户就不会下载冗余代码。

为此,我们将在 /admin 路由组件中加载 admin 模块,而不是在 store.js 中导入和注册它。

复制代码
// store.js
import { userAccountModule } from './modules/userAccount'
export const store = new Vuex.Store({
modules: {
user: userAccountModule,
}
})
// Admin.vue
import adminModule from './admin.js'
export default {
// other component logic
mounted () {
this.$store.registerModule('admin', adminModule)
},
beforeDestroy () {
this.$store.unregisterModule('admin')
}
}

我们来看看都发生了什么!

我们先是在 Admin.vue(/admin route)导入和注册 admin Store,等到用户退出管理面板,我们就取消注册该模块,以防止同一模块被多次注册。

现在,因为 admin 模块是在 Admin.vue(而不是 store.js)中导入的,所以它将与经过代码拆分的 Admnin.vue 捆绑在一起!

现在我们知道如何使用动态 Vuex 模块注册将特定于路由的模块分发到适当的捆绑包中。让我们来看看稍微复杂一些的场景。

延迟加载 Vuex 模块

假设 Home.vue 上有客户评价部分,我们希望显示客户对服务的积极评价。因为有很多,所以我们不想在用户进入网站后立即显示它们,而是在用户需要查看时才显示它们。我们可以添加一个“Show Testimonials”按钮,点击这个按钮后将加载并显示客户评价。

为了保存客户评价数据,我们还需要另外一个 Vuex 模块,我们把它叫作 testimonials。这个模块将负责显示之前添加的评价和添加新的评价,但我们不需要了解实现细节。

我们希望只在用户单击了按钮后才下载 testimonials 模块,因为在这之前不需要它。让我们来看看如何利用动态模块注册和动态导入来实现这个功能。Testimonials.vue 是 Home.vue 中的一个组件。

让我们快速过一下代码。

当用户单击 Show Testimonials 按钮时,将调用 getTestimonials() 方法。它负责调用 getTestimonialsModule() 来获取 testimonials.js。在 promise 完成之后(意味着模块已加载),我们就会动态注册它,并触发负责获取客户评价的动作。

testimonials.js 被捆绑到一个单独的文件中,只有在调用 getTestimonialsModule 方法时才会下载这个文件。

当我们退出管理面板时,只是在 beforeDestroy 生命周期 hook 中取消了之前注册的模块,如果再次进入这个路由,就不会重复加载。

参考链接:

更多内容,请关注前端之巅。

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论

最新评论

王晓亮 2019 年 04 月 10 日 01:50 0 回复
很不错哦
没有更多了