Vue项目性能优化

876 阅读7分钟

Vue-CLI 性能优化

问题

是否有首屏加载时间过长的问题.?

为什么会出现这样的问题.?

解决方法是什么.?

下面方法都可以大程度的优化项目结构, 进而减少首屏加载的时间

首先得知道 Webpack 的 build 是如何打包分割代码的

目前代码打包分割有三个规则

  1. 入口起点, 根据entry配置来分割代码
  2. 动态导入: 通过模块引入import()分割代码
  3. splitChunks: 代码分割配置规则, 根据规则分割代码

以下使用vue create创建一个新项目, build后输出的文件为以下

build-1.1

Q1: 为什么是打包出这几个文件.?

这里就要查看一下Vue CLI的默认打包配置了

以下贴出关键代码

{
  // 入口配置
  entry: {
    app: [
      './src/main.js'
    ]
  },
  // 出口配置
  output: {
    path: 'E:\\demo\\vue-cli-vue2-async-components-demo\\dist',
    filename: 'js/[name].[contenthash:8].js',
    publicPath: '/',
    chunkFilename: 'js/[name].[contenthash:8].js'
  },
  // 代码分割配置
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendors: {
          name: 'chunk-vendors',
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          chunks: 'initial'
        },
        common: {
          name: 'chunk-common',
          minChunks: 2,
          priority: -20,
          chunks: 'initial',
          reuseExistingChunk: true
        }
      }
    }
  }
}

在这里可以看到:

清楚知道app.[contenthash:8].js是由于 规则1 的分割规则, 通过入口配置entry分割出来的

那么about.[contenthash:8].js 呢?

这时候就要查看Vue Router的路由懒加载了

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue') // 关键代码
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

以上就是初始化项目的路由配置

关键代码component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')

webpackChunkName是什么.?

Webpack的魔法注释, 作用: 分割到哪个chunk内

最后的chunk-vendors.[contenthash:8].js就是因为splitChunks的分割规则切割出来 主要就是包含node_modules内引用的插件的包了

代码分割

在Vue项目中, 除了第一个路由页面, 其他路由都是使用 import()引入, 进行路由懒加载, 当路由被访问的时候才加载对应组件,这样就更加高效了

这其实是利用了Vue 的异步组件 (opens new window)和 Webpack 的代码分割功能 (opens new window),轻松实现路由组件的懒加载

其中有个重要的点, 就是Webpack的魔法注释

const Foo = () => import(/* webpackChunkName: "group-foo" */ './Foo.vue')

Webpack 会根据这个魔法注释的名字进行代码分隔, 如上配置之后, 会有一个命名的group-foo的chunk被单独打包出来一个文件 这个效果不仅仅可应用于路由懒加载, 也可以使用到单文件组件中

组件分割

在父组件中, 需要点击按钮后弹出一个弹窗 一般会把这个弹窗封装为单独一个单文件组件, 一般如下:

直接引入组件, 直接注册到components

<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png" />
    <el-button @click="$refs.homeAdd.dialogVisible = true">显示</el-button>
    <HelloWorld msg="Welcome to Your Vue.js App" />
    <HomeAdd ref="homeAdd"></HomeAdd>
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'
import HomeAdd from './HomeAdd.vue'

export default {
  name: 'Home',
  mounted() {
    console.log('Home')
  },
  components: {
    HelloWorld,
    HomeAdd
  }
}
</script>

这样编写代码后的打包是这样的:

build-1.2

能看到app.[contenthash:8].js从之前的6.57kb增加到了6.86kb

因为打包把引入的组件也打包进去了, 当页面越来越多, 就会一直增加该主包的体积

其实可以这样写:

<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png" />
    <el-button @click="$refs.homeAdd.dialogVisible = true">按钮</el-button>
    <HelloWorld msg="Welcome to Your Vue.js App" />
    <HomeAdd ref="homeAdd"></HomeAdd>
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  name: 'Home',
  mounted() {
    console.log('Home')
  },
  components: {
    HelloWorld,
    HomeAdd: () => import(/* webpackChunkName: "home.add" */ './HomeAdd.vue')
  }
}
</script>
build-1.3

通过这样, 可以把弹窗单独打包成home.add的chunk, 使得该业务分隔成更多更小的包, 避免某个包过大导致的加载时候耗费过多时间

插件分割

如果我们需要使用lodash内的一个方法, 那么我们就会引入该包, 然后调用, 看看这样引入后, 打包后的项目是什么样的.?

![build-1.4](E:\project\MyNote\_img\build\build-1.4.png)<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png" />
    {{ value }}
    <HelloWorld msg="Welcome to Your Vue.js App" />
    <HomeAdd ref="homeAdd"></HomeAdd>
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'
import _ from 'lodash' // 引入lodash

export default {
  name: 'Home',
  mounted() {
    this.compute()
    console.log('Home')
  },
  data() {
    return {
      value: ''
    }
  },
  methods: {
    compute() {
      const arr = ['Turtle', 'Hello', 'World']
      this.value = _.join(arr, '-') // 调用loadsh的join方法
    }
  },
  components: {
    HelloWorld,
    HomeAdd: () => import(/* webpackChunkName: "home.add" */ './HomeAdd.vue')
  }
}
</script>

build-1.4

发现chunk-vendors.[contenthash:8].js133kb增加到了212kb

分割也可以应用于 javascript 插件的引用

<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png" />
    <button @click="compute">计算</button>
    {{ value }}
    <HelloWorld msg="Welcome to Your Vue.js App" />
    <HomeAdd ref="homeAdd"></HomeAdd>
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  name: 'Home',
  mounted() {
    console.log('Home')
  },
  data() {
    return {
      value: ''
    }
  },
  methods: {
    async compute() {
      const { default: _ } = await import(/* webpackChunkName: "lodash" */ 'lodash')
      const arr = ['Turtle', 'Hello', 'World']
      this.value = _.join(arr, '-')
    }
  },
  components: {
    HelloWorld,
    HomeAdd: () => import(/* webpackChunkName: "home.add" */ './HomeAdd.vue')
  }
}
</script>

build-1.5

这里就会把lodash单独打包出来, 减少了chunk-vendors.[contenthash:8].js的体积

可以仔细查看DevTools的输出看到点击之后才请求该 js 资源

代码分割

在 Vue 项目中, 会把公用的 js 打包进一个chunk-vendors.[hash]:8.js的文件中, 当引用的插件或者项目业务代码多的时候, 该文件会越来越大, 在浏览器加载的时候, 就会耗费更多时间, 导致页面首屏白屏时间过长 解决方案: 通过定向代码分隔, 把某些插件从该文件抽离, 单独成文件

技术点: 需要利用 Webpack 自带的 splitChunks 功能, 进行配置分割

配置项vue.config.js

module.exports = {
  chainWebpack(config) {
    config.when(process.env.NODE_ENV !== 'development', config => {
      config.optimization.splitChunks({
        chunks: 'all',
        cacheGroups: {
          elementUI: {
            name: 'chunk-elementUI',
            priority: 20,
            test: /[\\/]node_modules[\\/]_?element-ui(.*)/
          }
        }
      })
    })
  }
}

一般的项目都会全量引入一个UI, 举例子为elementUI,

以上配置会把elementUI单独打包出来, 生成一个chunk-elementUI.[hash]:8.js文件

大概会有600kb的大小, 所以可以为主包减少这么多的体积

splitChunks 代码分割配置项详解

此配置对象代表SplitChunksPlugin的默认配置

splitChunks: {
    chunks: "async",
    minSize: 30000,
    minChunks: 1,
    maxAsyncRequests: 5,
    maxInitialRequests: 3,
    automaticNameDelimiter: '~',
    name: true,
    cacheGroups: {
        vendors: {
            test: /[\\/]node_modules[\\/]/,
            priority: -10
        },
    	default: {
            minChunks: 2,
            priority: -20,
            reuseExistingChunk: true
        }
    }
}

这里对于每一项进行解释

chunks

  • 含义: 打包模式, 模式: async异步,all全部
  • 默认值: async异步, 只对异步引入的模块进行分割
  • all: 不管是异步还是同步导入的模块, 都进行代码分割

minSize

  • 含义: 当导入的模块需要超过该值, 才会进行代码分割
  • 默认值: 30000字节

minChunks

  • 含义: 一个模块需要被导入多少次才会进行代码分割
  • 默认值: 1
  • 注意: 该配置项只适用于同步引入的模块, 异步模块都会进行代码分割

maxAsyncRequests

  • 含义: 同时加载的模块数是几个
  • 举例: 当我们异步引入了10个类库, 按照正常情况下, 每个类库都会进行代码分割成一个单独的js文件(脱离main.js), 即生成了除main.js外的10个分割js文件, 如果maxAsyncRequests: 5 则打包时, 前5个类库会进行代码分割, 生成对应的5个js文件, 后面的5个类库依然存在于main.js中,不进行代码分割
  • 默认值: 5

maxInitialRequests

  • 含义: 对入口文件entry进行分割的时候, 最多能分割出多少个文件, 超出之后不进行分割
  • 默认值: 3

maxAsyncRequestsmaxInitialRequests区别

  • maxAsyncRequests包含入口文件及其入口依赖文件(实际上也是模块)中所导入的模块的一起来统计是否超过maxAsyncRequests设置的值
  • maxInitialRequests只是对入口文件中直接导入的模块进行统计

automaticNameDelimiter

  • 含义: 文件名连接符号
  • 默认值: ~

name

  • 含义: 决定缓存组cacheGroups内的name是否生效
  • 默认值: true

cacheGroups

缓存组

Q: 什么是缓存组.?

A:

  1. 只对同步导入模块起作用, 把同步导入的模块根据相关配置进行分割缓存起来
    1. 没有设置缓存组, 那么会根据默认配置, 满足后才会分割
    2. 如果存在缓存组, 会把每个模块内符合缓存组配置项中模块先放到缓存组内, 等把全部模块都分析完毕, 再把符合缓存组配置项中模块全部打包在一起
  2. 对异步导入模块不起作用, 因为异步导入的模块都会单独生成一个模块

cacheGroups 继承 splitChunks 里的所有属性的值,如 chunksminSizeminChunksmaxAsyncRequestsmaxInitialRequestsautomaticNameDelimitername ,我们还可以在 cacheGroups 中重新赋值,覆盖 splitChunks 的值

另外,还有一些属性只能在 cacheGroups 中使用:testpriorityreuseExistingChunk

  • text
    • 含义: 正则匹配条件
  • priority
    • 缓存组的优先级
    • 数字越大, 等级越高
  • reuseExistingChunk
    • 如果一个模块被打包了, 再遇上相同模块时会复用之前模块

开启gzip压缩

gizp压缩是一种http请求优化方式,通过减少文件体积来提高加载速度

htmljscss文件甚至json数据都可以用它压缩,可以减小60%以上的体积

compression-webpack-plugin

webpack在打包时可以借助 compression webpack plugin 实现gzip压缩,首先需要安装该插件

yarn add -D compression-webpack-plugin

vue.config.js进行配置

const CompressionPlugin = require('compression-webpack-plugin')

module.exports = {
  configureWebpack: () => {
    if (process.env.NODE_ENV === 'production') {
      return {
        plugins: [
          new CompressionPlugin({
            test: /\.js$|\.html$|\.css$|\.jpg$|\.jpeg$|\.png/, // 需要压缩的文件类型
            threshold: 10240, // 对10K以上的进行压缩
            deleteOriginalAssets: false // 是否删除原文件
          })
        ]
      }
    }
  }
}

安装后打包报错

TypeError: Cannot read property 'tapPromise' of undefined

原因是因为compression-webpack-plugin这个版本高了,得降低点版本

yarn add -D compression-webpack-plugin@6.1.1

nginx配置

在配置完Vue部分后直接部署到nginx上是不会生效的,还必须打开nginxgzip功能才可以

首先你要准备配置一下nginx,在 http 中:

// nginx开启gzip服务
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
// 需要开启gzip的格式
gzip_types text/plain text/css application/json application/x-javascript text/xml 
  application/xml application/xml+rss text/javascript image/jpeg image/gif image/png image/jpg;

CDN引入

一般我们习惯于npm引入包, 但是引入的包, 会在打包时候打包进去, 进而增加打包后的体积

那么在浏览器加载时候, 某些包过大, 就会导致页面白屏时间过长, 体验感较差

这时候就可以把某些包用 CDN引入形式加载到项目内

配置如下:

const env = process.env.NODE_ENV === 'development' ? '' : '.min'
const cdn = {
  css: [
    '//unpkg.com/element-ui@2.13.2/lib/theme-chalk/index.css',
    '//cdn.bootcdn.net/ajax/libs/animate.css/3.5.1/animate.css',
    '/cdn/iconfont/1.0.0/index.css',
    '/cdn/avue/2.7.3/index.css'
  ],
  js: [
    '/util/aes.js',
    `//cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue${env}.js`,
    `//cdn.jsdelivr.net/npm/vuex@3.6.0/dist/vuex${env}.js`,
    `//cdn.jsdelivr.net/npm/vue-router@3.4.9/dist/vue-router${env}.js`,
    `//unpkg.com/axios@0.21.0/dist/axios${env}.js`,
    '//unpkg.com/element-ui@2.13.2/lib/index.js',
    '/cdn/avue/2.7.3/avue.min.js',
    '//cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js',
    '//cdn.jsdelivr.net/npm/lodash@4.17.20/lodash.min.js',
    '//unpkg.com/xlsx@0.16.9/dist/xlsx.min.js'
  ]
}

module.exports = {
  pages: {
    index: {
      entry: 'src/main.js',
      cdn: cdn
    }
  },
  chainWebpack: config => {
    //忽略的打包文件
    config.externals({
      vue: 'Vue',
      'vue-router': 'VueRouter',
      vuex: 'Vuex',
      axios: 'axios',
      'element-ui': 'ELEMENT',
      moment: 'moment',
      lodash: '_',
      xlsx: 'XLSX'
    })
  }
}
<!DOCTYPE html>
<html>

<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
  <meta name="format-detection" content="telephone=no">
  <meta http-equiv="X-UA-Compatible" content="chrome=1"/>
  <!-- 关键遍历 -->
  <% for (let i in htmlWebpackPlugin.options.cdn.css) { %>
    <link rel="stylesheet" href="<%= htmlWebpackPlugin.options.cdn.css[i] %>" />
  <% } %>
  <title>XXX</title>
</head>

<body>
<div id="app"></div>
<!-- 关键遍历 -->
<% for (let i in htmlWebpackPlugin.options.cdn.js) { %>
  <script
    type="text/javascript"
    src="<%= htmlWebpackPlugin.options.cdn.js[i] %>"
  ></script>
<% } %>
</body>

</html>

关键点:

  1. config.externals, 引入的包都会有全局注入的变量, 那么在页面引用该包时候, 指向引用该变量

    'vue-router': 'VueRouter'

    意思引入vue-router这个包时候, 去找VueRouter变量

    如何查看包注入的是哪个变量? 只能通过去查看源码, 导出的变量是哪个

  2. pages.index.cdn: cdn, 把CDN引入放在一个变量内, 注入到``pages.index.cdn, index.名称`该名称可以随意, 只是个遍历标识