談一談 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] 閱讀相關原始碼。
參考資料
官方文件: http://github.com/neutrinojs/webpack-chain
_ build-scripts-demo_: http://github.com/CavsZhouyou/build-scripts-demo
build-scripts : http://github.com/ice-lab/build-scripts