# webpack 优化

# 一、影响webpack性能的因素

如果我们在构建项目中使用了大量的loader和第三方库,会使我们构建项目的时间过长,打包之后的代码体积过大。于是,就遇到了webpack 的优化瓶颈,总结webpack影响性能主要是两个方面:

  • webpack 的构建过程太花时间
  • webpack 打包的结果体积太大

# 二、webpack 优化解决方案

# 1、合理使用loader

  • 用 test,include 或 exclude 来帮我们避免不必要的转译,优化loader的管辖范围。

比如 webpack 官方在介绍 babel-loader 时给出的示例:

module: {
  rules: [
    {
      test: /\.js$/,
      exclude: /(node_modules|bower_components)/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env']
        }
      }
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 2、缓存babel编译过的文件

loader: 'babel-loader?cacheDirectory=true'
1

如上,我们只需要为 loader 增加相应的参数设定。选择开启缓存将转译结果缓存至文件系统,则至少可以将 babel-loader 的工作效率提升两倍

像dll第三方类库的本质也是减少打包类库次数 , 实现代码抽离 ,减少打包以后的文件体积

# 3、使externals优化cdn静态资源

我们可以将些JS件存储在CDN上(减少Webpack打包出来的js体积),在index.html中通过标签引,如:

<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
 <meta http-equiv="X-UA-Compatible" content="ie=edge">
 <title>Document</title>
</head>
<body>
 <div id="root">root</div>
 <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13

我们希望在使时,仍然可以通过import的式去引(如:import $ from 'jquery' ),并且希望webpack不会对其进打包,此时就可以配置 externals 。

//webpack.config.js
module.exports = {
//...
externals: {
//jquery通过script引之后,全局中即有了 jQuery 变量
'jquery': 'jQuery'
 }
}
1
2
3
4
5
6
7
8

# 4、DLLPlugin类库引入

处理第三方库的姿势有很多:

  • Externals 会引发重复打包的问题;详见 (opens new window)
  • 而CommonsChunkPlugin 每次构建时都会重新构建一次 vendor;
  • 出于对效率的考虑,DllPlugin是最佳选择。

DllPlugin 是基于 Windows 动态链接库(dll)的思想被创作出来的。这个插件会把第三方库单独打包到一个文件中,这个文件就是一个单纯的依赖库。这个依赖库不会跟着你的业务代码一起被重新打包,只有当依赖自身发生版本变化时才会重新打包。

用 DllPlugin 处理文件,要分两步走:

  • (1)、基于 dll 专属的配置文件,打包 dll 库。
  • (2)、基于 webpack.config.js 文件,打包业务代码。

以一个基于 React 的简单项目为例,我们的 dll 的配置文件可以编写如下:

const path = require('path')
const webpack = require('webpack')

module.exports = {
    entry: {
      // 依赖的库数组
      vendor: [
        'prop-types',
        'babel-polyfill',
        'react',
        'react-dom',
        'react-router-dom',
      ]
    },
    output: {
      path: path.join(__dirname, 'dist'),
      filename: '[name].js',
      library: '[name]_[hash]',
    },
    plugins: [
      new webpack.DllPlugin({
        // DllPlugin的name属性需要和libary保持一致
        name: '[name]_[hash]',
        path: path.join(__dirname, 'dist', '[name]-manifest.json'),
        // context需要和webpack.config.js保持一致
        context: __dirname,
      }),
    ],
}
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

编写完成之后,运行这个配置文件,我们的 dist 文件夹里会出现这样两个文件:vendor-manifest.jsonvendor.js

vendor.js 不必解释,是我们第三方库打包的结果。

这个多出来的 vendor-manifest.json,则用于描述每个第三方库对应的具体路径,我这里截取一部分给大家看下:

{
  "name": "vendor_397f9e25e49947b8675d",
  "content": {
    "./node_modules/core-js/modules/_export.js": {
      "id": 0,
        "buildMeta": {
        "providedExports": true
      }
    },
    "./node_modules/prop-types/index.js": {
      "id": 1,
        "buildMeta": {
        "providedExports": true
      }
    },
    ...
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

随后,我们只需在 webpack.config.js 里针对 dll 稍作配置:

const path = require('path');
const webpack = require('webpack')
module.exports = {
  mode: 'production',
  // 编译入口
  entry: {
    main: './src/index.js'
  },
  // 目标文件
  output: {
    path: path.join(__dirname, 'dist/'),
    filename: '[name].js'
  },
  // dll相关配置
  plugins: [
    new webpack.DllReferencePlugin({
      context: __dirname,
      // manifest就是我们第一步中打包出来的json文件
      manifest: require('./dist/vendor-manifest.json'),
    })
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 5、CodeSplit

在生产环境下,webpack 默认开启了 CodeSplit,它会有一套最佳实践的默认配置,但是对于你的项目可能会略有不同。下面列出了配置项的解释:

splitChunks: {
    chunks: "async", // 可选值 "initial", "async" and "all", 代表同步导入、异步导入、两个都包括
    minSize: 30000,  // 被分割的文件大小 最小为 30kb,只有被分割的代码加起来大于这个 size,才会分割一个单独文件,否则就不分割
    maxSize: 60000, // 最大的文件大小,maxSize只是一个提示,当模块大于maxSize 可能会违反给定的规则
    maxAsyncSize: 60000,// 按需加载时的最大文件大小, 不设置等于 maxSize
    maxInitialSize: 60000, // 页面初始化加载时的最大文件大小,不设置等于 maxSize
    minRemainingSize: 0, // 打包时的最后一个 chunk 的大小不小于其给定的值,development 下默认为 0, production 模式下默认值等于 minSize
    minChunks: 1,    // 打包出来的 chunck 有 1 一个以上的chunck对其进行了引用, 
    maxAsyncRequests: 5, // 通过代码分割最终分离出来的后,页面在进行加载时,按需加载时的最大并行请求数,相当于文件数。
    maxInitialRequests: 3, // 页面初始化时的最大并行请求数
    automaticNameDelimiter: '~', // 分割符
    automaticNamePrefix: '', // 设置前缀
    name: true, // chunk 的名字,设置为 true 会基于 cacheGroup 的 key 和 chunk 来取名,也可以传递一个 function
    cacheGroups: { // 可以重写 splitChunks 中的任何选项
        vendors: {
            test: /[\\/]node_modules[\\/]/,
            priority: -10
        },
    other: {
            enforce: false, // 忽略 splitChunks.minSize, splitChunks.minChunks, splitChunks.maxAsyncRequests and splitChunks.maxInitialRequests 并且总是为这个 cacheGroup 创建 chunk
            filename: '', // 页面初始化时的 chunk 名称
            idHint: 'vendors',
            test: /[\\/]src[\\/]/, // 可以是一个路径,也可以是一个 chunkName, 如果一个 chunkName 被匹配到,那个所有这个 chunk 中的 module 将会被选中
            minChunks: 2,     // 打包出来的 chunck 有 2 一个以上的chunck对其进行了引用, 例如 如果只有一个地方对 lodash 进行了引用,那么不会对 lodash 进行代码分割
            priority: -20, // 优先级 值越大优先级越高
            reuseExistingChunk: true // 防止分割的代码当中包含重复的,如果当前块包含已经分离出来的模块,它将被重用,而不是生成一个新的。这可能会影响块的结果文件名。
        }
    }
}
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

在 CodeSplit 的基础之上,我们可以使用导入语法 import() 来实现按需加载,从而降低代码体积。

import(/* webpackChunkName: "lodash" */ 'lodash').then(({ default: _ }) => {
   _.join(['hello'], ['webpack'])
})
1
2
3

CodeSplit 会把动态导入的模块单独生成一个文件,使用时再进行下载。

webpack 4.6.0 之后支持了 prefetching 和 preloading,即预加载。使用方法分别如下:

????
import(/* webpackPrefetch: true */ 'lodash').then(({ default: _ }) => {
   _.join(['hello'], ['webpack'])
})
import(/* webpackPreload: true */ 'lodash').then(({ default: _ }) => {
   _.join(['hello'], ['webpack'])
})
1
2
3
4
5
6
7

两者的区别在于:

  • prefetch:使用 prefetch 加载的文件在未来可能会用到,所以 webpack 会在父组件 loaded 后将以下标签添加到 head 标签内,并在浏览器有空闲时间的时候去下载该文件。
<link rel="prefetch" href="xxxxx"> 
1
  • preload:加载的文件需要立即用到,在浏览器的主渲染机制介入前就进行预加载,这一机制使得资源可以更早的得到加载并可用,且更不易阻塞页面的初步渲染,进而提升性能。
<link rel="preload" href="xxxxx"> 
1

# 6、happypack多线程编译

我们都知道nodejs是单线程。无法一次性执行多个任务。这样会使得所有任务都排队执行。happypack可以根据cpu核数优势,建立子进程child_process,充分利用多核优势解决这个问题。提高了打包的效率。

const HappyPack = require('happypack')
// 手动创建进程池
const happyThreadPool =  HappyPack.ThreadPool({ size: os.cpus().length })

module.exports = {
  module: {
    rules: [
      ...
      {
        test: /\.js$/,
        // 问号后面的查询参数指定了处理这类文件的HappyPack实例的名字
        loader: 'happypack/loader?id=happyBabel',
        ...
      },
    ],
  },
  plugins: [
    ...
    new HappyPack({
      // 这个HappyPack的“名字”就叫做happyBabel,和楼上的查询参数遥相呼应
      id: 'happyBabel',
      // 指定进程池
      threadPool: happyThreadPool,
      loaders: ['babel-loader?cacheDirectory']
    })
  ],
}
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

happypack成功,启动了三个进程编译。加快了loader的加载速度。

# 7、并行压缩 terser-webpack-plugin

使用 terser-webpack-plugin 插件

配置

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
    optimization: {
        minimizer: [
            new TerserPlugin({
                parallel: true,
                cache: true
            })
        ]
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

# 8、scope Hoisting

scope Hoisting的作用是分析模块之前的依赖关系 , 把打包之后的公共模块合到同一个函数中去。它会代码体积更小,因为函数申明语句会产生大量代码;代码在运行时因为创建的函数作用域更少了,内存开销也随之变小。

const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');

module.exports = {
  resolve: {
    // 针对 Npm 中的第三方模块优先采用 jsnext:main 中指向的 ES6 模块化语法的文件
    mainFields: ['jsnext:main', 'browser', 'main']
  },
  plugins: [
    // 开启 Scope Hoisting
    new ModuleConcatenationPlugin(),
  ],
};
1
2
3
4
5
6
7
8
9
10
11
12

# 9、tree Shaking 删除冗余代码

Tree-Shaking可以通过分析出import/exports依赖关系。对于没有使用的代码。可以自动删除。这样就减少了项目的体积。

举个例子:

import { a, b } from './pages'
a()
1
2

pages 文件里,我虽然导出了两个页面:

export const a = ()=>{ console.log(666) }
export const b = ()=>{ console.log(666) }
1
2

所以打包的结果会保留这部分:

export const a = ()=>{ console.log(666) }
1

b方法直接删掉,这就是 Tree-Shaking 帮我们做的事情。删掉了没有用到的代码。

Tree Shaking 是一个术语,在计算机中表示消除死代码,依赖于ES Module的静态语法分析(不执行任何的代码,可以明确知道模块的依赖关系)

在webpack实现Tree shaking有两种不同的方案:

  • usedExports:通过标记某些函数是否被使用,之后通过Terser来进行优化的
  • sideEffects:跳过整个模块/文件,直接查看该文件是否有副作用

两种不同的配置方案, 有不同的效果

# usedExports

配置方法也很简单,只需要将usedExports设为true

module.exports = {
    ...
    optimization:{
        usedExports
    }
}
1
2
3
4
5
6

使用之后,没被用上的代码在webpack打包中会加入unused harmony export mul注释,用来告知 Terser 在优化时,可以删除掉这段代码

# sideEffects

sideEffects用于告知webpack compiler哪些模块时有副作用,配置方法是在package.json中设置sideEffects属性 如果sideEffects设置为false,就是告知webpack可以安全的删除未用到的exports 如果有些文件需要保留,可以设置为数组的形式

"sideEffecis":[
    "./src/util/format.js",
    "*.css" // 所有的css文件
]
1
2
3
4

上述都是关于javascript的tree shaking,css同样也能够实现tree shaking

# 10、按需加载

像vue 和 react spa应用,首次加载的过程中,由于初始化要加载很多路由,加载很多组件页面。会导致 首屏时间 非常长。一定程度上会影响到用户体验。所以我们需要换一种按需加载的方式。一次只加载想要看到的内容

  • require.ensure 形式

当我们不需要按需加载的时候,我们的代码是这样的

import AComponent from '../pages/AComponent'
<Route path="/a" component={AComponent}>
1
2

为了开启按需加载,我们要稍作改动。

首先 webpack 的配置文件要走起来:

output: {
    path: path.join(__dirname, '/../dist'),
    filename: 'app.js',
    publicPath: defaultSettings.publicPath,
    // 指定 chunkFilename
    chunkFilename: '[name].[chunkhash:5].chunk.js',
},
1
2
3
4
5
6
7
const getComponent => (location, cb) {
  require.ensure([], (require) => {
    cb(null, require('../pages/AComponent').default)
  }, 'a')
}
<Route path="/a" getComponent={getComponent}>
1
2
3
4
5
6

对,核心就是这个方法:

require.ensure(dependencies, callback, chunkName)
1
  • import形式
import B from '@/pages/business/b.vue'
1

按需加载变成了:

const B = () => import('@/pages/business/b.vue')
1

无论是require.ensure形式,还是import 形式的按需加载。都是采取异步模式,跳转 对应这个路由的时候,异步方法的回调才会生效,才会真正地去获取组件页面的内容。做到了按需加载的目的。

# 11、按需引入

不知道大家有没有体会到,当我们用antd等这种UI组件库的时候。明明只想要用其中的一两个组件,却要把整个组件库连同样式库一起引进来,就会造成打包后的体积突然增加了好几倍。为了解决这个问题,我们可以采取按需引入的方式。

拿antd为例,需要我们在.babelrc文件中这样声明,

{
"presets": [
   [
    "@babel/preset-env",
    {
      "targets": {
          "chrome": "67"
      },
    "useBuiltIns": "usage",
     "corejs": 2
    }
   ],
    "@babel/preset-react"
 ],
  "plugins": [
  [
   "@babel/plugin-transform-runtime",
  ],
  //重点按需引入antd里面的style
  [  "import", {
   "libraryName": "antd",
   "libraryDirectory": "es",
   "style": true
  }]
 ]
}
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

经过如上配置之后,我们会发现体积比没有处理的要小很多。

# 12、优化resolve.modules

resolve.modules配置webpack去哪些目录下寻找第三方模块。默认是去node_modules目录下寻找。

有时你的项目中会有一些模块大量被其他模块依赖和导入,由于其他模块的位置分布不定,针对不同的文件都要去计算被导入模块文件的相对路径,这个路径有时候会很长,例如:import './../../components/button',这时你可以利用modules配置项优化,假如那些大量导入的模块都在./src/components目录下:

modules:['./src/components', 'node_modules']
1

# 13、优化resolve.alias

resolve.alias配置通过别名来将原导路径映射成个新的导路径。拿react为例,我们引的react库,般存在两套代码:

cjs

采commonJS规范的模块化代码

umd

已经打包好的完整代码,没有采模块化,可以直接执

默认情况下,webpack会从件./node_modules/bin/react/index开始递归解析和处理依赖的件。我们可以直接指定件,避免这处的耗时。

resolve: {
//查找第三方优化
modules: [path.resolve(__dirname, "./node_modules")],
alias: {
"@": path.join(__dirname, "./src"),
react: path.resolve(__dirname, "./node_modules/react/umd/react.production.min.js"),
"react-dom": path.resolve(__dirname, "./node_modules/react-dom/umd/react-dom.production.min.js")
  },
},
1
2
3
4
5
6
7
8
9

# 14、图片压缩

通常一个项目我们会引入很多各种格式的图片,多张图片被打包以后,如果不做压缩的话,体积还是相当大的,所以生产环境对图片体积的压缩就显得格外重要了。

方式

  • 使用tinypng手动压缩,比较零碎,也不够自动化
  • imagemin
  • image-webpack-loader来进行自动压缩
module.exports = {
    module: {
        {
            test: /\.(jpg|jpeg|png|gif)$/,
            use: [
                {
                    loader: 'url-loader',
                    options: {
                        limit: 8192,
                        outputPath: "img/",
                        name: "[name]-[hash:6].[ext]"
                    }
                },
                {
                    loader: 'image-webpack-loader',
                    options: {
                      mozjpeg: {
                        progressive: true,
                        quality: 65
                      },
                      // optipng.enabled: false will disable optipng
                      optipng: {
                        enabled: false,
                      },
                      pngquant: {
                        quality: '65-90',
                        speed: 4
                      },
                      gifsicle: {
                        interlaced: false,
                      },
                      // the webp option will enable WEBP
                      webp: {
                        quality: 75
                      }
                    }
                }
            ]
        }
    }
}
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

# 15、动态Polyfill (优化构建体积)

通常我们在项目中会使用babel来将很多es6中的API进行转换成es5,但是还是有很多新特性没法进行完全转换,比如promise、async await、map、set等语法,那么我们就需要通过额外的polyfill(垫片)来实现语法编译上的支持。

各个polyfill版本的优缺点

这里我们还是推荐使用第三种方式,由polyfill.io 官方为我们提供的服务。

我们可以先来使用polyfill.io 验证一下,在不同的User Agent,是会下发不同的polyfill。

# 16、resolve.extensions

resolve.extensions在导⼊语句没带⽂件后缀时,webpack会⾃动带上后缀后,去尝试查找⽂件是否存在。

  • 后缀尝试列表尽量的⼩
  • 导⼊语句尽量的带上后缀。

如果想优化到极致的话,不建议用extensionx, 因为它会消耗一些性能。虽然它可以带来一些便利。

# 17、抽离css

借助mini-css-extract-plugin:本插件会将 CSS 提取到单独的文件中,为每个包含 CSS 的 JS 文件创建一个 CSS 文件,并且支持 CSS 和 SourceMaps 的按需加载。

# 18、css代码压缩

css-minimizer-webpack-plugin

# 19、Html文件代码压缩

html-minifier-terser

# 20、文件大小压缩

对文件的大小进行压缩,减少http传输过程中宽带的损耗

compression-webpack-plugin

# 21、css tree shaking

const PurgeCssPlugin = require('purgecss-webpack-plugin')
module.exports = {
    ...
    plugins:[
        new PurgeCssPlugin({
            path:glob.sync(`${path.resolve('./src')}/**/*`), {nodir:true}// src里面的所有文件
            satelist:function(){
                return {
                    standard:["html"]
                }
            }
        })
    ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • paths:表示要检测哪些目录下的内容需要被分析,配合使用glob
  • 默认情况下,Purgecss会将我们的html标签的样式移除掉,如果我们希望保留,可以添加一个safelist的属性

# 22、babel-plugin-transform-runtime减少ES6转化ES5的冗余

Babel 插件会在将 ES6 代码转换成 ES5 代码时会注入一些辅助函数。在默认情况下, Babel 会在每个输出文件中内嵌这些依赖的辅助函数代码,如果多个源代码文件都依赖这些辅助函数,那么这些辅助函数的代码将会出现很多次,造成代码冗余。为了不让这些辅助函数的代码重复出现,可以在依赖它们时通过 require('babel-runtime/helpers/createClass') 的方式导入,这样就能做到只让它们出现一次。babel-plugin-transform-runtime 插件就是用来实现这个作用的,将相关辅助函数进行替换成导入语句,从而减小 babel 编译出来的代码的文件大小。