webpack-chain 用法及原理

655 阅读7分钟

一、开始

webpack-chain 提供了链式API,来修改或生成 Webpack 配置。Vue CLI 内部的 Webpack 配置就是用它来维护的,另外,在 vue.config.js 中可以配置 chainWebpack 字段,我们可以用链式方式来修改配置。

二、用法

1. ChainedMap 和 ChainedSet

webpack-chain 中有两个核心对象,ChainedMap 和 ChainedSet。一般情况下,如果 Webpack 原配置项是对象类型,在webpack-chain 中会对应生成 ChainedMap,如果 Webpack 原配置项是数组类型,在 webpack-chain 中会对应生成 ChainedSet。

ChainedMap 和 ChainedSet 的操作类似于 JavaScript Map, 为链式和生成配置提供了一些便利。

ChainedMap 中常用的 API:

// 从 Map 移除所有 配置.
clear()

// 通过键值从 Map 移除单个配置.  // key: *
delete(key)

// 获取 Map 中相应键的值。   // key: *    // returns: value
get(key)

// 获取 Map 中相应键的值。如果键在Map中不存在,则ChainedMap中该键的值会被配置为fn的返回值.
// key: *  // fn: Function () -> value   // returns: value
getOrCompute(key, fn)

// 配置Map中 已存在的键的值。 // key: * // value: *
set(key, value)

// Map中是否存在一个配置值的特定键,返回 真或假。  // key: *   // returns: Boolean
has(key)

// 返回 Map中已存储的所有值的数组。// returns: Array
values()

// 返回Map中全部配置的一个对象, 其中 键是这个对象属性,值是相应键的值, 如果Map是空,返回 `undefined`
// 使用 `.before() 或 .after()` 的ChainedMap, 则将按照属性名进行排序。 returns: Object, undefined if empty
entries()

// 提供一个对象,这个对象的属性和值将 映射进 Map。你也可以提供一个数组作为第二个参数以便忽略合并的属性名称。
// obj: Object // omit: Optional Array
merge(obj, omit)

// 对当前配置上下文执行函数。  handler: Function -> ChainedMap。 一个把ChainedMap实例作为单个参数的函数
batch(handler)

// 条件执行一个函数去继续配置
when(condition, whenTruthy, whenFalsy)

有一个地方需要注意,ChainedMap 的 entries() 方法会返回对象类型。而 Javascript中 的 Object.entries(obj) 方法会返回二维数组,比如:

console.log(Object.entries({a:1,b:2}))
// [['a', 1], ['b', 2]]

ChainedSet 中常用的 API:

// 添加/追加 给Set末尾位置一个值.
add(value)

// 添加 给Set开始位置一个值.
prepend(value)

// 移除Set中全部值.
clear()

// 移除Set中一个指定的值.
delete(value)

// 检测Set中是否存在一个值.
has(value)


// 返回Set中值的数组.
values()

// 连接给定的数组到 Set 尾部。
merge(arr)


// 对当前配置上下文执行函数。
batch(handler)

// 条件执行一个函数去继续配置
when(condition, whenTruthy, whenFalsy)

2. Shorthand methods

Shorthand methods 可翻译成简便方法,就是把 ChainedMap.set(key, value),转成 ChainedMap.[key](value),方便使用。

// 在 ChainedMap 上设置一个值的 速记方法
devServer.hot(true);

// 上述方法等效于:
devServer.set('hot', true);

3. 例子

// 导入 webpack-chain 模块,该模块导出了一个用于创建一个webpack配置API的单一构造函数。
const Config = require('webpack-chain');

// 对该单一构造函数创建一个新的配置实例
const config = new Config();

// 用链式API改变配置
// 每个API的调用都会跟踪对存储配置的更改。

// 会把默认的入口清空
config.entryPoints.clear() 

config
  // 修改 entry 配置
  .entry('index')
    .add('src/index.js')
    .end()
  // 修改 output 配置
  .output
    .path('dist')
    .filename('[name].bundle.js')
    .chunkFilename("chunks/[name].[chunkhash].js")
    .libraryTarget("umd");


// 配置路径别名,resolve.alias 其实是一个 ChainMap 对象
config.resolve.alias
  .set('assets',resolve('src/assets'))
  .set('components',resolve('src/components'))
  .set('static',resolve('src/static'))
  .delete('static') // 删掉指定的别名


// 创建loader
// config.module
//   .rule(name)
//     .use(name)
//       .loader(loader)
//       .options(options)


// 创建一个具名规则,以后用来修改规则
config.module
  .rule('lint')
    .test(/\.js$/)
    .pre()
    .include
      .add('src')
      .end()
    // 还可以创建具名use (loaders)
    .use('eslint')
      .loader('eslint-loader')
      .options({
        rules: {
          semi: 'off'
        }
      });

config.module
  .rule('compile')
    .test(/\.js$/)
    .include
      .add('src')
      .add('test')
      .end()
    .use('babel')
      .loader('babel-loader')
      .options({
        presets: [
          ['@babel/preset-env', { modules: false }]
        ]
      });

config.module.rule('eslint').uses.clear();

/**
 * 也可以创建一个具名的插件
 * 先指定名字(这个名字是自定义的),然后通过 use 添加插件
 * use 的第二个参数为插件参数,必须是一个数组,也可以不传
 */

config
  .plugin('clean')
    .use(CleanPlugin, [['dist'], { root: '/dir' }]);

// 移除插件
config.plugins.delete('extract')

// 指定插件在 xx 插件之前/之后调用
config.plugin('html')
  .use(require('html-webpack-plugin'))
  .before('extract')

// 动态修改插件参数
// 使用 tap 方法修改参数
config
  .plugin(name)
  .tap(args => newArgs)


// 自定义插件的实例化的过程
// 通过 init 方法,返回一个实例,这将代替原有的实例化过程
config
  .plugin(name)
  .init((Plugin, args) => new Plugin(...args));


// 利用when,条件满足时才修改或生成配置
config
  .when(
    process.env.PUBLISH_ENV !== 'test',
    (config) => {
      config.optimization.minimizer('terser').tap((args) => {
        // remove debugger
        args[0].terserOptions.compress.drop_debugger = true;
        // 移除 console.log
        args[0].terserOptions.compress.pure_funcs = ['console.log', 'console.table'];
        // 去掉注释
        args[0].terserOptions.output = {
          comments: false,
        };
        return args;
      });
    },
  );

// 导出这个修改完成的要被webpack使用的配置对象
module.exports = config.toConfig();

三、原理

1. 基本的继承链

webpack-chain 目录结构如下,可以看到它是极其扁平的,阅读难度不大。

- Chainable.js     
- ChainedSet.js    
- DevServer.js     
- Optimization.js  
- Output.js        
- Plugin.js        
- ResolveLoader.js 
- Use.js
- ChainedMap.js    
- Config.js        
- Module.js        
- Orderable.js     
- Performance.js   
- Resolve.js       
- Rule.js

其中有几个业务无关的 Class:ChainedMapChainedSetOrderbaleChainable,其中ChainedMapChainedSet 都继承自 Chainable,部分 ChainedMap 继承自 Orderbale。

Chainable 是最底层的逻辑,只提供了 end/batch 方法:

module.exports = class {
  constructor(parent) {
    this.parent = parent;
  }

  batch(handler) {
    handler(this);
    return this;
  }

  end() {
    return this.parent;
  }
};

2. 总体逻辑

webpack-chain 对外暴露 Config 类,其继承自 ChainMap,在 Config 的构造函数中,会实例化 resolvenodemoduledevServer 等,其实就是 Webpack 的第一层配置项。

module.exports = class extends ChainedMap {
  constructor() {
    super();
    this.devServer = new DevServer(this);
    this.entryPoints = new ChainedMap(this);
    this.module = new Module(this);
    this.node = new ChainedMap(this);
    this.optimization = new Optimization(this);
    this.output = new Output(this);
    this.performance = new Performance(this);
    this.plugins = new ChainedMap(this);
    this.resolve = new Resolve(this);
    this.resolveLoader = new ResolveLoader(this);
    this.extend([
      'amd',
      'bail',
      'cache',
      'context',
      'devtool',
      'externals',
      'loader',
      'mode',
      'name',
      'parallelism',
      'profile',
      'recordsInputPath',
      'recordsPath',
      'recordsOutputPath',
      'stats',
      'target',
      'watch',
      'watchOptions',
    ]);
  }
}

resolvemoduleoptimization 等有自己的类,也就是它们有自己的方法和属性。以 Module 为例,它也继承自 ChainMap,在它的构造函数中声明了 rulesdefaultRules

module.exports = class extends ChainedMap {
  constructor(parent) {
    super(parent);
    this.rules = new ChainedMap(this);
    this.defaultRules = new ChainedMap(this);
    this.extend(['noParse', 'strictExportPresence']);
  }

  defaultRule(name) {
    return this.defaultRules.getOrCompute(
      name,
      () => new Rule(this, name, 'defaultRule'),
    );
  }

  rule(name) {
    return this.rules.getOrCompute(name, () => new Rule(this, name, 'rule'));
  }
}

ChainMap 是 webpack-chain 的核心,可以看作一个与业务无关的类,就是提供类似 Map 的 API,包括 getsetvaluesentries 等,还有 order 排序方法、merge 融合属性方法等,以及下面会提到的 extend 方法。

module.exports = class extends Chainable {
  constructor(parent) {
    super(parent);
    this.store = new Map();
  }

  extend(methods) {
    this.shorthands = methods;
    methods.forEach((method) => {
      this[method] = (value) => this.set(method, value);
    });
    return this;
  }

  clear() {
    this.store.clear();
    return this;
  }

  delete(key) {
    this.store.delete(key);
    return this;
  }
}

当调用 config.toConfig() 的时候,会调用每个属性的 toConfigentriesvalues 方法,将其返回一个大配置对象。每个属性的 toConfig 方法内部也会调用 entriesvalues 方法,返回当前的配置项。

// Config.js
module.exports = class extends ChainedMap {
  toConfig() {
    const entryPoints = this.entryPoints.entries() || {};

    return this.clean(
      Object.assign(this.entries() || {}, {
        node: this.node.entries(),
        output: this.output.entries(),
        resolve: this.resolve.toConfig(),
        resolveLoader: this.resolveLoader.toConfig(),
        devServer: this.devServer.toConfig(),
        module: this.module.toConfig(),
        optimization: this.optimization.toConfig(),
        plugins: this.plugins.values().map((plugin) => plugin.toConfig()),
        performance: this.performance.entries(),
        entry: Object.keys(entryPoints).reduce(
          (acc, key) =>
            Object.assign(acc, { [key]: entryPoints[key].values() }),
          {},
        ),
      }),
    );
  }
}

3. Shorthand methods 原理

Shorthand methods 核心是下面的 extend 方法,它定义在 ChainedMap 上。每个 ChainedMap 的实例,都会在初始化的时候调用它。

extend(methods) {
  this.shorthands = methods;
  methods.forEach(method => {
    this[method] = value => this.set(method, value);
  });
  return this;
}

经过 extend 的处理后,ChainedMap.set(key, value) 就可以写成 ChainedMap.[key](value)

4. before/after 原理

webpack-chain 中有个 Orderable 类,其定义如下。对于 pluginRuleUse 这三个类,会继承自 Orderable,也就是增加了 beforeafter 方法。当调用 before 时,会执行 this.__before = name,当调用 after 时,会执行 this.__after = name

module.exports = (Class) =>
  class extends Class {
    before(name) {
      if (this.__after) {
        throw new Error(
          `Unable to set .before(${JSON.stringify(
            name,
          )}) with existing value for .after()`,
        );
      }

      this.__before = name;
      return this;
    }

    after(name) {
      if (this.__before) {
        throw new Error(
          `Unable to set .after(${JSON.stringify(
            name,
          )}) with existing value for .before()`,
        );
      }

      this.__after = name;
      return this;
    }

    merge(obj, omit = []) {
      if (obj.before) {
        this.before(obj.before);
      }

      if (obj.after) {
        this.after(obj.after);
      }

      return super.merge(obj, [...omit, 'before', 'after']);
    }
  };

注意 pluginRuleUse 也继承了 ChainedMap,在调用它上面的 entriesvalues 方法时,会先调用 order 方法调整顺序。

// ChainedMap.js
module.exports = class extends Chainable {
  constructor(parent) {
    super(parent);
    this.store = new Map();
  }

  order() {
    const entries = [...this.store].reduce((acc, [key, value]) => {
      acc[key] = value;
      return acc;
    }, {});
    const names = Object.keys(entries);
    const order = [...names];

    names.forEach((name) => {
      if (!entries[name]) {
        return;
      }

      const { __before, __after } = entries[name];

      if (__before && order.includes(__before)) {
        order.splice(order.indexOf(name), 1);
        order.splice(order.indexOf(__before), 0, name);
      } else if (__after && order.includes(__after)) {
        order.splice(order.indexOf(name), 1);
        order.splice(order.indexOf(__after) + 1, 0, name);
      }
    });

    return { entries, order };
  }

  entries() {
    const { entries, order } = this.order();

    if (order.length) {
      return entries;
    }

    return undefined;
  }

  values() {
    const { entries, order } = this.order();

    return order.map((name) => entries[name]);
  }
};

5. 作用域

这里的作用域,或者叫上下文,当配置的时候当前操作的对象的,以下面例子说明:

config.module
  .rule('compile')
    .test(/\.js$/)
    .include
      .add('src')
      .add('test')
      .end()
    .use('babel')
      .loader('babel-loader')
      .options({
        presets: [
          ['@babel/preset-env', { modules: false }]
        ]
      });

简单来说,链式配置的过程中,遇到新的属性会进入下一个属性的上下文,遇到方法则在当前上下文操作。

moduleconfig 上的属性,rulemodule 上的属性,test/include/userule 上的属性(test 同时是方法),include.adduse.loaderuse.options 则是方法,它们在相应的上下文操作。

对应的 Webpack 配置如下。

{
  module: {
    rules: [
      { 
        test: /\.js$/, 
        include: ['src', 'test'],
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: [
                ['@babel/preset-env', { modules: false }]
              ]
            }
          }
        ] 
      }
    ],
  },
}

看到这里,其实发现直接写 Webpack 配置似乎更直观、直接、简单。即使写 webpack-chain,也是需要先熟悉 Webpack 自身配置。

四、总结

人们对于 webpack-chain 的态度,可分为两种,一种觉得好用,另一种觉得它比较多余,增加了学习成本,还把配置变成了黑盒。个人比较倾向于后者,也许在配置生成之后,再用 webpack-chain 修改可能稍微便利了一点点,但这也导致了无法做到可见即可得。

尽管如此,学习下链式 API 的实现原理是很有帮助的。链式API中所有的简便方法,其实只是换了个方式来维护内部的 config,比如 pluginsrules

五、相关资料

  1. 我曾为配置 webpack 感到痛不欲生
  2. 前端工程化:webpack-chain