Vue雙向綁定原理及實現(xiàn)
雖然類似的文章已經(jīng)很多了尉共,但是我還是要寫一篇博客來做個總結(jié)晨逝,也可以說是做個筆記?話不多說捷绒,直接開始瑰排。先上效果圖:
Object.defineProperty
Vue實現(xiàn)雙向綁定的核心是Object.defineProperty()
方法,它可以自定義屬性的setter
和getter
暖侨,如此一來椭住,我們就可劫持?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
屬性扳肛,并且定義了它的getter
和setter
方法傻挂,如此一來,在改變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):
監(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ù)中傳入el
和data
,此外還需要提供可供掛載的 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'
}
})
小結(jié)
- 監(jiān)聽器Observer:用來監(jiān)聽屬性變化以及通知訂閱者更新視圖。
- 訂閱者Watcher:觸發(fā)依賴收集宾尚,執(zhí)行視圖更新丙笋。
- 模板編譯Compile:初始化模板,解析指令煌贴,綁定訂閱者御板。
- 這個示例只是為了學(xué)習(xí),還有很多不完善的地方牛郑,源碼點我怠肋。