從零開始編寫自己的JavaScript框架(二)
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的原始碼。
相關文章
- 從零開始編寫自己的JavaScript框架(一)JavaScript框架
- 從零開始寫JavaScript框架(二)JavaScript框架
- 從零開始寫JavaScript框架(一)JavaScript框架
- 從零開始使用JavaScript編寫資料表格控制元件(二)JavaScript控制元件
- 從零開始編寫指令碼引擎指令碼
- 從零開始打造自己的PHP框架――第2章PHP框架
- 從零開始手寫Koa2框架框架
- 從零開始編寫一個 Python 非同步 ASGI WEB 框架Python非同步Web框架
- 從零開始使用JavaScript編寫資料表格控制元件(一)JavaScript控制元件
- 從零開始寫Java Web框架——maven 外掛JavaWeb框架Maven
- 從零開始寫一個Javascript解析器JavaScript
- 從零開始編寫一個babel外掛Babel
- 從零搭建自己的Vue管理端框架(二)Vue框架
- 從零開始寫一個微前端框架-沙箱篇前端框架
- 從零開始實現一個RPC框架(二)RPC框架
- VuePress從零開始搭建自己的部落格Vue
- 從零開始:用REACT寫一個格鬥遊戲(二)React遊戲
- Re從零開始的UI庫編寫生活之表格元件UI元件
- Re從零開始的UI庫編寫生活之表單UI
- Re從零開始的UI庫編寫生活之按鈕UI
- 從零開始手寫一個微前端框架-渲染篇前端框架
- 開始編寫自己的技術部落格...
- 從零開始搭建屬於自己的網站網站
- 從零開始寫一個ExporterExport
- Re從零開始的UI庫編寫生活之規範制定UI
- 從零開始仿寫一個抖音App——開始APP
- 從零搭建自己的SpringBoot後臺框架(二)Spring Boot框架
- 從零開始打造一個iOS圖片載入框架(二)iOS框架
- 【從零開始寫一個簡單的ImageLoader框架】ImageLoader分析框架
- 從零開始的Java RASP實現(二)Java
- vuePress從零開始搭建自己專屬的文件集合Vue
- Re從零開始的UI庫編寫生活之進度條元件UI元件
- 從零開始寫一個網頁網頁
- 深度:從零編寫一個微前端框架前端框架
- 從零開始寫一個微前端框架-樣式隔離篇前端框架
- 從零開始寫一個微前端框架-資料通訊篇前端框架
- 從零開始實現一個RPC框架(零)RPC框架
- 從零開始用golang編寫一個分散式測試工具Golang分散式