1. 程式人生 > >JavaScript實現簡單的雙向資料繫結(Ember、Angular、Vue)

JavaScript實現簡單的雙向資料繫結(Ember、Angular、Vue)

什麼是雙向資料繫結呢?
簡單的說
就是UI檢視與資料繫結在了一塊
也就是資料和檢視是同步改變的
雙向資料繫結最常見的應用場景就是表單
(應用場景還是很有限的)

現在我們要實現這樣一個簡單的資料繫結
輸入欄中輸入字元
和它繫結的節點內容同步改變
此外還有一個按鈕用於生成隨機數改變input和div內的資料

首先我們先把需要把html的簡單結構實現

<input id="input" data-bind="demo">
<div id="output" data-bind="demo"></div>
<button id
="random">
隨機數</button>

還需要在js中獲取這些DOM節點

let $ = document.querySelector.bind(document);
let $i = $('#input');
let $o = $('#output');
let $random = $('#random');

簡易實現

如果僅僅是為了實現這樣的效果實際上非常簡單
我們很容易就可以想到input事件,然後動態改變
那麼我們首先就來簡單的實現一下

let def = 'default';
$i.value = def;
$o.textContent = def;

$i
.oninput = function(){ $o.textContent = $i.value; } $random.onclick = function(){ let rand = Math.floor(Math.random()*10e5) $i.value = rand; $o.textContent = rand; }

雖然實現了效果
但是實際上只有檢視改變,影響資料改變的過程
而且也沒有把節點聯絡在一起

資料模型(Ember.js原理)

Ember.js使用了這種資料模型的方法
雖然很麻煩,但是很容易讓我們理解
實際上就是把資料還有要節點封裝在了一起
這樣後續的更新一定會經過這個模型
模型就瞭解了變化
從而做出處理

首先我們來實現一個數據模型的類
當我們需要繫結一組節點時
就可以例項化這個資料模型
(為了方便下面我都使用ES6語法)

class DataModel {
  constructor(str = ''){
    this.data = str;
    this.nodes = [];
  }
  bindTo(node){
    this.nodes.push(node);
    this.update();
  }
  update(){
    const INPUT_NODE = ['INPUT','TEXTAREA'];
    let {nodes} = this;
    for(let i = 0, node; node = nodes[i++];){
      if(INPUT_NODE.includes(node.nodeName)){
        if(node.value !== this.data){ //避免游標跳到尾部
          node.value = this.data;
        }
      }else{
        node.textContent = this.data;
      }
    }
  }
  set(str){
    if(str !== this.value){
      this.data = str;
      this.update();
    }
  }
  get(){
    return this.data;
  }
}

this.data就是我們模型的資料
this.nodes是我們繫結的節點列表
bindTo方法接受我們的Dom節點並傳入節點列表
既然有新節點進加入組織了(節點繫結),那麼也肯定要讓它接受新的資料(資料與UI改變)
update方法用於更新檢視,實際上是遍歷所有繫結節點,判斷型別然後做出改變

聲明瞭資料模型類後我們就可以為input和div繫結到一個模型中了

let gModel = {
  demo: new DataModel('default')
};
//資料->檢視
gModel[$i.getAttribute('data-bind')].bindTo($i);
gModel[$o.getAttribute('data-bind')].bindTo($o);

gModel是我們宣告的一個全域性資料模型物件
因為頁面中不一定只有這一組資料繫結
$i$odata-bind屬性值就相當於它們的“組織名”
這裡我就起名demo了
使用模型的API來繫結這兩個節點

//檢視->資料
$i.addEventListener('input', function(){
  gModel[this.getAttribute('data-bind')].set(this.value);
});

$random.onclick = function(){
  gModel.demo.set(Math.floor(Math.random()*10e5));
}

最後繫結input事件還有按鈕的click事件
當輸入值後,就改變這個模型的data
set方法改變data的同時還會觸發所有與之繫結在一起的節點做出更新

髒檢查(Angular.js原理)

Augular.js採用髒檢查的方式來實現雙向資料繫結
其原理是不會去監聽資料的變化
而是我覺得你可能要發生資料變化的時候(使用者互動,DOM操作等)
就去檢查你的所有資料,看看到底有沒有變化
不過這個資料檢查是元件級別的
雖然如此,很多時候還是會產生很多沒用的檢查

我們需要模擬以下元件的核心函式

class Scope {
  constructor(){
    this.nodes = [];
    this.watchers = [];
  }
  watch(watchExp, listener = function(){}){
    this.watchers.push({
      watchExp,
      listener
    });
  }
  digest(){
    let dirty;
    let {watchers} = this;
    do {
      dirty = false;
      for(var i = 0, watcher; watcher = watchers[i++];){
        let newValue = watcher.watchExp();
        let oldValue = watcher.last;
        if(newValue !== oldValue){
          dirty = true;
          watcher.listener(newValue, oldValue);
          watcher.last = newValue;
        }
      }
    }while(dirty);
  }
  update(newValue){
    const INPUT_NODE = ['INPUT','TEXTAREA'];
    let {nodes} = this;
    for(let i = 0, node; node = nodes[i++];){
      if(INPUT_NODE.includes(node.nodeName)){
        if(node.value !== newValue){
          node.value = newValue;
        }
      }else{
        node.textContent = newValue;
      }
    } 
  }
  bindTo(node){
    let {nodes} = this;
    let key = node.getAttribute('data-bind');
    if(!key){
      return;
    }
    nodes.push(node);
    this.update(this[key]);
    this.watch(() => {
      return this[key];
    }, (newValue, oldValue) => {
      this.update(newValue);
    });
  }
}

區別於上一種方法
這裡的this.nodes代表某元件的全部節點
this.watchers陣列儲存著watcher
每一個watcher封裝著用於髒檢查的函式
而watch方法就負責向watchers中新增watcher
它接受兩個引數,一個取值函式watchExp和一個回撥函式listener

digest方法會遍歷整個watcher
last儲存著上一個值,再通過取值函式獲取值
通過比較可以知道值有沒有變髒
如果髒了,就觸發回撥函式(渲染資料)並且更新last值
還要重新檢查一遍watchers確保last和資料一致
(這裡沒有處理互相繫結死迴圈的問題,可以設定檢查上限)

宣告完元件類,我們就可以例項化一個元件
繫結節點,監聽事件,還要手動進行髒檢查

let scope = new Scope();
scope.demo = 'default';

//資料->檢視
scope.bindTo($i);
scope.bindTo($o);

//檢視->資料
$i.addEventListener('input', function(){
  scope[this.getAttribute('data-bind')] = this.value;
  scope.digest();
});

$random.onclick = function(){
  scope.demo = Math.floor(Math.random()*10e5);
  scope.digest();
}

訪問器監聽(Vue.js原理)

vue.js實現資料變化影響檢視變化的方式便是利用了ES5的setter
資料改變,觸發setter渲染檢視
檢視影響資料沒什麼好說的,肯定需要監聽input事件

這裡我就寫的簡單點了

let data = {};
let def = 'default';
$i.value = def;
$o.textContent = def;

//資料->檢視
Object.defineProperty(data, 'demo', {
  set: function(newValue){
    $i.value = newValue;
    $o.textContent = newValue;
  }
});

//檢視->資料
$i.addEventListener('input', function() {
  data[this.getAttribute('data-bind')] = this.value;
});

$random.onclick = function() {
  data.demo = Math.floor(Math.random()*10e5);
};

實際上vue實現的要比這複雜多得多
因為setter在很多情況下並不是萬金油
也就是說並不是物件屬性的任何變動它都能夠監聽的到
比如說以下場景:

  • 向物件新增新屬性
  • 刪除現有屬性
  • 陣列改變

關於這些問題這裡就不討論了
如果有時間的同學可以去研究以下原始碼

此外還要說明一下
原本ES7草案中的Object.observe()由於嚴重的效能問題已經被移除了