前言
之前項目中一直在用vue,也邊做邊學摸滾打爬了近一年。對一些基礎原理性的東西有過了解,但是不深入,例如面試經常問的vue的響應式原理,可能大多數人都能答出來Object.defineProperty進行數據劫持,但是深入其實現細節,還是有很多之前沒考慮到的東西,例如依賴收集后如何通知訂閱器,以及訂閱發布模式如何實現等等。過程中讀了部分源碼,受益匪淺,除此之外,動手去實現它也是個很棒的學習方式,話不多說,看代碼,倉庫地址。
實現
vue的更新機制我們簡單概括一下就是,先對template進行解析,若檢測到template中使用了data中定義的屬性,則生成一個對應的watcher,通過劫持getter進行依賴(即watcher)收集,收集的內容保存在訂閱器Dep,通過劫持setter做到改變屬性從而通知訂閱器更新,那么我們首先要做的就是對屬性進行劫持。
vue2.0中使用的是Object.defineProperty,有傳言說vue 3.0將會使用Proxy來代替Object.defineProperty,其有諸多好處:
- defineProperty不能對數組進行劫持,因此vue的文檔中才會提到只有push、pop等8種方法能夠檢測變化,而arr[index] = newValue並不能檢測變化,push等方法能檢測變化也是因為開發者對Array原生方法進行hack實現的。
- defineProperty只能改變對象的某一個屬性,若需要劫持整個對象,必須遍歷對象,對每個屬性劫持,因此效率並不高。而Proxy更像是一個代理,它會產生一個新的對象,該對象內部的屬性均以實現劫持。但要注意,某個屬性若也是一個對象類型,需要對該屬性也執行proxy操作才能實現劫持。
Proxy目前來看唯一的缺點就是兼容性可能存在問題,不過無傷大雅,我們也順應潮流,使用Proxy來實現數據劫持,代碼很簡單:
/**
* 接受一個對象,對屬性進行依賴追蹤
*/
function observable(obj) {
const dep = new Dep()
const proxy = new Proxy(obj, {
get(target, property) {
const value = target[property]
if (value && typeof value === 'object') { // 若屬性為object,遞歸處理
target[property] = observable(value)
}
if (Dep.target) { // Dep.target指向當前watcher
dep.addWatcher(Dep.target)
}
return target[property]
},
set(target, property, value) {
target[property] = value
dep.notify() // 通知訂閱器
}
})
return proxy
}
注意該方法需要返回proxy實例,因為只有通過proxy實例訪問屬性才具有劫持效果。我們可以看到代碼中有一個Dep,這個東西即是訂閱器,可以理解為它維護了一個依賴(watcher)的數組,並實現了一些管理數據的方法諸如addWatcher添加依賴,以及需要提供一個notify方法來遍歷所有的watcher執行其相應的更新函數,同樣代碼很簡單:
/**
* 依賴收集器,存放所有的watcher,並提供發布功能(notify)
*/
class Dep {
constructor() {
this.watchers = []
}
addWatcher(watcher) { // 添加watcher
this.watchers.push(watcher)
}
notify() { // 通知方法,調用即依次遍歷所有watcher執行更新
this.watchers.forEach((watcher) => {
watcher.update()
})
}
}
最后我們來看下watcher,我們知道watcher即我們所說的依賴,它是在編譯template的時候,若找到data中聲明的屬性,即會生成一個對應的watcher實例,觸發依賴收集,加入訂閱器。同時還需要提供一個update函數,在觸發notify的時候調用來更新視圖,代碼如下:
/**
* watcher即所謂的依賴,監聽具體的某個屬性
*/
class Watcher {
constructor(proxy, property, cb) {
this.proxy = proxy
this.property = property
this.cb = cb
this.value = this.get()
}
update() { // 執行更新
const newValue = this.proxy[this.property]
if (newValue !== this.value && this.cb) { // 對比property新舊值,決定是否更新
this.cb(newValue)
}
}
get() { // 只在初始化時調用,用於依賴收集
Dep.target = this // 將自身指向Dep.target,執行完依賴收集再去釋放
const value = this.proxy[this.property]
Dep.target = null
return value
}
}
至此,響應式原理大致已經成形,接着我們只要寫一個簡易的模板解析,demo就能跑起來啦。我這邊的實現比較挫,僅僅是通過正則匹配來實現了一個不帶diff的virture dom,純屬娛樂,重點還是在實現響應式原理上,這邊貼一下代碼:
let init = false // 只在初始化時去生成watcher
const eventMap = new Map() // 存放事件
const root = document.getElementById('root') // 根節點
/**
* 用於將傳入RayActive的vm對象進行代理,可通過this.xx訪問this.data.xx
* @param {Object} vm
* @param {Proxy} proxydata 經過proxy代理的vm.data對象,使this.xx操作也能觸發視圖更新
*/
function vmProxy(vm, proxydata) {
return new Proxy(vm, {
get(target, property) {
return target.data[property] || target.methods[property]
},
set(target, property, value) {
proxydata[property] = value
}
})
}
/**
* 編譯vm,分別對data和render做相應處理
* @param {Object} vm 需要被編譯的vm對象
*/
function compile(vm) {
const proxydata = compileData(vm.data)
compileRender(proxydata, vm.render)
bindEvents(vm, vmProxy(vm, proxydata))
}
/**
*
* @param {Object} data 需要被編譯的vm中的data對象
*/
function compileData(data) {
return observable(data)
}
/**
*
* @param {*} render 需要被編譯的render字符串
* @param {*} proxydata 經proxy轉換過的data
*/
function compileRender(proxydata, render) {
if (render) {
const variableRegexp = /\{\{(.*?)\}\}/g
const variableResult = render.replace(variableRegexp, (a, b) => { // 替換變量為相應的data值
if (!init) { // 只在初始化時去生成watcher
new Watcher(proxydata, b, function() {
compileRender(proxydata, render)
})
}
return proxydata[b]
})
const eventRegexp = /(?<=<.*)@(.*)="(.*?)"(?=.*>)/
const result = variableResult.replace(eventRegexp, (a, b, c) => { // 為綁定事件的標簽添加唯一id標識
const id = Math.random().toString(36).slice(2)
eventMap.set(id, {
type: b,
method: c
})
return a + ` id=${id}`
})
init = true
root.innerHTML = result
}
}
/**
* 通過root節點做事件代理,綁定模板中聲明的事件
* @param {*} vm
* @param {*} proxyvm 經過proxy代理的vm
*/
function bindEvents(vm, proxyvm) {
for (let [key, value] of eventMap) {
root.addEventListener(value.type, (e) => {
const method = vm.methods[value.method]
if (method && e.target.id === key) {
method.apply(proxyvm) // 將vm中methods方法的this指向經過proxy的vm對象
}
})
}
}
/**
* 可理解為Vue中的Vue類,使用方式為new RayActive(vm)
*/
class RayActive {
constructor(vm) {
compile(vm)
}
}
總結
這個簡易實現僅僅是幫助大家學習vue的一些原理性的東西,跟vue比其他來只是冰山一角。這個代碼還有很大的優化空間,比如執行notify時這里會通知所有的watcher等等,值得有空去研究一下。同時,我們能看到訂閱發布模式帶來的好處。如果不引入訂閱器,那我們更新dom的代碼得放到setter中去,那么就耦合了數據劫持與操作dom的邏輯。引入訂閱器,能讓我們在proxy中僅僅做依賴收集和通知的操作,剩下的各種復雜的或是個性化的邏輯可以放到watcher中去實現,完美做到了關注點分離。