Webpack打包优化

1,016 阅读6分钟

Webpack打包优化


Webapck 4 之后默认为我们做了很多配置项,内部开启了很多优化功能。对于开发人员,这种开箱即用的体验显然是很好的,但是同时也会导致我们忽略了很多需要学习的东西,一旦出现什么问题的时候,我们就无从下手了,下面我们就来看一下主要的优化配置项。

DefinePlugin

DefinePlugin 是用来为我们的代码来注入全局成员的,在 production 模式下,这个插件就会默认开启。它会在我们的环境中注入了一个 process.env.NODE_ENV 这样一个环境变量,我们可以通过这个环境变量去判断运行环境,从而去执行一些相应的逻辑。

const webpack = require('webpack')

module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new webpack.DefinePlugin({
      // 值要求的是一个代码片段
      API_BASE_URL: JSON.stringify('https://api.example.com')
    })
  ]
}

这样我们就可以直接在环境中使用 API_BASE_URL 这个变量了

// main.js
console.log(API_BASE_URL)

Tree-shaking

Tree-shaking 顾名思义就是摇树,伴随着摇这个动作,我们会将树上的枯树枝和枯树叶摇下来。而在我们的项目中 Tree-shaking 会将我们代码中没有引用的部分去掉,Tree-shaking 并不是某一个配置选项,它是一组功能搭配使用的效果。我们可以使用 optimization 去开启一些功能,optimization 就是优化的意思,下面我们来看看怎样去配置它

module.exports = {
  mode: 'none',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  },
  optimization: {
    // 模块只导出被使用的成员
    usedExports: true,
    // 尽可能合并每一个模块到一个函数中
    concatenateModules: true,
    // 压缩输出结果
    // minimize: true
  }
}

我们可以将 usedExports 想象成它就是去标记"枯树叶"的,而 minimize 就是去摇下这些枯树叶的。而 concatenateModules 将所有的代码都尽可能的合并到一个函数中去,这样既提升了运行效率,又减少了代码的体积。这个特性又被称为 Scope Hoisting,这时 Webpack3 中提出的一个特性。

  • Tree-shaking 与 babel

由于 Webpack 的发展比较快,所以我们在找资料的时候,找到的资料并不一定适用于我们当前的版本,Tree-shaking 更是如此,很多资料中都显示如果我们使用的 babel-loader 的话,就会导致 Tree-shaking 失效。因为 Tree-shaking 使用的前提就是必须使用 ES Modules 规范去组织我们的代码,而 @babel/preset-env 这个插件内部就会将 ES Modules 的代码转换为 commonjs 代码的方式,所以 Tree-shaking 就不能生效。但是实际你同时开启两者的话,Tree-shaking 还是会生效的,因为 @babel/preset-env 这个插件最新的版本内部将 ES Modules 转换为 commonjs 关掉了。

module.exports = {
  mode: 'none',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              // 如果 Babel 加载模块时已经转换了 ESM,则会导致 Tree Shaking 失效
              // ['@babel/preset-env', { modules: 'commonjs' }]
              // ['@babel/preset-env', { modules: false }]
              // 也可以使用默认配置,也就是 auto,这样 babel-loader 会自动关闭 ESM 转换
              ['@babel/preset-env', { modules: 'auto' }]
            ]
          }
        }
      }
    ]
  },
  optimization: {
    // 模块只导出被使用的成员
    usedExports: true,
    // 尽可能合并每一个模块到一个函数中
    // concatenateModules: true,
    // 压缩输出结果
    // minimize: true
  }
}

sideEffects

Webpack4 中还新增了一个叫 sideEffects 的新特性,它允许我们去标识我们的代码是否有副作用,从而为 Tree shaking 提供更大的压缩空间。副作用就是模块去执行时除了导出成员之外所做的事情,sideEffects 一般只有我们在去开发一个 npm 模块的时候才会去使用,那是因为官网将 sideEffects 和 Tree shaking 混到了一起,所以很多人误认为它们两个是因果关系,其实它们两个的关系不大。 当我们去封装组件的时候,我们一般会将所有的组件都导入在一个文件中,然后通过这个文件集体导出,但是其他文件引入这个文件的时候,就会将这个导出文件的所有组件都引入

// components/index.js
export { default as Button } from './button'
export { default as Heading } from './heading'
// main.js
import { Button } from './components'
document.body.appendChild(Button())

这样 Webpack 在打包的时候,也会将 Heading 组件打包到文件中,这时 sideEffects 就能解决这个问题

module.exports = {
  mode: 'none',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  },
  optimization: {
    sideEffects: true,
  }
}

同时我们在 packag.json 中导入将没有副作用的文件关闭,这样就不会将无用的文件打包到项目中了

{
  "name": "side-effects",
  "version": "0.1.0",
  "main": "index.js",
  "author": "maoxiaoxing",
  "license": "MIT",
  "scripts": {
    "build": "webpack"
  },
  "devDependencies": {
    "webpack": "^4.41.2",
    "webpack-cli": "^3.3.9"
  },
  "sideEffects": false
}

使用 sideEffects 的需要注意的是,我们的代码中真的没有副作用,如果有副作用的代码,我们就不能去这样配置了。

// exten.js
// 为 Number 的原型添加一个扩展方法
Number.prototype.pad = function (size) {
  // 将数字转为字符串 => '8'
  let result = this + ''
  // 在数字前补指定个数的 0 => '008'
  while (result.length < size) {
    result = '0' + result
  }
  return result
}

例如我们在 extend.js 文件中为 Number 的原型添加一个方法,我们并没有向外导出成员,只是基于原型扩展了一个方法,我们在其他文件导入这个 extend.js

// main.js
// 副作用模块
import './extend'
console.log((8).pad(3))

如果我们还标识项目中所有模块没有副作用的话,这个添加在原型的方法就不会被打包进去,在运行中肯定会报错,还有就是我们在代码中导入的 css 模块,也都是副作用模块,我们就可以在 package.json 中去标识我们的副作用模块

{
  "name": "side-effects",
  "version": "0.1.0",
  "main": "index.js",
  "author": "maoxiaoxing",
  "license": "MIT",
  "scripts": {
    "build": "webpack"
  },
  "devDependencies": {
    "webpack": "^4.41.2",
    "webpack-cli": "^3.3.9"
  },
  "sideEffects": [
    "./src/extend.js",
    "*.css"
  ]
}

这样标识的有副作用的模块也会被打包进来。

Webpack 代码分割(Code Splitting)

模块化的优势固然很明显,但是也存在一些弊端,就是在我们的项目中所有的代码都会被打包到一起,如果我们的项目过大的话,那么我们的打包结果就会特别大。但是实际的情况是,我们在首次加载的时候,并不是所有的模块都是必须加载的,但是这些模块又被打包到一起,所以一方面在浏览器运行的时候会慢,一方面也会浪费一些流量和带宽。所以合理的方式就是将我们的代码按照一定的规则打包到多个 js 文件中去,做分包处理、按需加载,这样我们就会大大提高我们的应用的响应速率。那么有人可能会想到 Webpack 不就是将我们代码中散落的代码合并到一个函数中去执行,从而去提高效率,这里为什么又要做分包处理,不是自相矛盾吗?其实任何事情都是物极必反,Webpack 做代码合并是因为我们在开发中往往模块化颗粒度太细,所以 Webpack 必须将很多代码合并到一起,但是如果总体代码量过大的话,就会导致我们的单个打包文件过大,反而影响效率。所以模块化颗粒度太小不行,太大也不行,而 Code Splitting 就是为了解决我们模块化颗粒度太大的问题。

  • 多入口打包

多入口打包就是将一个页面作为一个打包入口,而对于不同页面中公共的部分再去提取到公共的文件中去,而多入口打包的配置也很容易

const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'none',
  entry: { // 多入口打包,多个入口文件
    index: './src/index.js',
    album: './src/album.js'
  },
  output: {
    filename: '[name].bundle.js' // 由于多入口打包,采用占位符
  },
  optimization: {
    splitChunks: {
      // 自动提取所有公共模块到单独 bundle
      chunks: 'all'
    }
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/index.html',
      filename: 'index.html',
      chunks: ['index'] // 配置chunk,防止同时载入
    }),
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/album.html',
      filename: 'album.html',
      chunks: ['album']
    })
  ]
}
  • Webpack 按需加载

按需加载是我们在开发中常见的需求,我们在处理打包的时候,我们可以需要哪个模块是,再加载哪个模块。Webpack 中支持动态导入的方式去支持按需加载我们的模块,所有动态加载的模块都会被自动分包,相比于分包加载的方式,动态加载的方式更加灵活。 例如我们有两个模块 album 和 posts,我们就可以使用 import 去实现动态导入,import 返回一个 promise 对象,

// ./posts/posts.js
export default () => {
    const posts = document.createElement('div')
    posts.className = 'posts'
    ...
    return posts
}
// ./album/album.js
export default () => {
    const album = document.createElement('div')
    album.className = 'album'
    ...
    return album
}
// import posts from './posts/posts'
// import album from './album/album'

const render = () => {
  const hash = window.location.hash || '#posts'

  const mainElement = document.querySelector('.main')

  mainElement.innerHTML = ''

  if (hash === '#posts') {
    // mainElement.appendChild(posts())\
    // 魔法注释:给模块重命名
    import(/* webpackChunkName: 'components' */'./posts/posts').then(({ default: posts }) => {
      mainElement.appendChild(posts())
    })
  } else if (hash === '#album') {
    // mainElement.appendChild(album())
    import(/* webpackChunkName: 'components' */'./album/album').then(({ default: album }) => {
      mainElement.appendChild(album())
    })
  }
}

render()

window.addEventListener('hashchange', render)

css 的模块化打包

  • MiniCssExtractPlugin 是一个能够将 css 文件从打包文件中单独提取出来的插件,通过这个插件我们就可以实现 css 模块的按需加载。
  • optimize-css-assets-webpack-plugin 是一个能够压缩 css 文件的插件,因为使用了 MiniCssExtractPlugin 之后,就不需要使用 style 标签的形式去加载 css 了,所以我们就不需要 style-loader 了
  • terser-webpack-plugin 因为 optimize-css-assets-webpack-plugin 是需要使用在 optimization 的 minimizer 中的,而开启了 optimization,Webpack 就会认为我们的压缩代码需要自己配置,所以 js 文件就不会压缩了,所以我们需要安装 terser-webpack-plugin 再去压缩 js 代码
// 安装 mini-css-extract-plugin
yarn add mini-css-extract-plugin --dev
// 安装 optimize-css-assets-webpack-plugin
yarn add optimize-css-assets-webpack-plugin --dev
// 安装 terser-webpack-plugin
yarn add terser-webpack-plugin --dev

接下来我们就可以配置它们了

const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
const TerserWebpackPlugin = require('terser-webpack-plugin')

module.exports = {
  mode: 'none',
  entry: {
    main: './src/index.js'
  },
  output: {
    filename: '[name].bundle.js'
  },
  optimization: {
    minimizer: [
      new TerserWebpackPlugin(), // 压缩 js 代码
      new OptimizeCssAssetsWebpackPlugin() // 压缩模块化的 css 代码
    ]
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          // 'style-loader', // 将样式通过 style 标签注入
          MiniCssExtractPlugin.loader, // 使用 MiniCssExtractPlugin 的 loader 就不需要 style-loader 了
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: 'Dynamic import',
      template: './src/index.html',
      filename: 'index.html'
    }),
    new MiniCssExtractPlugin()
  ]
}

输出 hash 文件名

一般我们去部署前端的资源文件的时候,我们都会启用服务器的静态资源缓存,这样对于用户的浏览器而言就可以缓存住我们的静态资源,后续就不再需要请求服务器去请求这些静态资源了,这样我们的应用的相应速度就会有一个大幅度的提升。不过开启客户端的静态资源缓存也会有问题,如果我们在设置缓存时间过短的话,那么缓存就没什么意义了,而设置过长的话,一旦应用发生了更新,就没有办法即时更新到客户端。为了解决这个问题,我们就需要在生产模式下,为文件名使用 hash,这样一旦我们的文件资源发生改变,我们的文件名称也会随之发生改变,而对于客户端而言,全新的文件名也就意味着全新的请求,这样我们就可以将缓存时间设置的非常长,也不用去担心文件不更新的问题。

  • hash
    项目级别的 hash,一旦任何文件发生修改,都会生成新的 hash

  • chunkhash 只要是同一路的打包,hash 都是相同的,例如一个模块内的 js 和 css 的 hash 前缀都是相同的

  • contenthash 文件级别的 hash,根据文件内容输出的 hash 值,只要是不同的文件就有不同的 hash 值,这也是最推荐的 hash 方式

const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
const TerserWebpackPlugin = require('terser-webpack-plugin')

module.exports = {
  mode: 'none',
  entry: {
    main: './src/index.js'
  },
  output: {
    filename: '[name]-[contenthash:8].bundle.js'
  },
  optimization: {
      ...
  },
  module: {
      ...
  },
  plugins: [
    ...
    new MiniCssExtractPlugin({
      filename: '[name]-[contenthash:8].bundle.css'
    })
  ]
}