zoukankan      html  css  js  c++  java
  • .4-浅析webpack源码之convert-argv模块

      上一节看了一眼预编译的总体代码,这一节分析convert-argv模块。

      这个模块主要是对命令参数的解析,也是yargs框架的核心用处。

     

    生成默认配置文件名数组

    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属性是否存在,这个属性来源于参数d与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) { /* ... */ }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) { /* ... */ }
    }

      首先看一下里面的工具函数,区别了不同参数类型的命令。

      指令分类如下:

      ifArg:基本处理函数

      ifArgpair:命令参数存在键值对形式

      ifBooleanArg:无参命令

      mapArgToBoolean:命令参数为布尔类型

      (这里面的argv[name]均代表一个对应的指令,如:argv["entry"]代表--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) {
            if (bool === true)
                options[optionName || name] = true;
            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] = [];
        }
    }

    5、bindRules

    function bindRules(arg) {
        // 指令可以是a=b 也可以是单独的a
        ifArgPair(arg, function(name, binding) {
            // 没有等号的时候
            if(name === null) {
                name = binding;
                binding += "-loader";
            }
            // 生成对应的test正则与loader
            var rule = {
                test: new RegExp("\." + name.replace(/[-[]/{}()*+?.\^$|]/g, "\$&") + "$"), // eslint-disable-line no-useless-escape
                loader: binding
            };
            // 生成前置或后置loader
            if(arg === "module-bind-pre") {
                rule.enforce = "pre";
            } else if(arg === "module-bind-post") {
                rule.enforce = "post";
            }
            options.module.rules.push(rule);
        }, function() {
            ensureObject(options, "module");
            ensureArray(options.module, "rules");
        });
    }
    bindRules("module-bind");
    bindRules("module-bind-pre");
    bindRules("module-bind-post");

      后面的bindRules可以看出如果要在命令中引入loader,可以使用module-bind、module-bind-pre、module-bind-post三个参数。

      该指令参数一般用“=”号连接需要转换的文件类型与对应的loader,测试案例如下:

      

      等号两侧的字符串会变成name与binding传入函数中,并自动生成对应的test、loader并push进module.rules中。

      也可以用没有等号的字符串,此时name默认为该字符串,loader会在后面加一个-loader,测试代码如下:

      

      至于其余两个pre、post没啥讲的。

    6、loadPlugin

    function loadPlugin(name) {
        var loadUtils = require("loader-utils");
        var args;
        try {
            var p = name && name.indexOf("?");
            if(p > -1) {
                // 解析参数
                args = loadUtils.parseQuery(name.substring(p));
                name = name.substring(0, p);
            }
        } catch(e) {
            console.log("Invalid plugin arguments " + name + " (" + e + ").");
            process.exit(-1); // eslint-disable-line
        }
    
        var path;
        try {
            var resolve = require("enhanced-resolve");
            // 尝试获取插件模块的绝对路径
            path = resolve.sync(process.cwd(), name);
        } catch(e) {
            console.log("Cannot resolve plugin " + name + ".");
            process.exit(-1); // eslint-disable-line
        }
        var Plugin;
        try {
            // 加载模块
            Plugin = require(path);
        } catch(e) {
            console.log("Cannot load plugin " + name + ". (" + path + ")");
            throw e;
        }
        try {
            // 返回插件实例
            return new Plugin(args);
        } catch(e) {
            console.log("Cannot instantiate plugin " + name + ". (" + path + ")");
            throw e;
        }
    }

      这里的步骤比较清晰,如下:

    1、判断传入参数是否形式类似于pluginname?params,对后面的参数进行解析

    2、尝试获取插件的绝对路径

    3、尝试加载模块

    4、尝试调用new方法并返回模块实例

      参数解析用到了loadUtils模块的parseQuery方法,这里进去看一下源码:

    const specialValues = {
        "null": null,
        "true": true,
        "false": false
    };
    
    function parseQuery(query) {
        // 传入的query字符串必须以?开头
        if(query.substr(0, 1) !== "?") {
            throw new Error("A valid query string passed to parseQuery should begin with '?'");
        }
        query = query.substr(1);
        // 如果只传一个问号返回空对象
        if(!query) {
            return {};
        }
        // ?{...}的情况
        // 调用JSON5尝试进行对象解析
        // JSON5是对JSON的扩展
        if(query.substr(0, 1) === "{" && query.substr(-1) === "}") {
            return JSON5.parse(query);
        }
        // 其余情况切割,或&符号
        const queryArgs = query.split(/[,&]/g);
        const result = {};
        queryArgs.forEach(arg => {
            const idx = arg.indexOf("=");
            // 类似于处理get请求的参数 例如:?a=1&b=2
            if(idx >= 0) {
                let name = arg.substr(0, idx);
                // decodeURIComponent对URI进行解码
                let value = decodeURIComponent(arg.substr(idx + 1));
                // 将null、true、false字符串转换为值
                if(specialValues.hasOwnProperty(value)) {
                    value = specialValues[value];
                }
                // key以[]结尾
                if(name.substr(-2) === "[]") {
                    // 截取key并设置值为数组
                    name = decodeURIComponent(name.substr(0, name.length - 2));
                    if(!Array.isArray(result[name]))
                        result[name] = [];
                    result[name].push(value);
                }
                // 正常情况直接在result对象上添加属性
                else {
                    name = decodeURIComponent(name);
                    result[name] = value;
                }
            } else {
                // ?-a&+b&c => result = {a:false,b:true,c:true}
                if(arg.substr(0, 1) === "-") {
                    result[decodeURIComponent(arg.substr(1))] = false;
                } else if(arg.substr(0, 1) === "+") {
                    result[decodeURIComponent(arg.substr(1))] = true;
                } else {
                    result[decodeURIComponent(arg)] = true;
                }
            }
        });
        return result;
    }

      除去不合理的传参,可以用两种模式进行传参:

    1、正常模式:?a&a=1&-a&+b&a[]=1

      前缀为"-"、"+"会在else被处理,"-"符号开头值会被视为false,无前缀或者为"+"会被视为true。

      类似于get请求参数会被一样处理,进行字符串切割并依次添加进result对象。

      最后一种比较特殊,代表参数a是一个数组,学过JAVA或者C++应该会熟悉这种声明方式。

    2、JSON模式:?{...}

      以"{"开头"}"结尾会被进行JSON解析,注意这里不是普通的JSON.parse,而是引入了一个JSON的扩展JSON5,该工具相对于JSON扩展了多项功能,例如:

    (1)JSON不允许有注释

    (2)JSON中的key必须要用双引号包起来

    (3)JSON对象、数组尾部不允许出现多余的逗号

      等等。

      详情可见:https://www.npmjs.com/package/json5

      测试代码如下:

    普通模式: 

    JSON模式:

    7、processResolveAlias

    function processResolveAlias(arg, key) {
        ifArgPair(arg, function(name, value) {
            // 必须以a=1这种键值对形式进行传参
            if(!name) {
                throw new Error("--" + arg + " <string>=<string>");
            }
            /** 
             * resolve:{
             *     alias:{
             *      
             *     }  
             * }
             */
            ensureObject(options, key);
            ensureObject(options[key], "alias");
            options[key].alias[name] = value;
        });
    }
    processResolveAlias("resolve-alias", "resolve");
    processResolveAlias("resolve-loader-alias", "resolveLoader");

      这里处理--resolve-alias指令与resolve-loader-alias指令,该指令参数必须严格按照a=b形式。

      测试代码如下:

      

      因为配置文件只有entry和output,所以属性都是undefined或false,都会跳过。

      这里简单看几个常用的:

    // 热重载
    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
        }));
    });

      可以看到,使用--hot、--debug、--optimize-minimize指令会分别加载3个插件,一个是处理loader中Options属性的LoaderOptionsPlugin插件,一个是代码压缩插件UglifyJsPlugin,还有一个就是热重载插件,3个插件后面的章节有空再讲。所有属性在之前的config-yargs中被配置,但是默认值为false,而ifBooleanArg在传入值为false时不会执行回调,所以这里并不是加载任何东西。

      其他还有很多指令类似于--output-path可以设置output.path参数等等,有兴趣的可以自己去源码看。 

      最后剩下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如图所示:

      

      真是累……

  • 相关阅读:
    Ztree-
    富文本编辑器Ueditor
    通知 弹框
    ResultEntity
    echart
    定时器,定时发邮件JavaMail
    重定向传值
    图片验证码
    异步json发送put或者delete
    异步时间格式转换插件
  • 原文地址:https://www.cnblogs.com/QH-Jimmy/p/8023612.html
Copyright © 2011-2022 走看看