拓展 - webpack的配置与优化

包含 webpack 的运行机制、基础配置以及打包优化方面的知识

运行机制

webpack 的运行过程可以简单概述为如下流程:

初始化配置参数 -> 绑定事件钩子回调 -> 确定Entry逐一遍历 -> 使用loader编译文件 -> 输出文件

整个过程就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。

插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。
webpack 通过 Tapable 来组织这条复杂的生产线。 webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。

webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。

webpack 的详细运行过程如下:

  1. webpack 会读取你在命令行传入的配置以及项目里的 webpack.config.js 文件,初始化本次构建的配置参数,并且执行配置文件中的插件实例化语句,生成 Compiler 传入 plugin 的 apply 方法,为 webpack 事件流挂上自定义钩子。
  2. webpack开始读取配置的 Entries,递归遍历所有的入口文件。
  3. webpack 会依次进入其中每一个入口文件(entry),先使用用户配置好的 loader 对文件内容进行编译(buildModule),之后再将编译好的文件内容解析生成 AST 静态语法树,分析文件的依赖关系逐个拉取依赖模块并重复上述过程,最后将所有模块中的 require 语法替换成 webpack_require 来模拟模块化操作。
  4. 所有文件的编译及转化都已经完成,包含了最终输出的资源,我们可以拿到输出的资源、代码块chunk 等等信息。

这里涉及到插件(plugin)和 解析器(loader)

loader:它是一个转换器,将A文件进行编译成B文件,比如:将A.less转换为A.css,单纯的文件转换过程。

plugin:它并不直接操作文件,而是基于事件机制工作,会监听webpack打包过程中的某些节点,执行广泛的任务,比如打包优化、文件管理、环境注入等。

插件

从形态上看,插件通常是一个带有 apply 函数的类:

class SomePlugin {
    apply(compiler) {
    }
}

webpack 会在启动后按照注册的顺序逐次调用插件对象的 apply 函数,同时传入编译器对象 compiler ,例如:

plugins: [
  new CleanWebpackPlugin(),
  new HtmlWebpackPlugin({
    template: resolve(__dirname, 'index.html'),
    cache: true
  }),
  new CopyPlugin({
    patterns: [
      {
        from: resolve(__dirname, 'src/assets'),
        to: resolve(__dirname, 'dist/assets')
      }
    ]
  })
],

插件开发者可以以此为起点触达到 webpack 内部定义的任意钩子,例如:

class SomePlugin {
    apply(compiler) {
        compiler.hooks.thisCompilation.tap('SomePlugin', (compilation) => {
        })
    }
}

配置拆分

对配置进行拆分是为了便于在不同环境中使用不同的配置项,例如在生产环境需要生成文件的 hash 值,但是开发环境不需要,在开发环境取消 hash 命名能够加快构建的速度。

webpack 一般会分为三个配置文件:base(公共部分)、dev(开发环境)、prod(生产环境)

在调用 npm script 时传入的环境参数会被用来判断导出何种配置,如果传入的是 development,那么使用webpack-merge 插件将 base 和 dev 配置文件进行合并,然后输出;如果传入的是 production,那么将 base 和 prod 配置文件合并导出

// webpack.config.js

const { merge } = require('webpack-merge');
const devConfig = require('./webpack.dev');
const prodConfig = require('./webpack.prod');
const baseConfig = { ... };

module.exports = (env) => {
  return env.development ? merge(baseConfig, devConfig) : merge(baseConfig, prodConfig);
};

在 package.json 文件中调用

"scripts": {
    "start": "webpack-dev-server --env.development",
    "build": "webpack --env.production"
}

插件合集

下面列举了常用的 loader 和插件合集:

npm i node-sass sass fibers @babel/core @babel/preset-env @babel/preset-typescript autoprefixer cssnano css-loader sass-loader babel-loader postcss-loader file-loader --save-dev
npm i webpack webpack-cli webpack-dev-server html-webpack-plugin clean-webpack-plugin copy-webpack-plugin mini-css-extract-plugin terser-webpack-plugin webpack-merge webpack-bundle-analyzer progress-bar-webpack-plugin --save-dev

基础配置

// 配置遵循 CommonJS 规范
// path 用于解决不同系统下的路径差异
const { resolve } = require('path');

const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const { merge } = require('webpack-merge');
const devConfig = require('./webpack.dev');
const prodConfig = require('./webpack.prod');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');

const baseConfig = {
  // 单入口配置生成 chunk 会被命名为 main
  // entry: "./index.js",
  // 非 SPA 应用需要配置多个入口
  // 每个属性的键名会是生成的 chunk 的名称
  entry: {
    index: './index.js'
    // search: "./search.js"
  },
  output: {
    // resolve 传入路径从右至左解析,遇到第一个绝对路径是完成解析
    // __dirname 则是获得当前文件所在目录的完整路径名
    path: resolve(__dirname, 'dist'),
    // 单入口应用可以直接固定文件名
    // filename: "index.bundle.js"
    // 在多入口应用中常使用 [name].bundle.js 来确保每个文件具有唯一名称
    // 在 webpack.pro.js 中还会使用 [contentHash] 来生成全新的文件名
    filename: '[name].bundle.js'
  },
  // resolve 用来配置模块如何被解析
  resolve: {
    // 设置别名,用于简化路径
    alias: {
      // vue 需要的特殊配置
      // $ 用于表示精确匹配,此时 import 'vue/index.js' 不会被解析作别名
      vue$: 'vue/dist/vue.esm.browser.js'
      // Utilities: resolve(__dirname, 'src/utilities/'),
      // 原先的导入方式:import Utility from '../../utilities/utility'
      // 配置了别名之后可以简化为:import Utility from 'Utilities/utility';
    }
  },
  // 配置 loader
  module: {
    rules: [
      // loader 自下而上执行,处理后的结果会传递给下一个 loader
      // test 用来筛选资源,当 test 的值为字符串时,可以为资源(或其所在目录)的绝对路径
      // use 支持数组形式添加多个 loader
      // include 表示哪些目录的文件需要被处理
      // exclude 表示哪些目录的文件不需要被处理,能够有效的加快打包速度
      // test、include、exclude 都支持使用数组传入多个值
      {
        test: /\.(sa|sc|c)ss$/,
        // loader 是从右往左执行的,这里先经过 sass-loader 再到 css-loader
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader', 'sass-loader'],
        include: resolve(__dirname, 'src'),
        exclude: /node_modules/
      },
      // use 也支持对象传入,这是为了添加格外配置 options
      // 自 babel7 开始,babel 已经能够识别 ts,不需要添加额外的 ts-loader
      {
        test: /\.(js|jsx|ts|tsx)$/,
        use: {
          loader: 'babel-loader',
          options: {
            cacheDirectory: true,
            presets: ['@babel/preset-env', '@babel/preset-TypeScript']
          }
        },
        include: resolve(__dirname, 'src'),
        exclude: /node_modules/
      },
      // file-loader 目的是保持 css 定义的 url 属性或者 img 标签中的 src 属性在打包时的正确引用
      {
        test: /\.(png|svg|jpg|jpeg|gif|webp|woff|woff2|eot|ttf|otf)$/,
        use: [
          'file-loader'
        ],
        include: resolve(__dirname, 'src'),
        exclude: /node_modules/
      }
    ]
  },
  plugins: [
    // 在打包之前将指定文件夹清空,默认是 dist
    new CleanWebpackPlugin(),
    // 将 CSS 内容提取到单独文件
    new MiniCssExtractPlugin(),
    // 会在打包结束之后自动创建一个index.html, 并将打包好的JS自动引入到这个文件中
    new HtmlWebpackPlugin({
      template: resolve(__dirname, 'index.html'),
      cache: true
    }),
    // 将素材文件拷贝到指定文件夹
    new CopyPlugin({
      patterns: [
        {
          from: resolve(__dirname, 'src/assets'),
          to: resolve(__dirname, 'dist/assets')
        }
      ]
    }),
    new ProgressBarPlugin()
  ],
  // 用于屏蔽不必要的控制台输出
  stats:{
    modules: false,
    children: false,
    chunks: false,
    chunkModules: false
  }
};

// 根据 npm script 传入的环境参数暴露出不同的配置
// webpack-merge 能够合并两个配置文件
module.exports = (env) => {
  return env.development ? merge(baseConfig, devConfig) : merge(baseConfig, prodConfig);
};

开发环境配置

const { HotModuleReplacementPlugin } = require('webpack');

module.exports = {
  // 可选值 'none' | 'development' | 'production'
  // 通过 CLI 参数 --mode=production 传递,会将 process.env.NODE_ENV 设置为对应的 mode 值
  mode: 'development',
  output: {
    // 取消在 bundle 中引入「所包含模块信息」的相关注释,有利于加快大型项目的构建速度
    pathinfo: false
  },
  // 生成 sourcemap 文件,便于定位错误位置,不使用 sourcemap 能够加快编译速度
  devtool: 'source-map',
  plugins: [
    // 内置热更新功能
    new HotModuleReplacementPlugin(),
  ],
  // 设置本地服务器
  devServer: {
    port: 10086,
    // 自动打开浏览器
    open:true,
    // hot 开启 HMR 功能
    hot: true
  }
};

生产环境配置

const TerserPlugin = require('terser-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  mode: 'production',
  output: {
    filename: '[name].[contentHash].bundle.js'
  },
  plugins: [
    // bundle 包分析工具
    new BundleAnalyzerPlugin()
  ],
  // optimization 优化
  optimization: {
    // splitChunks 用于提取公共依赖模块,减轻 index.bundle.js 的大小
    // chunk 默认使用 async,只提取按需加载模块,其他参数:initial(初始块)、all(全部块)
    splitChunks: { chunks: 'async' },
    // 文件压缩
    minimize: true,
    minimizer: [
      new TerserPlugin({
        // 开启缓存
        cache: true
      })
    ]
  }
};

编译速度优化

构建打点

使用 Speed Measure Plugin 插件对构建的全过程进行打点,了解每一个构建步骤的耗时,针对性的进行优化,配置也很简单,在原有的配置外使用smp.wrap包裹即可

cnpm install --save-dev speed-measure-webpack-plugin
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
 
const smp = new SpeedMeasurePlugin();
 
module.exports = smp.wrap(YourWebpackConfig);

输入结果如下图:

缓存

类似浏览器缓存,当目标文件已经存在的时候,就不会重复进行编译,直接读取即可,loader 中大多都有 cache 配置项。

module.exports = {
  module: {
    rules: [
        {
          test: /\.(js|jsx|ts|tsx)$/,
          use: {
            loader: 'babel-loader',
            options: {
              cacheDirectory: true,
              presets: ['@babel/preset-env', '@babel/preset-TypeScript']
            }
          },
        }
    ],
  },
};

如果 loader 没有内置缓存,也可以使用 cache-loader ,在原有的 loader 前加上 cache-loader 即可:

cnpm install --save-dev cache-loader
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [ 
            MiniCssExtractPlugin.loader, 
            'cache-loader', 
            'css-loader', 
            'postcss-loader', 
            'sass-loader'
        ]
      },
    ],
  },
};

下面是开启了 css、babel、HtmlWebpackPlugin、TerserPlugin 缓存的耗时,相比未开启缓存快了 41%

多核打包

happypack 可以获取到 cpu 的核数,榨干 cpu 的线程,加快打包速度

实现方式:将原本卸载 rules 内的 loader 移至 plugins 下的 HappyPack 函数内:

cnpm install --save-dev happypack
const HappyPack = require('happypack');
const os = require('os');
// 开辟一个线程池
// 拿到系统CPU的最大核数,happypack 将编译工作灌满所有线程
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length });

module.exports = {
  module: {
    rules: [
      {
        test: /\.(sa|sc|c)ss$/,
        use: ['happypack/loader?id=css']
      },
      {
        test: /\.(js|jsx|ts|tsx)$/,
        exclude: /node_modules/,
        use: ['happypack/loader?id=babel'],
      },
    ],
  },
  plugins:[
      new HappyPack({
        id: 'babel',
        threadPool: happyThreadPool,
        loaders: [{
          loader: 'babel-loader',
          query: {
            cacheDirectory: true,
            presets: ['@babel/preset-env', '@babel/preset-TypeScript']
          }
        }]
      }),
      // 可以定义多个 happypack
      new HappyPack({
        id: 'other',
        threadPool: happyThreadPool,
        loaders: []
      }),
  ]
};

重要:MiniCssExtractPlugin 无法与 happypack 共存,不要用 happypack 对 MiniCssExtractPlugin 进行包裹。

下面是开启了 babel 多线程的耗时,相比未开启多线程快了 8%

抽离

webpack 进行抽离的方式有两种:

  1. 使用 webpack-dll-plugin 在首次构建的时候就对静态资源进行打包,后续只要引用这个已经打包好的静态资源即可,类似于预编译。
  2. 使用 externals 将静态资源进行剔除,并通过 CDN 进行加载。

两者的优缺点:

webpack-dll-plugin:

  1. 因为第一次编译后就不再参与编译,需要手动去维护,容易出现版本错误;
  2. 插件会将静态资源打包成一份文件,虽然减少了请求数量,但是单个文件会变得很大, HTTP2 多路复用特性更适合碎片化的小文件。

external:极度依赖 CDN 资源的稳定性,如果是关键依赖资源缺失,页面会无法加载

使用 external

修改 webpack 配置:

module.exports = {
  ...,
  externals: {
    // key是我们 import 的包名,value 是CDN为我们提供的全局变量名
    // 所以最后 webpack 会把一个静态资源编译成:module.export.react = window.React
    "react": "React",
    "react-dom": "ReactDOM",
    "redux": "Redux",
    "react-router-dom": "ReactRouterDOM"
  }
}

配置好 external 属性后,就需要在 index.html 中引入 CDN 资源,例如:

<head>
   <script src="https://cdn.bootcdn.net/ajax/libs/rxjs/6.4.0/rxjs.umd.min.js"></script>
</head>

将 vue、localforage、rxjs、lodash 剥离后,打包时间缩短了 13%,但其主要的作用还是减少包的大小

优化后的配置

基础配置

// 配置遵循 CommonJS 规范
// path 用于解决不同系统下的路径差异
const { resolve } = require('path');

const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const { merge } = require('webpack-merge');
const devConfig = require('./webpack.dev');
const prodConfig = require('./webpack.prod');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const smp = new SpeedMeasurePlugin();

const HappyPack = require('happypack');
const os = require('os');
// 开辟一个线程池
// 拿到系统CPU的最大核数,happypack 将编译工作灌满所有线程
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length });

const baseConfig = {
  // 单入口配置生成 chunk 会被命名为 main
  // entry: "./index.js",
  // 非 SPA 应用需要配置多个入口
  // 每个属性的键名会是生成的 chunk 的名称
  entry: {
    index: './index.js'
    // search: "./search.js"
  },
  output: {
    // resolve 传入路径从右至左解析,遇到第一个绝对路径是完成解析
    // __dirname 则是获得当前文件所在目录的完整路径名
    path: resolve(__dirname, 'dist'),
    // 单入口应用可以直接固定文件名
    // filename: "index.bundle.js"
    // 在多入口应用中常使用 [name].bundle.js 来确保每个文件具有唯一名称
    // 在 webpack.pro.js 中还会使用 [contentHash] 来生成全新的文件名
    filename: '[name].bundle.js'
  },
  // resolve 用来配置模块如何被解析
  resolve: {
    // 设置别名,用于简化路径
    alias: {
      // vue 需要的特殊配置
      // $ 用于表示精确匹配,此时 import 'vue/index.js' 不会被解析作别名
      // vue$: 'vue/dist/vue.esm.browser.js'
      // Utilities: resolve(__dirname, 'src/utilities/'),
      // 原先的导入方式:import Utility from '../../utilities/utility'
      // 配置了别名之后可以简化为:import Utility from 'Utilities/utility';
    }
  },
  // 配置 loader
  module: {
    rules: [
      // loader 自下而上执行,处理后的结果会传递给下一个 loader
      // test 用来筛选资源,当 test 的值为字符串时,可以为资源(或其所在目录)的绝对路径
      // use 支持数组形式添加多个 loader
      // include 表示哪些目录的文件需要被处理
      // exclude 表示哪些目录的文件不需要被处理,能够有效的加快打包速度
      // test、include、exclude 都支持使用数组传入多个值
      {
        test: /\.(sa|sc|c)ss$/,
        // loader 是从右往左执行的,这里先经过 sass-loader 再到 css-loader
        use: [MiniCssExtractPlugin.loader, 'cache-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
        include: resolve(__dirname, 'src'),
        exclude: /node_modules/
      },
      // use 也支持对象传入,这是为了添加格外配置 options
      // 自 babel7 开始,babel 已经能够识别 ts,不需要添加额外的 ts-loader
      {
        test: /\.(js|jsx|ts|tsx)$/,
        use: ['happypack/loader?id=babel'],
        include: resolve(__dirname, 'src'),
        exclude: /node_modules/
      },
      // file-loader 目的是保持 css 定义的 url 属性或者 img 标签中的 src 属性在打包时的正确引用
      {
        test: /\.(png|svg|jpg|jpeg|gif|webp|woff|woff2|eot|ttf|otf)$/,
        use: [
          'file-loader'
        ],
        include: resolve(__dirname, 'src'),
        exclude: /node_modules/
      }
    ]
  },
  plugins: [
    // 在打包之前将指定文件夹清空,默认是 dist
    new CleanWebpackPlugin(),
    // 将 CSS 内容提取到单独文件
    new MiniCssExtractPlugin(),
    // 会在打包结束之后自动创建一个index.html, 并将打包好的JS自动引入到这个文件中
    new HtmlWebpackPlugin({
      template: resolve(__dirname, 'index.html'),
      cache: true
    }),
    // 将素材文件拷贝到指定文件夹
    new CopyPlugin({
      patterns: [
        {
          from: resolve(__dirname, 'src/assets'),
          to: resolve(__dirname, 'dist/assets')
        }
      ]
    }),
    new ProgressBarPlugin(),
    new HappyPack({
      id: 'babel',
      threadPool: happyThreadPool,
      loaders: [{
        loader: 'babel-loader',
        query: {
          cacheDirectory: true,
          presets: ['@babel/preset-env', '@babel/preset-TypeScript']
        }
      }]
    })
  ],
  // 用于屏蔽不必要的控制台输出
  stats: {
    modules: false,
    children: false,
    chunks: false,
    chunkModules: false
  }
};

// 根据 npm script 传入的环境参数暴露出不同的配置
// webpack-merge 能够合并两个配置文件
module.exports = (env) => {
  return smp.wrap(env.development ? merge(baseConfig, devConfig) : merge(baseConfig, prodConfig));
};

开发环境

const { HotModuleReplacementPlugin } = require('webpack');

module.exports = {
  // 可选值 'none' | 'development' | 'production'
  // 通过 CLI 参数 --mode=production 传递,会将 process.env.NODE_ENV 设置为对应的 mode 值
  mode: 'development',
  output: {
    // 取消在 bundle 中引入「所包含模块信息」的相关注释,有利于加快大型项目的构建速度
    pathinfo: false
  },
  // 生成 sourcemap 文件,便于定位错误位置,不使用 sourcemap 能够加快编译速度
  devtool: 'source-map',
  plugins: [
    // 内置热更新功能
    new HotModuleReplacementPlugin(),
  ],
  // 设置本地服务器
  devServer: {
    port: 10086,
    // 自动打开浏览器
    open:true,
    // hot 开启 HMR 功能
    hot: true
  }
};

生产环境

const TerserPlugin = require('terser-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  mode: 'production',
  output: {
    filename: '[name].[contentHash].bundle.js'
  },
  plugins: [
    // bundle 包分析工具
    new BundleAnalyzerPlugin()
  ],
  // optimization 优化
  optimization: {
    // splitChunks 用于提取公共依赖模块,减轻 index.bundle.js 的大小
    // chunk 默认使用 async,只提取按需加载模块,其他参数:initial(初始块)、all(全部块)
    splitChunks: { chunks: 'async' },
    // 文件压缩
    minimize: true,
    minimizer: [
      new TerserPlugin({
        // 开启缓存
        cache: true
      })
    ]
  }
};

webpack 常见的坑

使用 yarn link 或者 npm link 进行开发时,可能会出现以下报错:

ReactJS: How to handle Invalid Hook Call Warning “Uncaught Error: Invalid hook call. Hooks can only be called inside of the body of a function component” in ReactJS

这是由于存在多个 react 版本所造成的,如果两个项目引用同一个 react 即可解决,所以在使用 link 库的项目的 webpack.config.js 中设置 alias:

alias: {
    react: path.resolve("./node_modules/react")
}        

此时报错就消失了

拓展 - webpack的配置与优化

https://hashencode.github.io/post/bc2a0873/

作者

BiteByte

发布于

2020-12-10

更新于

2024-01-11

许可协议