RequireJS原始碼分析(上)

Shenfq發表於2017-12-25

requirejs作為AMD(Asynchronous Module Definition--非同步的模組載入機制)規範的實現,還是有必要看看的。初識requirejs原始碼,必須先弄清楚requirejs的模組是如何定義的,並且要知道入口在哪個地方,如果清楚了呼叫方式,看原始碼的時候才會覺得順暢。

在看原始碼的過程中,我新增了一些程式碼註釋。如果要檢視新增過註釋的原始碼,可以直接在我的github上進行fork。我這裡的原始碼是目前最新的版本2.3.5。另外附上requirejs官方的原始碼

我把requirejs一共分成了三個部分,這三個部分外面是一個閉包,並且兩個定義的全域性變數。

var requirejs, require, define;
(function (global, setTimeout) {
    //1、定義一些變數與工具方法
    var req, s, head ////some defined
    
    //add some function 
    
    //2、建立一個模組載入的上下文
    function newContext(contextName) {
        //somecode
        
        //定義一個模組載入器
        Module = function (map) {}
        Module.prototype = {
            //原型鏈上
        };
        
        context = { //上下文環境
            config: config, //配置
            contextName: contextName, //預設為 "_"
            nextTick: req.nextTick, //通過setTimeout,把執行放到下一個佇列
            makeRequire: function (relMap, options) {
                
                function localRequire () {
                    //somecode
                    //通過setTimeout的方式載入依賴,放入下一個佇列,保證載入順序
            		context.nextTick(function () {
            		
            			intakeDefines();
            
            			requireMod = getModule(makeModuleMap(null, relMap));
            			
            			requireMod.skipMap = options.skipMap;
            
            			requireMod.init(deps, callback, errback, {
            				enabled: true
            			});
            
            			checkLoaded();
            		});
            
            		return localRequire;
                }
                return localRequire;
            }
            //xxxx
		}
        context.require = context.makeRequire(); //載入時的入口函式
        return context;
    }
    
    //3、定義require、define方法,匯入data-main路徑與進行模組載入
    req = requirejs = function (deps, callback, errback, optional) {
        //xxxx
        
        context = getOwn(contexts, contextName);  //獲取預設環境
    	if (!context) {
    		context = contexts[contextName] = req.s.newContext(contextName); //建立一個名為'_'的環境名
    	}
    
    	if (config) {
    		context.configure(config);  //設定配置
    	}
    
    	return context.require(deps, callback, errback);
    }
    
    req.config = function (config) {
		return req(config);
	};
	
	s = req.s = {
		contexts: contexts,
		newContext: newContext
	};
	
    req({}); //初始化模組載入的上下文環境
    
    define = function (name, deps, callback) {
    
    }
    
    req(cfg); //載入data-main,主入口js
    
}(this, (typeof setTimeout === 'undefined' ? undefined : setTimeout)));
複製程式碼

上面的程式碼基本能看出requirejs的三個部分,中間省略了很多程式碼。看過大概結構之後,來跟著我一步一步的窺探requirejs是如何載入與定義模組的。

requirejs如何載入入口js

使用過requirejs的朋友都知道,我們會在引入requirejs的時候,在script標籤新增data-main屬性,作為配置和模組載入的入口。具體程式碼如下:

<script type="text/javascript" src="./require.js" data-main="./js/main.js"></script>
複製程式碼

requirejs先通過判斷當前是否為瀏覽器環境,如果是瀏覽器環境,就遍歷當前頁面上所有的script標籤,取出其中的data-main屬性,並通過計算,得到baseUrl和需要提前載入js的檔名。具體程式碼如下:

var isBrowser = !!(typeof window !== 'undefined' && typeof navigator !== 'undefined' && window.document);

function scripts() { //獲取頁面上所有的target標籤
    return document.getElementsByTagName('script');
}

function eachReverse(ary, func) {
    if (ary) {
        var i;
        for (i = ary.length - 1; i > -1; i -= 1) {
            if (ary[i] && func(ary[i], i, ary)) {
                break;
            }
        }
    }
}

if (isBrowser) {
    head = s.head = document.getElementsByTagName('head')[0];
    baseElement = document.getElementsByTagName('base')[0];
    if (baseElement) {
    	head = s.head = baseElement.parentNode;
    }
}
	
if (isBrowser && !cfg.skipDataMain) {
    eachReverse(scripts(), function (script) {  //遍歷所有的script標籤
    	//如果head標籤不存在,讓script標籤的父節點充當head
    	if (!head) {
    		head = script.parentNode;
    	}
    	
    	dataMain = script.getAttribute('data-main');
    	if (dataMain) {  //獲取data-main屬性(如果存在)
    		//儲存dataMain變數,防止轉換後任然是路徑 (i.e. contains '?')
    		mainScript = dataMain;
    		
    		//如果沒有指定明確的baseUrl,設定data-main屬性的路徑為baseUrl
    		//只有當data-main的值不為一個外掛的模組ID時才這樣做
    		if (!cfg.baseUrl && mainScript.indexOf('!') === -1) {
    			//取出data-main中的路徑作為baseUrl
    			src = mainScript.split('/'); //通過  /  符,進行路徑切割
    			mainScript = src.pop();  //拿出data-main中的js名
    			subPath = src.length ? src.join('/') + '/' : './';  //拼接父路徑,如果data-main只有一個路徑,則表示當前目錄
    			cfg.baseUrl = subPath;
    		}
    		
    		//去除js字尾,作模組名
    		mainScript = mainScript.replace(jsSuffixRegExp, '');
    		//如果mainScript依舊是一個路徑, 將mainScript重置為dataMain
    		
    		if (req.jsExtRegExp.test(mainScript)) {
    			mainScript = dataMain;
    		}
    		
    		//將data-main的模組名放入到deps陣列中
    		cfg.deps = cfg.deps ? cfg.deps.concat(mainScript) : [mainScript];
    
    		return true;
    	}
    });
}
複製程式碼

在進行過上述操作後,我們可以得到一個cfg物件,該物件包括兩個屬性baseUrl和deps。比如我們上面的案例中,script標籤有個屬性data-main="./js/main.js",經過requirejs的轉後,得到的cfg物件為:

cfg = {
    baseUrl: "./js/",
    deps: ["main"]
}
複製程式碼

拿到cfg物件後,requirejs呼叫了req方法:req(cfg);。req方法就是require方法,是整個requirejs的入口函式,相當於是一個分發裝置,進行引數型別的匹配,再來判斷當前是config操作還是require操作,並且在這個方法裡還會建立一個上下文環境,所有的模組載入和require相關的配置都會在這個上下文進行中進行。在呼叫req(cfg);之前,requirejs還呼叫了一次req方法:req({});,這一步操作就是為了建立模組載入的上下文。我們還在直接來看看req方法的原始碼吧:

//最開始定義的變數
var defContextName = '_', //預設載入的模組名
    contexts = {}; //模組載入的上下文環境的容器

req = requirejs = function (deps, callback, errback, optional) {
    //Find the right context, use default
    var context, config,
    	contextName = defContextName; //預設的上下文環境
    //引數修正
    // Determine if have config object in the call.
    if (!isArray(deps) && typeof deps !== 'string') {
    	// deps is a config object
    	config = deps;  //第一個引數如果不是陣列也不是字串表示為配置引數
    	if (isArray(callback)) {
    		// 調整引數,callback此時是deps
    		deps = callback;
    		callback = errback;
    		errback = optional;
    	} else {
    		deps = [];
    	}
    }
    
    if (config && config.context) {
    	contextName = config.context;
    }
    
    context = getOwn(contexts, contextName);  //獲取預設環境
    if (!context) { //如果是第一次進入,呼叫newContext方法進行建立
    	context = contexts[contextName] = req.s.newContext(contextName); //建立一個名為'_'的環境名
    }
    
    if (config) {
    	context.configure(config);  //設定配置
    }
    
    //如果只是載入配置,deps、callback、errback這幾個引數都是空,那麼呼叫require方法什麼都不會發生
    return context.require(deps, callback, errback); //最後呼叫context中的require方法,進行模組載入
};

req.config = function (config) {
    return req(config); //require.config方法最終也是呼叫req方法
};

if (!require) {  //require方法就是req方法
    require = req;
}

s = req.s = {
    contexts: contexts,
    newContext: newContext //建立新的上下文環境
};
複製程式碼

繼續按照之前req(cfg);的邏輯來走,根據傳入的cfg,會呼叫context.configure(config);,而這個context就是之前說的requirejs三部分中的第二個部分的newContext函式建立的,建立得到的context物件會放入全域性的contexts物件中。我們可以在控制檯列印contexts物件,看到裡面其實只有一個名為'_'的context,這是requrejs預設指定的上下文。

上下文物件

newContext函式中有許多的區域性變數用來快取一些已經載入的模組,還有一個模組載入器(Module),這個後面都會用到。還是先看呼叫的configure方法:

function newContext (contextName) {
    var context, config = {};
    
    context = {
        configure: function (cfg) {
            //確保baseUrl以 / 結尾
            if (cfg.baseUrl) { 
            	//所有模組的根路徑,
            	//預設為requirejs的檔案所在路徑,
            	//如果設定了data-main,則與data-main一致
            	if (cfg.baseUrl.charAt(cfg.baseUrl.length - 1) !== '/') {
            		cfg.baseUrl += '/';
            	}
            }
            
            //其他程式碼,用於新增一些替他配置,與本次載入無關
            
            //如果配置項裡指定了deps或者callback, 則呼叫require方法
            //如果實在requirejs載入之前,使用require定義物件作為配置,這很有用
            if (cfg.deps || cfg.callback) {
            	context.require(cfg.deps || [], cfg.callback);
            }
        },
        makeRequire: function (relMap, options) {
            
        }
    }
    
    return context;
} 
複製程式碼

這個方法主要是用來做配置,在我們傳入的cfg引數中其實並不包含requirejs的主要配置項,但是在最後因為有deps屬性,邏輯能繼續往下走,呼叫了require方法:context.require(cfg.deps);。上面的程式碼中能看出,context的require方法是使用makeRequire建立的,這裡之所以用makeRequire來建立require方法,主要使用建立一個函式作用域來儲存,方便為require方法擴充一些屬性。

context = {
    makeRequire: function (relMap, options) {
        options = options || {};
        function localRequire(deps, callback, errback) { //真正的require方法
        	var id, map, requireMod;
        
        	if (options.enableBuildCallback && callback && isFunction(callback)) {
        		callback.__requireJsBuild = true;
        	}
        
        	if (typeof deps === 'string') {
        		//如果deps是個字串,而不是個陣列,進行一些其他處理
        	}
        	
        	intakeDefines();
        
        	//通過setTimeout的方式載入依賴,放入下一個佇列,保證載入順序
        	context.nextTick(function () {
        		intakeDefines();
        
        		requireMod = getModule(makeModuleMap(null, relMap));
        		
        		requireMod.skipMap = options.skipMap;
        
        		requireMod.init(deps, callback, errback, {
        			enabled: true
        		});
        
        		checkLoaded();
        	});
        
        	return localRequire;
        }
        //mixin型別與extend方法,對一個物件進行屬性擴充套件
        mixin(localRequire, {
            isBrowser,
            isUrl,
            defined,
            specified
        });
        
        return localRequire;
    }
};
context.require = context.makeRequire(); //載入時的入口函式
複製程式碼

最初我是使用打斷點的方式來閱讀原始碼的,每次在看到context.nextTick的之後,就沒有往下進行了,百思不得其解。然後我看了看nextTick到底是用來幹嘛的,發現這個方法其實就是個定時器。

context = {
    nextTick: req.nextTick, //通過setTimeout,把執行放到下一個佇列
};
req.nextTick = typeof setTimeout !== 'undefined' ? function (fn) {
	setTimeout(fn, 4);
} : function (fn) { fn(); };
複製程式碼

我也很費解,為什麼要把一些主邏輯放入到一個定時器中,這樣所有的載入都會放到下一個任務佇列進行。檢視了requirejs的版本迭代,發現nextTick是在2.10這個版本加入的,之前也沒有這個邏輯。 而且就算我把requirejs原始碼中的nextTick這段邏輯去除,程式碼也能正常執行。

去除nextTick

tips:
這裡的setTimeout之所以設定為4ms,是因為html5規範中規定了,setTimeout的最小延遲時間(DOM_MIN_TIMEOUT_VALUE)時,這個時間就是4ms。但是在2010年之後,所有瀏覽器的實現都遵循這個規定,2010年之前為10ms。

html5相關規範

後來參考了網路上其他部落格的一些想法,有些人認為設定setTimeout來載入模組是為了讓模組的載入是按照順序執行的,這個目前我也沒研究透徹,先設個todo在這裡,哈哈哈

終於在requirejs的wiki上看到了相關文件,官方說法是為了讓模組的載入非同步化,為了防止一些細微的bug(具體是什麼bug,還不是很清楚)。

requirejs wiki

好了,還是繼續來看requirejs的原始碼吧。在nextTick中,首先使用makeModuleMap來構造了一個模組對映, 然後立刻通過getModule新建了一個模組載入器。

//requireMod = getModule(makeModuleMap(null, relMap));  //nextTick中的程式碼

//建立模組對映
function makeModuleMap(name, parentModuleMap, isNormalized, applyMap) {
    var url, pluginModule, suffix, nameParts,
    	prefix = null,
    	parentName = parentModuleMap ? parentModuleMap.name : null,
    	originalName = name,
    	isDefine = true, //是否是define的模組
    	normalizedName = '';
    
    //如果沒有模組名,表示是require呼叫,使用一個內部名
    if (!name) {
    	isDefine = false;
    	name = '_@r' + (requireCounter += 1);
    }
    
    nameParts = splitPrefix(name);
    prefix = nameParts[0];
    name = nameParts[1];
    
    if (prefix) { //如果有外掛字首
    	prefix = normalize(prefix, parentName, applyMap);
    	pluginModule = getOwn(defined, prefix); //獲取外掛
    }
    
    if (name) {
        //對name再進行一些特殊處理
    }
    
    return {
        prefix: prefix,
        name: normalizedName,
        parentMap: parentModuleMap,
        unnormalized: !!suffix,
        url: url,
        originalName: originalName,
        isDefine: isDefine,
        id: (prefix ?
            prefix + '!' + normalizedName :
            normalizedName) + suffix
    };
}

//獲取一個模組載入器
function getModule(depMap) {
    var id = depMap.id,
        mod = getOwn(registry, id);
    
    if (!mod) { //對未註冊模組,新增到模組註冊器中
        mod = registry[id] = new context.Module(depMap);
    }
    
    return mod;
}

//模組載入器
Module = function (map) {
    this.events = getOwn(undefEvents, map.id) || {};
    this.map = map;
    this.shim = getOwn(config.shim, map.id);
    this.depExports = [];
    this.depMaps = [];
    this.depMatched = [];
    this.pluginMaps = {};
    this.depCount = 0;
    
    /* this.exports this.factory
       this.depMaps = [],
       this.enabled, this.fetched
    */
};

Module.prototype = {
    init: function () {},
    fetch: function () {},
    load: function () {},
    callPlugin: function () {},
    defineDep: function () {},
    check: function () {},
    enable: function () {},
    on: function () {},
    emit: function () {}
};
複製程式碼
requireMod.init(deps, callback, errback, {
	enabled: true
});
複製程式碼

拿到建立的模組載入器之後,立即呼叫了init方法。init方法中又呼叫了enable方法,enable方法中為所有的depMap又重新建立了一個模組載入器,並呼叫了依賴項的模組載入器的enable方法,最後呼叫check方法,check方法又馬上呼叫了fetch方法,fatch最後呼叫的是load方法,load方法迅速呼叫了context.load方法。千言萬語不如畫張圖。

Module模組載入

確實這一塊的邏輯很繞,中間每個方法都對一些作用域內的引數有一些修改,先只瞭解大致流程,後面慢慢講。 這裡重點看下req.load方法,這個方法是所有模組進行載入的方法。

req.createNode = function (config, moduleName, url) {
    var node = config.xhtml ?
        document.createElementNS('http://www.w3.org/1999/xhtml', 'html:script') :
        document.createElement('script');
    node.type = config.scriptType || 'text/javascript';
    node.charset = 'utf-8';
    node.async = true; //建立script標籤新增了async屬性
    return node;
};
req.load = function (context, moduleName, url) { //用來進行js模組載入的方法
    var config = (context && context.config) || {},
    	node;
    if (isBrowser) { //在瀏覽器中載入js檔案
    
        node = req.createNode(config, moduleName, url); //建立一個script標籤
        
        node.setAttribute('data-requirecontext', context.contextName); //requirecontext預設為'_'
        node.setAttribute('data-requiremodule', moduleName); //當前模組名
        
        if (node.attachEvent &&
            !(node.attachEvent.toString && node.attachEvent.toString().indexOf('[native code') < 0) &&
            !isOpera) {
            
            useInteractive = true;
            
            node.attachEvent('onreadystatechange', context.onScriptLoad);
        } else {
            node.addEventListener('load', context.onScriptLoad, false);
            node.addEventListener('error', context.onScriptError, false);
        }
        node.src = url;
        
        if (config.onNodeCreated) { //script標籤建立時的回撥
            config.onNodeCreated(node, config, moduleName, url);
        }
        
        currentlyAddingScript = node;
        if (baseElement) { //將script標籤新增到頁面中
            head.insertBefore(node, baseElement);
        } else {
            head.appendChild(node);
        }
        currentlyAddingScript = null;
        
        return node;
    } else if (isWebWorker) { //在webWorker環境中
    	try {
            setTimeout(function () { }, 0);
            importScripts(url); //webWorker中使用importScripts來載入指令碼
            
            context.completeLoad(moduleName);
    	} catch (e) { //載入失敗
            context.onError(makeError('importscripts',
                'importScripts failed for ' +
                moduleName + ' at ' + url,
                e,
                [moduleName]));
    	}
    }
};
複製程式碼

requirejs載入模組的方式是通過建立script標籤進行載入,並且將建立的script標籤插入到head中。而且還支援在webwork中使用,在webWorker使用importScripts()來進行模組的載入。

最後可以看到head標籤中多了個script:

require執行之後的head標籤

使用define定義一個模組

requirejs提供了模組定義的方法:define,這個方法遵循AMD規範,其使用方式如下:

define(id?, dependencies?, factory);
複製程式碼

define三個引數的含義如下:

  1. id表示模組名,可以忽略,如果忽略則定義的是匿名模組;
  2. dependencies表示模組的依賴項,是一個陣列;
  3. factory表示模組定義函式,函式的return值為定義模組,如果有dependencies,該函式的引數就為這個陣列的每一項,類似於angularjs的依賴注入。

factory也支援commonjs的方式來定義模組,如果define沒有傳入依賴陣列,factory會預設傳入三個引數require, exports, module。 沒錯,這三個引數與commonjs對應的載入方式保持一致。require用來引入模組,exports和module用來匯出模組。

//寫法1:
define(
    ['dep1'],
    function(dep1){
        var mod;
        //...
        
        return mod;
    }
);

//寫法2:
define(
    function (require, exports, module) {
        var dep1 = require('dep1'), mod;

        //...
           
        exports = mod;
    }

});
複製程式碼

廢話不多說,我們還是直接來看原始碼吧!

/**
 * 用來定義模組的函式。與require方法不同,模組名必須是第一個引數且為一個字串,
 * 模組定義函式(callback)必須有一個返回值,來對應第一個參數列示的模組名
 */
define = function (name, deps, callback) {
	var node, context;

	//執行匿名模組
	if (typeof name !== 'string') {
		//引數的適配
		callback = deps;
		deps = name;
		name = null;
	}

	//這個模組可以沒有依賴項
	if (!isArray(deps)) {
		callback = deps;
		deps = null;
	}

	//如果沒有指定名字,並且callback是一個函式,使用commonJS形式引入依賴
	if (!deps && isFunction(callback)) {
		deps = [];
		//移除callback中的註釋,
		//將callback中的require取出,把依賴項push到deps陣列中。
		//只在callback傳入的引數不為空時做這些
		if (callback.length) { //將模組的回撥函式轉成字串,然後進行一些處理
			callback
				.toString()
				.replace(commentRegExp, commentReplace) //去除註釋
				.replace(cjsRequireRegExp, function (match, dep) {
					deps.push(dep); //匹配出所有呼叫require的模組
				});

			//相容CommonJS寫法
			deps = (callback.length === 1 ? ['require'] : ['require', 'exports', 'module']).concat(deps);
		}
	}

	//If in IE 6-8 and hit an anonymous define() call, do the interactive
	//work.
	if (useInteractive) { //ie 6-8 進行特殊處理
		node = currentlyAddingScript || getInteractiveScript();
		if (node) {
			if (!name) {
				name = node.getAttribute('data-requiremodule');
			}
			context = contexts[node.getAttribute('data-requirecontext')];
		}
	}

	//如果存在context將模組放到context的defQueue中,不存在contenxt,則把定義的模組放到全域性的依賴佇列中
	if (context) {
		context.defQueue.push([name, deps, callback]);
		context.defQueueMap[name] = true;
	} else {
		globalDefQueue.push([name, deps, callback]);
	}
};
複製程式碼

通過define定義模組最後都會放入到globalDefQueue陣列中,當前上下文的defQueue陣列中。具體怎麼拿到定義的這些模組是使用takeGlobalQueue來完成的。


/**
 * 內部方法,把globalQueue的依賴取出,放到當前上下文的defQueue中
 */
function intakeDefines() { //獲取並載入define方法新增的模組
	var args;

	//取出所有define方法定義的模組(放在globalqueue中)
	takeGlobalQueue();

	//Make sure any remaining defQueue items get properly processed.
	while (defQueue.length) {
		args = defQueue.shift();
		if (args[0] === null) {
			return onError(makeError('mismatch', 'Mismatched anonymous define() module: ' +
				args[args.length - 1]));
		} else {
			//args are id, deps, factory. Should be normalized by the
			//define() function.
			callGetModule(args);
		}
	}
	context.defQueueMap = {};
}

function takeGlobalQueue() {
	//將全域性的DefQueue新增到當前上下文的DefQueue
	if (globalDefQueue.length) {
		each(globalDefQueue, function (queueItem) {
			var id = queueItem[0];
			if (typeof id === 'string') {
				context.defQueueMap[id] = true;
			}
			defQueue.push(queueItem);
		});
		globalDefQueue = [];
	}
}

//intakeDefines()方法是在makeRequire中呼叫的
makeRequire: function (relMap, options) { //用於構造require方法
    options = options || {};
    
    function localRequire(deps, callback, errback) { //真正的require方法
    
        intakeDefines();
        
        context.nextTick(function () {
			//Some defines could have been added since the
			//require call, collect them.
			intakeDefines();
		}
    }
}

//同時依賴被載入完畢的時候也會呼叫takeGlobalQueue方法
//之前我們提到requirejs是向head頭中insert一個script標籤的方式載入模組的
//在載入模組的同時,為script標籤繫結了一個load事件
node.addEventListener('load', context.onScriptLoad, false);

//這個事件最後會呼叫completeLoad方法
onScriptLoad: function (evt) {
	if (evt.type === 'load' ||
		(readyRegExp.test((evt.currentTarget || evt.srcElement).readyState))) {
		var data = getScriptData(evt);
		context.completeLoad(data.id);
	}
}

completeLoad: function (moduleName) {
    var found;
    takeGlobalQueue();//獲取載入的js中進行define的模組
	while (defQueue.length) {
		args = defQueue.shift();
		if (args[0] === null) {
			args[0] = moduleName;
			
			if (found) {
				break;
			}
			found = true;
		} else if (args[0] === moduleName) {
			found = true;
		}

		callGetModule(args);
	}
	context.defQueueMap = {};
}

複製程式碼

無論是通過require的方式拿到defie定義的模組,還是在依賴載入完畢後,通過scriptLoad事件拿到定義的模組,這兩種方式最後都使用callGetModule()這個方法進行模組載入。下面我們還是詳細看看callGetModule之後,都發生了哪些事情。

function callGetModule(args) {
	//跳過已經載入的模組
	if (!hasProp(defined, args[0])) {
		getModule(makeModuleMap(args[0], null, true)).init(args[1], args[2]);
	}
}
複製程式碼

其實callGetModule方法就是呼叫了getModule方法(之前已經介紹過了),getModule方法返回一個Module(模組載入器)例項,最後呼叫例項的init方法。init方法會呼叫check方法,在check方法裡會執行define方法所定義的factory,最後將模組名與模組儲存到defined全域性變數中。

exports = context.execCb(id, factory, depExports, exports);
defined[id] = exports;
複製程式碼

到這裡定義模組的部分已經結束了。這篇文章先寫到這兒,這裡只理清了模組的定義和requirejs的初次載入還有requirejs的入口js是如何引入的,這一部分很多細節都沒有講到。自己挖個坑在這兒,下一部分會深入講解Module模組載入器的構成,還有require方法是如何引入依賴的。

下期再見。

原文連結

相關文章