從 RequireJs 源碼剖析腳本載入原理
引言
俗話說的好,不喜歡研究原理的程序員不是好的程序員,不喜歡讀源碼的程序員不是好的 jser。這兩天看到了有關前端模塊化的問題,才發現 JavaScript 社區為了前端工程化真是煞費苦心。今天研究了一天前端模塊化的問題,先是大概了解了下模塊化的標準規範,然後了解了一下 RequireJs 的語法和使用方法,最後研究了下 RequireJs 的設計模式和源碼,所以想記錄一下相關的心得,剖析一下模塊載入的原理。
一、認識 RequireJs在開始之前,我們需要了解前端模塊化,本文不討論有關前端模塊化的問題,有關這方面的問題可以參考阮一峰的系列文章 Javascript 模塊化編程。
使用 RequireJs 的第一步:前往官網 http://requirejs.org/;
第二步:下載文件;
第三步:在頁面中引入 requirejs.js 並設置 main 函數;
1
然後我們就可以在 main.js 文件里編程了,requirejs 採用了 main 函數式的思想,一個文件即為一個模塊,模塊與模塊之間可以依賴,也可以毫無干係。使用 requirejs ,我們在編程時就不必將所有模塊都引入頁面,而是需要一個模塊,引入一個模塊,就相當於 Java 當中的 import 一樣。
定義模塊:
1 //直接定義一個對象
2 define({
3 color: "black",
4 size: "unisize"
5 });
6 //通過函數返回一個對象,即可以實現 IIFE
7 define(function {
8 //Do setup work here
9
10 return {
11 color: "black",
12 size: "unisize"
13 }
14 });
15 //定義有依賴項的模塊
16 define(["./cart", "./inventory"], function(cart, inventory) {
17 //return an object to define the "my/shirt" module.
18 return {
19 color: "blue",
20 size: "large",
21 addToCart: function {
22 inventory.decrement(this);
23 cart.add(this);
24 }
25 }
26 }
27 );
導入模塊:
1 //導入一個模塊
2 require(["foo"], function(foo) {
3 //do something
4 });
5 //導入多個模塊
6 require(["foo", "bar"], function(foo, bar) {
7 //do something
8 });
關於 requirejs 的使用,可以查看官網 API ,也可以參考 RequireJS 和 AMD 規範,本文暫不對 requirejs 的使用進行講解。
二、main 函數入口requirejs 的核心思想之一就是使用一個規定的函數入口,就像 C++ 的 int main,Java 的 public static void main,requirejs 的使用方式是把 main 函數緩存在 script 標籤上。也就是將腳本文件的 url 緩存在 script 標籤上。
初來乍到電腦同學一看,哇!script 標籤難道還有什麼不為人知的屬性嗎?嚇得我趕緊打開了 W3C 查看相關 API,並為自己的 HTML 基礎知識感到慚愧,可是遺憾的是 script 標籤並沒有相關的屬性,甚至這都不是一個標準的屬性,那麼它到底是什麼玩意呢?下面直接上一部分 requirejs 源碼:
1 //Look for a data-main attribute to set main script for the page
2 //to load. If it is there, the path to data main becomes the
3 //baseUrl, if it is not already set.
4 dataMain = script.getAttribute("data-main");
實際上在 requirejs 中只是獲取在 script 標籤上緩存的數據,然後取出數據載入而已,也就是跟動態載入腳本是一樣的,具體是怎麼操作,在下面的講解中會放出源碼。
三、動態載入腳本這一部分是整個 requirejs 的核心,我們知道在 Node.js 中載入模塊的方式是同步的,這是因為在伺服器端所有文件都存儲在本地的硬碟上,傳輸速率快而且穩定。而換做了瀏覽器端,就不能這麼幹了,因為瀏覽器載入腳本會與伺服器進行通信,這是一個未知的請求,如果使用同步的方式載入,就可能會一直阻塞下去。為了防止瀏覽器的阻塞,我們要使用非同步的方式載入腳本。因為是非同步載入,所以與模塊相依賴的操作就必須得在腳本載入完成後執行,這裡就得使用回調函數的形式。
我們知道,如果顯示的在 HTML 中定義腳本文件,那麼腳本的執行順序是同步的,比如:
1 //module1.js
2 console.log("module1");
1 //module2.js
2 console.log("module2");
1 //module3.js
2 console.log("module3");
1
2
3
那麼在瀏覽器端總是會輸出:
但是如果是動態載入腳本的話,腳本的執行順序是非同步的,而且不光是非同步的,還是無序的:
1 //main.js
2 console.log("main start");
3
4 var script1 = document.createElement("script");
5 script1.src = "scripts/module/module1.js";
6 document.head.appendChild(script1);
7
8 var script2 = document.createElement("script");
9 script2.src = "scripts/module/module2.js";
10 document.head.appendChild(script2);
11
12 var script3 = document.createElement("script");
13 script3.src = "scripts/module/module3.js";
14 document.head.appendChild(script3);
15
16 console.log("main end");
使用這種方式載入腳本會造成腳本的無序載入,瀏覽器按照先來先運行的方法執行腳本,如果 module1.js 文件比較大,那麼極其有可能會在 module2.js 和 module3.js 後執行,所以說這也是不可控的。要知道一個程序當中最大的 BUG 就是一個不可控的 BUG ,有時候它可能按順序執行,有時候它可能亂序,這一定不是我們想要的。
注意這裡的還有一個重點是,"module" 的輸出永遠會在 "main end" 之後。這正是動態載入腳本非同步性的特徵,因為當前的腳本是一個 task,而無論其他腳本的載入速度有多快,它都會在Event Queue的後面等待調度執行。這裡涉及到一個關鍵的知識 — Event Loop ,如果你還對 JavaScript Event Loop 不了解,那麼請先閱讀這篇文章深入理解 JavaScript 事件循環(一)— Event Loop。
四、導入模塊原理在上一小節,我們了解到,使用動態載入腳本的方式會使腳本無序執行,這一定是軟體開發的噩夢,想像一下你的模塊之間存在上下依賴的關係,而這時候他們的載入順序是不可控的。動態載入同時也具有非同步性,所以在 main.js 腳本文件中根本無法訪問到模塊文件中的任何變數。那麼 requirejs 是如何解決這個問題的呢?我們知道在 requirejs 中,任何文件都是一個模塊,一個模塊也就是一個文件,包括主模塊 main.js,下面我們看一段 requirejs 的源碼:
1 /**
2 * Creates the node for the load command. Only used in browser envs.
3 */
4 req.createNode = function (config, moduleName, url) {
5 var node = config.xhtml ?
6 document.createElementNS("http://www.w3.org/1999/xhtml", "html:script") :
7 document.createElement("script");
8 node.type = config.scriptType || "text/javascript";
9 node.charset = "utf-8";
10 node.async = true;
11 return node;
12 };
在這段代碼中我們可以看出, requirejs 導入模塊的方式實際就是創建腳本標籤,一切的模塊都需要經過這個方法創建。那麼 requirejs 又是如何處理非同步載入的呢?傳說江湖上最高深的醫術不是什麼靈丹妙藥,而是以毒攻毒,requirejs 也深得其精髓,既然動態載入是非同步的,那麼我也用非同步來對付你,使用 onload 事件來處理回調函數:
1 //In the browser so use a script tag
2 node = req.createNode(config, moduleName, url);
3
4 node.setAttribute("data-requirecontext", context.contextName);
5 node.setAttribute("data-requiremodule", moduleName);
6
7 //Set up load listener. Test attachEvent first because IE9 has
8 //a subtle issue in its addEventListener and script onload firings
9 //that do not match the behavior of all other browsers with
10 //addEventListener support, which fire the onload event for a
11 //script right after the script execution. See:
12 //https://connect.microsoft.com/IE/feedback/details/648057/script-onload-event-is-not-fired-immediately-after-script-execution
13 //UNFORTUNATELY Opera implements attachEvent but does not follow the script
14 //script execution mode.
15 if (node.attachEvent &&
16 //Check if node.attachEvent is artificially added by custom script or
17 //natively supported by browser
18 //read https://github.com/requirejs/requirejs/issues/187
19 //if we can NOT find [native code] then it must NOT natively supported.
20 //in IE8, node.attachEvent does not have toString
21 //Note the test for "[native code" with no closing brace, see:
22 //https://github.com/requirejs/requirejs/issues/273
23 !(node.attachEvent.toString && node.attachEvent.toString.indexOf("[native code") < 0) &&
24 !isOpera) {
25 //Probably IE. IE (at least 6-8) do not fire
26 //script onload right after executing the script, so
27 //we cannot tie the anonymous define call to a name.
28 //However, IE reports the script as being in "interactive"
29 //readyState at the time of the define call.
30 useInteractive = true;
31
32 node.attachEvent("onreadystatechange", context.onScriptLoad);
33 //It would be great to add an error handler here to catch
34 //404s in IE9+. However, onreadystatechange will fire before
35 //the error handler, so that does not help. If addEventListener
36 //is used, then IE will fire error before load, but we cannot
37 //use that pathway given the connect.microsoft.com issue
38 //mentioned above about not doing the "script execute,
39 //then fire the script load event listener before execute
40 //next script" that other browsers do.
41 //Best hope: IE10 fixes the issues,
42 //and then destroys all installs of IE 6-9.
43 //node.attachEvent("onerror", context.onScriptError);
44 } else {
45 node.addEventListener("load", context.onScriptLoad, false);
46 node.addEventListener("error", context.onScriptError, false);
47 }
48 node.src = url;
注意在這段源碼當中的監聽事件,既然動態載入腳本是非同步的的,那麼乾脆使用 onload 事件來處理回調函數,這樣就保證了在我們的程序執行前依賴的模塊一定會提前載入完成。因為在事件隊列里, onload 事件是在腳本載入完成之後觸發的,也就是在事件隊列裡面永遠處在依賴模塊的後面,例如我們執行:
1 require(["module"], function (module) {
2 //do something
3 });
那麼在事件隊列裡面的相對順序會是這樣:
相信細心的同學可能會注意到了,在源碼當中不光光有 onload 事件,同時還添加了一個 onerror 事件,我們在使用 requirejs 的時候也可以定義一個模塊載入失敗的處理函數,這個函數在底層也就對應了 onerror 事件。同理,其和 onload 事件一樣是一個非同步的事件,同時也永遠發生在模塊載入之後。
談到這裡 requirejs 的核心模塊思想也就一目了然了,不過其中的過程還遠不直這些,博主只是將模塊載入的實現思想拋了出來,但 requirejs 的具體實現還要複雜的多,比如我們定義模塊的時候可以導入依賴模塊,導入模塊的時候還可以導入多個依賴,具體的實現方法我就沒有深究過了, requirejs 雖然不大,但是源碼也是有兩千多行的... ...但是只要理解了動態載入腳本的原理過後,其思想也就不難理解了,比如我現在就可以想到一個簡單的實現多個模塊依賴的方法,使用計數的方式檢查模塊是否載入完全:
1 function myRequire(deps, callback){
2 //記錄模塊載入數量
3 var ready = 0;
4 //創建腳本標籤
5 function load (url) {
6 var script = document.createElement("script");
7 script.type = "text/javascript";
8 script.async = true;
9 script.src = url;
10 return script;
11 }
12 var nodes = ;
13 for (var i = deps.length - 1; i >= 0; i--) {
14 nodes.push(load(deps[i]));
15 }
16 //載入腳本
17 for (var i = nodes.length - 1; i >= 0; i--) {
18 nodes[i].addEventListener("load", function(event){
19 ready++;
20 //如果所有依賴腳本載入完成,則執行回調函數;
21 if(ready === nodes.length){
22 callback
23 }
24 }, false);
25 document.head.appendChild(nodes[i]);
26 }
27 }
實驗一下是否能夠工作:
1 myRequire(["module/module1.js", "module/module2.js", "module/module3.js"], function{
2 console.log("ready!");
3 });
Yes, it"s work!
總結requirejs 載入模塊的核心思想是利用了動態載入腳本的非同步性以及 onload 事件以毒攻毒,關於腳本的載入,我們需要注意一下幾點:
- 在 HTML 中引入