Vue數據綁定原理及簡單實現


本篇文章中的代碼只是部分片段,完整代碼存放於github上https://github.com/Q-Zhan/simple-vue

進入正文~實現數據綁定主要是要實現兩個方面的功能:數據變化導致視圖變化,視圖變化導致數據變化。后者比較容易實現,就是監聽視圖的事件,然后在回調函數中改變數據。所以重點是數據變化時如何改變視圖。
這里的思路是通過object.defineProperty()來對數據的屬性設置一個set函數,設置后當數據改變時set函數就會被調用,我們就可以里面進行視圖更新操作。

具體實現過程


如上圖所示,我們需要一個監聽器Observer來給所有的屬性設置set函數。如果屬性發生了變化,就要通知所有的訂閱者Watcher。而這些Watcher統一存放在消息訂閱器Dep中,這樣比較方便統一管理。Watcher接受到來自Dep的通知后就執行相應的操作去更新視圖。

Observer

監聽器的核心代碼如下:

function observe(data) {
  if (!data || typeof data !== 'object') {
    return;
  }
  Object.keys(data).forEach(function(key) {  // 遍歷屬性,遞歸設置set函數
    defineReactive(data, key, data[key]);
  });
}
function defineReactive(data, key, val) {
  observe(val)
  var dep = new Dep()
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function() {
      if (Dep.target) {
        dep.addSub(Dep.target)  // 添加watcher
      }
      return val
    },
    set: function(newVal) {
      if (val === newVal) {
        return;
      }
      val = newVal;
      dep.notify()  // 通知dep
    }
  })
}

通過調用observe()函數來遞歸地給data對象設置set和get函數,在data的屬性被get時添加watcher,被set時通知dep,dep的notify會接着通知所有的watcher去執行更新操作。

這里需要對defineProperty做一個補充,上述的observe遞歸過程,在value值為對象時會繼續遞歸,只有當value值是非對象時才return,然后調用definePropery。所以對於data里面的數組arr,vue實際監聽的是arr[0]、arr[1]...arr[n],而不是arr本身。所以對於改變arr的操作,arr[0] = 9這樣是可以被監聽到的,而arr.push('123')這樣是不行的,因為push方法本質上只是改變了arr[n+1]的值,而這個值本身是沒有被監聽的,即沒有設置set函數。
vue為了方便我們對數組的操作,對數組的一些常用方法進行額外的封裝,即對vue的data的屬性的原型賦值為封裝層,當我們使用this.arr.push時,根據原型鏈向上找會先找到封裝層的push,而不會使用原生的push。封裝層的push做的事情是先觸發原生push方法,然后再監聽新push的項,再觸發消息訂閱器dep的notify方法,從而提醒watcher去更新視圖。

Dep

消息訂閱器的核心代碼如下:

function Dep() {
  this.subs = []  // 訂閱者數組
}
Dep.prototype = {
  addSub: function(sub) {
    this.subs.push(sub)
  },
  notify: function() {
    this.subs.forEach(function(sub) {
      sub.update()
    })
  }
}
Dep.target = null

消息訂閱器比較簡單,就是維護一個subs數組。當監聽新屬性時把它push進subs數組中,然后dep被通知時觸發notify函數,從而觸發subs數組中每個watcher的update操作。

Watcher

function Watcher(vm, exp, cb) {
  this.cb = cb
  this.vm = vm
  this.exp = exp
  this.value = this.get()
}

Watcher.prototype = {
  update: function() {
    this.run()
  },
  run: function() {
    var value = this.vm.data[this.exp]
    var oldVal = this.value
    if (value !== oldVal) {
      this.value = value
      this.cb.call(this.vm, value, oldVal)  // 執行更新時的回調函數
    }
  },
  get: function() {
    Dep.target = this
    var value = this.vm.data[this.exp]  // 讀取data的屬性,從而執行屬性的get函數
    Dep.target = null
    return value
  }
}

Watcher的主要功能是去觸發屬性的get函數,從而添加watcher到Dep的subs數組中。另外就是在update()中更新屬性的值並觸發更新回調函數。
使用Watcher的方法如下:

var el = document.getElementById('XXX')
observe(data)
new Watcher(vm, exp, function(value) {  // vm表示某個實例,exp表示屬性名
  el.innerHTML = value
})

為了使用時的整潔,我們需要把代碼稍微包裝下。

SimpleVue

function SimpleVue (data, el, exp) {
  var self = this
  this.data = data
  Object.keys(data).forEach(function(key) {
    self.proxyKeys(key)
  })
  observe(data)
  el.innerHTML = this.data[exp]
  new Watcher(this, exp, function(value) {
    el.innerHTML = value
  })
  return this
}

SimpleVue.prototype = {
  proxyKeys: function(key) {
    var self = this
    Object.defineProperty(this, key, {
      enumerable: false,
      configurable: true,
      get: function() {
        return self.data[key]
      },
      set: function(newVal) {
        self.data[key] = newVal
      }
    })
  }
}

SimpleVue做的事情就是使用observe遞歸地給data的每個屬性都加上get和set,然后對於要監聽的屬性exp新建一個Watcher對象去監聽。(Watcher對象觸發屬性exp的get函數從而添加訂閱事件到Dep,而且會在屬性的update方法里面觸發監聽回調函數)
使用如下:

// html
<h1 id="name">{{name}}</h1>  //這個{{name}}暫時沒用

// js
var el = document.querySelector('#name')
var selfVue = new SimpleVue({ name: 'hello'}, el, 'name')
setTimeout(function() {
  selfVue.name = '123'
}, 2000)

需要注意的是SimpleVue原型的proxyKeys是為了將selfVue.data.name這種操作代理為selfVue.name。這下我們就可以直接通過selfVue.name = "XXX"來改變數據了,並且視圖也會相應變化。

Compile

上面的例子都是寫死一個屬性去替換,而真正的使用時我們需要去解析dom節點,對類如{{}}的進行替換並綁定watcher。這個解析過程通過Compile來實現。

nodeToFragement: function(el) {
    var fragment = document.createDocumentFragment()
    var child = el.firstChild
    // 將dom節點移到fragment
    while(child) {
      fragment.appendChild(child)
      child = el.firstChild
    }
    return fragment
  },
  compileElement: function(el) {
    var childNodes = el.childNodes
    var self = this;
    [].slice.call(childNodes).forEach(function(node) {
      var reg = /\{\{(.*)\}\}/
      var text = node.textContent
      if (self.isTextNode(node) && reg.test(text)) {
        self.compileText(node, reg.exec(text)[1])
      }
      if (node.childNodes && node.childNodes.length) {
        self.compileElement(node)  // 遞歸遍歷子節點
      }
    });
  },
  compileText: function(node, exp) {
    var self = this
    var initText = this.vm[exp]
    this.updateText(node, initText)
    new Watcher(this.vm, exp, function(value) {
      self.updateText(node, value)
    })
  },

compile主要做三件事情。一是將dom節點移入DocumentFragment中去,因為DocumentFragment中操作dom節點不會引起瀏覽器的重繪,性能會比直接操作dom節點好很多。二是遞歸調用compileElement函數來遍歷所有子節點,如果子節點包含{{}}形式的則調用compileText。三是compileText函數創建新的watcher。

當然加入compile后SimpleVue也要有相應的變化:

function SimpleVue (options) {
  var self = this
  this.vm = this
  this.data = options.data
  Object.keys(this.data).forEach(function(key) {
    self.proxyKeys(key)
  })
  observe(this.data)
  new Compile(options.el, this.vm)
  return this
}

[參考資料]:https://www.cnblogs.com/libin-1/p/6893712.html。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM