webpack相关内容

Posted by Youzi on February 5, 2021

关于 webpack

其实项目做了挺多的,但是我接触下来,多数都是用固有脚手架(像vue-cli | create-react-app)去构建的,或者部分是二次开发的,业务代码写的越多,构建方面的东西就越来越少了,下决心好好了解下webpack

本文会通过从 0 配置一个使用 Vue 的完整项目,来说明 webpack 各个属性的功能作用及使用方式;

webpack首先可以看做是个模块打包机;不过它还具备一些分析项目结构,代码编译(像TS | CSS 预编译语言),代码优化的工具;webpack最终输出的结果是HTML | CSS | JS这些浏览器可执行的代码包;概括一下webpack的功能内容:

  • 代码编译:TS编译成JSless编译成CSS
  • 文件优化:代码压缩,代码优化,静态资源处理等;
  • 代码分割:提取公共代码,代码按模块进行划分,提高首屏加载效率等等;
  • 模块合并:模块的合并和分割;
  • 热更新:dev环境时,项目的热更新;
  • 代码校验:利用git husky等工具,可以添加提交钩子,在提交代码时校验格式风格,单元测试等等;
  • 自动发布:devops 的一环,提交后可配置自动打包构建生产包并传输到发布系统上;

webpack 核心概念

  • entry: 入口,执行开发环境或者生产环境构建时,将从入口文件开始,递归查找依赖包进行构建;
  • output: 输出,经过一系列打包处理后,得到的最终代码包;
  • loader: 模块转换器,用于把模块内容按照需求转换成新内容,常见的有css-loader
  • plugin: 插件,在 webpack 构建流程中的特定时机,注入扩展逻辑来改变构建结果;
  • stats: stats 可以控制打包的时候,terminal的日志输出项包括哪些;

webpack 中所有的文件都是模块,最后都会转换成按照 JS 的模块来加载;

2022-03-22 更新

感觉这篇文章写偏了,我想的是记录学习 webpack 的核心原理或者概念的过程,重启一下。

loader

我个人理解loader是一种将各类资源转换成 JS 能识别的文件的工具。比如css-loader, ts-loader等等。

loader 的使用

在配置文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// webpack.config.js
const webpackConfig = {
  module: {
    rules: [
      {
        test: /\.js/,
        loader: 'testLoader'
      },
      {
        test: /\.jsx/,
        use: ['loader1', 'loader2']
      }
    ]
  },
  resolveLoader: {
    // 配置loader的路径
    modules: [
      'node_modules'
      // ...
    ]
  }
};

注意:多个 loader 执行顺序是自下而上,自右向左执行

实践中发现,如果test属性是一样的,那么写在后面的rules会覆盖前面的。

写一个 loader

loader本质是一个函数;且函数体不应该用箭头函数声明,因为经常需要在函数内部只用this访问一些变量

参数:content文件内容通常是string | buffermap文件 sourcemap 的内容, meta文件的元信息。

返回值:将处理后的content返回。

loader又分为同步和异步,看两个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 同步loader
const syncLoader = function (content, map, meta) {
  console.log(content);
  return content;
  // 或者也可以调用callback方法
  // this.callback(null, content,...)
  // return undefined
};

// 异步loader
const asyncLoader = function (content, map, meta) {
  const callback = this.async();
  asynFunc(content, map, meta)
    .then((content1, map1, meta1) => {
      callback(null, content, map, meta);
    })
    .catch(err => {
      callback(err);
    });
};

在 loader 函数内部,还有很多属性是webpack在构建时注入到上下文this上的,详见https://webpack.docschina.org/api/loaders/,很多都能在开发时用到;

记录一个自己写的 loader

在一个项目中,需要进行项目的迁移,从 h5 端迁移到小程序端,用了微信团队开源的一个kbone框架,在处理css文件时,我把项目中用到rem的地方都转成了用rpx,这里使用了一个自己写的loader,基本思路和postcss-pxtoviewport差不多。由于设计图都是按750px物理像素出的,所以其实1rem = 100px = 200rpx,按这个思路去转换的。

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
const getUnitRegexp = function (unit) {
  return new RegExp('"[^"]+"|\'[^\']+\'|url\\([^\\)]+\\)|(\\d*\\.?\\d+)' + unit, 'g');
};

const modulesReg = /node\_modules/;

module.exports = function (content, map, meta) {
  // console.warn(content);
  // 不处理node_modules的css文件
  if (modulesReg.test(this.resourcePath)) {
    this.callback(null, content, map, meta);
    return void 0;
  }
  const reg = getUnitRegexp('rem');

  const callback = this.async();
  callback(
    null,
    content.replace(reg, (m, $1) => {
      if (!$1) return m;
      // console.warn($1);
      return `${$1 * 200}rpx`;
    })
  );
  return void 0;
};

从 0 开始配置 webpack

初始化

初始化空项目,安装 webpack,命令行工具,开发使用的服务器插件;

1
2
npm init
npm i webpack webpack-cli webpack-dev-server -D

webpack 基础配置文件,webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const path = require('path');

module.exports = {
  // 区别生产/开发
  mode: 'development',
  // 入口
  entry: path.resolve(__dirname, './src/index.js'),
  output: {
    // 输出目录
    path: path.resolve(__dirname, 'dist'),
    // 输出文件名
    filename: 'bundle.js'
  }
};

npm 配置文件package.json中,修改scripts属性:

1
2
3
4
5
{
  "scripts": {
    "dev": "webpack --config ./webpack.config.js"
  }
}

这样我们在终端里运行npm run dev | yarn run dev都能运行webpack的打包代码;

添加 HTML 解析功能

由于我们的项目通常是 SPA 项目,所以会有入口的 HTML 文件,但 webpack 无法打包 HTML 文件,所以要引入一些插件来帮助处理,选择常用的html-webpack-plugin,添加配置到webpack.config.js中(npm 安装插件的步骤就不重复了):

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
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  // 区别生产/开发
  mode: 'development',
  // 入口
  entry: path.resolve(__dirname, './src/index.js'),
  output: {
    // 输出目录
    path: path.resolve(__dirname, 'dist'),
    // 输出文件路径和文件名
    filename: 'js/[name].js'
  },
  plugins: [
    new HtmlWebpackPlugin({
      // 使用的 html 模板路径
      template: path.resolve(__dirname, './index.html'),
      // 打包后输出的文件名
      filename: 'index.html',
      // index.html 模板内,通过 <%= htmlWebpackPlugin.options.title %> 拿到的变量
      title: '手搭 Vue 开发环境'
    })
  ]
};

添加对 Vue 文件的解析功能

和上面的 HTML 一样,webpack 无法对.vue文件进行解析和打包,所以需要添加几个loader来识别;

  • 首先安装vue-loader

它是一个基于 webpack 的loader插件,用于解析和编译.vue文件;我们都知道单文件组件由template | script | style三部分组成,vue-loader会把这三部分提取出来,分别交给对应的其他loader去处理,所以其实vue-loader的最主要作用是提取.vue文件;

  • 然后是vue/compiler-sfc

在 vue2.x 版本里插件名是vue-template-compiler,其作用是将template编译为 ast 语法树,再由语法树生成 render 函数(Vue 所有组件都可以直接用 render 函数的形式写出来)

webpack.config.js中添加:

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
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
// 最新的 vue-loader 中,VueLoaderPlugin 插件的位置有所改变
const { VueLoaderPlugin } = require('vue-loader/dist/index');
module.exports = {
  // 区别生产/开发
  mode: 'development',
  // 入口
  entry: path.resolve(__dirname, './src/index.js'),
  output: {
    // 输出目录
    path: path.resolve(__dirname, 'dist'),
    // 输出文件路径和文件名
    filename: 'js/[name].js'
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: ['vue-loader']
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      // 使用的 html 模板路径
      template: path.resolve(__dirname, './index.html'),
      // 打包后输出的文件名
      filename: 'index.html',
      // index.html 模板内,通过 <%= htmlWebpackPlugin.options.title %> 拿到的变量
      title: '手搭 Vue 开发环境'
    }),
    // 添加 VueLoaderPlugin 插件
    new VueLoaderPlugin()
  ]
};

添加VueLoaderPlugin的作用是将定义过的其他规则比如css/js的规则,应用到.vue文件里;

现在我们有了处理HTML | Vue的工具,还差css的;添加:

  • style-loader:将css样式插入到页面的style标签中;
  • css-loader:处理样式中的url,比如图片引入经常用到的url('@/xxx.png'),此时浏览器识别不了@符号;

对于处理样式的工具,还有预编译语言less | scss之类的,处理方法都是一样的,它们也有对应的loader工具;

在 rule 里面添加:

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
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
// 最新的 vue-loader 中,VueLoaderPlugin 插件的位置有所改变
const { VueLoaderPlugin } = require('vue-loader/dist/index');
module.exports = {
  // 区别生产/开发
  mode: 'development',
  // 入口
  entry: path.resolve(__dirname, './src/index.js'),
  output: {
    // 输出目录
    path: path.resolve(__dirname, 'dist'),
    // 输出文件路径和文件名
    filename: 'js/[name].js'
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: ['vue-loader']
      },
      {
        test: '/.css$/',
        use: ['style-loader', 'css-loader']
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      // 使用的 html 模板路径
      template: path.resolve(__dirname, './index.html'),
      // 打包后输出的文件名
      filename: 'index.html',
      // index.html 模板内,通过 <%= htmlWebpackPlugin.options.title %> 拿到的变量
      title: '手搭 Vue 开发环境'
    }),
    // 添加 VueLoaderPlugin 插件
    new VueLoaderPlugin()
  ]
};
  • 其他常用插件

clean-webpack-plugin:作用是每次打包时都会清除原有的打包目录,防止文件重新打包后还有残留文件;

1
2
3
4
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
{
  plugins: [new CleanWebpackPlugin()];
}

添加 babel

由于前端的浏览器兼容性是一个比较难处理的问题,所以我们在编译打包的时候,会考虑把所有的 JS 代码重新编译一遍,编译为设定的版本(比如 ES5…);

babel-loader:这个 loader 有几个核心的插件:

  • @babel/core:核心库;
  • @babel/preset-env:通过配置浏览器版本,来决定编译后的 JS 的版本;
  • babel-loaderwebpack的 loader;

在 rules 添加代码:

1
2
3
4
5
6
7
8
9
10
11
{
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/, // 不编译node_modules下的文件
        loader: 'babel-loader'
      }
    ];
  }
}

读取的 babel 配置文件,.babelrc

1
2
3
4
5
6
7
8
9
10
11
12
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "browsers": ["last 2 versions"] // 最近 2 个版本的浏览器
        }
      }
    ]
  ]
}

添加 devServer

webpack 提供的供开发环境下使用的本地 server 插件;需要安装依赖:webpack-dev-server,需要的配置都在这里了:https://webpack.docschina.org/configuration/dev-server/#root

webpack的基本工作流程

运行方式

·webpack·运行方式有两种,分别是命令行式的,以及直接在代码中运行的方式;

1
2
3
4
5
6
7
// cmd
webpack --config webpack.config.js

// js
const webpack = require('webpack')
const config = require('./webpack.config')
webpack(config, (err, status) => {})

流程

以上两种运行webpack的方式,本质上都是执行webpack.js中的webpack函数,实际该函数就是处理了一下传入的配置,调用各类webpack内部插件,然后调用compiler.run(callback)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// webpack()
/**
 * @param {WebpackOptions} options 配置对象
 * @param {funtion(Error=, stats=): void=} callback 回调函数
 * @return {compiler | multiCompiler} compiler对象
 */
const webpack = (options, callback) => {
  // 默认的配置和传入的配置merge后生成的新配置对象
  options = new WebpackOptionsDefaulter().process(options)
  // 生成一个compiler
  let compiler = new Compiler(options.context)
  // 加载内部插件
  compiler.options = new WebpackOptionsApply().process(options, compiler)
  if(callback) {
    compiler.run(callback)
  }
  return compiler
}

大致过程如下:

  1. 创建编译器compiler的实例;
  2. 根据webpack参数加载参数中的插件,以及内置的默认插件;
  3. 执行编译过程,创建编译过程的compilation实例,从入口递归添加并构建模块,模块构建完成后,冻结模块并进行优化;
  4. 构建与优化过程结束后提交产物,将产物内容写到输出文件中。

webpack生命周期与插件

webpack核心模块compiler | compilationextends Tapable,用于实现webpack工作流中的生命周期划分,生命周期通常被称为hook;webpack引擎给予插件系统搭建,不同插件在工作流中的一个或几个时间段上对构建流程进行处理,最终将源码变成最终的产物代码;

一个webpack插件是包含apply方法的JS对象,该方法执行逻辑通常是注册某个hook,并添加对应hook中的处理函数;

一个例子:

1
2
3
4
5
6
7
8
class HelloWorldPlugin {
  apply(compiler) {
    compiler.hooks.run.tap("HelloWorldPlugin", compilation => {
      console.log('hello world');
    })
  }
}
module.exports = HelloWorldPlugin;

以上代码在webpack4及以上的版本是可以运行的,因为hooks从4.x版本开始支持,而在以前版本,通常使用Tapable.plugin方法来调用对应的hooks,例如上面的例子要写成:

1
2
3
4
5
6
7
class HelloWorldPlugin {
  apply(compiler) {
    compiler.plugin('run', (compilation, callback) => {
      console.log('hello world');
    })
  }
}

compiler hooks

构建器的生命周期可以分成三个阶段:初始化阶段、构建阶段、产物生成阶段;

初始化阶段

  • environment, afterEnvironment: 在创建了compiler实例且执行了配置内定义的插件的apply方法后触发;
  • entryOption, afterPlugins, afterResolvers:在webpackOptionsApply.js中,这三个hooks分别在执行entryOptions和其他webpack内置插件,以及解析了resolver配置后触发;

构建阶段

  • normalModuleFactory、contextModuleFactory:在两类模块工厂创建后触发。

  • beforeRun、run、watchRun、beforeCompile、compile、thisCompilation、compilation、make、afterCompile:在运行构建过程中触发。

产物生成阶段

shouldEmit、emit、assetEmitted、afterEmit:在构建完成后,处理产物的过程中触发。

failed、done:在达到最终结果状态时触发。

compilation hooks