從零開始編寫自己的JavaScript框架(一)

民工精髓發表於2013-07-10

1. 模組的定義和載入

1.1 模組的定義

一個框架想要能支撐較大的應用,首先要考慮怎麼做模組化。有了核心和模組載入系統,外圍的模組就可以一個一個增加。不同的JavaScript框架,實現模組化方式各有不同,我們來選擇一種比較優雅的方式作個講解。

先問個問題:我們做模組系統的目的是什麼?如果覺得這個問題難以回答,可以從反面來考慮:假如不做模組系統,有什麼樣的壞處?

我們經歷過比較粗放、混亂的前端開發階段,頁面裡充滿了全域性變數,全域性函式。那時候要複用js檔案,就是把某些js函式放到一個檔案裡,然後讓多個頁面都來引用。

考慮到一個頁面可以引用多個這樣的js,這些js互相又不知道別人裡面寫了什麼,很容易造成命名的衝突,而產生這種衝突的時候,又沒有哪裡能夠提示出來。所以我們要有一種辦法,把作用域比較好地隔開。

JavaScript這種語言比較奇怪,奇怪在哪裡呢,它的現有版本里沒package跟class,要是有,我們也沒必要來考慮什麼自己做模組化了。那它是要用什麼東西來隔絕作用域呢?

在很多傳統高階語言裡,變數作用域的邊界是大括號,在{}裡面定義的變數,作用域不會傳到外面去,但我們的JavaScript大人不是這樣的,他的邊界是function。所以我們這段程式碼,i仍然能打出值:

for (var i=0; i<5; i++) {
    //do something
}
alert(i);

那麼,我們只能選用function做變數的容器,把每個模組封裝到一個function裡。現在問題又來了,這個function本身的作用域是全域性的,怎麼辦?我們想不到辦法,拔劍四顧心茫然。

我們有沒有什麼可參照的東西呢?這時候,腦海中一群語言飄過: C語言飄過:“我不是面嚮物件語言哦~不需要像你這麼組織哦~”,“死開!” Java飄過:“我是純面嚮物件語言哦,連main都要在類中哦,編譯的時候通過裝箱清單指定入口哦~”,“死開!” C++飄過:“我也是純面嚮物件語言哦”,等等,C++是純物件導向的語言嗎?你的main是什麼???main是特例,不在任何類中!

啊,我們發現了什麼,既然無法避免全域性的作用域,那與其讓100個function都全域性,不如只讓一個來全域性,其他的都由它管理。

本來我們打算自己當上帝的,現在只好改行先當個工商局長。你想開店嗎?先來註冊,不然封殺你!於是良民們紛紛來註冊。店名叫什麼,從哪進貨,賣什麼的,一一登記在案,為了方便下面的討論,我們連進貨的過程都讓工商局管理起來。

店名,指的就是這裡的模組名,從哪裡進貨,代表它依賴什麼其他模組,賣什麼,表示它對外提供一些什麼特性。

好了,考慮到我們的這個註冊管理機構是個全域性作用域,我們還得把它掛在window上作為屬性,然後再用一個function隔離出來,要不然,別人也定義一個同名的,就把我們覆蓋掉了。

(function() {
    window.thin = {
        define: function(name, dependencies, factory) {
            //register a module
        }
    };
})();

在這個module方法內部,應當怎麼去實現呢?我們的module應當有一個地方儲存,但儲存是要在工商局內部的,不是隨便什麼人都可以看到的,所以,這個儲存結構也放在工商局同樣的作用域裡。

用什麼結構去儲存呢?工商局備案的時候,店名不能跟已有的重複,所以我們發現這是用map的很好場景,考慮到JavaScript語言層面沒有map,我們弄個Object來存。

(function() {
    var moduleMap = {};
    window.thin = {
        define: function(name, dependencies, factory) {
            if (!moduleMap[name]) {
                var module = {
                    name: name,
                    dependencies: dependencies,
                    factory: factory
                };
                moduleMap[name] = module;
            }
            return moduleMap[name];
        }
    };
})();

現在,模組的儲存結構就搞好了。

1.2 模組的使用

存的部分搞好了,我們來看看怎麼取。現在來了一個商家,賣木器的,他需要從一個賣釘子的那邊進貨,賣釘子的已經來註冊過了,現在要讓這個木器廠能買到釘子。現在的問題是,兩個商家處於不同的作用域,也就是說,它們互相不可見,那通過什麼方式,我們才能讓他們產生呼叫關係呢?

個人解決不了的問題還是得靠政府,有困難要堅決克服,沒有困難就製造困難來克服。現在困難有了,該克服了。商家說,我能不能給你我的進貨名單,你幫我查一下它們在哪家店,然後告訴我?這麼簡單的要求當然一口答應下來,但是採用什麼方式傳遞給你呢?這可犯難了。

我們參考AngularJS框架,寫了一個類似的程式碼:

thin.define("A", [], function() {
    //module A
});

thin.define("B", ["A"], function(A) {
    //module B
    var a = new A();
});

看這段程式碼特別在哪裡呢?模組A的定義,毫無特別之處,主要看模組B。它在依賴關係裡寫了一個字串的A,然後在工廠方法的形參寫了一個真真切切的A型別。嗯?這個有些奇怪啊,你的A型別要怎麼傳遞過來呢?其實是很簡單的,因為我們宣告瞭依賴項的陣列,所以可以從依賴項,挨個得到對應的工廠方法,然後建立例項,傳進來。

use: function(name) {
    var module = moduleMap[name];

    if (!module.entity) {
        var args = [];
        for (var i=0; i<module.dependencies.length; i++) {
            if (moduleMap[module.dependencies[i]].entity) {
                args.push(moduleMap[module.dependencies[i]].entity);
            }
            else {
                args.push(this.use(module.dependencies[i]));
            }
        }

        module.entity = module.factory.apply(noop, args);
    }

    return module.entity;
}

我們可以看到,這裡面遞迴獲取了依賴項,然後當作引數,用這個模組的工廠方法來例項化了一下。這裡我們多做了一個判斷,如果模組工廠已經執行過,就快取在entity屬性上,不需要每次都建立。以此類推,假如一個模組有多個依賴項,也可以用類似的方式寫,毫無壓力:

thin.define("D", ["A", "B", "C"], function(A, B, C) {
    //module D
    var a = new A();
    var b = new B();
    var c = new C();
});

注意了,D模組的工廠,實參的名稱未必就要是跟依賴項一致,比如,以後我們程式碼較多,可以給依賴項和模組名稱加名稱空間,可能變成這樣:

thin.define("foo.D", ["foo.A", "foo.B", "foo.C"], function(A, B, C) {
    //module D
    var a = new A();
    var b = new B();
    var c = new C();
});

這段程式碼仍然可以正常執行。我們來做另外一個測試,改變形參的順序:

thin.define("A", [], function() {
    return "a";
});

thin.define("B", [], function() {
    return "b";
});

thin.define("C", [], function() {
    return "c";
});

thin.define("D", ["A", "B", "C"], function(B, A, C) {
    return B + A + C;
});

var D = thin.use("D");
alert(D);

試試看,我們的D打出什麼結果呢?結果是"abc",所以說,模組工廠的實參只跟依賴項的定義有關,跟形參的順序無關。我們看到,在AngularJS裡面,並非如此,實參的順序是跟形參一致的,這是怎麼做到的呢?

我們先離開程式碼,思考這麼一個問題:如何得知函式的形參名陣列?對,我們是可以用func.length得到形參個數,但無法得到每個形參的變數名,那怎麼辦呢?

AngularJS使用了一種比較極端的辦法,分析了函式的字面量。眾所周知,在JavaScript中,任何物件都隱含了toString方法,對於一個函式來說,它的toString就是自己的實現程式碼,包含函式簽名和註釋。下面我貼一下AngularJS裡面的這部分程式碼:

var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m;
var FN_ARG_SPLIT = /,/;
var FN_ARG = /^\s*(_?)(\S+?)\1\s*$/;
var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg;
function annotate(fn) {
  var $inject,
      fnText,
      argDecl,
      last;

  if (typeof fn == 'function') {
    if (!($inject = fn.$inject)) {
      $inject = [];
      fnText = fn.toString().replace(STRIP_COMMENTS, '');
      argDecl = fnText.match(FN_ARGS);
      forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg){
        arg.replace(FN_ARG, function(all, underscore, name){
          $inject.push(name);
        });
      });
      fn.$inject = $inject;
    }
  } else if (isArray(fn)) {
    last = fn.length - 1;
    assertArgFn(fn[last], 'fn');
    $inject = fn.slice(0, last);
  } else {
    assertArgFn(fn, 'fn', true);
  }
  return $inject;
}

可以看到,這個程式碼也不長,重點是型別為function的那段,首先去除了註釋,然後獲取了形參列表字串,這段正則能獲取到兩個結果,第一個是全函式的實現,第二個才是真正的形參列表,取第二個出來split,就得到了形參的字串列表了,然後按照這個順序再去載入依賴模組,就可以讓形參列表不對應於依賴項陣列了。

AngularJS的這段程式碼很強大,但是要損耗一些效能,考慮到我們的框架首要原則是簡單,甚至可以為此犧牲一些靈活性,我們不做這麼複雜的事情了。

1.3 模組的載入

到目前為止,我們可以把多個模組都定義在一個檔案中,然後手動引入這個js檔案,但是如果一個頁面要引用很多個模組,引入工作就變得比較麻煩,比如說,單頁應用程式(SPA)一般比較複雜,往往包含數以萬計行數的js程式碼,這些程式碼至少分佈在幾十個甚至成百上千的模組中,如果我們也在主介面就載入它們,載入時間會非常難以接受。但我們可以這樣看:主介面載入的時候,並不是用到了所有這些功能,能否先載入那些必須的,而把剩下的放在需要用的時候再去載入?

所以我們可以考慮萬能的AJAX,從服務端獲取一個js的內容,然後……,怎麼辦,你當然說不能eval了,因為據說eval很evil啦,但是它evil在哪裡呢?主要是破壞全域性作用域啦,怎麼怎麼,但是如果這些檔案裡面都是按照我們規定的模組格式寫,好像也沒有什麼在全域性作用域的……,好吧。

算了,我們還是用最簡單的方式了,就是動態建立script標籤,然後設定src,新增到document.head裡,然後監聽它們的完成事件,做後續操作。真的很簡單,因為我們的框架不需要考慮那麼多種情況,不需要AMD,不需要require那麼麻煩,用這框架的人必須按照這裡的原則寫。

所以,說真的我們這裡沒那麼複雜啦,要是你們想看更詳細原理的不如去看這個,解釋得比我好哎:http://coolshell.cn/articles/9749.html#jtss-tsina

[補一段,@Franky 大神指出了這篇文章中一些不符合現狀的地方,我把它也貼在這裡,供讀者參考]

很多觀點都是 史蒂夫那本老書上的觀點. 和那時候同期產生的一些資料和資料...所以顯得不少東西說的太想當然了... 譬如script標籤的載入和執行會阻塞後面資源的載入和執行之類的.說的過於肯定了. 比如chrome7+就開始逐漸改進的 預載入機制 就分 head 裡的資源, body裡的資源 .兩個資源是否跨界三種情形. 不提這些瀏覽器. 我們看看ie10也同樣改進了 死迴圈10秒 這後面的圖片能被提前載入. 就更不用說其他A級瀏覽器的豐富的優化策略了. 所以還是建議博主, 別拿幾年前的老資料作為依據.尤其這些資料是用來說明更新速度像在賽跑一樣的各個瀏覽器了.

關於 defer , 似乎史蒂夫的老書上是這麼說的麼? 顯然沒有測試全非ie瀏覽器的各個版本.或者是他測試資料的時候ff某大版本的幾個beta子版本還沒出現?

其次是就你的載入器提到的預載入策略. 你有測過所有瀏覽器用object預載入可能涉及到的問題麼(比如chrome,8,9的預載入的會話級別的資源型別快取bug). 拋開這個問題不談,假設你預載入到一半,使用者再次觸發了載入.你覺得這種情況如果頻繁發生.是否合適? 你的預載入策略連script.onload狀態都無法測知,進一步優化的可能性就消失了. 考慮下為什麼seajs 的 umd要設計成那個樣子?

最後吐槽下你的程式碼. 有注意到你用 document.body.appendChild 來像dom 中插入指令碼. 我的建議是 永遠不要這樣做.除非你可以無視ie6使用者.以及ie7缺失某些補丁的子版本.

你可以選擇body 可以.但請用insertBefore. 但在某些極端情況下.這仍然會發生問題. 最佳實踐是 head.insertBefore 向其第一個子節點插入.(你甚至無需檢測是否存在子節點. 這個api會在沒有子節點的時候,行為同appendChild). 而更加穩妥的情況是. 如果注入script. 發現document.head還沒有被構建時. 可以自己造一個. 這才是一個通用載入器要做到的程度...

我也偷懶了,只是貼一下程式碼,順便解釋一下,介面把所依賴的js檔案路徑放在陣列裡,然後挨個建立script標籤,src設定為路徑,新增到head中,監聽它們的完成事件。在這個完成時間裡,我們要做這麼一些事情:在fileMap裡記錄當前js檔案的路徑,防止以後重複載入,檢查列表中所有檔案,看看是否全部載入完了,如果全載入好了,就執行回撥。

require: function (pathArr, callback) {
    for (var i = 0; i < pathArr.length; i++) {
        var path = pathArr[i];

        if (!fileMap[path]) {
            var head = document.getElementsByTagName('head')[0];
            var node = document.createElement('script');
            node.type = 'text/javascript';
            node.async = 'true';
            node.src = path + '.js';
            node.onload = function () {
                fileMap[path] = true;
                head.removeChild(node);
                checkAllFiles();
            };
            head.appendChild(node);
        }
    }

    function checkAllFiles() {
        var allLoaded = true;
        for (var i = 0; i < pathArr.length; i++) {
            if (!fileMap[pathArr[i]]) {
                allLoaded = false;
                break;
            }
        }

        if (allLoaded) {
            callback();
        }
    }
}

1.4 小結

到此為止,我們的簡易框架的模組定義系統就完成了。完整的程式碼如下:

(function () {
    var moduleMap = {};
    var fileMap = {};

    var noop = function () {
    };

    var thin = {
        define: function(name, dependencies, factory) {
            if (!moduleMap[name]) {
                var module = {
                    name: name,
                    dependencies: dependencies,
                    factory: factory
                };

                moduleMap[name] = module;
            }

            return moduleMap[name];
        },

        use: function(name) {
            var module = moduleMap[name];

            if (!module.entity) {
                var args = [];
                for (var i=0; i<module.dependencies.length; i++) {
                    if (moduleMap[module.dependencies[i]].entity) {
                        args.push(moduleMap[module.dependencies[i]].entity);
                    }
                    else {
                        args.push(this.use(module.dependencies[i]));
                    }
                }

                module.entity = module.factory.apply(noop, args);
            }

            return module.entity;
        },

        require: function (pathArr, callback) {
            for (var i = 0; i < pathArr.length; i++) {
                var path = pathArr[i];

                if (!fileMap[path]) {
                    var head = document.getElementsByTagName('head')[0];
                    var node = document.createElement('script');
                    node.type = 'text/javascript';
                    node.async = 'true';
                    node.src = path + '.js';
                    node.onload = function () {
                        fileMap[path] = true;
                        head.removeChild(node);
                        checkAllFiles();
                    };
                    head.appendChild(node);
                }
            }

            function checkAllFiles() {
                var allLoaded = true;
                for (var i = 0; i < pathArr.length; i++) {
                    if (!fileMap[pathArr[i]]) {
                        allLoaded = false;
                        break;
                    }
                }

                if (allLoaded) {
                    callback();
                }
            }
        }
    };

    window.thin = thin;
})();

測試程式碼如下:

thin.define("constant.PI", [], function() {
    return 3.14159;
});

thin.define("shape.Circle", ["constant.PI"], function(pi) {
    var Circle = function(r) {
        this.r = r;
    };

    Circle.prototype = {
        area : function() {
            return pi * this.r * this.r;
        }
    }

    return Circle;
});

thin.define("shape.Rectangle", [], function() {
    var Rectangle = function(l, w) {
        this.l = l;
        this.w = w;
    };

    Rectangle.prototype = {
        area: function() {
            return this.l * this.w;
        }
    };

    return Rectangle;
});

thin.define("ShapeTypes", ["shape.Circle", "shape.Rectangle"], function(Circle, Rectangle) {
    return {
        CIRCLE: Circle,
        RECTANGLE: Rectangle
    };
});

thin.define("ShapeFactory", ["ShapeTypes"], function(ShapeTypes) {
    return {
        getShape: function(type) {
            var shape;

            switch (type) {
                case "CIRCLE": {
                    shape = new ShapeTypes[type](arguments[1]);
                    break;
                }
                case "RECTANGLE":  {
                    shape = new ShapeTypes[type](arguments[1], arguments[2]);
                    break;
                }
            }

            return shape;
        }
    };
});

var ShapeFactory = thin.use("ShapeFactory");
alert(ShapeFactory.getShape("CIRCLE", 5).area());
alert(ShapeFactory.getShape("RECTANGLE", 3, 4).area());

在這個例子裡定義了四個模組,每個模組只需要定義自己所直接依賴的模組,其他的可以不必定義。也可以來這裡看測試連結:http://xufei.github.io/thin/demo/demo.0.1.html

相關文章