談一談 build-scripts 架構設計

一、寫在前面

在 ICE、Rax 等項目研發中,我們或多或少都會接觸到 build-scripts 的使用。build-scripts 是集團共建的統一構建腳手架解決方案,其除了提供基礎的 start、build 和 test 命令外,還支持靈活的插件機制供開發者擴展構建配置。

本文嘗試通過場景演進的方式,來由簡至繁地講解一下 build-scripts 的架構演進過程,注意下文描述的演進過程意在講清 build-scripts 的設計原理及相關方法的作用,並不代表 build-scripts 實際設計時的演進過程,如果文中存在理解錯誤的地方,還望指正。

二、架構演進

0. 構建場景

我們先來構建這樣一個業務場景:

假設我們團隊內有一個前端項目 project-a,項目使用 webpack 來進行構建打包。

項目 project-a

project-a
  |- /dist
   |- main.js
  |- /src
    |- say.js
    |- index.js
  |- /scripts
    |- build.js
  |- package.json
  |- package-lock.json

project-a/src/say.js

const sayFun = () ={
  console.log('hello world!');
};

module.exports = sayFun;

project-a/src/index.js

const say = require('./say');

say();

project-a/scripts/build.js

const path = require('path');
const webpack = require('webpack');

// 定義 webpack 配置
const config = {
  entry: './src/index',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, '../dist'),
  },
};

// 實例化 webpack
const compiler = webpack(config);

// 執行 webpack 編譯
compiler.run((err, stats) ={
  compiler.close((closeErr) ={});
});

project-a/package.json

{
  "name""project-a",
  "version""1.0.0",
  "description""",
  "main""dist/main.js",
  "scripts"{
    "build""node scripts/build.js",
    "test""echo \"Error: no test specified\" && exit 1"
  },
  "keywords"[],
  "author""",
  "license""ISC",
  "devDependencies"{
    "webpack""^5.74.0"
  }
}

過段時間由於業務需求,我們新建了一個前端項目 project-b。由於項目類型相同, 項目 project-b 想要複用項目 project-a 的 webpack 構建配置, 此時應該怎麼辦呢?

1. 拷貝配置

爲了項目快速上線,我們可以先直接從項目 project-a 拷貝一份 webpack 構建配置到項目 project-b ,再配置一下 package.json 中的 build 命令,項目 project-b 即可 “完美複用”。

項目 project-b

  project-b
    |- /dist
+     |- main.js
    |- /src
      |- say.js
      |- index.js
+   |- /scripts
+     |- build.js
    |- package.json
    |- package-lock.json

project-b/package.json

  {
    "name""project-b",
    "version""1.0.0",
    "description""",
    "main""dist/main.js",
    "scripts"{
+     "build""node scripts/build.js",
      "test""echo \"Error: no test specified\" && exit 1"
    },
    "keywords"[],
    "author""",
    "license""ISC",
+   "devDependencies"{
+     "webpack""^5.74.0"
+   }
  }

2. 封裝 npm 包

下面我們的場景先來演進一下:

項目 project-b 上線一段時間後,團隊內推行項目 TS 化,我們首先對項目 project-a 進行了如下改造:

項目 project-a

  project-a
    |- /dist
     |- main.js
    |- /src
-     |- say.js
-     |- index.js
+     |- say.ts
+     |- index.ts
    |- /scripts
      |- build.js
+   |- tsconfig.json
    |- package.json
    |- package-lock.json

project-a/scripts/build.js

  const path = require('path');
  const webpack = require('webpack');

  // 定義 webpack 配置
  const config = {
    entry: './src/index',
+   module: {
+     rules: [
+       {
+         test: /\.ts?$/,
+         use: 'ts-loader',
+         exclude: /node_modules/,
+       },
+     ],
+   },
+   resolve: {
+     extensions: ['.ts''.js'],
+   },

    ...

  };

  ...

  // 執行 webpack 編譯
  compiler.run((err, stats) ={
    compiler.close((closeErr) ={});
  });

project-a/package.json

  {
    "name""project-a",

     ...

    "devDependencies"{
+     "ts-loader""^9.3.1",
+     "typescript""^4.8.2",
+     "@types/node""^18.7.14",
      "webpack""^5.74.0"
    }
  }

由於項目 project-b 也需要完成 TS 化,所以我們不得不按照項目 project-a 的修改,在項目 project-b 裏也重複修改一次。此時通過拷貝在項目間複用配置的問題就暴露出來了:構建配置更新時,項目間需要同步手動修改,配置維護成本較高,且存在修改不一致的風險。

一般來說,拷貝只能臨時解決問題,並不是一個長期的解決方案。如果構建配置需要在多個項目間複用,我們可以考慮將其封裝爲一個 npm 包來獨立維護。下面我們新建一個 npm 包 build-scripts 來做這件事:

npm 包 build-scripts

build-scripts
  |- /bin
   |- build-scripts.js
  |- /lib (ts 構建目錄,文件同 src)
  |- /src
   |- /commands
     |- build.ts
  |- tsconfig.json
  |- package.json
  |- package-lock.json

build-scripts/bin/build-scripts.js

#!/usr/bin/env node

const program = require('commander');
const build = require('../lib/commands/build');

(async () ={

  // build 命令註冊
  program.command('build').description('build project').action(build);

  // 判斷是否有存在運行的命令,如果有則退出已執行命令
  const proc = program.runningCommand;
  if (proc) {
    proc.on('close', process.exit.bind(process));
    proc.on('error'() ={
      process.exit(1);
    });
  }

  // 命令行參數解析
  program.parse(process.argv);

  // 如果無子命令,展示 help 信息
  const subCmd = program.args[0];
  if (!subCmd) {
    program.help();
  }

})();

build-scripts/src/commands/build.ts

import * as path from 'path';
import * as webpack from 'webpack';

export = async () ={
  const rootDir = process.cwd();

  // 定義 webpack 配置
  const config = {
    entry: path.resolve(rootDir, './src/index'),
    module: {
      rules: [
        {
          test: /\.ts?$/,
          use: require.resolve('ts-loader'),
          exclude: /node_modules/,
        },
      ],
    },
    resolve: {
      extensions: ['.ts''.js'],
    },
    output: {
      filename: 'main.js',
      path: path.resolve(rootDir, './dist'),
    },
  };

  // 實例化 webpack
  const compiler = webpack(config);

  // 執行 webpack 編譯
  compiler.run((err, stats) ={
    compiler.close((closeErr) ={});
  });
};

build-scripts/package.json

{
  "name""build-scripts",
  "version""1.0.0",
  "description""",
  "bin"{
    "build-scripts""bin/build-scripts.js"
  },
  "scripts"{
    "build""tsc",
    "start""tsc -w",
    "test""echo \"Error: no test specified\" && exit 1"
  },
  "keywords"[],
  "author""",
  "license""ISC",
  "dependencies"{
    "commander""^9.4.0",
    "ts-loader""^9.3.1",
    "webpack""^5.74.0"
  },
  "devDependencies"{
    "@types/webpack""^5.28.0",
    "typescript""^4.8.2"
  }
}

我們將項目的構建配置抽離到 npm 包 build-scripts 裏進行統一維護,同時以腳手架的方式來提供項目調用,降低項目的接入成本。項目 project-a 和項目 project-b 只需做如下改造:

項目 project-a

  project-a
    |- /dist
     |- main.js
    |- /src
      |- say.ts
      |- index.ts
-   |- /scripts
-     |- build.js
    |- tsconfig.json
    |- package.json
    |- package-lock.json

project-a/package.json

  {
    "name""project-a",

     ...

    "scripts"{
-     "build""node scripts/build.js",
+     "build""build-scripts build",
      "test""echo \"Error: no test specified\" && exit 1"
    },

     ...

    "devDependencies"{
-     "ts-loader""^9.3.1",
+     "build-scripts""^1.0.0",
      "typescript""^4.8.2",
      "@types/node""^18.7.14",
-     "webpack""^5.74.0"
    }
  }

項目 project-b 改造同項目 project-a

改造完成後,項目 project-a 和項目 project-b 不再需要在項目裏獨立維護構建配置,而是通過統一腳手架的方式調用 build-scripts 的 build 命令進行構建打包。後續構建配置更新時,各個項目也只需要升級 npm 包 build-scripts 版本即可,避免了之前手動拷貝帶來的修改維護問題。

3. 添加用戶配置

下面我們的場景再來演進一下:

由於業務需求,我們又新建了一個前端項目 project-c。項目 project-c 想要接入 build-scripts 進行構建打包,但它的打包入口並不是默認的 src/index,構建目錄也不是 /dist,此時應該怎麼辦呢?

一般來說,不同項目對構建配置都會有一定的自定義需求,所以我們需要將一些常用的配置開放給項目進行設置,例如 entry、outputDir 等。基於這個目的,我們下面來對 build-scripts 進行一下改造:

我們首先來爲項目 project-c 新增一個用戶配置文件 build.json。

項目 project-c

  project-c
    |- /build
     |- main.js
    |- /src
      |- say.ts
      |- index1.ts
+   |- build.json
    |- tsconfig.json
    |- package.json
    |- package-lock.json

project-c/build.json

{
  "entry""./src/index1",
  "outputDir""./build"
}

然後我們來對 build-scritps 裏的執行邏輯進行一下改造,讓 build-scripts 在執行構建命令時,先讀取當前項目下的用戶配置 build.json,然後使用用戶配置來覆蓋默認的構建配置。

build-scripts/src/commands/build.ts

  import * as path from 'path';
  import * as webpack from 'webpack';

  export = async () ={
    const rootDir = process.cwd();

+   // 獲取用戶配置
+   let userConfig: { [name: string]: any } = {};
+   try {
+     userConfig = require(path.resolve(rootDir, './build.json'));
+   } catch (error) {
+     console.log('Config error: build.json is not exist.');
+     return;
+   }

+   // 用戶配置非空及合法性校驗
+   if (!userConfig.entry) {
+     console.log('Config error: userConfig.entry is not exist.');
+     return;
+   }
+   if (typeof userConfig.entry !== 'string') {
+     console.log('Config error: userConfig.entry is not valid.');
+     return;
+   }
+   if (!userConfig.outputDir) {
+     console.log('Config error: userConfig.outputDir is not exist.');
+     return;
+   }
+   if (typeof userConfig.outputDir !== 'string') {
+     console.log('Config error: userConfig.outputDir is not valid.');
+     return;
+   }

    // 定義 webpack 配置
    const config = {
-     entry: path.resolve(rootDir, './src/index'),
+     entry: path.resolve(rootDir, userConfig.entry),

      ...

      output: {
        filename: 'main.js',
-       path: path.resolve(rootDir, './dist'),
+       path: path.resolve(rootDir, userConfig.outputDir),
      },
    };

    ...

  };

通過上面的改造,我們就可以基本實現項目 project-c 對於構建配置的自定義需求。

但仔細觀察後,我們可以發現上面的改造方式存在一些問題:

  1. 單個配置的判空、合法性校驗及默認配置覆蓋邏輯在代碼中是分散的,後期配置增加不易管理。

  2. 單個配置的覆蓋邏輯是和默認配置耦合在一起的,且單個配置判空失敗後沒有默認值兜底,不利於默認配置的獨立維護。

基於以上問題,我們再來對 build-scripts 進行一下改造:

npm 包 build-scripts

  build-scripts
    |- /bin
     |- build-scripts.js
    |- /lib (ts 構建目錄,文件同 src)
    |- /src
      |- /commands
       |- build.ts
+     |- /configs
+      |- build.ts
+     |- /core
+       |- ConfigManager.ts
    |- tsconfig.json
    |- package.json
    |- package-lock.json

我們首先將默認的構建配置抽離到一個獨立的文件 configs/build.ts進行維護。

build-scripts/src/configs/build.ts

const path = require('path');
const rootDir = process.cwd();

const buildConfig = {
  entry: path.resolve(rootDir, './src/index'),
  module: {
    rules: [
      {
        test: /\.ts?$/,
        use: require.resolve('ts-loader'),
        exclude: /node_modules/,
      },
    ],
  },
  resolve: {
    extensions: ['.ts''.js'],
  },
  output: {
    filename: 'main.js',
    path: path.resolve(rootDir, './dist'),
  },
};

export default buildConfig;

然後我們新增一個 ConfigManager 類來進行構建配置的管理,負責用戶配置和默認構建配置的合併。

build-scripts/src/core/ConfigManager.ts

import _ = require('lodash');
import path = require('path');
import assert = require('assert');

// 配置類型定義
interface IConfig {
  [key: string]: any;
}

// 用戶配置註冊信息類型定義
interface IUserConfigRegistration {
  [key: string]: IUserConfigArgs;
}
interface IUserConfigArgs {
  name: string;
  defaultValue?: any;
  validation?: (value: any) => Promise<boolean>;
  configWebpack?: (defaultConfig: IConfig, value: any) => void;
}

class ConfigManager {
  // webpack 配置
  public config: IConfig;
  // 用戶配置
  public userConfig: IConfig;
  // 用戶配置註冊信息
  private userConfigRegistration: IUserConfigRegistration;

  constructor(config: IConfig) {
    this.config = config;
    this.userConfig = {};
    this.userConfigRegistration = {};
  }

  /**
   * 註冊用戶配置
   *
   * @param {IUserConfigArgs[]} configs
   * @memberof ConfigManager
   */
  public registerUserConfig = (configs: IUserConfigArgs[]) ={
    configs.forEach((conf) ={
      const configName = conf.name;

      // 判斷配置屬性是否已註冊
      if (this.userConfigRegistration[configName]) {
        throw new Error(
          `[Config File]${configName} already registered in userConfigRegistration.`
        );
      }

      // 添加配置的註冊信息
      this.userConfigRegistration[configName] = conf;

      // 如果當前項目的用戶配置中不存在該配置值,則使用該配置註冊時的默認值
      if (
        _.isUndefined(this.userConfig[configName]) &&
        Object.prototype.hasOwnProperty.call(conf, 'defaultValue')
      ) {
        this.userConfig[configName] = conf.defaultValue;
      }
    });
  }

  /**
   * 獲取用戶配置
   *
   * @private
   * @return {*}
   * @memberof ConfigManager
   */
  private getUserConfig = () ={
    const rootDir = process.cwd();
    try {
      this.userConfig = require(path.resolve(rootDir, './build.json'));
    } catch (error) {
      console.log('Config error: build.json is not exist.');
      return;
    }
  }

  /**
   * 執行註冊用戶配置
   *
   * @param {*} configs
   * @memberof ConfigManager
   */
  private runUserConfig = async () ={
    for (const configInfoKey in this.userConfig) {
      const configInfo = this.userConfigRegistration[configInfoKey];

      // 配置屬性未註冊
      if (!configInfo) {
        throw new Error(
          `[Config File]: Config key '${configInfoKey}' is not supported.`
        );
      }

      const { name, validation } = configInfo;
      const configValue = this.userConfig[name];

      // 配置值校驗
      if (validation) {
        const validationResult = await validation(configValue);
        assert(
          validationResult,
          `${name} did not pass validation, result: ${validationResult}`
        );
      }

      // 配置值更新到默認 webpack 配置
      if (configInfo.configWebpack) {
        await configInfo.configWebpack(this.config, configValue);
      }
    }
  }

  /**
   * webpack 配置初始化
   */
  public setup = async () ={
    // 獲取用戶配置
    this.getUserConfig();

    // 用戶配置校驗及合併
    await this.runUserConfig();
  }
}

export default ConfigManager;

然後修改 build 命令執行邏輯,通過初始化 ConfigManager 實例對構建配置進行管理。

build-scripts/src/commands/build.ts

  import * as path from 'path';
  import * as webpack from 'webpack';

+ import defaultConfig from '../configs/build';
+ import ConfigManager from '../core/ConfigManager';

  export = async () ={
    const rootDir = process.cwd();

-   // 獲取用戶配置
-   let userConfig: { [name: string]: any } = {};
-   try {
-     userConfig = require(path.resolve(rootDir, './build.json'));
-   } catch (error) {
-     console.log('Config error: build.json is not exist.');
-     return;
-   }

-   // 用戶配置非空及合法性校驗
-   if (!userConfig.entry) {
-     console.log('Config error: userConfig.entry is not exist.');
-     return;
-   }
-   if (typeof userConfig.entry !== 'string') {
-     console.log('Config error: userConfig.entry is not valid.');
-     return;
-   }
-   if (!userConfig.outputDir) {
-     console.log('Config error: userConfig.outputDir is not exist.');
-     return;
-   }
-   if (typeof userConfig.outputDir !== 'string') {
-     console.log('Config error: userConfig.outputDir is not valid.');
-     return;
-   }

-   // 定義 webpack 配置
-   const config = {
-     entry: path.resolve(rootDir, userConfig.entry),
-     module: {
-       rules: [
-         {
-           test: /\.ts?$/,
-           use: require.resolve('ts-loader'),
-           exclude: /node_modules/,
-         },
-       ],
-     },
-     resolve: {
-       extensions: ['.ts''.js'],
-     },
-     output: {
-       filename: 'main.js',
-       path: path.resolve(rootDir, userConfig.outputDir),
-     },
-   };

+   // 初始化配置管理類
+   const manager = new ConfigManager(defaultConfig);
+
+   // 註冊用戶配置
+   manager.registerUserConfig([
+     {
+       // entry 配置
+       name: 'entry',
+       // 配置值校驗
+       validation: async (value) ={
+         return typeof value === 'string';
+       },
+       // 配置值合併
+       configWebpack: async (defaultConfig, value) ={
+         defaultConfig.entry = path.resolve(rootDir, value);
+       },
+     },
+     {
+       // outputDir 配置
+       name: 'outputDir',
+       // 配置值校驗
+       validation: async (value) ={
+         return typeof value === 'string';
+       },
+       // 配置值合併
+       configWebpack: async (defaultConfig, value) ={
+         defaultConfig.output.path = path.resolve(rootDir, value);
+       },
+     },
+   ]);
+
+   // webpack 配置初始化
+   await manager.setup();

    // 實例化 webpack
-   const compiler = webpack(config);
+   const compiler = webpack(manager.config);

    // 執行 webpack 編譯
    compiler.run((err, stats) ={
      compiler.close((closeErr) ={});
    });
  };

通過上面的改造,我們將用戶配置的覆蓋邏輯和默認構建配置進行了解耦,同時通過 ConfigManager 類的 registerUserConfig 方法將用戶配置的校驗、覆蓋等邏輯等聚合在一起進行管理。

改造完成後,整體的執行流程如下:

4. 添加插件機制

下面我們的場景再來演進一下:

由於業務需求,項目 project-c 需要處理 xml 文件, 所以項目的構建配置中需要增加 xml 文件的處理 loader,但是 build-scripts 並不支持 config.module.rules 的擴展,此時應該怎麼辦呢?

我們之前新增的用戶配置方案只適用於一些簡單的配置覆蓋,如果項目涉及到複雜的構建配置自定義操作,就無能爲力了。

社區中一般的做法是將構建配置 eject 到項目中,由用戶自行修改,比如 react-scripts 。但是 eject 操作是不可逆的,如果後續構建配置有更新,項目就無法直接通過升級 npm 包的方式完成更新,同時單個項目對於構建配置的擴展也無法在多個項目間複用

理想的方式是設計一種插件機制,能夠讓用戶可插拔式地對構建配置進行擴展,同時這些插件也可以在項目間複用。基於這個目的,我們來對 build-scripts 進行一下改造:

用戶配置 build.json 中新增 plugins 字段,用於配置自定義插件列表。

project-c/build.json

  {
    "entry""./src/index1",
    "outputDir""./build",
+   "plugins"["build-plugin-xml"]
  }

然後我們再來改造一下 ConfigManager 裏的執行邏輯,讓 ConfigManager 在執行完用戶配置和默認配置的合併後,去依次執行項目 build.json 中定義的插件列表,並將合併後的配置以參數的形式傳入插件

build-scripts/core/ConfigManager.ts

  import _ = require('lodash');
  import path = require('path');
  import assert = require('assert');

  ...

  class ConfigManager {
    // webpack 配置
    public config: IConfig;

    ...

    /**
     * 執行註冊用戶配置
     *
     * @param {*} configs
     * @memberof ConfigManager
     */
    private runUserConfig = async () ={
      for (const configInfoKey in this.userConfig) {
+       if (configInfoKey === 'plugins') return;
        const configInfo = this.userConfigRegistration[configInfoKey];

        ...

      }
    }

+   /**
+    * 執行插件
+    *
+    * @private
+    * @memberof ConfigManager
+    */
+   private runPlugins = async () ={
+     for (const plugin of this.userConfig.plugins) {
+       const pluginPath = require.resolve(plugin, { paths: [process.cwd()] });
+       const pluginFn = require(pluginPath);
+       await pluginFn(this.config);
+     }
+   }

    /**
     * webpack 配置初始化
     */
    public setup = async () ={
      // 獲取用戶配置
      this.getUserConfig();

      // 用戶配置校驗及合併
      await this.runUserConfig();

+     // 執行插件
+     await this.runPlugins();
    }
  }

  export default ConfigManager;

通過插件執行時傳入的構建配置,我們就可以直接在插件內部完成構建配置對於 xml-loader 的擴展。

build-plugin-xml/index.js

module.exports = async (webpackConfig) ={
  // 空值屬性判斷
  if (!webpackConfig.module) webpackConfig.module = {};
  if (!webpackConfig.module.rules) webpackConfig.module.rules = [];

  // 添加 xml-loader
  webpackConfig.module.rules.push({
    test: /\.xml$/i,
    use: require.resolve('xml-loader'),
  });
};

基於以上的插件機制,項目可以對構建配置實現任意的自定義擴展,同時插件還可以 npm 包的形式在多個項目間複用

改造完成後,整體的執行流程如下:

5. 引入 webpack-chain

下面我們的場景再來演進一下:

由於構建性能問題(僅爲場景假設),插件 build-plugin-xml 需要將 xml-loader 的匹配規則調整到 ts-loader 的匹配規則之前,所以我們對插件 build-plugin-xml 進行了如下改造:

module.exports = async (webpackConfig) ={
  // 空值屬性判斷
  if (!webpackConfig.module) webpackConfig.module = {};
  if (!webpackConfig.module.rules) webpackConfig.module.rules = [];

  // 定義 xml-loader 規則
  const xmlRule = {
    test: /\.xml$/i,
    use: require.resolve('xml-loader'),
  };

  // 找到 ts-loader 規則位置
  const tsIndex = webpackConfig.module.rules.findIndex(
    (rule) => String(rule.test) === '/\\.ts?$/'
  );

  // 添加 xml-loader 規則
  if (tsIndex > -1) {
    webpackConfig.module.rules.splice(tsIndex - 1, 0, xmlRule);
  } else {
    webpackConfig.module.rules.push(xmlRule);
  }
};

改造完成後,插件 build-plugin-xml 針對 xml-loader 的擴展一共做了四件事:

  1. 對 webapck 進行空值屬性判斷和補齊。

  2. 定義 xml-loader 規則。

  3. 找到 ts-loader 規則的位置。

  4. 將 xml-loader 規則插入到 ts-loader 規則前。

觀察上面的改造我們可以發現,雖然我們的構建配置並不複雜,但針對於它的修改和擴展還是比較繁瑣的。這主要是由於 webpack 構建配置是以一個 JavaScript 對象的形式來進行維護的,一般項目中的配置對象往往很大,且內部屬性間存在層層嵌套,針對配置對象的修改和擴展會涉及到各種判空、遍歷、分支處理等操作,所以邏輯會顯得比較複雜。

爲了解決插件中構建配置修改和擴展邏輯複雜的問題,我們可以在項目中來引入 webpack-chain :

webpack-chain 是一種 webpack 的流式配置方案,通過鏈式調用的方式來操作配置對象。其核心是 ChainedMap 和 ChainedSet 兩個對象類型,藉助 ChainedMap 和 ChainedSet 提供的操作方法,我們能夠很方便地對配置對象進行修改和擴展,可以避免之前手動操作 JavaScript 對象時帶來的繁瑣。這裏不做過多介紹,感興趣的同學可以查看官方文檔 [1]。

我們先來將默認的構建配置修改爲 webpack-chain 的方式。

build-scripts/src/configs/build.ts

+ import * as Config from 'webpack-chain';

  const path = require('path');
  const rootDir = process.cwd();

- const buildConfig = {
-   entry: path.resolve(rootDir, './src/index'),
-   module: {
-     rules: [
-       {
-         test: /\.ts?$/,
-         use: require.resolve('ts-loader'),
-         exclude: /node_modules/,
-       },
-     ],
-   },
-   resolve: {
-     extensions: ['.ts''.js'],
-   },
-   output: {
-     filename: 'main.js',
-     path: path.resolve(rootDir, './dist'),
-   },
- };

+ const buildConfig = new Config();
+
+ buildConfig.entry('index').add('./src/index');
+
+ buildConfig.module
+   .rule('ts')
+   .test(/\.ts?$/)
+   .use('ts-loader')
+   .loader(require.resolve('ts-loader'));
+
+ buildConfig.resolve.extensions.add('.ts').add('.js');
+
+ buildConfig.output.filename('main.js');
+ buildConfig.output.path(path.resolve(rootDir, './dist'));

  export default buildConfig;

然後我們將 ConfigManager 中涉及到構建配置的地方也切換爲 webpack-chain 的方式。

src/core/ConfigManager.ts

  import _ = require('lodash');
  import path = require('path');
  import assert = require('assert');
+ import WebpackChain = require('webpack-chain');

  ...

  interface IUserConfigArgs {
    name: string;
    defaultValue?: any;
    validation?: (value: any) => Promise<boolean>;
-   configWebpack?: (defaultConfig: IConfig, value: any) => void;
+   configWebpack?: (defaultConfig: WebpackChain, value: any) => void;
  }

  class ConfigManager {
    // webpack 配置
-   public config: IConfig;
+   public config: WebpackChain;
    // 用戶配置
    public userConfig: IConfig;
    // 用戶配置註冊信息
    private userConfigRegistration: IUserConfigRegistration;

-   constructor(config: IConfig) {
+   constructor(config: WebpackChain) {
      this.config = config;
      this.userConfig = {};
      this.userConfigRegistration = {};
    }

    ...

  }

  export default ConfigManager;

同時用戶配置中涉及到構建配置的地方也切換爲 webpack-chain 的方式。

src/commands/build.ts

  ...

  export = async () ={

    ...

    // 註冊用戶配置
    manager.registerUserConfig([
      {

        ...

        // 配置值合併
        configWebpack: async (defaultConfig, value) ={
-         defaultConfig.entry = path.resolve(rootDir, value);
+         defaultConfig.entry('index').clear().add(path.resolve(rootDir, value));
        },
      },
      {

        ...

        // 配置值合併
        configWebpack: async (defaultConfig, value) ={
-         defaultConfig.output.path = path.resolve(rootDir, value);
+         defaultConfig.output.path(path.resolve(rootDir, value));
        },
      },
    ]);

    // webpack 配置初始化
    await manager.setup();

    // 實例化 webpack
-   const compiler = webpack(manager.config);
+   const compiler = webpack(manager.config.toConfig());

    ...

  };

藉助 webpack-chain ,插件 build-plugin-xml 針對 xml-loader 的擴展邏輯可以簡化爲:

  module.exports = async (webpackConfig) ={
-   // 空值屬性判斷
-   if (!webpackConfig.module) webpackConfig.module = {};
-   if (!webpackConfig.module.rules) webpackConfig.module.rules = [];
-
-   // 定義 xml 規則
-   const xmlRule = {
-     test: /\.xml$/i,
-     use: require.resolve('xml-loader'),
-   };
-
-   // 找到 ts 規則位置
-   const tsIndex = webpackConfig.module.rules.findIndex(
-     (rule) => String(rule.test) === '/\\.ts?$/'
-   );
-
-   // 添加 xml 規則
-   if (tsIndex > -1) {
-     webpackConfig.module.rules.splice(tsIndex - 1, 0, xmlRule);
-   } else {
-     webpackConfig.module.rules.push(xmlRule);
-   }

+   webpackConfig.module
+     .rule('xml')
+     .before('ts')
+     .test(/\.xml$/i)
+     .use('xml-loader')
+     .loader(require.resolve('xml-loader'));

  };

相對之前複雜的空值判斷和對象遍歷邏輯,webpack-chain 極大地簡化了插件內部對於配置對象的修改和擴展操作,無論是代碼質量,還是開發體驗,相對於之前來說都有不小的提升。

6. 插件化默認構建配置

下面我們的場景再來演進一下:

假設現在接入 build-scripts 的項目都是 react 項目, 由於業務方向的調整,後續團隊的技術棧會切換到 rax,新增的 rax 項目想繼續使用 build-scripts 進行項目間構建配置的複用,此時應該怎麼辦呢?

由於 build-scripts 裏默認的構建配置是基於 react 的,所以 rax 項目是沒辦法直接基於插件進行擴展的,難道需要基於 rax 構建配置再新建一個 build-scritps 項目嗎?這樣顯然是沒辦法做到核心邏輯複用的。我們來換個思路想想,既然插件可以修改構建配置,那麼能不能將構建配置的初始化也放在插件裏?這樣就能夠實現構建配置和 build-scripts 的解耦,任意類型的項目都能夠基於 build-scripts 來進行構建配置的管理和擴展。

基於這個目的,我們下面來對 build-scripts 進行一下改造:

我們首先對 ConfigManager 裏的邏輯進行一下調整,新增 setConfig 方法提供給插件進行構建配置的初始化,由於插件還承擔修改和擴展構建配置的職責,而這部分邏輯的調用是在初始配置和用戶配置合併後的,所以我們通過 onGetWebpackConfig 方法註冊回調函數的方式來執行這部分邏輯。

src/core/ConfigManager.ts

  import _ = require('lodash');
  import path = require('path');
  import assert = require('assert');
  import WebpackChain = require('webpack-chain');

  ...

+ // webpack 配置修改函數類型定義
+ type IModifyConfigFn = (defaultConfig: WebpackChain) => void;

  class ConfigManager {
    // webpack 配置
    public config: WebpackChain;
    // 用戶配置
    public userConfig: IConfig;
    // 用戶配置註冊信息
    private userConfigRegistration: IUserConfigRegistration;
+   // 已註冊的 webpack 配置修改函數
+   private modifyConfigFns: IModifyConfigFn[];

-   constructor(config: WebpackChain) {
-     this.config = config;
+   constructor() {
      this.userConfig = {};
      this.userConfigRegistration = {};
+     this.modifyConfigFns = [];
    }

+   /**
+    * 設置 webpack 配置
+    *
+    * @param {WebpackChain} config
+    * @memberof ConfigManager
+    */
+   public setConfig = (config: WebpackChain) ={
+     this.config = config;
+   };

+   /**
+    * 註冊 webpack 配置修改函數
+    *
+    * @param {(defaultConfig: WebpackChain) => void} fn
+    * @memberof ConfigManager
+    */
+   public onGetWebpackConfig = (fn: (defaultConfig: WebpackChain) => void) ={
+     this.modifyConfigFns.push(fn);
+   };

    /**
     * 註冊用戶配置
     *
     * @param {IUserConfigArgs[]} configs
     * @memberof ConfigManager
     */
    public registerUserConfig = (configs: IUserConfigArgs[]) ={

       ...

    };

    /**
     * 獲取用戶配置
     *
     * @private
     * @return {*}
     * @memberof ConfigManager
     */
    private getUserConfig = () ={

       ...

    };

    /**
     * 執行註冊用戶配置
     *
     * @param {*} configs
     * @memberof ConfigManager
     */
    private runUserConfig = async () ={

      ...

    };

    /**
     * 執行插件
     *
     * @private
     * @memberof ConfigManager
     */
    private runPlugins = async () ={
      for (const plugin of this.userConfig.plugins) {
        const pluginPath = require.resolve(plugin, { paths: [process.cwd()] });
        const pluginFn = require(pluginPath);
-       await pluginFn(this.config);
+       await pluginFn({
+         setConfig: this.setConfig,
+         registerUserConfig: this.registerUserConfig,
+         onGetWebpackConfig: this.onGetWebpackConfig,
+       });
      }
    };

+   /**
+    * 執行 webpack 配置修改函數
+    *
+    * @private
+    * @memberof ConfigManager
+    */
+   private runWebpackModifyFns = async () ={
+     this.modifyConfigFns.forEach((fn) => fn(this.config));
+   };

    /**
     * webpack 配置初始化
     */
    public setup = async () ={
      // 獲取用戶配置
      this.getUserConfig();

+     // 執行插件
+     await this.runPlugins();

      // 用戶配置校驗及合併
      await this.runUserConfig();

-     // 執行插件
-     await this.runPlugins();

+     // 執行 webpack 配置修改函數
+     await this.runWebpackModifyFns();
    };
  }

  export default ConfigManager;

然後我們將 build-scripts 裏默認配置相關的邏輯給抽離出來。

npm 包 build-scripts

  build-scripts
    |- /bin
     |- build-scripts.js
    |- /lib (ts 構建目錄,文件同 src)
    |- /src
      |- /commands
       |- build.ts
-     |- /configs
-      |- build.ts
      |- /core
       |- ConfigManager.ts
    |- tsconfig.json
    |- package.json
    |- package-lock.json

由於用戶配置一般是跟默認構建配置走的,所以我們也抽離出來。

src/commands/build.ts

- import * as path from 'path';
  import * as webpack from 'webpack';

- import defaultConfig from '../configs/build';
  import ConfigManager from '../core/ConfigManager';

  export = async () ={
-   const rootDir = process.cwd();

    // 初始化配置管理類
-   const manager = new ConfigManager(defaultConfig);
+   const manager = new ConfigManager();

-   // 註冊用戶配置
-   manager.registerUserConfig([
-     {
-       // entry 配置
-       name: 'entry',
-       // 配置值校驗
-       validation: async (value) ={
-         return typeof value === 'string';
-       },
-       // 配置值合併
-       configWebpack: async (defaultConfig, value) ={
-         defaultConfig.entry('index').clear().add(path.resolve(rootDir, value));
-       },
-     },
-     {
-       // outputDir 配置
-       name: 'outputDir',
-       // 配置值校驗
-       validation: async (value) ={
-         return typeof value === 'string';
-       },
-       // 配置值合併
-       configWebpack: async (defaultConfig, value) ={
-         defaultConfig.output.path(path.resolve(rootDir, value));
-       },
-     },
-   ]);

    // webpack 配置初始化
    await manager.setup();

    // 實例化 webpack
    const compiler = webpack(manager.config.toConfig());

    // 執行 webpack 編譯
    compiler.run((err, stats) ={
      compiler.close((closeErr) ={});
    });
  };

我們將抽離的默認構建配置的相關邏輯,封裝到插件 build-plugin-base 裏。

build-plugin-base/index.js

const Config = require('webpack-chain');

const path = require('path');
const rootDir = process.cwd();

module.exports = async ({ setConfig, registerUserConfig }) ={
  /**
   * 設置默認配置
   */
  const buildConfig = new Config();

  buildConfig.entry('index').add('./src/index');

  buildConfig.module
    .rule('ts')
    .test(/\.ts?$/)
    .use('ts-loader')
    .loader(require.resolve('ts-loader'));

  buildConfig.resolve.extensions.add('.ts').add('.js');

  buildConfig.output.filename('main.js');
  buildConfig.output.path(path.resolve(rootDir, './dist'));

  setConfig(buildConfig);

  /**
   * 註冊用戶配置
   */
  registerUserConfig([
    {
      // entry 配置
      name: 'entry',
      // 配置值校驗
      validation: async (value) ={
        return typeof value === 'string';
      },
      // 配置值合併
      configWebpack: async (defaultConfig, value) ={
        defaultConfig.entry('index').clear().add(path.resolve(rootDir, value));
      },
    },
    {
      // outputDir 配置
      name: 'outputDir',
      // 配置值校驗
      validation: async (value) ={
        return typeof value === 'string';
      },
      // 配置值合併
      configWebpack: async (defaultConfig, value) ={
        defaultConfig.output.path(path.resolve(rootDir, value));
      },
    },
  ]);
};

同時我們還需要調整一下 build-plugin-xml 裏的邏輯,將構建配置擴展的邏輯通過 onGetWebpackConfig 方法改爲回調函數的方式調用。

build-plugin-xml/index.js

- module.exports = async (webpackConfig) ={
+ module.exports = async ({ onGetWebpackConfig }) ={
+   onGetWebpackConfig((webpackConfig) ={
      webpackConfig.module
        .rule('xml')
        .test(/\.xml$/i)
        .use('xml-loader')
        .loader(require.resolve('xml-loader'));
+   });
  };

通過以上的改造,我們實現了默認構建配置和 build-scripts 的解耦,理論上任意類型的項目均可基於 build-scripts 來實現構建配置的項目間複用及擴展。

改造完成後,整體的執行流程如下:

7. 添加多任務機制

最後我們的場景再來擴展一下:

假設單個項目的構建產物不止一種,例如 Rax 項目需要打包構建爲 H5 和 小程序兩種類型,兩種類型對應的是不同的構建配置,但 build-scripts 只支持一份構建配置, 此時應該怎麼辦呢?

webpack 其實默認是支持多構建配置執行的,我們只需要向 webpack 的 compiler 實例傳入一個數組就行:

const webpack = require('webpack');

webpack([
  { entry: './index1.js', output: { filename: 'bundle1.js' } },
  { entry: './index2.js', output: { filename: 'bundle2.js' } }
](err, stats) ={
  process.stdout.write(stats.toString() + '\n');
})

基於 webpack 的多配置執行能力,我們可以來考慮爲 build-scripts 設計一種多任務機制。 基於這個目的,我們下面來對 build-scripts 進行一下改造:

首先我們來調整一下 ConfigManager 裏的邏輯,將 webapck 的默認配置改爲數組形式,同時新增 registerTask 方法來進行 webpack 默認配置的註冊,同時調整一下 webpack 默認配置引用的相關邏輯。

build-scripts/src/commands/ConfigManager.ts

  import _ = require('lodash');
  import path = require('path');
  import assert = require('assert');
  import WebpackChain = require('webpack-chain');

  ...

  // webpack 配置修改函數類型定義
  type IModifyConfigFn = (defaultConfig: WebpackChain) => void;

+ // webpack 任務配置類型定義
+ export interface ITaskConfig {
+   name: string;
+   chainConfig: WebpackChain;
+   modifyFunctions: IModifyConfigFn[];}

  class ConfigManager {
-   // webpack 配置
-   public config: WebpackChain;
+   // webpack 配置列表
+   public configArr: ITaskConfig[];
    // 用戶配置
    public userConfig: IConfig;
    // 用戶配置註冊信息
    private userConfigRegistration: IUserConfigRegistration;
-   // 已註冊的 webpack 配置修改函數
-   private modifyConfigFns: IModifyConfigFn[];

    constructor() {
+     this.configArr = [];
      this.userConfig = {};
      this.userConfigRegistration = {};
-     this.modifyConfigFns = [];
    }

-   /**
-     * 設置 webpack 配置
-    *
-    * @param {WebpackChain} config
-    * @memberof ConfigManager
-    */
-   public setConfig = (config: WebpackChain) ={
-     this.config = config;
-   };

+   /**
+    * 註冊 webpack 任務
+    *
+    * @param {string} name
+    * @param {WebpackChain} chainConfig
+    * @memberof ConfigManager
+    */
+   public registerTask = (name: string, chainConfig: WebpackChain) ={
+     const exist = this.configArr.find((v)boolean => v.name === name);
+     if (!exist) {
+       this.configArr.push({
+         name,
+         chainConfig,
+         modifyFunctions: [],
+       });
+     } else {
+       throw new Error(`[Error] config '${name}' already exists!`);
+     }
+   };

    /**
     * 註冊 webpack 配置修改函數
     *
+    * @param {string} name
     * @param {(defaultConfig: WebpackChain) => void} fn
     * @memberof ConfigManager
     */
-   public onGetWebpackConfig = (fn: (defaultConfig: WebpackChain) => void) ={
-     this.modifyConfigFns.push(fn);
-   };
+   public onGetWebpackConfig = (
+     name: string,
+     fn: (defaultConfig: WebpackChain) => void
+   ) ={
+     const config = this.configArr.find((v)boolean => v.name === name);
+
+     if (config) {
+       config.modifyFunctions.push(fn);
+     } else {
+       throw new Error(`[Error] config '${name}' does not exist!`);
+     }
+   };

    /**
     * 註冊用戶配置
     *
     * @param {IUserConfigArgs[]} configs
     * @memberof ConfigManager
     */
    public registerUserConfig = (configs: IUserConfigArgs[]) ={

      ...

    };

    /**
     * 獲取用戶配置
     *
     * @private
     * @return {*}
     * @memberof ConfigManager
     */
    private getUserConfig = () ={

      ...

    };

    /**
     * 執行註冊用戶配置
     *
     * @param {*} configs
     * @memberof ConfigManager
     */
    private runUserConfig = async () ={
      for (const configInfoKey in this.userConfig) {

       ...

        // 配置值更新到默認 webpack 配置
        if (configInfo.configWebpack) {
-         await configInfo.configWebpack(this.config, configValue);
+         // 遍歷已註冊的 webapck 任務
+         for (const webpackConfigInfo of this.configArr) {
+           await configInfo.configWebpack(
+             webpackConfigInfo.chainConfig,
+             configValue
+           );
+         }
        }
      }
    };

    /**
     * 執行插件
     *
     * @private
     * @memberof ConfigManager
     */
    private runPlugins = async () ={
      for (const plugin of this.userConfig.plugins) {
        const pluginPath = require.resolve(plugin, { paths: [process.cwd()] });
        const pluginFn = require(pluginPath);
        await pluginFn({
-         setConfig: this.setConfig,
+         registerTask: this.registerTask,
          registerUserConfig: this.registerUserConfig,
          onGetWebpackConfig: this.onGetWebpackConfig,
        });
      }
    };

    /**
     * 執行 webpack 配置修改函數
     *
     * @private
     * @memberof ConfigManager
     */
    private runWebpackModifyFns = async () ={
-     this.modifyConfigFns.forEach((fn) => fn(this.config));
+     for (const webpackConfigInfo of this.configArr) {
+       webpackConfigInfo.modifyFunctions.forEach((fn) =>
+         fn(webpackConfigInfo.chainConfig)
+       );
+     }
    };

    /**
     * webpack 配置初始化
     */
    public setup = async () ={
      // 獲取用戶配置
      this.getUserConfig();

      // 執行插件
      await this.runPlugins();

      // 用戶配置校驗及合併
      await this.runUserConfig();

      // 執行 webpack 配置修改函數
      await this.runWebpackModifyFns();
    };
  }

  export default ConfigManager;

build 命令執行時的構建配置獲取也需要改爲數組的形式。

build-scripts/src/commands/build.ts

  import * as webpack from 'webpack';

  import ConfigManager from '../core/ConfigManager';

  export = async () ={
    // 初始化配置管理類
    const manager = new ConfigManager();

    // webpack 配置初始化
    await manager.setup();

    // 實例化 webpack
-   const compiler = webpack(manager.config.toConfig());
+   const compiler = webpack(
+     manager.configArr.map((config) => config.chainConfig.toConfig())
+   );

    // 執行 webpack 編譯
    compiler.run((err, stats) ={
      compiler.close((closeErr) ={});
    });
  };

插件 build-plugin-base 也需要調整默認構建配置的註冊方式。

build-plugin-base/index.js

  const Config = require('webpack-chain');

  const path = require('path');
  const rootDir = process.cwd();

- module.exports = async ({ setConfig, registerUserConfig }) ={
+ module.exports = async ({ registerTask, registerUserConfig }) ={
    /**
     * 設置默認配置
     */
    const buildConfig = new Config();

    ...

-   setConfig(buildConfig)
+   registerTask('base', buildConfig);

    /**
     * 註冊用戶配置
     */
    registerUserConfig([

     ...

    ]);
  };

插件 build-plugin-xml 也需要添加上對應的 webpack 任務名稱參數。

build-plugin-xml/index.js

  module.exports = async ({ onGetWebpackConfig }) ={
-   onGetWebpackConfig((webpackConfig) ={
+   onGetWebpackConfig('base'(webpackConfig) ={
      webpackConfig.module
        .rule('xml')
        .before('ts')
        .test(/\.xml$/i)
        .use('xml-loader')
        .loader(require.resolve('xml-loader'));
    });
  };

通過以上的改造,我們爲 build-scripts 增加了多任務執行的機制,可以實現單個項目下的多構建任務執行

改造完成後,整體的執行流程如下:

三、寫在最後

以上我們通過場景演進的方式,對 build-scripts 核心的設計原理和相關方法進行了講解。通過以上的分析,我們可以看出 build-scripts 本質上是一個具有靈活插件機制的配置管理方案,不僅僅侷限於 webpack 配置,任何有跨項目間配置複用及擴展的場景,都可以藉助 build-scripts 的設計思路。

注:文中涉及示例代碼可通過倉庫 __ build-scripts-demo[2]_ 查看,同時 build-scripts 中未介紹到的相關方法,感興趣的同學也可以通過倉庫 __build-scripts_[3]_ 閱讀相關源碼。_

參考資料

[1]

官方文檔: https://github.com/neutrinojs/webpack-chain

[2]

_ build-scripts-demo_: _https://github.com/CavsZhouyou/build-scripts-demo_

[3]

build-scripts: https://github.com/ice-lab/build-scripts

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/PZDuXn7_uz8yZnHANSACag