從零開始編寫自己的JavaScript框架(二)
2. 數(shù)據(jù)綁定
2.1 數(shù)據(jù)綁定的原理
數(shù)據(jù)綁定是一種很便捷的特性,一些RIA框架帶有雙向綁定功能,比如Flex和Silverlight,當(dāng)某個數(shù)據(jù)發(fā)生變更時,所綁定的界面元素也發(fā)生變更,當(dāng)界面元素的值發(fā)生變化時,數(shù)據(jù)也跟著變化,這種功能在處理表單數(shù)據(jù)的填充和收集時,是非常有用的。
在HTML中,原生是沒有這樣的功能的,但有些框架做到了,它們是怎么做到的呢?我們來做個簡單的試試,順便探討一下其中原理。
先看數(shù)據(jù)到界面上的的綁定,比如:
- <input vm-value="name"/>
- var person = {
- name: "Tom"
- };
如果我們給name重新賦值,person.name = "Jerry",怎么才能讓界面得到變更?
從直覺來說,我們需要在name發(fā)生改變的時候,觸發(fā)一個事件,或者調(diào)用某個指定的方法,然后才好著手做后面的事情,比如:
- var person = {
- name: "Tom",
- setName: function(newName) {
- this.name = newName;
- //do something
- }
- };
這樣我們可以在setName里面去給input賦值。推而廣之,為了使得實體包含的多個屬性都可以運作,可以這么做:
- var person = {
- name: "Tom",
- gender: 5
- set: function(key, value) {
- this[key] = value;
- //do something
- }
- };
或者合并兩個方法,只判斷是否傳了參數(shù):
- Person.prototype.name = function(value) {
- if (arguments.length == 0) {
- return this._name;
- }
- else {
- this._name = value;
- }
- }
這種情況下,賦值的時候就是person.name("Tom"),取值的時候就是var name = person.name()了。
有一些框架是通過這種方式來變通實現(xiàn)數(shù)據(jù)綁定的,對數(shù)據(jù)的寫入只能通過方法調(diào)用。但這種方式很不直接,我們來想點別的辦法。
在C#等一些語言里,有一種東西叫做存取器,比如說:
- class Person
- {
- private string name;
- public string Name
- {
- get
- {
- return name;
- }
- set
- {
- name = value;
- }
- }
- }
用的時候,person.Name = "Jerry",就會調(diào)用到set里,相當(dāng)于是個方法。
這一點非常好,很符合我們的需要,那JavaScript里面有沒有類似存取器的特性呢?老早以前是沒有的,但現(xiàn)在有了,那就是Object.defineProperty,它的第三個參數(shù)就是可選的存取函數(shù)。比如說
- var person = {};
- // Add an accessor property to the object.
- Object.defineProperty(person, "name", {
- set: function (value) {
- this._name = value;
- //do something
- },
- get: function () {
- return this._name;
- },
- enumerable: true,
- configurable: true
- });
賦值的時候,person.name = "Tom",取值的時候,var name = person.name,簡直太美妙了。注意這里define的時候,是定義在實例上的,如果想要定義到類型里面,可以在構(gòu)造器里面定義。
現(xiàn)在我們從數(shù)據(jù)到DOM的綁定可以解決掉了,至少我們能夠在變量被更改的時候去做一些自己的事情,比如查找這個屬性被綁定到哪些控件了,然后挨個對其賦值。框架怎么知道屬性被綁定到哪些控件了呢?這個直接在第二部分的實現(xiàn)過程中討論。
再看控件到數(shù)據(jù)的綁定,這個其實很好理解。無非就是給控件添加change之類的事件監(jiān)聽,在這里面把關(guān)聯(lián)到的數(shù)據(jù)更新掉。到這里,我們在原理方面已經(jīng)沒有什么問題了,現(xiàn)在開始準備把它寫出來。
2.2 數(shù)據(jù)綁定的實現(xiàn)
我們的框架啟動之后,要先把前面所說的這種綁定關(guān)系收集起來,這種屬性會分布于DOM的各個角落,一個很現(xiàn)實的做法是,遞歸遍歷界面的每個DOM節(jié)點,檢測該屬性,于是我們代碼的結(jié)構(gòu)大致如下所示。
- function parseElement(element) {
- for (var i=0; i<element.attributes.length; i++) {
- parseAttribute(element.attributes[i]);
- }
- for (var i=0; i<element.children.length; i++) {
- parseElement(element.children[i]);
- }
- }
但是我們這時候面臨一個問題,比如你的輸入框綁定在name變量上,這個name應(yīng)該從屬于什么?它是全局變量嗎?
我們在開始做這個框架的時候強調(diào)了一個原則:業(yè)務(wù)模塊不允許定義全局變量,框架內(nèi)部也盡量少有全局作用域,到目前為止,我們只暴露了thin一個全局入口,所以在這里不能破壞這個原則。
#p#
因此,我們要求業(yè)務(wù)開發(fā)人員去定義一個視圖模型,把變量包裝起來,所包裝的不限于變量,也可以有方法。比如下面,我們定義了一個實體叫Person,帶兩個變量,兩個方法,后面我們來演示一下怎么把它們綁定到HTML界面。
- thin.define("Person", [], function() {
- function Person() {
- this.name = "Tom";
- this.age = 5;
- }
- Person.prototype = {
- growUp: function() {
- this.age++;
- }
- };
- return Person;
- });
模型方面都準備好了,現(xiàn)在來看界面:
- <div vm-model="Person">
- <input type="text" vm-value="name"/>
- <input type="text" vm-value="age"/>
- <input type="button" vm-click="growUp" value="Grow Up"/>
- </div>
為了使得結(jié)構(gòu)更加容易看,我們把界面的無關(guān)屬性比如樣式之類都去掉了,只留下不能再減少的這么一段?,F(xiàn)在我們可以看到,在界面的頂層定義一個vm- model屬性,值為實體的名稱。兩個輸入框通過vm-value來綁定到實例屬性,vm-init綁定界面的初始化方法,vm-click綁定按鈕的點 擊事件。
好了,現(xiàn)在我們可以來掃描這個簡單的DOM結(jié)構(gòu)了。想要做這么一個綁定,首先要考慮數(shù)據(jù)從哪里來?在綁定name和code屬性之前,毫無疑問,應(yīng)當(dāng)先實例化一個Person,我們怎么才能知道需要把Person模塊實例化呢?
當(dāng)掃描到一個DOM元素的時候,我們要先檢測它的vm-model屬性,如果有值,就取這個值來實例化,然后,把這個值一直傳遞下去,在掃描其他屬 性或者下屬DOM元素的時候都帶進去。這么一來,parseElement就變成一個遞歸了,于是它只好有兩個參數(shù),變成了這樣:
- function parseElement(element, vm) {
- var model = vm;
- if (element.getAttribute("vm-model")) {
- model = bindModel(element.getAttribute("vm-model"));
- }
- for (var i=0; i<element.attributes.length; i++) {
- parseAttribute(element, element.attributes[i], model);
- }
- for (var i=0; i<element.children.length; i++) {
- parseElement(element.children[i], model);
- }
- }
看看我們打算怎么來實例化這個模型,這個bindModel方法的參數(shù)是模塊名,于是我們先去use一下,從工廠里生成出來,然后new一下,先這么return出去吧。
- function bindModel(modelName) {
- thin.log("model" + modelName);
- var model = thin.use(modelName, true);
- var instance = new model();
- return instance;
- }
現(xiàn)在我們開始關(guān)注parseAttribute函數(shù),可能的attribute有哪些種類呢?我列舉了一些很常用的:
init,用于綁定初始化方法
click,用于綁定點擊
value,綁定變量
enable和disable,綁定可用狀態(tài)
visible和invisible,綁定可見狀態(tài)
然后就可以實現(xiàn)我們parseAttribute函數(shù)了:
- function parseAttribute(element, attr, model) {
- if (attr.name.indexOf("vm-") == 0) {
- var type = attr.name.slice(3);
- switch (type) {
- case "init":
- bindInit(element, attr.value, model);
- break;
- case "value":
- bindValue(element, attr.value, model);
- break;
- case "click":
- bindClick(element, attr.value, model);
- break;
- case "enable":
- bindEnable(element, attr.value, model, true);
- break;
- case "disable":
- bindEnable(element, attr.value, model, false);
- break;
- case "visible":
- bindVisible(element, attr.value, model, true);
- break;
- case "invisible":
- bindVisible(element, attr.value, model, false);
- break;
- case "element":
- model[attr.value] = element;
- break;
- }
- }
注意到最后還有個element類型,本來可以不要這個,但我們考慮到將來,一切都是組件化的時候,界面上打算不寫id,也不依靠選擇器,而是用某個標志來定位元素,所以加上了這個,文章最后的示例中使用了它。
#p#
這么多綁定,不打算都講,用bindValue函數(shù)來說明一下吧:
- function bindValue(element, key, vm) {
- thin.log("binding value: " + key);
- vm.$watch(key, function (value, oldValue) {
- element.value = value || "";
- });
- element.onkeyup = function () {
- vm[key] = element.value;
- };
- element.onpaste = function () {
- vm[key] = element.value;
- };
- }
我們假定每個模型實例上帶有一個$watch方法,用于監(jiān)控某變量的變化,可以傳入一個監(jiān)聽函數(shù),當(dāng)變量變化的時候,自動調(diào)用這個函數(shù),并且把新舊兩個值傳回來。
在這個代碼里,我們使用$watch方法給傳入的key添加一個監(jiān)聽,監(jiān)聽器里面給監(jiān)聽元素賦值。我們這里偷懶了一下,假定所有的綁定元素都是輸入 框,所以直接給element.value設(shè)置值,為了防止值為空導(dǎo)致顯示undefined,把值跟空字符串用短路表達式做了個轉(zhuǎn)換。
接下來,也對element的幾個可能導(dǎo)致值變化的事件進行了監(jiān)聽,在里面把模型上對應(yīng)的值更新掉。這樣雙向綁定就做好了。
然后回頭來看$watch的實現(xiàn)。很顯然這里也要一個map,我們給它取名為$watchers,存放屬性的綁定關(guān)系,對于每個屬性,它的值需要保存一份,供getter獲取,同時還有一個數(shù)組,存放了該屬性綁定的處理函數(shù)。當(dāng)屬性發(fā)生變更的時候,去挨個把它們調(diào)用一下。
- var Binder = {
- $watch: function (key, watcher) {
- if (!this.$watchers[key]) {
- this.$watchers[key] = {
- value: this[key],
- list: []
- };
- Object.defineProperty(this, key, {
- set: function (val) {
- var oldValue = this.$watchers[key].value;
- this.$watchers[key].value = val;
- for (var i = 0; i < this.$watchers[key].list.length; i++) {
- this.$watchers[key].list[i](val, oldValue);
- }
- },
- get: function () {
- return this.$watchers[key].value;
- }
- });
- }
- this.$watchers[key].list.push(watcher);
- }
- };
但是vm怎么就有$watcher呢,每個地方都去判斷一下非空然后再去創(chuàng)建其實挺麻煩的,所以,這個屬性我們可以直接在實例化模型的時候創(chuàng)建出來。
- function bindModel(name) {
- thin.log("binding model: " + name);
- var model = thin.use(name, true);
- var instance = new model().extend(Binder);
- instance.$watchers = {};
- return instance;
- }
看看這里的寫法,為什么$watchers要額外設(shè)置,而$watch就可以放在Binder里面來extend呢?
先解釋extend干了什么,它做的是一個對象的淺拷貝,也就是說,把Binder的屬性和方法都復(fù)制給了創(chuàng)建出來的model實例,注意,這個所 謂的復(fù)制,如果是簡單類型,那確實復(fù)制了,如果是引用類型,那復(fù)制的其實只是一個引用,所以如果$watchers也放在Binder里,不同的 instance就共享一個$watchers,邏輯就是錯誤的。那為什么$watcher又可以放在這里復(fù)制呢?因為它是函數(shù),它的this始終指向當(dāng) 前的執(zhí)行主體,也就是說,如果放在instance1上執(zhí)行,指向的就是instance1,放在instance2上執(zhí)行,指向的就是 instance2,我們利用這一點,就可以不用讓每個實例都創(chuàng)建一份$watcher方法,而是共用同一個。
同理,我們可以把enable,visible,init,click這些都做起來,init的執(zhí)行時間放在掃描完vm-model那個element之下的所有DOM節(jié)點之后。
嗯,我們是不是可以試一下了?來寫個代碼:
- <!DOCTYPE html>
- <html>
- <head>
- <title>Simple binding demo</title>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <meta name="description" content="binding">
- <meta name="author" content="xu.fei@outlook.com">
- <script type="text/javascript" src="../js/thin.js"></script>
- </head>
- <body>
- <div vm-model="test.Person">
- <input type="text" vm-value="name"/>
- <input type="text" vm-value="age"/>
- <input type="text" vm-value="age"/>
- <input type="button" vm-click="growUp" value="Grow Up"/>
- </div>
- <div vm-model="test.Person" vm-init="init">
- <input type="text" vm-value="name"/>
- <input type="text" vm-value="age"/>
- <input type="button" vm-click="growUp" value="Grow Up"/>
- </div>
- <script type="text/javascript">
- thin.define("test.Person", [], function () {
- function Person() {
- this.name = "Tom";
- this.age = 5;
- }
- Person.prototype = {
- init: function () {
- this.name = "Jerry";
- this.age = 3;
- },
- growUp: function () {
- this.age++;
- }
- };
- return Person;
- });
- </script>
- </body>
- </html>
或者訪問這里:http://xufei.github.io/thin/demo/simple-binding.html
以剛才文章提到的內(nèi)容,還不能完全解釋這個例子的效果,因為沒看到在哪里調(diào)用parseElement的。說來也簡單,就在thin.js里面,直 接寫了一個thin.ready,在那邊調(diào)用了這個函數(shù),去解析了document.body,于是測試頁面里面才可以只寫綁定和視圖模型。
我們還有一個更實際一點的例子,結(jié)合了另外一個系列里面寫的簡單DataGrid控件,做了一個很基礎(chǔ)的人員管理界面:http://xufei.github.io/thin/demo/binding.html
2.3 小結(jié)
到此為止,我們的綁定框架勉強能夠運行起來了!雖然很簡陋,而且要比較新的瀏覽器才能跑,但畢竟是跑起來了。
注意Object.defineProperty僅在Chrome等瀏覽器中可用,IE需要9以上才比較正常。在司徒正美的avalon框架中,巧 妙使用VBScript繞過這一限制,利用vbs的property和兩種語言的互通,實現(xiàn)了低版本IE的兼容。我們這個框架的目標不是兼容,而是為了說 明原理,所以感興趣的朋友可以去看看avalon的源碼。