从零开始学 Webpack 4.0(四)

92 阅读15分钟

原文本人写于 2022-05-08 16:57:07

一、前言

通过上篇文章的学习,我们可以在项目中使用 ES6 的语法来编写代码了,下面让我们继续 Webpack 的学习之旅。

项目结构

本文会以 demo04 作为项目文件夹,各文件直接拿之前项目 demo03 的,接下来做些修改。

20220504_01.jpg

package.json

{
  "name": "demo04",
  ...
}

package-lock.json

{
  "name": "demo04",
  ...
}

webpack.config.js

...
module.exports = {
  ...
  plugins: [
    new HtmlWebpackPlugin({
      ...
      title: 'demo04自定义title'
    }),
    ...
  ],
  ...
}

.babelrc(原文件内容清空)

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage"
      }
    ]
  ],
  "plugins": []
}

src 目录下新增 math.js

math.js

const add = (a, b) => a + b
const minus = (a, b) => a - b

export {add, minus}

index.js(原文件内容清空)

import { add } from './math.js'

console.log(add(1, 2))

安装依赖

npm ci

运行打包命令

npm run bundle

打包成功,dist 目录下 index.html 放入浏览器运行,观察控制台打印结果。

20220504_02.jpg

二、Tree Shaking

本节内容主要以了解为主。

在 math.js 中我们定义了方法 add() 以及 minus(),接着在 index.js 中引入 math.js 这个模块,并使用了里面的 add()。查看打包生成 dist 目录下 main.js 的代码,在里面搜索 add 以及 minus,我们会发现虽然没有使用 minus 这个方法,但它一样被打包到了 main.js 里面去,这样是没有必要的,只会增加打包后 main.js 文件的体积。

20220504_03.jpg 20220504_04.jpg

这时候我们就需要使用到 Tree Shaking,翻成中文的意思就是“摇树”,把一个模块内不需要的内容都摇掉。

Tree Shaking 只支持 ES Module 的引入,因为 ES Module 底层是静态引入的方式,而 CommonJs 是动态引入的方式。

我们可以把 math.js 理解为一棵树,它里面会导出很多内容,这些内容可以理解为小的树形结构,这里我们只会引入树的一部分,引入的内容进行打包,不引入的内容 Tree Shaking 帮助我们剔除掉、摇晃掉。

Development 模式默认是没有 Tree Shaking 功能的,需要我们做些配置。

webpack.config.js

...
module.exports = {
  ...
  plugins: [
    ...
  ],
  optimization: {
    // 意思是导出的模块被使用了才去打包
    usedExports: true
  },
  ...
}

当项目中进行了例如 import '@babel/polyfill' 的操作,@babel/polyfill 这个包实际上不会导出任何的内容,它是在 window 对象上绑定了一些全局变量,并没有直接去导出模块。

使用了 Tree Shaking,它发现这里没有导出任何内容,于是打包的时候可能就忽略了 @babel/polyfill 这个包。这时候需要我们在 package.json 内 sideEffects 设置 @babel/polyfill,Tree Shaking 就不会对它进行处理。

同理,当项目中进行了类似 import './style.css' 的操作,可以在 sideEffects 里进行设置避免 css 文件被 Tree Shaking 剔除。

我们代码中没有引入 @babel/polyfill 这样的包,也没有引入样式文件,不需要 Tree Shaking 去避免什么文件,就可以把 sideEffects 设为 false。

package.json 内不能写注释,本文只是方便说明如何使用。

package.json

{
  "name": "demo04",
  "sideEffects": false,
  // "sideEffects": ["@babel/polyfill"]
  // "sideEffects": ["*.css"]
  // "sideEffects": ["@babel/polyfill", "*.css"]
  ...
}

这时候运行打包命令,然后观察 dist 目录下 main.js,依然能在该文件中找到 minus 被打包了,但我们可以看到多了一行注释说导出了 add minus,使用了 add。

20220505_01.jpg

Development 模式在做打包的时候,即使你使用了 Tree Shaking,它也不会去剔除没使用的内容,只会在代码里提示你一下。因为在开发环境下的代码,一般都需要做些调试,如果 Tree Shaking 把一些代码剔除掉了,那可能调试的时候一些代码的行数 SourceMap 在对应的时候会出错。

Production 模式下自动开启 Tree Shaking,不需要我们去设置 webpack.config.js 内 usedExports,package.json 的 sideEffects 也会有默认的配置。

开启 Production 模式,并进行相关设置。

webpack.config.js

...
module.exports = {
  mode: 'production',
  devtool: 'cheap-module-source-map',
  ...
  plugins: [
    ...
  ],
  // optimization: {
  //   usedExports: true
  // },
  ...
}

package.json

{
  "name": "demo04",
  // 不设置 sideEffects
  // "sideEffects": false
}

因为 Production 模式会对打包的代码进行一个压缩处理,待会在 main.js 里面是搜索不到 add、minus 这些方法名的,那就不好查看 Tree Shaking 是否生效,我们需要对源代码进行一些修改。

math.js

const add = (a, b) => {
  console.log(a + b)
}
const minus = (a, b) => {
  console.log(a - b)
}

export {add, minus}

index.js

import { add } from './math.js'

add(1, 2)

运行打包命令,打包成功,观察 dist 目录下 main.js,对 console.log 进行搜索,只能搜索到一个 add 的实现,证明 Tree Shaking 生效了,没有对 minus 进行一个打包。

20220505_02.jpg

Tree Shaking 的作用就是减少生产环境的打包体积,在引入一个模块的时候,不引入所有的代码,只引入需要的代码进行打包。它在开发环境中没有什么优势,也不会生效。生产环境中会自动开启 Tree Shaking,并且有相关的默认配置,一般不需要做修改,除非自己引入的某个模块因为 Tree Shaking 而被剔除了导致项目不能正常运行,这时候就需要设置下 package.json 的 sideEffects,告诉 Webpack 这个模块不需要 Tree Shaking 去处理。

三、Development 和 Production 模式的区分打包

代码压缩

开发环境的代码一般不需要压缩,生产环境的代码默认压缩。

SourceMap

开发环境设置的 SourceMap 一般提示比较全,帮助我们快速定位问题。

生产环境是准备上线的代码,这时候 SourceMap 也就不是很重要了,可以不设置,或者生成个 .map 文件来存储。

webpack.config.js 配置文件

因为开发环境和生产环境的打包配置内容有些区别,在切换模式打包的时候,往往需要去修改 webpack.config.js 里的配置项,比较麻烦。

我们可以把 webpack.config.js 拆分为 webpack.common.js、webpack.dev.js、webpack.prod.js,用 webpack.common.js 存储开发环境与生产环境共有的配置项,用 webpack.dev.js 存储开发环境需要的配置项,用 webpack.prod.js 存储生产环境需要的配置项。

webpack.dev.js 与 webpack.prod.js 都需要合并 webpack.common.js 才能得到各自环境需要的完整配置项,于是需要用到一个第三方模块 webpack-merge。

安装 webpack-merge

npm install webpack-merge@4.1.5 -D

删除项目根路径下 webpack.config.js,新建与 src 目录同级的 build 目录来管理配置文件,build 目录内新建 webpack.common.js、webpack.dev.js、webpack.prod.js。

webpack.common.js

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

module.exports = {
  entry: {
    // 这里的路径不用修改是因为命令是在项目根路径下运行的
    main: './src/index.js'
  },
  module: {
    rules: [
      {
        test: /\.(jpg|png|gif)$/,
        use: {
          loader: 'url-loader',
          options: {
            name: '[name].[ext]',
            outputPath: 'images/',
            limit: 2048
          }
        }
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.scss$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              importLoaders: 2,
              modules: true
            }
          },
          'postcss-loader',
          'sass-loader'
        ]
      },
      {
        test: /\.(ttf|woff|woff2|svg|eot)$/,
        use: {
          loader: 'file-loader',
          options: {
            name: '[name].[ext]',
          }
        }
      },
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel-loader'
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'src/index.html',
      title: 'demo04自定义title'
    }),
    // new CleanWebpackPlugin(['dist'])
    // 原先同级的 dist 目录变成上级目录,需从 'dist' 改为 '../dist'。
    // 但是 CleanWebpackPlugin 认为配置文件当前所在的 build 目录就是项目根目录,
    // 它只能清除根目录下的目录,不能清除根目录外部的文件夹,这时候就需要 root 来指定项目所在根目录。
    // 当指定了 root 项目根目录,就不需要填写 '../dist',只需要填写相对于 root 的 'dist' 路径。
    new CleanWebpackPlugin(['dist'], {
      root: path.resolve(__dirname, '../')
    })
  ],
  output: {
    filename: '[name].js',
    // 因为现在配置文件在 build 目录下,要生成的 dist 目录是上级目录,'dist' 改为 '../dist'。
    path: path.resolve(__dirname, '../dist')
  }
}

webpack.dev.js

const webpack = require('webpack')
const merge = require('webpack-merge')
const commonConfig = require('./webpack.common.js')

const devConfig = {
  mode: 'development',
  devtool: 'cheap-module-eval-source-map',
  devServer: {
    contentBase: './dist',
    open: true,
    port: 8080,
    hot: true,
    hotOnly: true
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
}

module.exports = merge(commonConfig, devConfig)

webpack.prod.js

const merge = require('webpack-merge')
const commonConfig = require('./webpack.common.js')

const prodConfig = {
  mode: 'production',
  devtool: 'cheap-module-source-map'
}

module.exports = merge(commonConfig, prodConfig)

因为开发环境与生产环境使用的 Webpack 配置文件不一样,所以 package.json 里面的 scripts 脚本命令也要做相应的更改。

package.json

{
  ...
  "scripts": {
    "dev": "webpack-dev-server --config ./build/webpack.dev.js",
    "build": "webpack --config ./build/webpack.prod.js"
  },
  ...
}

这时候运行开发环境启动服务的命令,以及运行生产环境项目打包的命令都不会有问题。

开发环境 启动服务

npm run dev

生产环境 项目打包

npm run build

四、Webpack 和 Code Splitting

自己处理的 Code Splitting

Code Splitting 就是代码分割,我们看个例子。

首先安装下 lodash,它是一个功能集合,能够提供很多的方法,比如可以高性能地去使用一些字符串拼接的函数等等。

npm install lodash@4.17.11 --save

然后在 index.js 里去引入这个库并使用。

index.js

import _ from 'lodash'

console.log(_.join(['a', 'b', 'c']))
console.log(_.join(['a', 'b', 'c'], '***'))

运行打包命令

npm run build

打包成功,把 dist 目录下 index.html 放入浏览器运行,控制台正常打印结果。

20220506_01.jpg

接着来查看下 dist 目录下 main.js 内代码。

20220506_02.jpg

我们可以看到 lodash 这个工具库以及 index.js 编写的业务代码都被打包到了 main.js 里面去。

这会带来一个潜在的问题,假设 lodash 的大小为 1mb,业务代码的大小也为 1mb,打包生成的 main.js 在 2mb 左右,那当用户去访问 index.html 时就需要等待一个 2mb 的 js 文件加载完,才去执行里面的业务逻辑展示相应页面。带来的问题就是打包文件很大,页面加载时间长。

当修改了业务逻辑并重新打包,这时候用户访问页面又要去重新加载这个 2mb 的文件内容,这样的体验显然是很差的。

我们来做下代码分割,在 src 目录下新建 lodash.js,把 index.js 中对 lodash 的引入放到 lodash.js 中进行。

lodash.js

import _ from 'lodash'
window._ = _

index.js

// import _ from 'lodash'

console.log(_.join(['a', 'b', 'c']))
console.log(_.join(['a', 'b', 'c'], '***'))

设置新的入口文件

webpack.common.js

...
module.exports = {
  // entry 中的文件会按顺序被生成的 index.html 引入,
  // 必须先加载 lodash.js,main.js 才能正常调用相关方法。
  entry: {
    lodash: './src/lodash.js',
    main: './src/index.js'
  },
  ...
}

运行打包命令,打包成功,index.html 放入浏览器正常运行,控制台打印出结果。

生成的 dist 目录内容如图所示

20220507_01.jpg

现在原先假设 2mb 的 main.js 被拆分为 lodash.js(假设 1mb)和 main.js(假设 1mb)。

浏览器是支持并行加载文件的,同时加载 2 个 1mb 的文件可能比原先加载一个 2mb 的文件快一些,重点是当业务代码发生变更重新打包,用户只需要重新加载 main.js,原先的 lodash.js 没有任何变更,在浏览器缓存里取即可,这样就提高了网页的加载速度。

这种代码的拆分就是 Code Splitting,Code Splitting 本质上和 Webpack 是没有关系的,是一个单独的概念,用来提高整个项目的性能,但 Webpack 有些插件能够帮助我们方便地进行 Code Splitting,在 Webpack4 中就有个内置插件做代码分割,直接使用即可。

Webpack 的 SplitChunksPlugin

上面是我们自己处理的代码分割,接下来我们通过 Webpack 自带的 SplitChunksPlugin 智能地去实现 Code Splitting。

同步

配置文件去除 entry 里的 lodash,新增 SplitChunksPlugin 相关配置项。

webpack.common.js

...
module.exports = {
  entry: {
    main: './src/index.js'
  },
  ...
  plugins: [
    ...
  ],
  optimization: {
    // 开启代码分割
    splitChunks: {
      chunks: 'all'
    }
  },
  ...
}

修改 index.js,再次引入 lodash。

index.js

import _ from 'lodash'

console.log(_.join(['a', 'b', 'c']))
console.log(_.join(['a', 'b', 'c'], '***'))

运行打包命令,打包成功,这时候 dist 目录下会新增文件 vendors ~ main.js 以及 vendors ~ main.js.map。

vendors ~ main.js 里面存放的就是 lodash 工具库的相关代码。vendors ~ main.js.map 是前文提到过的 SourceMap,对应源代码的映射关系。

这时候 main.js 就只是存放 index.js 内的业务代码,达到了我们想要的代码分割效果。

异步

上面是同步引入模块的处理方式,当我们异步引入模块时的处理方式有些不一样。

修改 index.js 来异步引入模块

index.js

function getComponent () {
  return import ('lodash').then(({ default: _ }) => {
    var element = document.createElement('div')
    element.innerHTML = _.join(['a', 'b', 'c'], '-')
    return element
  })
}

// 函数执行后返回的其实是一个 import,这个 import 的返回值实际上是一个 promise。
getComponent().then(element => {
  document.body.appendChild(element)
})

optimization 里可以不用配置

webpack.common.js

...
module.exports = {
  ...
  optimization: {},
  ...
}

运行打包命令,打包成功,这时候 dist 目录下同样会生成文件来存取 lodash 工具库的相关代码,本文中生成了 1.js 以及 1.js.map。

20220507_02.jpg

当我们不想 lodash 生成的打包文件名为 1.js,而是打包后叫做 lodash.js,这时候就用到 Webpack 的 Magic Comments,也就是“魔法注释”。它可以添加到异步加载模块的代码中,变更打包后的文件名。

index.js

function getComponent () {
  return import (/* webpackChunkName:"lodash" */ 'lodash').then(({ default: _ }) => {
  ...
  })
}
...

运行打包命令,观察生成的打包文件,原先的 1.js 变成了 vendors~lodash.js,而不是我们想要的 lodash.js,这是因为受到了一些默认配置项的影响。查看官网 SplitChunksPlugin 的相关介绍,我们对 Webpack 打包的配置文件进行一些设置。

webpack.common.js

...
module.exports = {
  ...
  optimization: {
    splitChunks: {
      chunks: 'async',
      cacheGroups: {
        vendors: false,
        default: false
      }
    }
  },
  ...
}

运行打包命令,打包成功,lodash 工具库生成的打包文件名为 lodash.js。

小结

同步或者异步引入的模块,Webpack 都能够进行代码分割,它的底层是使用了 SplitChunksPlugin 这个插件。

同步的代码分割实现方式是以同步的形式引入模块,接着配置 Webpack 打包配置文件内 optimization 下的 splitChunks 开启代码分割。

异步的代码分割实现方式是以异步的形式引入模块,就直接能进行代码分割。当对打包生成文件有相应要求就需配置 Webpack 打包配置文件内 optimization 下的 splitChunks 配置项。

五、SplitChunksPlugin 配置参数

SplitChunksPlugin 这个插件的配置项比较多,接下来我们通过查看配置参数及相应的注释来理解这些配置项的作用。

默认配置项

splitChunks: {
  // async 对异步代码进行代码分割,initial 对同步代码进行代码分割,
  // all 对同步及异步代码都进行代码分割。
  chunks: 'async',

  // 同步引入的模块超过 30000 字节( 30kb 左右)才进行代码分割,
  // 该配置项对异步引入的模块无效。
  minSize: 30000,

  // 限制代码分割后打包文件大小,默认为 0 即对打包体积没限制。
  // 当设置为 50000,也就是 50kb 左右,假设代码分割后的文件为 1mb,
  // 它就会尝试对该文件再次进行拆分,看能否拆分成 20 份 50kb 左右的文件。
  // 但文件内是单独模块时一般是不能再拆分的,例如本文中的 lodash,
  // 只有文件内存在多个模块的时候它才能再次拆分。
  // 一般没有特殊需求的时候,保持为默认的 0 即可。
  maxSize: 0,

  // 同步引入的模块被多少个 entry 设置的入口文件引入才会去进行代码分割。
  minChunks: 1,

  // 对于异步引入的模块内代码进行分割,最多只能拆分出几个文件。
  // 例如入口文件 index.js 内异步引入模块 a,a 内同步引入了 6 个模块,
  // 这 6 个模块进行拆分后生成的文件数量不能超出 maxAsyncRequests 设置的 5。
  // 该配置项对 chunks 为 initial 无效。
  maxAsyncRequests: 5,

  // 每个入口文件,本身及引入的模块加起来最多只能被拆分为几个文件。
  // maxInitialRequests 优先级大于 maxAsyncRequests。
  maxInitialRequests: 3,

  // 文件生成时文件名的一些连接符,
  // 例如 vendors~lodash.js,vendors 是被 cacheGroups 划分的组名,lodash 则是文件名。
  automaticNameDelimiter: '~',

  // 让 cacheGroups 里面配置的文件生成名有效。
  name: true,

  // 缓存组,引入的模块根据组的匹配条件去划分将打包到哪个文件内。
  // 同步引入的模块必须有缓存组才能进行代码分割。
  cacheGroups: {
    vendors: {
      // 引入的模块来自 node_modules 目录下就会划分到该缓存组去生成打包文件。
      test: /[\\/]node_modules[\\/]/,
      // 优先级,当一个文件符合多个缓存组的条件,以哪个组该值比较大为优先。
      priority: -10
    },
    // 当要进行代码分割的模块不符合前面缓存组的条件就会来到 default 缓存组。
    // 该组没有进行一个 test 的条件匹配,所有引入的模块都符合该缓存组条件。
    default: {
      minChunks: 2,
      priority: -20,
      // 当一个模块已经在前面进行了打包,再次碰到该模块的引入会忽略,直接使用之前哪个文件内打包好的。
      reuseExistingChunk: true
    }
  }
}

除了默认配置,当我们想对打包后生成的文件名进行自定义,还可以进行一些设置。

新增配置

splitChunks: {
  ...
  cacheGroups: {
    vendors: {
      ...
      // 指定生成的文件名,当指定为 test,生成的文件名就为 test.js。
      // 同步及异步引入的模块都能够使用该配置项。
      name: 'test',
    },
    default: {
      ...
      // 指定生成的文件名,可以使用 output 里 filename 的占位符。
      // 只能同步引入的模块使用,异步引入的模块使用该配置项打包时直接报错。
      filename: '[name].js'
    }
  }
}

虽然 cacheGroups 内的 filename 不能用于引入的异步模块进行代码分割时自定义文件名,但是引入的异步模块可以使用上面提及的“魔法注释”。

splitChunks 内配置项比较多,实践出真知,建议小伙伴们尝试编写一些 demo,看修改一些配置项后的打包效果有何区别。

六、官方文档

学习完以上内容,可以前往 Webpack 4.0 官网 阅读相关文档。

建议阅读的内容为:

  • (1)文档 > PLUGIN,页面左侧的 SplitChunksPlugin。

七、总结

通过以上的学习,我们了解了什么是 Tree Shaking、知道了 Webpack 开发环境和生产环境打包的区别,对打包的配置文件进行了拆分、知道怎样进行代码分割以及 Webpack 自带的代码分割插件 SplitChunksPlugin 配置参数的作用。

本文最后的代码我会上传到 码云(gitee.com/phao97)上,项目文件夹为 demo04。

如果觉得本篇文章对你有帮助,不妨点个赞或者给相关的 Git 仓库一个 Star,你的鼓励是我持续更新的动力!