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

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

2. 資料繫結

2.1 資料繫結的原理

資料繫結是一種很便捷的特性,一些RIA框架帶有雙向繫結功能,比如Flex和Silverlight,當某個資料發生變更時,所繫結的介面元素也發生變更,當介面元素的值發生變化時,資料也跟著變化,這種功能在處理表單資料的填充和收集時,是非常有用的。

在HTML中,原生是沒有這樣的功能的,但有些框架做到了,它們是怎麼做到的呢?我們來做個簡單的試試,順便探討一下其中原理。

先看資料到介面上的的繫結,比如:

<input vm-value="name"/>
var person = {
    name: "Tom"
};

如果我們給name重新賦值,person.name = "Jerry",怎麼才能讓介面得到變更?

從直覺來說,我們需要在name發生改變的時候,觸發一個事件,或者呼叫某個指定的方法,然後才好著手做後面的事情,比如:

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
    }
};

或者合併兩個方法,只判斷是否傳了引數:

Person.prototype.name = function(value) {
    if (arguments.length == 0) {
        return this._name;
    }
    else {
        this._name = value;
    }
}

這種情況下,賦值的時候就是person.name("Tom"),取值的時候就是var name = person.name()了。

有一些框架是通過這種方式來變通實現資料繫結的,對資料的寫入只能通過方法呼叫。但這種方式很不直接,我們來想點別的辦法。

在C#等一些語言裡,有一種東西叫做存取器,比如說:

class Person
{
    private string name;

    public string Name
    {
        get
        {
            return name;
        }
        set
        {
            name = value;
        }
    }
}

用的時候,person.Name = "Jerry",就會呼叫到set裡,相當於是個方法。

這一點非常好,很符合我們的需要,那JavaScript裡面有沒有類似存取器的特性呢?老早以前是沒有的,但現在有了,那就是Object.defineProperty,它的第三個引數就是可選的存取函式。比如說:

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的時候,是定義在例項上的,如果想要定義到型別裡面,可以在構造器裡面定義。

現在我們從資料到DOM的繫結可以解決掉了,至少我們能夠在變數被更改的時候去做一些自己的事情,比如查詢這個屬性被繫結到哪些控制元件了,然後挨個對其賦值。框架怎麼知道屬性被繫結到哪些控制元件了呢?這個直接在第二部分的實現過程中討論。

再看控制元件到資料的繫結,這個其實很好理解。無非就是給控制元件新增change之類的事件監聽,在這裡面把關聯到的資料更新掉。到這裡,我們在原理方面已經沒有什麼問題了,現在開始準備把它寫出來。

2.2 資料繫結的實現

我們的框架啟動之後,要先把前面所說的這種繫結關係收集起來,這種屬性會分佈於DOM的各個角落,一個很現實的做法是,遞迴遍歷介面的每個DOM節點,檢測該屬性,於是我們程式碼的結構大致如下所示。

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應該從屬於什麼?它是全域性變數嗎?

我們在開始做這個框架的時候強調了一個原則:業務模組不允許定義全域性變數,框架內部也儘量少有全域性作用域,到目前為止,我們只暴露了thin一個全域性入口,所以在這裡不能破壞這個原則。

因此,我們要求業務開發人員去定義一個檢視模型,把變數包裝起來,所包裝的不限於變數,也可以有方法。比如下面,我們定義了一個實體叫Person,帶兩個變數,兩個方法,後面我們來演示一下怎麼把它們繫結到HTML介面。

thin.define("Person", [], function() {
    function Person() {
        this.name = "Tom";
        this.age = 5;
    }

    Person.prototype = {
        growUp: function() {
            this.age++;
        }
    };

    return Person;
});

模型方面都準備好了,現在來看介面:

<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>

為了使得結構更加容易看,我們把介面的無關屬性比如樣式之類都去掉了,只留下不能再減少的這麼一段。現在我們可以看到,在介面的頂層定義一個vm-model屬性,值為實體的名稱。兩個輸入框通過vm-value來繫結到例項屬性,vm-init繫結介面的初始化方法,vm-click繫結按鈕的點選事件。

好了,現在我們可以來掃描這個簡單的DOM結構了。想要做這麼一個繫結,首先要考慮資料從哪裡來?在繫結name和code屬性之前,毫無疑問,應當先例項化一個Person,我們怎麼才能知道需要把Person模組例項化呢?

當掃描到一個DOM元素的時候,我們要先檢測它的vm-model屬性,如果有值,就取這個值來例項化,然後,把這個值一直傳遞下去,在掃描其他屬性或者下屬DOM元素的時候都帶進去。這麼一來,parseElement就變成一個遞迴了,於是它只好有兩個引數,變成了這樣:

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方法的引數是模組名,於是我們先去use一下,從工廠裡生成出來,然後new一下,先這麼return出去吧。

function bindModel(modelName) {
    thin.log("model" + modelName);

    var model = thin.use(modelName, true);
    var instance = new model();

    return instance;
}

現在我們開始關注parseAttribute函式,可能的attribute有哪些種類呢?我列舉了一些很常用的:

  • init,用於繫結初始化方法
  • click,用於繫結點選
  • value,繫結變數
  • enable和disable,繫結可用狀態
  • visible和invisible,繫結可見狀態

然後就可以實現我們parseAttribute函式了:

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,也不依靠選擇器,而是用某個標誌來定位元素,所以加上了這個,文章最後的示例中使用了它。

這麼多繫結,不打算都講,用bindValue函式來說明一下吧:

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方法,用於監控某變數的變化,可以傳入一個監聽函式,當變數變化的時候,自動呼叫這個函式,並且把新舊兩個值傳回來。

在這個程式碼裡,我們使用$watch方法給傳入的key新增一個監聽,監聽器裡面給監聽元素賦值。我們這裡偷懶了一下,假定所有的繫結元素都是輸入框,所以直接給element.value設定值,為了防止值為空導致顯示undefined,把值跟空字串用短路表示式做了個轉換。

接下來,也對element的幾個可能導致值變化的事件進行了監聽,在裡面把模型上對應的值更新掉。這樣雙向繫結就做好了。

然後回頭來看$watch的實現。很顯然這裡也要一個map,我們給它取名為$watchers,存放屬性的繫結關係,對於每個屬性,它的值需要儲存一份,供getter獲取,同時還有一個陣列,存放了該屬性繫結的處理函式。當屬性發生變更的時候,去挨個把它們呼叫一下。

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怎麼就有$watch呢,每個地方都去判斷一下非空然後再去建立其實挺麻煩的,所以,這個屬性我們可以直接在例項化模型的時候建立出來。

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要額外設定,而$watch就可以放在Binder裡面來extend呢?

先解釋extend幹了什麼,它做的是一個物件的淺拷貝,也就是說,把Binder的屬性和方法都複製給了建立出來的model例項,注意,這個所謂的複製,如果是簡單型別,那確實複製了,如果是引用型別,那複製的其實只是一個引用,所以如果$watchers也放在Binder裡,不同的instance就共享一個$watchers,邏輯就是錯誤的。那為什麼$watch又可以放在這裡複製呢?因為它是函式,它的this始終指向當前的執行主體,也就是說,如果放在instance1上執行,指向的就是instance1,放在instance2上執行,指向的就是instance2,我們利用這一點,就可以不用讓每個例項都建立一份$watch方法,而是共用同一個。

同理,我們可以把enable,visible,init,click這些都做起來,init的執行時間放在掃描完vm-model那個element之下的所有DOM節點之後。

嗯,我們是不是可以試一下了?來寫個程式碼:

<!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

以剛才文章提到的內容,還不能完全解釋這個例子的效果,因為沒看到在哪裡呼叫parseElement的。說來也簡單,就在thin.js裡面,直接寫了一個thin.ready,在那邊呼叫了這個函式,去解析了document.body,於是測試頁面裡面才可以只寫繫結和檢視模型。

我們還有一個更實際一點的例子,結合了另外一個系列裡面寫的簡單DataGrid控制元件,做了一個很基礎的人員管理介面:http://xufei.github.io/thin/demo/binding.html

2.3 小結

到此為止,我們的繫結框架勉強能夠執行起來了!雖然很簡陋,而且要比較新的瀏覽器才能跑,但畢竟是跑起來了。

注意Object.defineProperty僅在Chrome等瀏覽器中可用,IE需要9以上才比較正常。在司徒正美的avalon框架中,巧妙使用VBScript繞過這一限制,利用vbs的property和兩種語言的互通,實現了低版本IE的相容。我們這個框架的目標不是相容,而是為了說明原理,所以感興趣的朋友可以去看看avalon的原始碼。

相關文章