從零開始寫JavaScript框架(二)

發表於2013-07-17

2. 資料繫結

2.1 資料繫結的原理

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

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

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

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

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

這樣我們可以在setName裡面去給input賦值。推而廣之,為了使得實體包含的多個屬性都可以運作,可以這麼做:

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

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

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

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

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

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

賦值的時候,person.name = “Tom”,取值的時候,var name = person.name,簡直太美妙了。注意這裡define的時候,是定義在例項上的,如果想要定義到型別裡面,可以在構造器裡面定義。

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

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

2.2 資料繫結的實現

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

但是我們這時候面臨一個問題,比如你的輸入框繫結在name變數上,這個name應該從屬於什麼?它是全域性變數嗎?

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

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

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

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

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

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

看看我們打算怎麼來例項化這個模型,這個bindModel方法的引數是模組名,於是我們先去use一下,從工廠裡生成出來,然後new一下,先這麼return出去吧。

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

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

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

注意到最後還有個element型別,本來可以不要這個,但我們考慮到將來,一切都是元件化的時候,介面上打算不寫id,也不依靠選擇器,而是用某個標誌來定位元素,所以加上了這個,文章最後的示例中使用了它。

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

我們假定每個模型例項上帶有一個$watch方法,用於監控某變數的變化,可以傳入一個監聽函式,當變數變化的時候,自動呼叫這個函式,並且把新舊兩個值傳回來。

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

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

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

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

看看這裡的寫法,為什麼$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節點之後。

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

或者訪問這裡: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的原始碼。

相關文章