談一談 build-scripts 架構設計

語言: CN / TW / HK

一、寫在前面

在 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.png

1. 拷貝配置

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

圖 2.png

專案 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.png

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.png

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.png

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 來實現構建配置的專案間複用及擴充套件。

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

圖 6.png

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 增加了多工執行的機制,可以 實現單個專案下的多構建任務執行

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

圖 7.png

三、寫在最後

以上我們通過場景演進的方式,對 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