手寫 Vue (二):響應式

1. 響應式的本質

提到 Vue 的響應式,一般指的是視圖跟隨數據的改變而更新。開發上帶來的便利是,在須要更新視圖呈現時,只需修改視圖渲染所須要的數據便可,而不用手動操做DOM。從實現來講,能夠分爲兩個部分:html

  • 監聽數據改變
  • 更新視圖

咱們很熟悉如何監聽鼠標的點擊,鍵盤的輸入等用戶事件,可是不多直接去監聽一個數據改變的事件。雖然,不存在數據改變這個事件,可是監聽數據改變是能夠作到的,而且從程序設計角度來講,和給事件綁定一個回調函數沒有本質的不一樣。vue

爲了比較監聽普通事件和監聽數據改變的區別,咱們先使用事件的方式,來實現「響應式」視圖更新。node

下面的代碼中,咱們定義了數據變量data和視圖更新函數updateupdate函數在更新視圖時,讀取了datatext屬性做爲視圖節點的文本內容。而後監聽一個input元素的input事件,事件的回調函數中,將用戶輸入的值替換data.text的當前值,而後調用update函數,通知視圖進行更新。react

若是輸入不生效,請點擊這裏數組

<input id='text' />
<div id='app'></div>
<script> /* 定義渲染數據和視圖更新函數 */ var data = { text: 'hello' } function update() { document.getElementById('app').textContent = data.text } update() /* 綁定 input 事件,在修改數據後更新視圖 */ var textElm = document.getElementById('text') textElm.value = data.text textElm.addEventListener('input', function() { data.text = this.value update() }) </script>
複製代碼

藉助input事件,咱們間接實現了「響應式」,但它只是起到一個紐帶的做用,不能直接對數據的改變做出響應。瀏覽器

2. 監聽數據改變

2.1 Object.defineProperty

Object.defineProperty(obj, prop, descriptor) 能夠給對象添加或者修改已有屬性。函數接受三個參數:markdown

  • obj: 要定義屬性的對象
  • prop: 要定義或修改的屬性的名稱,能夠StringSymbol類型
  • descriptor: 要定義或修改的屬性描述符,必須是Object類型

這裏重點須要瞭解的是屬性描述符對象 descriptordescriptor 支持如下字段:app

  • configurable: Boolean,爲true時,才能改變屬性描述符,以及刪除屬性
  • enumerable: Boolean,爲true時,能夠經過for ... inObject.keys 方法枚舉
  • value: 該屬性對應的值。能夠是任何有效的 JavaScript 值
  • writable: Boolean,爲true時,屬性值,也就是 value 才能被賦值運算符改變
  • get: 屬性的 getter 函數,當訪問該屬性時,會調用此函數
  • set: 屬性的 setter 函數,當屬性值被修改時,會調用此函數

其中 valuewritable 只能出如今數據描述符中;而getset只能出如今存取描述符中。一個屬性描述符descriptor只能是其中之一,所以當定義了 valuewritable ,就不能再定義 getset,不然報錯 Cannot both specify accessors and a value or writable attribute。反之亦然。函數

因爲,咱們須要在對象屬性改變時得到通知,我須要使用存取描述符來定義對象屬性,即定義set來響應屬性值的修改,定義get來響應屬性的訪問。oop

以上文的data爲例,咱們但願在經過data.text = xxx的方式改變對象的屬性值時,更新視圖,因此要從新定義屬性text的描述符,在set函數中調用視圖更新函數update。這裏還須要定義get,由於,我不但須要對屬性值更改時做出響應,同時在update函數中,咱們還須要讀取data.text的值,而若是不定義get,獲取的值就爲undefined

var data = {
  text: 'hello'
}

var text = data.text

Object.defineProperty(data, 'text', {
  get: function() {
    return text
  },
  set: function(newValue) {
    if (text !== newValue) {
      text = newValue
      update()
    }
  }
})
複製代碼

這樣定義後,咱們即可以直接修改data.text值更新視圖了。讀者能夠將如下完整代碼,保存到一個 html 文件中,而後在瀏覽器控制檯中經過data.text = 'world'賦值的方式,查看視圖的變化。

<div id='app'></div>
<script> /* 定義渲染數據和視圖更新函數 */ var data = { text: 'hello' } function update() { document.getElementById('app').textContent = data.text } update() /* 使用 Object.defineProperty 實現響應式視圖更新 */ var text = data.text Object.defineProperty(data, 'text', { get: function() { return text }, set: function(newValue) { if (text !== newValue) { text = newValue update() } } }) </script>
複製代碼

這裏只是針對data的屬性text定義響應式。爲了代碼更加通用,以用於任意對象,能夠編寫一個函數defineReactive(obj, key, update)(函數名參考了 Vue2 的定義,讀者能夠在 Vue2 源碼中搜索該函數)。

function defineReactive(obj, key, update) {
  var value = obj[key]
  Object.defineProperty(obj, key, {
    get: function() {
      return value
    },
    set: function(newValue) {
      if (value !== newValue) {
        value = newValue
        update()
      }
    }
  })
  return obj
}
複製代碼

因而上面的代碼能夠改寫成:

var data = {
  text: 'hello'
}
function update() {
  document.getElementById('app').textContent = data.text
}
update()

defineReactive(data, 'text', update)
複製代碼

2.2 Proxy

響應對象屬性改變,除了Object.definProperty外,瀏覽器還支持另外一個全局的構造函數Proxy,用於自定義對象的基本操做,如:屬性查找,賦值,枚舉,函數調用等。相比而言,前者只能自定義對象屬性的訪問和賦值。

Proxy的使用方法以下:

const proxy = new Proxy(target, handler)
複製代碼
  • target: 須要代理的目標對象。能夠是任何類型的對象,包括原生數組,函數,甚至另外一個代理
  • handler: 以函數做爲屬性的對象。屬性中的函數分別定義了在對 proxy 實例執行各類操做的自定義行爲

handelr 對象支持的方法(一般被稱爲traps,中文翻譯爲陷阱,能夠理解爲鉤子或者執行某項操做的回調函數)有:

  • get: 讀取屬性值時調用
  • set: 對屬性賦值時調用
  • has: 使用in操做符時調用
  • deleteProperty: 使用delete操做符時調用
  • ownKeys: 使用Object.getOwnPropertyNames方法和Object.getOwnPropertySymbols方法時調用
  • apply: 函數調用操做時調用
  • construct: 使用new操做符時調用
  • defineProperty: 使用Object.defineProperty方法時調用
  • getOwnPropertyDescriptor: 使用Object.getOwnPropertyDescriptor方法時調用
  • getPrototypeOf: 使用Object.getPrototypeOf方法時調用
  • setPrototypeOf: 使用Object.setPrototypeOf方法時調用
  • isExtensible: 使用Object.isExtensible方法時調用
  • preventExtensions: 使用Object.preventExtensions方法時調用

能夠看到Proxy對對象自定義行爲的控制比Object.defineProperty更加全面。這裏,咱們重點關注和後者相同部分,即getset。雖然名稱都是getset,但方法的傳參不一樣。Object.defineProperty是針對對象的某個屬性定義getset,而Proxy是針對整個對象。而且經過Proxy構造函數返回的是一個proxy實例,而不是原對象。所以,Proxy中的getset參數比Object.defineProperty的多了兩個參數:

  • obj: 要代理的目標對象,即 target
  • key: 代理對象訪問或設置的屬性

之前文的data對象爲例,定義getset方法以下:

const dataProxy = new Proxy(data, {
  get(obj, key) {
    return obj[key]
  },
  set(obj, key, newValue) {
    obj[key] = newValue
    // 表示成功
    return true
  }
})
複製代碼

這裏和Object.defineProperty還有最大不一樣的是,前者響應式在新返回的代理對象生效,而對原對象屬性盡心訪問和修改是不會觸發setget回調的。所以,若是使用Proxy重寫前文的響應式視圖更新,須要在讀取和設置對象屬性時使用dataProxy,完整代碼以下:

<div id='app'></div>
<script> function reactive(target, update) { var targetProxy = new Proxy(target, { get(obj, key) { return obj[key] }, set(obj, key, newValue) { obj[key] = newValue update() // 表示成功 return true } }) return targetProxy } var data = { text: 'hello' } var dataProxy = reactive(data, update) function update() { document.getElementById('app').textContent = dataProxy.text } update() </script>
複製代碼

若是一樣在瀏覽器控制檯修改數據,咱們應該使用dataProxy.text = 'xxx' 而不是 data.text = 'xxxx'

3. 基於虛擬DOM的視圖更新

《手寫 Vue (一)》中,咱們實現了基於虛擬 DOM 的視圖掛載。如今結合響應式實現虛擬 DOM 的到真實 DOM 的響應式更新。

完整代碼以下:

function Vue(options) {
  var vm = this
  function update () {
    vm.update()
  }
  var data = options.data
  var keys = Object.keys(data)
  for (var i = 0, l = keys.length; i < l; i++) {
    var key = keys[i]
    this[key] = data[key]
    defineReactive(this, key, update)
  }
  this.$options = options
}

Vue.prototype.render = function() {
  var render = this.$options.render
  return render.call(this, createVNode)
}

Vue.prototype.update = function() {
  var vnode = this.render()
  this.$el = createElm(vnode, this.$el.parentNode, this.$el)
}

Vue.prototype.$mount = function (id) {
  this.$el = document.querySelector(id)
  this.update()
  return this
}

function createVNode(tag, data, children) {
  var vnode = { tag: tag, data: undefined, children: undefined, text: undefined }
  if (typeof data === 'string') {
    vnode.text = data
  } else {
    vnode.data = data
    if (Array.isArray(children)) {
      vnode.children = children
    } else {
      vnode.children = [ children ]
    }
  }
  return vnode
}

function createElm(vnode, parentElm, refElm) {
  var elm
  // 建立真實DOM節點
  if (vnode.tag) {
    elm = document.createElement(vnode.tag)
  } else if (vnode.text) {
    elm = document.createTextNode(vnode.text)
  }
  // 將真實DOM節點插入到文檔中
  if (refElm) {
    parentElm.insertBefore(elm, refElm)
    parentElm.removeChild(refElm)
  } else {
    parentElm.appendChild(elm)
  }

  // 遞歸建立子節點
  if (Array.isArray(vnode.children)) {
    for (var i = 0, l = vnode.children.length; i < l; i++) {
      var childVNode = vnode.children[i]
      createElm(childVNode, elm)
    }
  } else if (vnode.text) {
    elm.textContent = vnode.text
  }

  return elm
}

function defineReactive(obj, key, update) {
  var value = obj[key]
  Object.defineProperty(obj, key, {
    get: function() {
      return value
    },
    set: function(newValue) {
      if (value !== newValue) {
        value = newValue
        update()
      }
    }
  })
  return obj
}
複製代碼

將以上代碼保存到文件myvue_2.js中,再新建html文件myvue_2.html,替換如下內容:

<div id="app"></div>
<script src="myvue_2.js"></script>
<script> var vm = new Vue( { data: { text: 'hello world!' }, render(h) { return h('div', this.text) } } ).$mount('#app') </script>
複製代碼

嘗試在瀏覽器控制檯輸入:

vm.text = 'anything you like!!!'
複製代碼

若是看到顯示內容即時更新爲你修改的內容,那麼,恭喜你成功作到了和 Vue 同樣的響應式視圖更新。

小結

咱們成功利用set攔截,實現了響應式視圖更新,可是還不夠完美,由於,咱們對data對象中任何屬性的賦值都會執行視圖更新操做,而無論update是否用到了這個屬性。這意味着,若是data有不少個屬性,但並不是全部屬性都會用於視圖的渲染,這樣咱們就會作一些多餘的視圖更新操做,顯然這是沒有意義的性能開銷。要作到自動根據update中實際使用的到屬性,只對用到的屬性執行視圖更新,就涉及到依賴的蒐集。關於依賴蒐集的實現,咱們在下一篇文章中繼續探討。

相關文章
相關標籤/搜索