所謂的雙向繫結,無非是從介面的操作能實時反映到資料,資料的變更能實時展現到介面。
資料繫結換種說法,如果我們有一個 user 物件和一個 name 屬性,一旦我們賦了一個新值給 user.name,在 UI 上就會顯示新的姓名了。
同樣地,如果 UI 包含了一個輸入使用者姓名的輸入框,輸入一個新值就應該會使 user 物件的 name 屬性做出相應的改變。
很多熱門的 JS 框架客戶端如 Ember.js,Angular.js 或者 KnockoutJS、Vue.js 等,都在最新特性上刊登了雙向資料繫結。
這並不意味著從零實現它很難,也不是說需要這些功能的時候,採用這些框架是唯一的選擇。
目前幾種主流的 MVC (VM) 框架都實現了雙向資料繫結,而我們可以把它簡單理解成是在單向繫結的基礎上給可輸入元素(input、textarea 等)新增了 change ( input ) 事件,來動態修改 Model 和 View,並沒有多高深;所以無需太過介懷是實現的單向或雙向繫結。( 混亂的前端界,動不動就玩捆綁 )
實現雙向資料繫結的做法有大致如下幾種:
釋出者-訂閱者模式(Backbone.js)
一般通過 sub, pub 的方式實現資料和檢視的繫結監聽
髒值檢查(Angular.js)
Angular.js 通過髒值檢測的方式比對資料是否有變更,來決定是否更新檢視,最簡單的方式就是通過 setInterval() 定時輪詢檢測資料變動,當然 Google 不會這麼 low,Angular 只有在指定的事件觸發時進入髒值檢測,大致如下:
- DOM 事件,譬如使用者輸入文字,點選按鈕等。( ng-click )
- XHR 響應事件 ( $http )
- 瀏覽器 Location 變更事件 ( $location )
- Timer 事件( $timeout , $interval )
- 執行 $digest() 或 $apply()
資料劫持(Vue.js)
vue.js 則是採用資料劫持結合釋出者-訂閱者模式的方式,通過 Object.defineProperty() 來劫持各個屬性的 setter,getter,在資料變動時釋出訊息給訂閱者,觸發相應的監聽回撥。
下面的想法實際上很基礎,可以被認為是 3 步走計劃:
- 我們需要一個 UI 元素和屬性相互繫結的方法。
- 我們需要監視屬性和 UI 元素的變化。
- 我們需要讓所有繫結的物件和元素都能感知到變化。
本文只對目前熱度幾乎三分 Javascript 天下的三個框架進行討論。
- Vue.js
- Angular.js
- React.js
Vue.js
我曾經在 Vue.js 的設計思想 一文中簡單剖析過 Vue.js。
基於 getter、setter 的方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
var msg = { age:'25', name:'Tony', get age(){ return "30"; }, set age(x){ return this.name ="chaoxi"; } }; msg.age = 1; console.log(msg.name); //chaoxi console.log(msg.age); //30 |
基於 defineProperty 的方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
var obj = { a: 12 }; Object.defineProperty(obj, "x", { get: function() { return this.a + 1 }, enumerable: true, configurable: true, set: function(y) { console.log(y); }, }); console.log(obj.x); //13 obj.x = 3; //執行set(3) 3 console.log(obj.x); //13 console.log(delete obj.x); //true for (key in obj) { console.log(obj[key]); //12 } |
Angular.js
髒檢測基本原理
眾所周知,Angular 的雙向繫結是採用“髒檢測”的方式來更新 DOM ,但是 Angular並不存在定時髒檢測(切記); Angular 對常用的 DOM 事件、XHR 事件進行了封裝,觸發時會呼叫 $digest cycle;在 $digest 流程中,Angular 將遍歷每個資料變數的 watcher,比較它的新舊值;當新舊值不同時,觸發 Listener 函式,執行相關的操作。
Angular主要通過 scopes 實現資料雙向繫結,AngularJS 的 scopes 包括以下四個主要部分:
- digest 迴圈以及 dirty-checking(髒檢測),包括 watch,watch,digest,和$apply。
- scope 繼承 這項機制使得我們可以建立 scope 繼承來分享資料和事件。
- 對集合、陣列和物件的有效 dirty-checking。
- 事件系統 on,on,emit,以及 $broadcast。
監聽一個變數何時變化,需要呼叫 $scope.$watch 函式,這個函式接受三個引數:需要檢測的值或者表示式(watchExp)、監聽函式、值變化時執行(Listener 匿名函式),是否開啟值檢測,為 true 時會檢測物件或者陣列的內部變更(即選擇以===的方式比較還是 Angular.equals 的方式)。
上道菜,嚐嚐吧!!!
1 2 3 4 5 6 7 |
$scope.name = 'Ryan'; $scope.$watch( function( ) { return $scope.name; }, function( newValue, oldValue ) { console.log('$scope.name was updated!'); } ); |
Angular 會在 $scope 物件上註冊你的監聽函式 Listener,你可以注意到會有日誌輸出 “$scope.name was updated!”,因為 $scope.name 由先前的 undefined 更新為 ‘Ryan’。當然, watcher 也可以是一個字串,效果和上面例子中的匿名函式一樣,例如在Angular 原始碼中:
1 2 3 4 5 6 7 8 |
if(typeof watchExp == 'string' &&get.constant){ var originalFn = watcher.fn; watcher.fn = function(newVal, oldVal, scope) { originalFn.call(this, newVal, oldVal, scope); arrayRemove(array, watcher); }; } |
上面這段程式碼將 watchExp 設定為一個函式,這個函式會呼叫帶有給定變數名的 Listener 函式。
以插值為例,當angular在compile編譯階段遇到這個語法元素時,內部處理邏輯如下:
1 2 3 4 5 6 7 |
walkers.expression = function( ast ){ var node = document.createTextNode(""); this.$watch(ast, function(newval){ dom.text(node, "" + (newval == null? "": "" + newval) ); }) return node; } |
這段程式碼很好理解,就是當遇到插值時,會新建一個 textNode,並把值寫入到該 nodeContent 中,那麼 Angular 怎麼判斷這個節點值改變或者說新增了一個節點?
這裡就不得不提到$digest函式,首先,通過 watch 介面,會產生一個監聽佇列 $$watchers 。 $scope物件下的的 $$watchers 物件下擁有你定義的所有的 watchers。如果你進入到 $$watchers 內部,會發現它這樣的一個陣列。
1 2 3 4 5 6 7 8 9 10 |
$$watchers = [ { eq: false, // whether or not we are checking for objectEquality 是否需要判斷物件級別的相等 fn: function( newValue, oldValue ) {}, // this is the listener function we've provided 這是我們提供的監聽器函式 last: 'Ryan', // the last known value for the variable$nbsp;$nbsp;變數的最新值 exp: function(){}, // this is the watchExp function we provided$nbsp;$nbsp;我們提供的watchExp函式 get: function(){} // Angular's compiled watchExp function angualr編譯過的watchExp函式 } ]; |
$watch 函式會返回一個 deregisterWatch function,這意味著如果我們使用 scope.$watch 對一個變數進行監視,那麼也可以通過呼叫deregisterWatch 這個函式來停止監聽。
React.js
React 強調的是單向資料流(一直活在滿世界雙向資料繫結的皮皮蝦)。 當然,即便是單向資料流也總要有個資料的來源,如果資料來源於頁面自身上的使用者輸入,那效果也就等同於雙向繫結了;其實 React.js 有別於 Vue.js、Angular.js,大部分人以為 React 是一個框架,確切的說,只能說它是一個用於構建使用者介面的 JS 庫。
要做到資料的單向流動,需要做到以下兩個方面。
資料狀態只儲存在一處不用多說了,主要就是資料結構的設計,要避免把一種狀態用兩種描述放在不同的表裡,然後再來同步。這樣你再精巧的程式碼都彌補不了資料結構的缺陷。資料結構比程式碼重要。
狀態的讀寫操作分開,在狀態改變後通知更新 UI。
寫操作直接運算元據,不要有中間狀態,然後通知資料更新,Realm 是通過 realm.write 來處理所有的寫操作。
1 2 3 4 5 6 7 8 9 |
realm.write(() => { let myCar = realm.create('Car', { //建立新的記錄 make: 'Honda', model: 'Civic', miles: 1000, }); myCar.miles += 20; // 更新 realm.delete(myCar); //刪除 }); |
如果你在realm.write() 之外試圖寫操作,就會丟擲錯誤,在更新後,會有一個 change event。
1 2 3 |
realm.addListener('change', () => { //通知更新介面 }) |
這樣讀寫分開可以降低程式的複雜度,使得邏輯更清晰。至於介面的更新就交給 React 了,配合得正好。
所以其實可以考慮直接使用 Realm 來作為 Flux 架構的 Store,而不用 Redux。
實現一個雙向資料繫結
還是有很多方法能夠實現上面的想法,有一個簡單有效的方法就是使用 PubSub 模式。
這個思路很簡單:我們使用資料特性來為 HTML 程式碼進行繫結,所有被繫結在一起的 JavaScript 物件和 DOM 元素都會訂閱一個PubSub物件。只要 JavaScript 物件或者一個HTML輸入元素監聽到資料的變化時,就會觸發繫結到 PubSub 物件上的事件,從而其他繫結的物件和元素都會做出相應的變化。
上菜
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
function DataBinder( object_id ) { // Create a simple PubSub object var pubSub = { callbacks: {}, on: function( msg, callback ) { this.callbacks[ msg ] = this.callbacks[ msg ] || []; this.callbacks[ msg ].push( callback ); }, publish: function( msg ) { this.callbacks[ msg ] = this.callbacks[ msg ] || [] for ( var i = 0, len = this.callbacks[ msg ].length; i len; i++ ) { this.callbacks[ msg ][ i ].apply( this, arguments ); } } }, data_attr = "data-bind-" + object_id, message = object_id + ":change", changeHandler = function( evt ) { var target = evt.target || evt.srcElement, // IE8 compatibility prop_name = target.getAttribute( data_attr ); if ( prop_name && prop_name !== "" ) { pubSub.publish( message, prop_name, target.value ); } }; // Listen to change events and proxy to PubSub if ( document.addEventListener ) { document.addEventListener( "change", changeHandler, false ); } else { // IE8 uses attachEvent instead of addEventListener document.attachEvent( "onchange", changeHandler ); } // PubSub propagates changes to all bound elements pubSub.on( message, function( evt, prop_name, new_val ) { var elements = document.querySelectorAll("[" + data_attr + "=" + prop_name + "]"), tag_name; for ( var i = 0, len = elements.length; i len; i++ ) { tag_name = elements[ i ].tagName.toLowerCase(); if ( tag_name === "input" || tag_name === "textarea" || tag_name === "select" ) { elements[ i ].value = new_val; } else { elements[ i ].innerHTML = new_val; } } }); return pubSub; } |