Vue雙向綁定原理及實現(xiàn)

Vue雙向綁定原理及實現(xiàn)

雖然類似的文章已經(jīng)很多了尉共,但是我還是要寫一篇博客來做個總結(jié)晨逝,也可以說是做個筆記?話不多說捷绒,直接開始瑰排。先上效果圖:

Vue雙向綁定.gif

Object.defineProperty

Vue實現(xiàn)雙向綁定的核心是Object.defineProperty()方法,它可以自定義屬性的settergetter暖侨,如此一來椭住,我們就可劫持?jǐn)?shù)據(jù),在數(shù)據(jù)改變時字逗,通過發(fā)布——訂閱模式來更新視圖函荣。請看下面的例子

const person = {}
Object.defineProperty(person, 'name', {
  get() {
    console.log(`get ${name}`)
    return name
  },
  set(newVal) {
    console.log(`set ${newVal}`)
    name = newVal
  }
})
person.name = 'smile' // 觸發(fā) set() 打印 set smile
person.name // 觸發(fā) get() 打印 get smile

上面的例子,通過Object.defineProperty()在對象person={}上定義了一個name屬性扳肛,并且定義了它的gettersetter方法傻挂,如此一來,在改變person上的name的時候就會觸發(fā)setter挖息,在讀取person上的name時就會觸發(fā)getter金拒。這時我們就可以在setter方法和getter方法中做一些事情,比如當(dāng)屬性發(fā)生變化時更新視圖了。

說白了就是绪抛,給監(jiān)聽的數(shù)據(jù)定義setter并編寫更新視圖的方法资铡,數(shù)據(jù)變化 -> 觸發(fā)set() -> 變化后的值更新到視圖

接下來,我們可以畫出流程圖幢码,并根據(jù)流程圖去實現(xiàn):

雙向綁定流程.png

監(jiān)聽器Observer和依賴收集

在知道如何通過setter方法劫持?jǐn)?shù)據(jù)后笤休,我們就可以開始實現(xiàn)了雙向綁定了。

const vm = new Vue({
  el: '#app',
  data: {
    name: 'smile'
  }
})

通過分析 Vue 實例的創(chuàng)建可以看到症副,在實例化時會傳入一個對象店雅,里面包括了el用于指定需要掛載的 DOM 節(jié)點,包括了data用于存儲需要雙向綁定的數(shù)據(jù)贞铣,因此我們需要定義一個 Vue 類闹啦,并在構(gòu)造函數(shù)中傳入eldata,此外還需要提供可供掛載的 DOM 節(jié)點辕坝。

<div id="app"></div>
// step0: 需要創(chuàng)建一個 Vue 類窍奋,并且在實例化時傳入需要監(jiān)聽的屬性,需要掛載的 DOM 節(jié)點位置
class Vue {
  constructor(options, prop) {
    this.$options = options
    this.$data = options.data
    this.$prop = prop
    this.$el = document.querySelector(options.el) // 獲取需要掛載的視圖模板

    observer(this.$data)  // step1: 對 data 中的屬性逐一定義 setter 方法
  }
}

接下來酱畅,我們需要對data里的屬性逐一定義setter方法琳袄,為了當(dāng)屬性發(fā)生變化時可以觸發(fā)setter方法,通知視圖更新纺酸。

function observer(data) {
  // data 必須存在窖逗,而且是個 object 類型
  if (!data || typeof data !== 'object') {
    return
  }
  // 給 data 中的屬性都定義 setter
  Object.keys(data).forEach(key => {
    defineReactive(data, key, data[key])
  })
}

接下來編寫defineReactive()方法,這時我們要想吁峻,既然要通知視圖更新,那么事先總得要收集需要更新的屬性在张,因為我們的視圖模板上可能會有多個屬性用含,因此我們創(chuàng)建一個Dep來收集所有的依賴,并進(jìn)行統(tǒng)一的管理

class Dep {
  constructor() {
    this.subs = [] // 存儲所有依賴
  }

  // 收集依賴
  addSubs(sub) {
    this.subs.push(sub)
  }

  // 通知視圖更新
  notify() {
    console.log('監(jiān)聽的屬性發(fā)生變化帮匾,觸發(fā)視圖更新')
    this.subs.forEach(sub => {
      // 通過調(diào)用依賴項 update() 方法來更新視圖
      sub.update()
    })
  }
}

那么問題來了啄骇,我們應(yīng)該在哪里收集依賴呢?在最上面的例子可以知道瘟斜,當(dāng)屬性被讀取時會觸發(fā)getter缸夹,因此我們可以根據(jù)屬性的getter方法來收集依賴。比如在視圖模板中定義了兩個{{name}}螺句,那么在我們模板編譯后獲取到兩個name屬性虽惭,然后依次觸發(fā)name屬性的getter方法收集依賴,依賴項中包含了視圖更新的回調(diào)蛇尚。這樣當(dāng)數(shù)據(jù)變化觸發(fā)setter后芽唇,就可以遍歷所有依賴,并執(zhí)行依賴的update方法來更新視圖了。現(xiàn)在不明白沒關(guān)系匆笤,等到最后就明白了研侣。

/**
  data 有可能是多級結(jié)構(gòu),所以需要遞歸的方式炮捧,給內(nèi)層的 object 屬性都定義 setter 方法
  data: {
    person: {
      age: 0,
      name: 'smile,
    }
  }
 */
function defineReactive(data, key, value) {
  observer(value) // value 可能還是一個 object
  const dep = new Dep()
  Object.defineProperty(data, key, {
    get() {
      // TODO: 這里進(jìn)行依賴收集
      // dep.addSubs()
      return value
    },
    set(newVal) {
      if (value !== newVal) {
        value = newVal
        dep.notify()
      }
    }
  })
}

現(xiàn)在庶诡,我們就監(jiān)聽到屬性的變化,并且觸發(fā)視圖更新的方法了

let vm = new Vue({
  el: '#app',
  data: {
    name: 'smile'
  }
})

// 賦值觸發(fā) set() 方法咆课,值發(fā)生改變末誓,觸發(fā) notify() 方法
// 因此控制臺打游凸А:監(jiān)聽的屬性發(fā)生變化秀菱,觸發(fā)視圖更新
vm.$data.name = 'laugh'

// 當(dāng) data 為多層級時

vm = new Vue({
  el: '#app',
  data: {
    person: {
      name: 'smile'
    }
  }
})

vm.$data.person.name = 'laugh'  // 同樣可以觸發(fā) setter

訂閱者Watcher

在上面的步驟中,已經(jīng)解決了通過setter觸發(fā)更新以及何時收集依賴的問題侥猬,現(xiàn)在我們就要定義訂閱者 Watcher 去觸發(fā)getter來進(jìn)行訂閱了善炫。在每次實例化 Watcher 的時候通過this.vm.$data[this.prop]來觸發(fā)getter進(jìn)行依賴收集并保存當(dāng)前的值撩幽。然后在 Watcher 中定義update()方法,當(dāng)值發(fā)生變化后觸發(fā)setter中調(diào)用update()方法執(zhí)行更新視圖的回調(diào)箩艺。

在有了訂閱者 Watcher 后窜醉,通過Dep.target = this把當(dāng)前 Watcher 保存到 Dep 中,并為了保證依賴只收集一次艺谆,需要在 Dep 的get()中判斷存在Dep.target時保存 Watcher榨惰,保存之后馬上把Dep.target置為null

// Dep.target = null 確保 Dep 的 target 屬性為空
class Dep {...}
Dep.target = null

// 改造 defineReactive() 中的 get()
get() {
  // 確保只收集一次
  if (Dep.target) {
    dep.addSubs(Dep.target)
  }
  return value
}

// 訂閱者 Watcher
class Watcher {
  // vm 表示當(dāng)前 Vue 實例
  // prop 表示需要訂閱的屬性
  // callback 表示觸發(fā)更新時的回調(diào)函數(shù),用于更新視圖模板
  constructor(vm, prop, callback) {
    this.vm = vm
    this.prop = prop
    this.callback = callback
    this.value = this.get()
  }

  update() {
    const value = this.vm.$data[this.prop]
    const oldVal = this.value
    if (value !== oldVal) {
      this.value = value
      this.callback(value)
    }
  }

  get() {
    Dep.target = this // 儲存訂閱器
    const value = this.vm.$data[this.prop] // 觸發(fā) get 收集訂閱器
    Dep.target = null
    return value
  }
}

此時每次執(zhí)行new Watcher()時相當(dāng)于:

Dep.target = this // 儲存訂閱器
if (Dep.target) { // 確保只在實例化 Watcher 時收集訂閱器
  dep.addSubs(Dep.target) // 收集訂閱器
}
Dep.target = null // 把 target 置為空静汤,避免后續(xù)觸發(fā) getter 時調(diào)用 addSubs() 方法

接下來就可以進(jìn)行測試了:

class Vue {
  constructor(options, prop) {
    this.$options = options
    this.$data = options.data
    this.$prop = prop
    this.$el = document.querySelector(options.el)

    observer(this.$data)

    // 把文本內(nèi)容設(shè)置為初始化時的值
    this.$el.textContent = this.$data[this.$prop]

    // 實例化訂閱器琅催,訂閱當(dāng)前屬性,當(dāng)值變化時執(zhí)行回調(diào)虫给,更換文本內(nèi)容
    new Watcher(this, this.$prop, value => {
      this.$el.textContent = value
    })
  }
}

const vm = new Vue({
  el: '#app',
  data: {
    name: 'smile'
  }
}, 'name')  // 第二個參數(shù)傳入需要監(jiān)聽的屬性 name

setTimeout(() => {
  vm.$data.name = 'laugh' // 兩秒后瀏覽器顯示的文本由 smile 變?yōu)?laugh
}, 2000)

到這里藤抡,就已經(jīng)完成響應(yīng)式核心代碼的編寫了,接下來就是解析模板抹估,解析出模板中{{name}}并添加訂閱者及更新函數(shù)缠黍,以及解析v-model等指令。

模板編譯Compile

在這個例子中药蜻,我們需要解析的 HTML 模板如下:

<div id="app">
  <input v-model="name" type="text">
  <h1>{{name}}</h1>
  <h2>{{name}}</h2>
</div>

在解析模板時瓷式,因為會頻繁操作 DOM,因此借助文檔片段(DocumentFragment)來優(yōu)化性能

class Compile {
  constructor(vm) {
    this.vm = vm
    this.el = vm.$el
    this.fragment = null
    this.init()
  }

  init() {
    // step0: 創(chuàng)建文檔片段
    this.fragment = this.createFragment(this.el)
    // step1: 解析模板 
    this.compileNode(this.fragment)
    // step2: 把模板添加到 DOM 中
    this.el.appendChild(this.fragment)
  }

  createFragment(el) {
    const fragment = document.createDocumentFragment()
    let child = el.firstChild
    // 將子節(jié)點语泽,全部 移動 文檔片段里
    while (child) {
      fragment.appendChild(child)
      child = el.firstChild
    }
    return fragment
  }

  compileNode(fragment) {
    let childNodes = fragment.childNodes
    Array.from(childNodes).forEach(node => {
      if (this.isElementNode(node)) {
        this.compile(node)
      }

      // 把插值表達(dá)式中的屬性匹配出來
      let reg = /\{\{(.*?)\}\}/
      let text = node.textContent

      if (reg.test(text)) {
        let prop = reg.exec(text)[1]
        this.compileText(node, prop) // 解析插值表達(dá)式
      }

      // 遞歸編譯子節(jié)點
      if (node.childNodes && node.childNodes.length) {
        this.compileNode(node)
      }
    })
  }

  compile(node) {
    let nodeAttrs = node.attributes
    Array.from(nodeAttrs).forEach(attr => {
      let name = attr.name
      // 判斷是否 Vue 指令
      if (this.isDirective(name)) {
        let value = attr.value
        if (name === 'v-model') {
          this.compileModel(node, value)
        }
      }
    })
  }

  // 編譯 v-model
  compileModel(node, prop) {
    let val = this.vm.$data[prop]
    this.updateModel(node, val)

    // 添加訂閱贸典,傳入視圖更新回調(diào)
    new Watcher(this.vm, prop, (value) => {
      this.updateModel(node, value)
    })

    // 監(jiān)聽 input 事件
    node.addEventListener('input', e => {
      let newValue = e.target.value
      if (val === newValue) {
        return
      }
      // 如果值發(fā)生改變,觸發(fā) setter 并更新視圖
      this.vm.$data[prop] = newValue
    })
  }

  compileText(node, prop) {
    let text = this.vm.$data[prop]
    // 把 {{name}} 替換為 name
    this.updateView(node, text)
    // 添加訂閱踱卵,傳入視圖更新回調(diào)
    new Watcher(this.vm, prop, (value) => {
      this.updateView(node, value)
    })
  }

  updateModel(node, value) {
    node.value = typeof value === 'undefined' ? '' : value
  }
  
  updateView(node, value) {
    node.textContent = typeof value === 'undefined' ? '' : value
  }

  isDirective(attr) {
    return attr.includes('v-')
  }

  isElementNode(node) {
    return node.nodeType === 1
  }
}

class Vue {
  constructor(options, prop) {
    this.$options = options
    this.$data = options.data
    this.$prop = prop
    this.$el = document.querySelector(options.el)

    observer(this.$data)
    new Compile(this)
  }
}

在這個示例中瓤漏,主要為了展示雙向綁定原理,因此沒有編譯其它指令,以及對各種情況的處理蔬充,但是到這里,就已經(jīng)足夠我們理解雙向綁定的原理饥漫,萬變不離其宗榨呆。

數(shù)據(jù)代理

在最后,我們修改data中的數(shù)據(jù)時需要通過vm.$data.name = 'smile'來修改庸队,我們想直接通過vm.name = 'smile'這樣來直接修改數(shù)據(jù)积蜻,結(jié)合最開始的Object.defineProperty()方法,我們又可以做一層數(shù)據(jù)代理彻消,把vm.key代理到vm.$data[key]上竿拆,如下代碼所示:

class Vue {
  constructor(options, prop) {
    this.$options = options
    this.$data = options.data
    this.$prop = prop
    this.$el = document.querySelector(options.el)

    Object.keys(this.$data).forEach(key => {
      this.proxyData(key)
    })

    observer(this.$data)
    new Compile(this)
  }

  proxyData(key) {
    Object.defineProperty(this, key, {
      get() {
        return this.$data[key]
      },
      set(value) {
        this.$data[key] = value
      }
    })
  }
}

const vm = new Vue({
  el: '#app',
  data: {
    name: 'smile'
  }
})
數(shù)據(jù)代理.gif

小結(jié)

  • 監(jiān)聽器Observer:用來監(jiān)聽屬性變化以及通知訂閱者更新視圖。
  • 訂閱者Watcher:觸發(fā)依賴收集宾尚,執(zhí)行視圖更新丙笋。
  • 模板編譯Compile:初始化模板,解析指令煌贴,綁定訂閱者御板。
  • 這個示例只是為了學(xué)習(xí),還有很多不完善的地方牛郑,源碼點我怠肋。
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市淹朋,隨后出現(xiàn)的幾起案子笙各,更是在濱河造成了極大的恐慌,老刑警劉巖础芍,帶你破解...
    沈念sama閱讀 216,372評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件杈抢,死亡現(xiàn)場離奇詭異,居然都是意外死亡者甲,警方通過查閱死者的電腦和手機春感,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評論 3 392
  • 文/潘曉璐 我一進(jìn)店門砌创,熙熙樓的掌柜王于貴愁眉苦臉地迎上來虏缸,“玉大人,你說我怎么就攤上這事嫩实」粽蓿” “怎么了?”我有些...
    開封第一講書人閱讀 162,415評論 0 353
  • 文/不壞的土叔 我叫張陵甲献,是天一觀的道長宰缤。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么慨灭? 我笑而不...
    開封第一講書人閱讀 58,157評論 1 292
  • 正文 為了忘掉前任朦乏,我火速辦了婚禮,結(jié)果婚禮上氧骤,老公的妹妹穿的比我還像新娘呻疹。我一直安慰自己,他們只是感情好筹陵,可當(dāng)我...
    茶點故事閱讀 67,171評論 6 388
  • 文/花漫 我一把揭開白布刽锤。 她就那樣靜靜地躺著,像睡著了一般朦佩。 火紅的嫁衣襯著肌膚如雪并思。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,125評論 1 297
  • 那天语稠,我揣著相機與錄音宋彼,去河邊找鬼。 笑死颅筋,一個胖子當(dāng)著我的面吹牛宙暇,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播议泵,決...
    沈念sama閱讀 40,028評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼占贫,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了先口?” 一聲冷哼從身側(cè)響起型奥,我...
    開封第一講書人閱讀 38,887評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎碉京,沒想到半個月后厢汹,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,310評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡谐宙,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,533評論 2 332
  • 正文 我和宋清朗相戀三年烫葬,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片凡蜻。...
    茶點故事閱讀 39,690評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡搭综,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出划栓,到底是詐尸還是另有隱情兑巾,我是刑警寧澤,帶...
    沈念sama閱讀 35,411評論 5 343
  • 正文 年R本政府宣布忠荞,位于F島的核電站蒋歌,受9級特大地震影響帅掘,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜堂油,卻給世界環(huán)境...
    茶點故事閱讀 41,004評論 3 325
  • 文/蒙蒙 一修档、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧府框,春花似錦萍悴、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至袜香,卻和暖如春撕予,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背蜈首。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評論 1 268
  • 我被黑心中介騙來泰國打工实抡, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人欢策。 一個月前我還...
    沈念sama閱讀 47,693評論 2 368
  • 正文 我出身青樓吆寨,卻偏偏與公主長得像,于是被迫代替她去往敵國和親踩寇。 傳聞我的和親對象是個殘疾皇子啄清,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,577評論 2 353

推薦閱讀更多精彩內(nèi)容