前端页面性能优化

前端Vue工程的性能优化笔记。

前端性能优化的目的是为了让用户访问网站的时候可以非常快的加载出来,常见的优化方法:减小资源大小、CDN加速、浏览器缓存。

Vue页面优化

v-for优化

不在同一级使用v-for、v-if

当v-if与v-for一起使用时,v-for具有比v-if更高的优先级,这意味着v-if将分别重复运行于每个v-for循环中

要避免出现这种情况,则在外层嵌套template,在这一层进行v-if判断,然后在内部进行v-for循环,如果条件出现在循环内部,可通过计算属性提前过滤掉那些不需要显示的项。

示例:

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
<!DOCTYPE html> 
<html>
<head>
   <title>Vue事件处理</title>
</head>
<body>
   <div id="demo">
       <h1>v-for和v-if谁的优先级高?应该如何正确使用避免性能问题?</h1>
<!-- <p v-for="child in children" v-if="isFolder">{{child.title}}</p> -->
       <template v-if="isFolder">
           <p v-for="child in children">{{child.title}}</p>
       </template>
   </div>
   <script src="../../dist/vue.js"></script>
   <script>
       // 创建实例
       const app = new Vue({
el: '#demo',
           data() {
               return {
                   children: [
                      {title:'foo'},
                      {title:'bar'},
                  ]
              }
          },
           computed: {
               isFolder() {
                   return this.children && this.children.length > 0              
}
          },
      });
       console.log(app.$options.render);
   </script>
</body>
</html>

两者同级时,渲染函数如下:

1
2
3
4
5
6
7
8
9
10
(function anonymous(
) {
with (this) {
return _c('div', { attrs: { "id": "demo" } }, [_c('h1', [_v("v-for和v-if谁的优先 级高?应该如何正确使用避免性能问题?")]), _v(" "),
_l((children), function (child) {
return (isFolder) ? _c('p',
[_v(_s(child.title))]) : _e()
})], 2)
}
})

两者不同级时,渲染函数如下:

1
2
3
4
5
6
7
8
9
10
(function anonymous(
) {
with (this) {
return _c('div', { attrs: { "id": "demo" } }, [_c('h1', [_v("v-for和v-if谁的优先 级高?应该如何正确使用避免性能问题?")]), _v(" "),
(isFolder) ? _l((children), function (child) {
return _c('p',
[_v(_s(child.title))])
}) : _e()], 2)
}
})

v-for的key值

注意:不建议使用index作为key值

示例:

1
2
3
4
5
         下标
1. 前天 0
2. 昨天 1
3. 今天 2
4. 明天 3

假设我们删除了List中下标为1的数据:

1
2
3
4
         下标
1. 前天 0
2. 今天 1
3. 明天 2

我们可以发现,除了第一个数据以外,其余数据的下标均发生了变化。以前的数据和重新渲染后的数据随着key值的变化从而没法建立关联关系,这就失去了key值存在的意义。

长列表性能优化

当你把一个普通的JavaScript对象传给Vue实例的data选项,Vue将遍历此对象所有的属性,并使用Object.defineProperty把这些属性全部转为getter/setter,这些getter/setter对用户来说是不可见的,但是在内部它们让Vue追踪依赖,在属性被访问和修改时通知变化。

然而有些时候我们的组件就是纯粹的数据展示,不会有任何改变,我们就不需要Vue来劫持我们的数据,在大量数据展示的情况下,这能够很明显的减少组件初始化的时间,那如何禁止Vue劫持我们的数据呢?可以通过Object.freeze方法来冻结一个对象,一旦被冻结的对象就再也不能被修改了。

示例:

1
2
3
4
5
6
7
8
9
export default {
data: () => ({
users: {}
}),
async created() {
const users = await axios.get("/api/users");
this.users = Object.freeze(users);
}
};

Object.freeze()可以冻结一个对象。一个被冻结的对象再也不能被修改;不能向这个对象添加新的属性,不能删除已有属性,不能修改该对象已有属性的可枚举性、可配置性、可写性,以及不能修改已有属性的值。此外,冻结一个对象后该对象的原型也不能被修改。

Webpack打包相关优化

我们可以对webpack打包过程进行优化,从而去减小资源大小,主要方法:

  • 压缩资源:
    • compression-webpack-plugin进行gzip压缩
    • terser-webpack-plugin打包时去除console.log以及debugger
  • 按需打包:
    • lodash按需引入
    • moment按需打包
  • 按需加载:
    • 组件路由懒加载
  • 代码分割:分割各个模块代码,提取相同部分代码,从而减少重复代码
    • splitChunks进行代码分割

在进行优化之前,首先通过webpack-bundle-analyzer插件去直观的看一下项目中各个模块的大小。

webpack-bundle-analyzer打包文件分析

通过使用webpack-bundle-analyzer可以让我们非常直观的看到项目中各模块的大小,方便后续进行优化。

依赖安装:

1
npm install webpack-bundle-analyzer –save-dev

然后在vue.config.js中添加配置:

1
2
3
4
5
6
7
8
9
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin

module.exports = {
configureWebpack: (config) => {
config.plugins.push(
new BundleAnalyzerPlugin()
);
},
}

这里使用默认配置(new BundleAnalyzerPlugin())。

然后每次运行时,系统默认浏览器打开http://127.0.0.1:8888/,展示项目的分析结果。

可选的配置选项:

  • analyzerMode:'server',可以是server,static或disabled。在server模式下,分析器将启动HTTP服务器来显示软件包报告。在“静态”模式下,会生成带有报告的单个HTML文件。在disabled模式下,你可以使用这个插件来将generateStatsFile设置为true来生成Webpack Stats JSON文件。
  • analyzerHost: '127.0.0.1', 将在“服务器”模式下使用的端口启动HTTP服务器。
  • analyzerPort: 8888, 端口号。
  • reportFilename: 'report.html', 路径捆绑,将在static模式下生成的报告文件。相对于捆绑输出目录。
  • defaultSizes: 'parsed',默认显示在报告中的模块大小匹配方式。应该是stat,parsed或者gzip中的一个。
  • openAnalyzer: true:在默认浏览器中自动打开报告。
  • generateStatsFile:false: 如果为true,则Webpack Stats JSON文件将在bundle输出目录中生成。
  • statsFilename: 'stats.json', 相对于捆绑输出目录。
  • statsOptions: nullstats.toJson()方法的选项。例如,您可以使用source:false选项排除统计文件中模块的来源。在这里查看更多选项:https://github.com/webpack/webpack/blob/webpack-1/lib/Stats.js#L21
  • logLevel: 'info',日志级别,可以是info, warn, error, silent。
  • excludeAssets:null,用于排除分析一些文件。

默认配置:

1
2
3
4
5
6
7
8
9
10
11
12
new BundleAnalyzerPlugin({
analyzerMode: 'server',
analyzerHost: '127.0.0.1',
analyzerPort: 8888,
reportFilename: 'report.html',
defaultSizes: 'parsed',
openAnalyzer: true,
generateStatsFile: false,
statsFilename: 'stats.json',
statsOptions: null,
logLevel: 'info'
});

depcheck依赖分析

随着项目工程逐步变大,引入的依赖也越来越多,其中有不少其实已经不再用到了,完全可以删除来减小依赖,这里可以用depcheck工具去检测是否不再使用。

依赖安装,全局安装:

1
npm install -g depcheck

然后在项目工程的目录下执行命令depcheck,下面是检测结果:

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
Unused dependencies
* amfe-flexible
* core-js
* file-saver
* less-loader
* postcss
* vue-axios
* vue-datepicker
* vue-notification
* vuejs-datepicker
Unused devDependencies
* @vue/cli-plugin-babel
* @vue/cli-plugin-eslint
* @vue/cli-plugin-unit-mocha
* @vue/test-utils
* babel-eslint
* chai
* postcss-pxtorem
* sass
* sass-loader
Missing dependencies
* moment: .\vue.config.js
* terser-webpack-plugin: .\vue.config.js
* video.js: .\src\main.js
* q: .\src\router\index.js

其中:

  • Unused dependencies:表示没有使用的依赖包
  • Unused devDependencies:表示没有使用的开发环境依赖包
  • Missing dependencies:表示使用到了但是没有在package.json文件中声明的依赖包

然后我们就可以根据检测结果进行无用依赖的删除。

注意事项-存在误判

这里depcheck依赖分析仅仅作为一个参考,存在部分误判的情况。

  1. 其实sasssass-loader是有用到的,在<style lang='sass'>这里。

  2. 其中less-loader是用到的,不过这个仅仅在开发环境需要,因此:

1
npm uninstall less-loader --save

然后:

1
npm install less-loader --save-dev

运行后报错:

1
2
3
4
5
 error  in ./src/views/xxxxxx.vue?vue&type=style&index=0&lang=less&

Module build failed (from ./node_modules/less-loader/dist/cjs.js):
TypeError: this.getOptions is not a function
at Object.lessLoader (xxxxxx\node_modules\less-loader\dist\index.js:19:24)

这是因为这个less-loader版本太高,移除后指定版本6.1.3安装:

1
2
npm uninstall less-loader --save-dev
npm install less-loader@6.1.3 --save-dev

compression-webpack-plugin进行gzip压缩

虽然Nginx可以将资源自动gzip压缩(开启gzip功能):

1
2
3
4
gzip on;              # 开启gzip
gzip_min_length 2k; # 超过2kb进行压缩
gzip_disable msie6; # ie6不适用gzip
gzip_types text/css application/javascript text/javascript image/jpeg image/png image/gif; # 需要处理的文件

不过这样会使得每次请求时都要压缩一次,比较浪费资源

因此,我们可以通过compression-webpack-plugin插件,在打包时提前将资源gzip压缩好,保存在服务端。

依赖安装:

1
npm install compression-webpack-plugin --save-dev

注:不要安装最新版本,否则容易build失败,报错类似在xx.js** undefined或者TypeEror之类的,一般都是版本问题。

然后在vue.config.js中添加配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
configureWebpack: (config) => {
config.plugins.push(
new CompressionPlugin({
test: /\.(js|css|html)?$/,
filename: '[path][base].gz',
algorithm: 'gzip',
minRatio: 0.8,
deleteOriginalAssets: false
})
);
},
}
  • minRatio:只有压缩率小于这个值的资源才会被处理

npm run build后,可以在dist/js目录下可以看gzip压缩前后的文件大小:

1
2
3
4
5
Mode           Length Name
---- ------ ----
-a---- 53586 chunk-041bbe7c.1f705809.js
-a---- 12789 chunk-041bbe7c.1f705809.js.gz
...

最后,别忘记在Nginx配置文件中开启gzip_static

1
gzip_static  on;

注:这种模式下,gzip压缩文件都是提前准备好的,如果没有.gz格式的文件就会自动返回原文件。

terser-webpack-plugin打包时去除console.log以及debugger

由于uglifyjs-webpack-plugin不识别ES6的语法,vue-cli3.0在打包过程中就使用了terser-webpack-plugin插件进行优化。

由于vue-cli工具中已经用到了terser-webpack-plugin,因此在vue-cli新建的项目中可以直接引入terser-webpack-plugin,无需安装。

vue.config.js中添加配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const TerserPlugin = require("terser-webpack-plugin");

module.exports = {
configureWebpack: (config) => {
config.plugins.push(
new TerserPlugin({
terserOptions: {
ecma: undefined,
warnings: false,
parse: {},
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log'], // 移除console
},
},
}),
);
},
}

这里去掉了console.log以及debugger

lodash按需引入

完整引入:

1
import _ from 'lodash'

手动按需引入:

1
import debounce from "lodash/debounce";

或者使用插件babel-plugin-lodash,然后在babel.config.js添加:

1
2
3
module.exports = {
plugins: ["lodash"],
};

然后发现,只引入了 lodash 的几个方法,从数量上看还不到总量的零头,但是好像 lodash 的一大半都被打包进去了。比如,map一共用到121个模块。

但是大多数人在使用时,并不会用到这些特殊的用法,却不得不为大量的冗余代码买单。因此,lodash就提供了lodash-webpack-plugin这个插件,可以使得打包的代码量减少99%。

插件用法,在vue.config.js中添加:

1
2
3
4
5
6
const LodashModuleReplacementPlugin = require("lodash-webpack-plugin");
module.exports = {
chainWebpack: (config) => {
config.plugin("loadshReplace").use(new LodashModuleReplacementPlugin());
},
};

lodash-webpack-plugin问题

不过,引入lodash-webpack-plugin后,例如,map方法的其他各种奇奇怪怪的用法就失效了,只剩下最基本的类似Array map的用法。

一个例子:clamp模块依赖toNumber进行参数处理(即支持传入字符串参数,并在内部先处理成数字)。但是使用Plugin后,Plugin会把toNumber替换成identity(即a => a),导致clamp不再支持字符串参数。如果传入的是字符串,返回的结果将发生变化。

而且,lodash-webpack-plugin会影响第三方模块的行为。如果第三方模块中也使用了 lodash 模块,而且用到了某些非常规用法,一旦使用了 Plugin 后,这个第三方模块使用的 lodash 的执行逻辑就可能发生变化。产生的后果可能是立即报错,也可能产生更严重的后果,即返回了和预期不一致的值,这个错误值在一系列流转之后,在另一个地方产生了 BUG。一旦出现了这种情况,因为这是一个第三方模块,问题的排查可能会非常困难。

结论:不建议使用lodash-webpack-plugin

Moment按需打包

moment.js占用空间大的原因在于,moment中包含了大量语言资源文件,但其实我们并不需要这些。

通过webpack自身的功能即可在打包时去掉这些用不到的语言包,在vue.config.js中添加:

1
2
3
4
5
6
7
8
9
const webpack = require('webpack');
module.exports = {
configureWebpack: config => {
const plugins = [
// 只保留中文语言资源
new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /zh-cn/),
]
},
}

组件路由懒加载

我们可以将不同路由对应的组件分割成不同的代码块,这样,当路由被访问的时候才加载对应组件

1
2
3
4
5
const Foo = () => import('./Foo.vue')

const router = new VueRouter({
routes: [{ path: '/foo', component: Foo }]
})

splitChunks进行代码分割

常见代码分割方式

Webpack中有以下三种常见的代码分割方式:

  • 入口起点:使用 entry 配置手动地分离代码
  • 动态导入:通过模块的内联函数调用来分离代码
  • 防止重复:使用 splitChunks 去重和分离 chunk

第一种方式,很简单,只需要在 entry 里配置多个入口即可:

1
entry: { app: "./index.js", app1: "./index1.js" }

第二种方式,就是在代码中自动将使用import()加载的模块分离成独立的包:

1
2
3
//...
import("./a");
//...

第三种方式,是使用 splitChunks 插件,配置分离规则,然后 webpack 自动将满足规则的 chunk 分离。一切都是自动完成的。

splitChunks默认配置

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
splitChunks: {
// 表示选择哪些 chunks 进行分割,可选值有:async,initial和all
chunks: "async",
// 表示新分离出的chunk必须大于等于minSize,默认为30000,约30kb。
minSize: 30000,
// 表示一个模块至少应被minChunks个chunk所包含才能分割。默认为1。
minChunks: 1,
// 表示按需加载文件时,并行请求的最大数目。默认为5。
maxAsyncRequests: 5,
// 表示加载入口文件时,并行请求的最大数目。默认为3。
maxInitialRequests: 3,
// 表示拆分出的chunk的名称连接符。默认为~。如chunk~vendors.js
automaticNameDelimiter: '~',
// 设置chunk的文件名。默认为true。当为true时,splitChunks基于chunk和cacheGroups的key自动命名。
name: true,
// cacheGroups 下可以可以配置多个组,每个组根据test设置条件,符合test条件的模块,就分配到该组。模块可以被多个组引用,但最终会根据priority来决定打包到哪个组中。默认将所有来自node_modules目录的模块打包至vendors组,将两个以上的chunk所共享的模块打包至default组。
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
//
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}

chunks

chunks用以告诉splitChunks的作用对象,其可选值有async、initial和all。默认值是async,也就是默认只选取异步加载的chunk进行代码拆分。

示例:

chunks: async

index.js

1
2
3
import("./a");

// ...

a.js

1
2
3
import "vue";

// ...

最后打包出来:

1
2
3
1.js    712 bytes
2.js 234 kb
app.js 8.46 kb
  • index.js作为入口文件,属于入口起点手动配置分割代码的情况,因此会独立打包。(app.js)
  • a.js通过import()进行加载,属于动态导入的情况,因此会独立打出一个包。(1.js)
  • vue来自node_modules目录,并且大于30kb;将其从a.js拆出后,与a.js并行加载,并行加载的请求数为2,未超过默认的5;vue拆分后,并行加载的入口文件并无增加,未超过默认的3。vue也符合splitChunks的拆分条件,单独打了一个包。(2.js)

chunks: initial:splitChunks的作用范围变成了非异步加载的初始chunk,例如我们的index.js就是初始化的时候就存在的chunk。而vue模块是在异步加载的chunk a.js中引入的,所以并不会被分离出来。

1
2
1.js    234 kb
app.js 8.46 kb

如果这样修改下:

index.js

1
2
import 'vue'
import('./a')

a.js

1
console.log('a')

最后打包出来:

1
2
3
4
2.js    638 bytes
app.js 9.52 kb
index.html 294 bytes
vendors~app.js 234 kb

可以看到,vue在index.js直接被引入,而index.js是初始chunk,所以分离出来打到了vendors~app.js中。

chunks: all

index.js

1
2
import 'vue-router'
import("./a");

a.js

1
import "vue";

最后打包出来:

1
2
3
4
5
2.js    706 bytes
3.js 234 kb
app.js 9.55 kb
index.html 294 bytes
vendors~app.js 72.6 kb

splitChunks的作用范围包括了初始chunk和异步chunk两种场景。因此index.js中的vue-router被分拆到了vendors~app.js中,而异步加载的chunk a.js中的vue被分拆到了3.js中。

cache groups

cacheGroups继承splitChunks里的所有属性的值,如chunks、minSize、minChunks、maxAsyncRequests、maxInitialRequests、automaticNameDelimiter、name,我们还可以在cacheGroups中重新赋值,覆盖splitChunks的值。另外,还有一些属性只能在cacheGroups中使用:test、priority、reuseExistingChunk。

cacheGroups有两个默认的组,一个是vendors,所有来自node_modules目录的模块;一个default,包含了由两个以上的chunk所共享的模块。

示例:

1
2
3
4
5
6
7
8
chunks:'all',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: "customName",
priority: -10
}
}

采用的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module.exports = {
chainWebpack: (config) => {
config.optimization.splitChunks({
chunks: 'all',
cacheGroups: {
//公用模块抽离
common: {
name: 'chunk-common',
chunks: 'initial',
minChunks: 2, //抽离公共代码时,这个代码块最小被引用的次数
},
//第三方库抽离
vendors: {
name: 'chunk-vendors',
priority: 1, //权重
test: /node_modules/,
chunks: 'initial',
minChunks: 2, //在分割之前,这个代码块最小应该被引用的次数
},
},
})
},
}

MiniCssExtractPlugin插件将CSS提取到单独的文件

此插件为每个包含CSS的JS文件创建一个单独的CSS文件,并支持CSS和SourceMap的按需加载。

注意:这里说的每个包含CSS的JS文件,并不是说组件对应的JS文件,而是打包之后的JS文件

优点:

  • CSS请求并行,如果样式文件大小较大,这会做更快提前加载
  • CSS单独缓存

缺点:

  • 需要额外的HTTP请求

依赖安装:

1
npm install --save-dev mini-css-extract-plugin

vue.config.js添加:

1
2
3
4
5
6
7
8
9
10
11
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
chainWebpack: (config) => {
let miniCssExtractPlugin = new MiniCssExtractPlugin({
filename: 'assets/[name].[hash:8].css',
chunkFilename: 'assets/[name].[hash:8].css'
})
config.plugin('extract-css').use(miniCssExtractPlugin);
},
}
  • filename:控制从打包后的入口JS文件中提取CSS样式生成的CSS文件的名称。
  • chunkFilename:控制从打包后的非入口JS文件中提取CSS样式生成的CSS文件的名称。

参考


----------- 本文结束啦感谢您阅读 -----------

赞赏一杯咖啡

欢迎关注我的其它发布渠道