RequireJS源碼分析(上)

requirejs做爲AMD(Asynchronous Module Definition--異步的模塊加載機制)規範的實現,仍是有必要看看的。初識requirejs源碼,必須先弄清楚requirejs的模塊是如何定義的,而且要知道入口在哪一個地方,若是清楚了調用方式,看源碼的時候纔會以爲順暢。javascript

在看源碼的過程當中,我添加了一些代碼註釋。若是要查看添加過註釋的源碼,能夠直接在個人github上進行fork。我這裏的源碼是目前最新的版本2.3.5。另外附上requirejs官方的源碼html

我把requirejs一共分紅了三個部分,這三個部分外面是一個閉包,而且兩個定義的全局變量。html5

var requirejs, require, define;
(function (global, setTimeout) {
    //一、定義一些變量與工具方法
    var req, s, head ////some defined
    
    //add some function 
    
    //二、建立一個模塊加載的上下文
    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;
    }
    
    //三、定義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是如何加載與定義模塊的。java

requirejs如何加載入口js

使用過requirejs的朋友都知道,咱們會在引入requirejs的時候,在script標籤添加data-main屬性,做爲配置和模塊加載的入口。具體代碼以下:node

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

requirejs先經過判斷當前是否爲瀏覽器環境,若是是瀏覽器環境,就遍歷當前頁面上全部的script標籤,取出其中的data-main屬性,並經過計算,獲得baseUrl和須要提早加載js的文件名。具體代碼以下:git

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對象爲:angularjs

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

拿到cfg對象後,requirejs調用了req方法:req(cfg);。req方法就是require方法,是整個requirejs的入口函數,至關因而一個分發裝置,進行參數類型的匹配,再來判斷當前是config操做仍是require操做,而且在這個方法裏還會建立一個上下文環境,全部的模塊加載和require相關的配置都會在這個上下文進行中進行。在調用req(cfg);以前,requirejs還調用了一次req方法:req({});,這一步操做就是爲了建立模塊加載的上下文。咱們還在直接來看看req方法的源碼吧:github

//最開始定義的變量
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默認指定的上下文。web

上下文對象

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方法是如何引入依賴的。

下期再見。

原文連接

相關文章
相關標籤/搜索