.4-淺析webpack源碼之convert-argv模塊
上一節看了一眼預編譯的總體代碼,這一節分析convert-argv模塊是如何解析入口函數並返回options。
生成默認配置文件名數組
module.exports = function(yargs, argv, convertOptions) { var options = []; // webapck -d // 生成map映射文件,告知模塊打包地點 if(argv.d) { /* ... */ } // webpack -p // 壓縮文件 if(argv.p) { /* ... */ } // 配置文件加載標記 var configFileLoaded = false; // 配置文件加載後的載體 var configFiles = []; // 排序 var extensions = Object.keys(interpret.extensions).sort(function(a, b) { return a === ".js" ? -1 : b === ".js" ? 1 : a.length - b.length; }); // 指定所有默認配置文件名 var defaultConfigFiles = ["webpack.config", "webpackfile"].map(function(filename) { return extensions.map(function(ext) { return { path: path.resolve(filename + ext), ext: ext }; }); }).reduce(function(a, i) { return a.concat(i); }, []); // more code... }
函數內部,首先判斷了argv.d與argv.p屬性是否存在,這個屬性來源於命令行參數,即webpack -d -p,測試如圖:
因為懶得加,所以直接跳過,進入到第二階段,生成默認配置文件名數組。
這裏引入了一個小的模塊interpret,調用Object.keys(interpret.extensions)返回一系列文件擴展名的數組,如圖:
由於獲取到的數組為亂序,所以這裏首先進行排序,規則為.js放在第一位,後面的按長度從小到大,結果是這樣:
接下來是兩個map與一個reduce的調用,首先兩個map會返回一個數組,包含兩個對象數組,對象包含path、ext兩個屬性,path代表路徑+文件名+後綴,ext就是後綴,調用map後會得到如下數組 (截取部分):
最後調用reduce方法將二維數組扁平化為一維數組,圖就不截了。
定義配置文件路徑與後綴
有了默認列表,第二步就是嘗試獲取對應的配置文件:
var i; // 從命令行讀取--config // argv.config => config.js if(argv.config) { var getConfigExtension = function getConfigExtension(configPath) { for(i = extensions.length - 1; i >= 0; i--) { var tmpExt = extensions[i]; if(configPath.indexOf(tmpExt, configPath.length - tmpExt.length) > -1) { return tmpExt; } } return path.extname(configPath); }; var mapConfigArg = function mapConfigArg(configArg) { // 獲取文件絕對路徑 var resolvedPath = path.resolve(configArg); // 獲取文件後綴 var extension = getConfigExtension(resolvedPath); return { path: resolvedPath, ext: extension }; }; // 包裝成數組 統一處理單、多配置文件情況 var configArgList = Array.isArray(argv.config) ? argv.config : [argv.config]; configFiles = configArgList.map(mapConfigArg); } // 如果未指定配置文件 嘗試匹配默認文件名 else { for(i = 0; i < defaultConfigFiles.length; i++) { var webpackConfig = defaultConfigFiles[i].path; // 檢測路徑中是否存在對應文件 if(fs.existsSync(webpackConfig)) { configFiles.push({ path: webpackConfig, ext: defaultConfigFiles[i].ext }); break; } } }
這裏的代碼比較簡單,如果調用了--config自定義配置文件,該指令後面的會被當成參數傳給argv.config。
存在argv.config則會對文件名與合法後綴數組進行匹配,檢測出配置文件的後綴包裝成對象返回。
如果不指定配置文件,會進入else代碼段開始遍歷默認配置文件數組,fs.existsSync檢測當前路徑是否存在該文件,有就當成配置文件包裝返回。
獲取配置文件輸出模塊並做簡單處理
上一步只是代表接確定了配置文件的絕對路徑,這個文件並不一定是有效且存在的。
這一步會獲取到配置文件的輸出並簡單處理:
if(configFiles.length > 0) { var registerCompiler = function registerCompiler(moduleDescriptor) { // ... }; var requireConfig = function requireConfig(configPath) { // 獲取到modules.exports輸出的內容 var options = require(configPath); // 二次處理 options = prepareOptions(options, argv); return options; }; // 本例中configFiles => [{path:‘d:\\workspace\\node_modules\\webpack\\bin\\config.js‘,ext:‘.js‘}] configFiles.forEach(function(file) { // interpret.extensions[.js]為null // 這裏直接跳出 registerCompiler(interpret.extensions[file.ext]); // 這裏的options是convert-argv.js開頭聲明的數組 options.push(requireConfig(file.path)); }); // 代表配置文件成功加載 configFileLoaded = true; }
這裏的處理情況有兩個:
1、根據後綴名二次處理
2、將路徑傳進一個prepareOptions模塊處理
這個模塊內容十分簡單,可以看一下:
"use strict"; module.exports = function prepareOptions(options, argv) { argv = argv || {}; // 判斷是否通過export default輸出 options = handleExport(options); // 非數組 if(Array.isArray(options)) { options = options.map(_options => handleFunction(_options, argv)); } else { // 當options為函數時 options = handleFunction(options, argv); } return options; }; function handleExport(options) { const isES6DefaultExported = ( typeof options === "object" && options !== null && typeof options.default !== "undefined" ); options = isES6DefaultExported ? options.default : options; return options; } function handleFunction(options, argv) { if(typeof options === "function") { options = options(argv.env, argv); } return options; }
這裏針對多配置(數組)與單配置進行了處理,判斷了模塊輸出的方式(ES6、CMD)以及輸出的類型(對象、函數),最後返回處理後的配置對象並標記配置文件已被加載。
終極處理函數
接下來就是最後一個階段:
if(!configFileLoaded) { return processConfiguredOptions({}); } else if(options.length === 1) { return processConfiguredOptions(options[0]); } else { return processConfiguredOptions(options); } function processConfiguredOptions(options) { // 非法輸出類型 if(options === null || typeof options !== "object") { console.error("Config did not export an object or a function returning an object."); process.exit(-1); // eslint-disable-line } // promise檢測 if(typeof options.then === "function") { return options.then(processConfiguredOptions); } // export default檢測 if(typeof options === "object" && typeof options.default === "object") { return processConfiguredOptions(options.default); } // 數組 if(Array.isArray(options) && argv["config-name"]) { /* ... */ } // 數組 if(Array.isArray(options)) { /* ... */ } else { // 單配置 processOptions(options); } if(argv.context) { options.context = path.resolve(argv.context); } // 設置默認上下文為進程當前絕對路徑 if(!options.context) { options.context = process.cwd(); } // 跳過 if(argv.watch) { /* ... */ } if(argv["watch-aggregate-timeout"]) { /* ... */ } if(typeof argv["watch-poll"] !== "undefined") { /* ... */ } if(argv["watch-stdin"]) { /* ... */ } return options; }
這裏根據不同的情況傳入空對象、單配置對象、多配置數組。
在函數的開頭又再次檢測了合法性、promise、ES6模塊輸出方法,由於本例只有一個配置對象,所以直接進processOptions函數,這個函數很長,簡化後源碼如下:
function processOptions(options) { // 是否存在output.filename var noOutputFilenameDefined = !options.output || !options.output.filename; function ifArg(name, fn, init, finalize) { /* ... */ } function ifArgPair(name, fn, init, finalize) { /* ... */ } function ifBooleanArg(name, fn) { /* ... */ } function mapArgToBoolean(name, optionName) { /* ... */ } function loadPlugin(name) { /* ... */ } function ensureObject(parent, name) { /* ... */ } function ensureArray(parent, name) { /* ... */ }function bindRules(arg) { /* ... */ } bindRules("module-bind"); bindRules("module-bind-pre"); bindRules("module-bind-post"); var defineObject; // 中間穿插大量ifArgPair、ifArg、ifBooleanArg等 mapArgToBoolean("cache"); function processResolveAlias(arg, key) { /* ... */ } processResolveAlias("resolve-alias", "resolve"); processResolveAlias("resolve-loader-alias", "resolveLoader"); mapArgToBoolean("bail"); mapArgToBoolean("profile"); // 無輸出文件名配置 if (noOutputFilenameDefined) { /* ... */ } // 處理命令參數 if (argv._.length > 0) { /* ... */ } // 無入口文件配置 if (!options.entry) { /* ... */ } }
首先看一下裏面的工具函數:
1、ifArgpair、ifArg
function ifArgPair(name, fn, init, finalize) { // 直接進入ifArg函數 // content => argv[name]的數組元素 // idx => 索引 ifArg(name, function(content, idx) { // 字符"="索引 var i = content.indexOf("="); if (i < 0) { // 無等號的字符 return fn(null, content, idx); } else { // 傳入=號左邊與右邊的字符 return fn(content.substr(0, i), content.substr(i + 1), idx); } }, init, finalize); } // init => 構造函數 // finalize => 析構函數 function ifArg(name, fn, init, finalize) { if (Array.isArray(argv[name])) { if (init) { init(); } argv[name].forEach(fn); if (finalize) { finalize(); } } else if (typeof argv[name] !== "undefined" && argv[name] !== null) { if (init) { init(); } fn(argv[name], -1); if (finalize) { finalize(); } } }
2、ifBooleanArg
// 當argv[name]不為false時才執行fn函數 function ifBooleanArg(name, fn) { ifArg(name, function(bool) { if (bool) { fn(); } }); }
3、mapArgToBoolean
function mapArgToBoolean(name, optionName) { ifArg(name, function(bool) { // argv[name]為true時 if (bool === true) options[optionName || name] = true; // argv[name]為false時 else if (bool === false) options[optionName || name] = false; }); }
4、ensureObject、ensureArray
// 保證指定屬性為對象 function ensureObject(parent, name) { if (typeof parent[name] !== "object" || parent[name] === null) { parent[name] = {}; } } // 保證指定屬性為數組 function ensureArray(parent, name) { if (!Array.isArray(parent[name])) { parent[name] = []; } }
剩下的bindRules負責處理rules,loadPlugin負責加載插件,processResolveAlias處理別名,單獨分節講吧,不然這一節要長到令人發指。
因為配置文件只有entry和out,所以基本上大部分屬性都是undefined和false,在ifArg的if與else if中log能進入的name,發現只有4個:
對應的執行函數分別為:
// output參數 ifBooleanArg("output-pathinfo", function() { ensureObject(options, "output"); options.output.pathinfo = true; }); // 熱重載 ifBooleanArg("hot", function() { ensureArray(options, "plugins"); var HotModuleReplacementPlugin = require("../lib/HotModuleReplacementPlugin"); options.plugins.push(new HotModuleReplacementPlugin()); }); // 默認加載loaderOptionsPlugin插件 ifBooleanArg("debug", function() { ensureArray(options, "plugins"); var LoaderOptionsPlugin = require("../lib/LoaderOptionsPlugin"); options.plugins.push(new LoaderOptionsPlugin({ debug: true })); }); // 默認加載代碼壓縮插件 ifBooleanArg("optimize-minimize", function() { ensureArray(options, "plugins"); var UglifyJsPlugin = require("../lib/optimize/UglifyJsPlugin"); var LoaderOptionsPlugin = require("../lib/LoaderOptionsPlugin"); options.plugins.push(new UglifyJsPlugin({ // devtool參數 sourceMap: options.devtool && (options.devtool.indexOf("sourcemap") >= 0 || options.devtool.indexOf("source-map") >= 0) })); options.plugins.push(new LoaderOptionsPlugin({ minimize: true })); });
可以看到,webpack會默認加載3個插件,一個是處理loader的LoaderOptionsPlugin插件,一個是代碼壓縮插件UglifyJsPlugin,還有一個就是熱重載插件。3個插件的加載別的章節講,output-pathinfo在之前的config-yargs.js中被定義,默認值是false,而ifBooleanArg在傳入值為false時不會執行回調。
最後剩下3個代碼塊:
// 無輸出文件名配置 if (noOutputFilenameDefined) { /* ... */ } // 處理命令參數 if (argv._.length > 0) { /* ... */ } // 無入口文件配置 if (!options.entry) { /* ... */ }
由於指令沒有傳任何參數,所以argv._是一個空數組,中間的可以跳過。
所以只需要看其余兩個,首先看簡單的無入口文件配置的情況,即配置文件沒有entry屬性:
if (!options.entry) { // 存在配置文件 但是沒有入口函數 if (configFileLoaded) { console.error("Configuration file found but no entry configured."); } // 未找到配置文件 else { console.error("No configuration file found and no entry configured via CLI option."); console.error("When using the CLI you need to provide at least two arguments: entry and output."); console.error("A configuration file could be named ‘webpack.config.js‘ in the current directory."); } console.error("Use --help to display the CLI options."); // 退出進程 process.exit(-1); // eslint-disable-line }
可以看出這是必傳參數,根據是否找到對應的配置文件報不同的錯誤。
另一種情況是不存在ouput或output.filename屬性:
if (noOutputFilenameDefined) { ensureObject(options, "output"); // convertOptions來源於第三個參數 // module.exports = function(yargs, argv, convertOptions) {...} // var options = require("./convert-argv")(yargs, argv) // 只傳了兩個參數 所以跳過 if (convertOptions && convertOptions.outputFilename) { options.output.path = path.resolve(path.dirname(convertOptions.outputFilename)); options.output.filename = path.basename(convertOptions.outputFilename); } // 嘗試從命令參數獲取output.filename // 命令的最後一個參數會被當成入口文件名 else if (argv._.length > 0) { options.output.filename = argv._.pop(); options.output.path = path.resolve(path.dirname(options.output.filename)); options.output.filename = path.basename(options.output.filename); } // 老套的報錯 不解釋 else if (configFileLoaded) { throw new Error("‘output.filename‘ is required, either in config file or as --output-filename"); } else { console.error("No configuration file found and no output filename configured via CLI option."); console.error("A configuration file could be named ‘webpack.config.js‘ in the current directory."); console.error("Use --help to display the CLI options."); process.exit(-1); // eslint-disable-line } }
可以看出,output.filename雖然是必須的,但是不一定需要在配置文件中,有兩個方式可以傳入。
一個是作為convert-argv.js的第三個參數傳入,由於在之前解析時默認只傳了兩個,這裏會跳過,暫時不清楚傳入地點。
另外一個是在命令中傳入,測試代碼:
至此,該模塊全部解析完畢,輸出options如圖所示:
真是累……
.4-淺析webpack源碼之convert-argv模塊