跳到主要內容
黯羽輕揚每天積累一點點

雙向數據繫結的 3 種實作方式

免費2017-02-12#JS#双向数据绑定#data binding#angular dirty checking#vue data binding#angular data binding#js数据绑定

用 3 種不同方式實作雙向數據繫結,包括控制數據模型、setter 監聽變化、髒檢查

寫在前面

從沒有狀態,到手動維護狀態,操作 DOM 更新視圖,再到後來的雙向數據繫結,各種前端解決方案一直在追求簡化數據與視圖之間的關聯方式,目前的趨勢是弱化 DOM,強化數據狀態,關注「真正的」邏輯

一.里程碑

Server-Side Rendering: Reset The Universe

There is no change. The universe is immutable.

沒有狀態,弱前端

視圖控制、邏輯功能都由伺服器端提供,前端僅處理表單檢驗之類的互動,直接把使用者行為告訴伺服器端:

前端:點下一步按鈕了,前端卒。
服務:好,這是下一頁

邏輯狀態都放在伺服器端,前端程式碼就一行 if (isValid) form.submit(); else alert('invalid')

P.S. 好久沒見過 ssr 了...

First-gen JS: Manual Re-rendering

I have no idea what I should re-render. You figure it out.

前端維護狀態,手動操作 DOM 更新視圖

前端程式碼越來越多,需要維護一些狀態,這個時期的前端框架(例如 Backbone.js, Ext JS, Dojo)想要按照「最佳實踐」分離 MVC:

前端框架:給我數據,給我模版,我幫你渲染
前端er:好,給你
前端框架:使用者剛才點按鈕了,我不知道哪塊要重新渲染,你自己來吧
前端er:document.getXXX().xxx()

框架幫忙分離數據和視圖,後續狀態更新需要手動操作 DOM,因為框架只管首次渲染,不追蹤狀態監聽變化

Ember.js: Data Binding

I know exactly what changed and what should be re-rendered because I control your models and views.

手動操作 DOM 太麻煩,想辦法簡化

框架想要弱化 DOM 操作,只關注數據,那麼就要知道數據到視圖的映射關係:

前端框架:給我數據,給我模版,我幫你渲染
前端er:好,給你
前端框架:不是這個,數據必須用我給你的盒子裝起來
前端er:好了,給你
前端er:我改數據了
前端框架:收到,頁面內容已經更新了

框架提供數據模型,把數據包起來,這樣後續的增刪改都必須走框架 API,框架就知道數據變了,更新對應的視圖,框架監聽了數據變化

AngularJS: Dirty Checking

I have no idea what changed, so I'll just check everything that may need updating.

用框架提供的數據模型太麻煩了,想辦法直接追蹤狀態變化

前端框架:給我數據,給我模版,我幫你渲染
前端er:好,給你
前端er:幫我監聽 data.name,變的時候更新 h1 的文本內容
前端框架:好
前端框架:變了,我知道怎麼做,之前是 xxx,現在是 aaa,那麼把 h1 內容改成 aaa

框架記下上次的值,在數據可能發生了變化時(DOM 操作或者手動通知),取最新的值和上次的比較,不一樣就用最新值更新視圖,保證視圖狀態和數據狀態一致

React: Virtual DOM

髒檢查太費勁(有風吹草動就要全檢查一遍),想別的辦法

I have no idea what changed so I'll just re-render everything and see what's different now.

不喜歡麻煩的數據模型,嫌髒檢查太傻,看能不能不監聽數據變化:

前端框架:給我數據,給我模版,我幫你渲染
前端er:好,給你
前端er:我換 data 了,你看著更新視圖
前端框架:好,從這裡向下通知,子孫們自己看要怎麼更新,簡單的(屬性更新)自己做,麻煩的(結構更新)告訴我
前端框架:哦,刪掉一個 span,插入 2 個 li,o 了

框架不主動追什麼時候數據變了,需要手動通知框架狀態變化,框架向下一看就知道該怎麼做

Vue

監聽數據變化,最簡單粗暴的方式難道不是定義 setter

但是定義 setter 沒辦法監聽所有數據變化,可是又能滿足大部分場景,少數場景的話,限制一下:

前端框架:給我數據,給我模版,我幫你渲染
前端er:好,給你
前端er:data.value = 'new value'
前端框架:setter 說值變了,我更新一下視圖
前端er:data.newKey = 'new key value'
前端框架:zzZ
前端er:$set('newKey', 'new key value')
前端框架:添新屬性了,我更新一下視圖

框架追蹤數據變化,更新視圖,少數場景需要手動通知狀態變化

二.setter 監聽變化

Vue 採用這種方式,基本思路如下:

  1. 數據-視圖:走訪 data 定義 setter,在 setterupdate view
  2. 視圖-數據:監聽 input 等需要雙向繫結的元素,監聽相關事件,在 handlerupdate data

視圖-數據的處理很簡單,沒有選擇,也沒有爭議。數據-視圖需要考慮 setter 無法應對的場景:

  • 給物件添新屬性(data.newKey = 'value')時, setter 監聽不到

  • delete 刪掉現有屬性, setter 監聽不到

  • 陣列變化監聽不到

第 1 個問題在 ES6 Proxy 能用之前無法解決Object.observe(), Array.observe() 已經廢棄了),所以需要提供額外 API 支援手動通知添加,例如:

var data = {a: 1};
var vm = new Vue({
  data: data
});
// 透過額外 API 通知框架添新屬性了
vm.$set('b', 2);

第 2 個問題無關緊要,需要 delete 的場景很少,賦值 undefined 再添一點額外判斷就能避免 delete

第 3 個問題比較嚴重,而且不好解決,Vue 透過篡改原生 Array 方法(把能改變陣列內容的陣列方法都注入一遍)來彌補

/**
 * Intercept mutating methods and emit events
 */
;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator () {
    //...
    //注入監聽部分
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    return result
  })
})

摘自 https://github.com/vuejs/vue/blob/dev/src/core/observer/array.js

這樣把透過陣列方法改變陣列內容的情況也收進來了,還存在 2 個問題:

  • arr.length=3 這樣修改長度引起的數據變化監聽不到

  • arr[0] = 1 這樣透過索引修改值的變化也監聽不到

像監聽物件那樣定義 lengthsetter ?不行,陣列的 length 不允許透過 Object.defineProperty() 篡改:

var a = [1, 2, 3];
Object.defineProperty(a, 'length', {
    set(newValue) {
        console.log('change to ' + newValue);
        return newValue;
    },
    get: x => x
});
// Uncaught TypeError: Cannot redefine property: length

透過 length 修改陣列內容可以用其它方式代替(比如 splice ),不是非常關鍵,這個就先不管了

透過索引修改陣列元素是很常用的方式,必須處理,所以要添加額外的 API,支援修改陣列指定元素:

// 老早的版本在監聽的陣列原型上添了$set()和$remove()
// 現在好像沒了,用全域的Vue.set()
Vue.set(vm.arr, 0, 'setted')

這樣數據變化監聽才算基本完備

嘗試實作

陣列稍微麻煩些就先不管了,這裡實作 inputdiv 與簡單數據物件的雙向繫結

關鍵的 setter 監聽變化部分:

var bind = function(node, data) {
    var key = node.getAttribute('data-bind');
    if (!key) return console.error('no data-bind key');

    // cache
    var cache = cacheData(data);

    // data to view
    if (cache._cachedNodes) {
        cache._cachedNodes.push(node);
    }
    else {
        cache._cachedNodes = [node];
        // setter: data to view
        Object.defineProperty(data, key, {
            enumerable: true,
            set: function(newValue) {
                cache[key] = newValue;
                // update view
                updateView(cache, newValue);
                return cache[key];
            },
            get: function() {
                return cache[key];
            }
        });
    }

    // init view
    updateView(cache, cache[key]);
};
// bind
bind($input, data);
bind($output, data);

setterupdate view ,完成數據-視圖的繫結

然後監聽 input 元素的 input 事件,完成視圖-數據的繫結:

// event: view to data
$input.addEventListener('input', function() {
    data[$input.getAttribute('data-bind')] = this.value;
});

當然,實際場景要考慮 inputcheckboxselect 等各種視圖變化會影響狀態的情況

直接修改數據,會被 setter 發現,更新視圖:

// 手動改變量值
$('#btn').onclick = function() {
    data.value = 'updated value ' + Date.now();
};

完整 Demo 位址: http://ayqy.net/temp/data-binding/setter.html

三.提供數據模型

Ember.js 採用這種方式

由框架提供一套數據模型,把實際數據包起來,後續更新必須走數據模型 API,框架就拿到了所有數據變化,從而完成數據-視圖的繫結。比較老的方式,用起來很麻煩,沒什麼好說的

嘗試實作

同樣,這裡嘗試實作 inputdiv 與字串的雙向繫結

先提供數據模型,在數據模型內部更新視圖:

// 提供數據模型
var MString = function(str) {
    this.value = str;
    this.nodes = [];
};
MString.prototype = {
    bindTo: function(node) {
        this.nodes.push(node);
        this.updateView();
    },
    updateView: function() {
        var VALUE_NODES = ['INPUT', 'TEXTAREA'];

        var nodesLen = this.nodes.length;
        if (nodesLen > 0) {
            for (var i = 0; i < nodesLen; i++) {
                if (VALUE_NODES.indexOf(this.nodes[i].tagName) !== -1) {
                    // 避免input賦值,光標跳到末尾
                    if (this.nodes[i].value !== this.value) {
                        this.nodes[i].value = this.value;
                    }
                }
                else {
                    this.nodes[i].innerText = this.value;
                }
            }
        }
    },
    set: function(str) {
        if (str !== this.value) {
            this.value = str;
            // update view
            this.updateView();
        }
    },
    get: function() {
        return this.value;
    }
};

提供數據模型其實相當於定義 setter ,同樣為了監聽變化,提供一套數據模型是萬無一失的,支援的數據操作都可以監聽到,不支援的也不可能引起變化,所以處理起來相當簡單

然後建立聯繫:

// setter: data to view
data[$input.getAttribute('data-bind')].bindTo($input);
data[$output.getAttribute('data-bind')].bindTo($output);

// event: view to data
$input.addEventListener('input', function() {
    data[$input.getAttribute('data-bind')].set(this.value);
});

後續數據更新需要走數據模型 API:

// 手動改變量值
$('#btn').onclick = function() {
    data.value = 'updated value ' + Date.now();
};

完整 Demo 位址位址: http://ayqy.net/temp/data-binding/model.html

四.髒檢查

Angular 採用這種方式

不監聽數據變化,只時不時地去檢查數據變了沒,變了就更新對應的視圖

那麼問題是:

  • 什麼時候檢查?

可能引起數據變化的時候都去檢查,比如 DOM 操作後,互動事件發生後,覺得數據可能不一致了,就檢查一遍看變了沒

  • 如何得知哪塊變了?

不知道哪塊變了,所以要把所有繫結到視圖的數據都檢查一遍,當然,「所有」不是指整頁,只是組件級的( $scope ),所以效能雖然不很好,但多數場景下無大礙

如果手動修改了數據,希望立即更新視圖,而髒檢查機制在將來某個時機才會執行,無法滿足,就必須手動執行髒檢查,此時就不那麼方便了

嘗試實作

仿照 Angular 風格做一套簡單的:

var Scope = function() {
    this.$$watchers = [];
};

Scope.prototype.$watch = function(watchExp, listener) {
    this.$$watchers.push({
        watchExp: watchExp,
        listener: listener || function() {}
        // 之後的髒檢查會添一個last屬性,用來快取oldValue
    });
};

Scope.prototype.$digest = function() {
    var dirty;

    do {
        dirty = false;

        // 走訪watcher,檢查last有沒有變髒
        for(var i = 0; i < this.$$watchers.length; i++) {
            // 用取值方法watchExp重新取一次值
            // 不直接記key取,更靈活些(可以在watchExp里手動包裝一個新值,不關心值在誰身上)
            var newValue = this.$$watchers[i].watchExp(),
                oldValue = this.$$watchers[i].last;

            if(oldValue !== newValue) {
                // 記下的last變髒了,觸發回調
                // 第一次digest時,last是undefined,所以至少會執行一次listener
                this.$$watchers[i].listener(newValue, oldValue);
                // 控制外層待會兒再檢查一遍,看執行listener有沒有引起別的變化
                //! 只要發現髒東西,就要再檢查一遍,確保digest完畢後last和數據一致
                dirty = true;
                // 更新快取值
                this.$$watchers[i].last = newValue;
            }
        }
    } while (dirty);
};

watch() 時記下取值方法和回調函式, digest() 過程走訪 watcher 重新取值檢查快取值髒不髒

一次走訪中只要發現一點髒東西,就要再檢查一遍,直到確定數據與快取值完全一致,那麼肯定存在死迴圈的情況(兩個數據變化相互關聯),所以至少應該加上最大連續檢查次數限制(TTL),避免死迴圈,這裡嫌麻煩就不添了

P.S. 詳細的 Angular 實作解析見參考資料

用髒檢查機制實作數據-視圖的繫結:

var bind = function(scope, node) {
    scope._nodes = scope._nodes || [];
    scope._nodes.push(node);
    var key = node.getAttribute('data-bind');
    if (!key) return console.error('no data bing key');

    // init view
    updateView(scope, scope[key]);

    // data to view
    scope.$watch(function(){
        return scope[key];
    }, function(newValue, oldValue) {
        // console.log(oldValue, newValue);
        updateView(scope, newValue);
    });
};
// bind
bind($scope, $input);
bind($scope, $output);

同樣,再實作視圖-數據的繫結:

// view to data
$input.addEventListener('input', function() {
    $scope.value = $input.value;
    $scope.$digest();
});

私自修改數據,想立即更新視圖的話,要手動執行髒檢查:

// 手動改變量值
$('#btn').onclick = function() {
    $scope.value = 'updated value ' + Date.now();
    $scope.$digest();
};

完整 Demo 位址: http://ayqy.net/temp/data-binding/dirty-checking.html

五.虛擬 DOM

3 種數據繫結方式上面已經介紹完了,因為虛擬 DOM 不能算作另一種實作雙向數據繫結的方式(雖然虛擬 DOM 做到了單向數據繫結)

React 採用這種方式,考慮虛擬 DOM 樹的更新:

  • 屬性更新,組件自己處理

  • 結構更新,重新「渲染」子樹(虛擬 DOM ),找出最小改動步驟,打包 DOM 操作,給真實 DOM 樹打補丁

單純從數據繫結來看, React 虛擬 DOM 沒有數據繫結,因為 setState() 不維護上一個狀態(狀態丟棄),不追蹤變化,談不上繫結

從數據更新機制來看, React 類似於提供數據模型的方式(必須透過 state 更新)

P.S. 結構更新也可以說是建立一棵子樹,與現有子樹做 diff ,記下最小改動步驟,打包 DOM 操作更新真實 DOM。但實際上是在一次遞迴向下檢查過程中,邊更新虛擬 DOM 樹邊記錄最小改動步驟的,所以上面弱化兩棵樹做 diff ,強調向下檢查子樹

沒有雙向數據繫結的話, input 場景要怎麼實作?

var App = React.createClass({
  render: function() {
    return (
      <div>
        <input type="text" value={this.state.text} onChange={this.onChange} />
        <div>{this.state.text}</div>
      </div>
    )
  },
  getInitialState: function() {
    return {text: this.props.text}
  },
  onChange: function(e) {
    this.setState({
      text: e.target.value
    });
  }
});

ReactDOM.render(
  <App text="hoho" />,
  document.getElementById('container')
);

透過框架提供的 API,手動通知數據變化,和操作 DOM 的方式很像:

var text = 'hoho';
window.onInput = function(e) {
    text = e.target.value;
    document.getElementById('output').innerText = text;
};

document.getElementById('container').innerHTML = '<div><input type="text" value="' + text + '" oninput="onInput(event)" /><div id="output">' + text + '</div></div>';

六.更新效率優化

首次渲染好說,後續更新時都面臨怎樣把數據更新對應到真實 DOM 更新的問題

React 中, setState() 不關注數據是不是之前的,變了還是沒變,只要傳入了狀態,就丟棄上一個狀態。也就是說,把原數據物件原封不動地再 setState() 一遍,也必須向下檢查整棵子樹,才能得到數據沒變,不需要更新視圖的結論。因為狀態丟棄機制,不追蹤數據變化細節,即便傳入同一份數據,也沒辦法立即確定狀態沒變

Vue 也面臨這樣的問題,但情況會好一些, setter 發現變化後,不知道這次的狀態更新對子樹的影響。但維護數據狀態、追蹤變化方案的明顯優勢就是知道每份數據所關聯的視圖,也就是說與指定數據相關的真實節點列表是已知的,直接更新依賴這份數據的所有真實節點就好(當然,還需要考慮狀態「劇烈」變化時維護更新不如整個替換的優化),所以 Vue watcher 有依賴收集機制,就是為了加快向下檢查(非依賴項不檢查)

Angular 需要檢查當前 $scope$$watchers 陣列,才能確定狀態變沒變,這樣就做了很多沒必要的檢查,一個簡單值屬性更新,可能只對應一個文本節點,也要把 $scope 下所有數據-視圖的繫結關係全檢查一遍。因為也是狀態丟棄機制,髒檢查只關心值,不關心數據結構,手動修改數據後,通知髒檢查說變了,髒檢查只能挨個再取一遍值比較,因為髒檢查不知道剛才修改的數據與髒檢查要取的值之間的對應關係,無法縮小範圍

React 的話,有簡單的優化方案,可以跳過沒變的數據檢查:用不可變的數據結構,比如 Immutable.js

這樣不用挨個檢查 state 的屬性才能確定沒發生變化,直接先 equals() 看看,相等就沒必要往下了

Om 就是這樣做的:

I know exactly what didn't change.

用不可變的數據結構能夠快速排除砍掉沒變的,得到發生變化的部分,提升 diff 效率

P.S. 當然,髒檢查用不可變的數據結構沒有意義,還是得走訪取值才知道變了沒,髒檢查壓根不關注數據結構。 setter 監聽變化用不可變數據結構的話,就太不靈活了,而且監聽的粒度不夠細,喪失了優勢。至於提供數據模型的方式,就更不用說了,因為數據模型都不可變了,哪有變化需要監聽

七.參考資料

這次的參考資料有很高的參考價值,建議接著挨個當正文看

評論

暫無評論,快來發表你的看法吧

提交評論