談一談 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 對於構建配置的自定義需求。
但仔細觀察後,我們可以發現上面的改造方式存在一些問題:
-
單個配置的判空、合法性校驗及默認配置覆蓋邏輯在代碼中是分散的,後期配置增加不易管理。
-
單個配置的覆蓋邏輯是和默認配置耦合在一起的,且單個配置判空失敗後沒有默認值兜底,不利於默認配置的獨立維護。
基於以上問題,我們再來對 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 的擴展一共做了四件事:
-
對 webapck 進行空值屬性判斷和補齊。
-
定義 xml-loader 規則。
-
找到 ts-loader 規則的位置。
-
將 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