前段時間筆者學習了一下Vue的源碼,也看了一些前輩對Vue源碼研究的博客,然後用es實現了一個基礎的數據雙向綁定框架Hue,作爲學習成果之一,在此分享給大家。Hue實現了@click,v-model, watch監聽屬性變化這幾個基本的功能,後續如有需要大家可以自行擴展,比如hook之類的,整個框架的組織架構也可以自行調整。其中很多內容的設計和實現都是參照了Vue源碼來實現的,後面會對代碼實現做進一步的闡述。
先看一下實現的效果動態圖:
下圖是Hue實例化的運行過程,也是整個Hue實現數據雙向綁定的過程總結:
下圖是Hue的代碼文件結構:
瀏覽器運行index.html就能看到動圖中的內容,以下是index.html的代碼:
<div id="app">
<span>Welcome to {{project}}, it's an example</span>
<div style="margin-top: 10px">
<div class="row">
<div class="label">name: </div>
<input v-model="people.name" />
</div>
<div class="row">
<div class="label">height:</div>
<input v-model="people.height" />
</div>
</div>
<div>Hello, I am called {{people.name}} and my height is {{people.height}} cm</div>
<div>
<button @click="getTxt(people.name, people.height)">信息輸出</button>
<button @click="change('code', 173)">設置</button>
</div>
</div>
<script src="./util.js"></script>
<script src="./observer.js"></script>
<script src="./watcher.js"></script>
<script src="./dep.js"></script>
<script src="./compiler.js"></script>
<script src="./main.js"></script>
<script>
new Hue({
el: '#app',
data: {
count: 1,
project: 'Hue',
txt: '35',
people: {
name: 'Alice',
height: 160
}
},
watch: {
'people.name': {
handler (n, o) {
console.log('watch ===> newName:', n, 'oldName:', o)
}
},
// 對象監聽
// 'people': {
// deep: true,
// handler (n, o) {
// console.log('people changed...')
// }
// }
},
methods: {
change (name, height) {
this.people.name = name
this.people.height = height
},
getTxt (name, height) {
console.log('name:', name, 'height:', height)
}
}
})
</script>
<style>
.label{
width: 60px;
text-align: right;
margin-right: 10px;
}
.row{
display: flex;
margin-bottom: 10px;
}
</style>
整個風格也是照着Vue來實現的,這個例子測試了三部分的內容:1 數據雙向綁定;2屬性變化監聽;3不同參數類型的函數綁定(針對基本數據類型和data中的屬性)。從Hue實例化開始,數據雙向綁定的大門就被打開了,接下來讓我們一起解開那些背後的祕密。以下是main.js文件:
class Hue {
constructor (options) {
let vm = this
vm.$options = options
vm._data = options.data
for (let key in vm._data) {
proxy(vm, '_data', key)
}
// 初始化$watch監聽函數
vm.$watch = function (key, cb) {
new Watcher(vm, key, cb)
}
initOptions(vm)
// watch 選項解析
resolveWatch(vm)
// 編譯模板
new Compiler(vm.$options.el, vm)
}
}
這裏做的第一件事情就是把Hue實例化中的參數掛載到實例的$options上去,方便Hue中的函數獲取相關的數據,其中proxy函數對_data的內容做了一層攔截,起到在實例內部簡化調用的作用(例如data中有個屬性name, 可以通過this._data.name獲得,經過proxy代理之後可以通過this.name獲取,符合我們的調用習慣)。緊接着我們開始初始化$watch,函數內部實例化了一個Watcher,這是爲data數據添加回調函數的。後面陸續進行了options的數據劫持,實例watch的解析,模板的編譯。當中的函數來自util.js文件,以下是文件的內容:
function proxy (target, sourceKey, key) {
Reflect.defineProperty(target, key, {
set (newVal) {
target[sourceKey][key] = newVal
},
get () {
return target[sourceKey][key]
}
})
}
function observe (data) {
if (typeof data !== 'object') return false
return new Observer(data)
}
function defineReactive (obj, key, value) {
let dep = new Dep()
Reflect.defineProperty(obj, key, {
get () {
if (Dep.target) {
dep.addDepend()
}
return value
},
set (newVal) {
value = newVal
dep.notify()
}
})
}
function pushTarget (watcher) {
Dep.target = watcher
}
function popTarget () {
Dep.target = null
}
function resolveWatch (vm) {
let watch = vm.$options.watch
if (!watch) return 'no watch'
Object.keys(watch).forEach(item => {
if (typeof watch[item] === 'function') {
vm.$watch(item, watch[item])
}
if (typeof watch[item] === 'object') {
if (watch[item].deep) {
Object.keys(vm[item]).forEach(key => {
vm.$watch(item + '.' + key, watch[item].handler)
})
} else {
vm.$watch(item, watch[item].handler)
}
}
})
}
function initOptions (vm) {
observe(vm._data)
}
我們看到proxy函數裏面使用了Reflect而不是Object上的defineProperty函數去進行對象屬性的setter,getter設置,這是因爲Reflect對以往Object內部的一些方法的錯誤行爲結果進行了return false的處理而不是直接報錯,以及它收錄了以往的一些命令式的操作,把他們都函數化了,例如 delete o.name 變成了Reflect.deleteProperty(o, 'name'), 'name' in obj 變成了Reflect.has(obj, 'name') 等等,以上僅僅是Reflect對象的一部分功能,詳情可以去參考es6 Reflect對象的相關內容。
接下來我們看$watch函數中實例化的Watcher內容,以下是watcher.js內容:
class Watcher {
constructor (vm, expression, cb) {
this.vm = vm
this.expression = expression
this.cb = cb
this.value = this.get()
}
get () {
let val = this.vm
pushTarget(this)
this.expression.split('.').forEach(item => {
val = val[item]
})
popTarget()
return val
}
update () {
let val = this.vm
this.expression.split('.').forEach((key) => {
val = val[key]
})
this.cb.call(this.vm, val, this.value)
this.value = val
}
addDep (dep) {
dep.addSub(this)
}
}
這是發佈-訂閱模式中的訂閱者,構造函數中傳入了Hue實例,訂閱的屬性,回調函數。this.value用以存儲當前的屬性值,之後會隨着屬性值的變化而更新。接下來我們看watcher的get函數,pushTarget的目的是把當前的watcher實例賦值給Dep.target,接下來我們根據expression對屬性取值,這時候會觸發屬性的getter,發佈者會進行依賴收集(defineReactive進行數據劫持的時候會在詳細講解)。最後我們通過popTarget函數把Dep.target賦值爲null,返回屬性值,get函數的功能就完了。pushTarget,popTarget這一組函數用來對Dep.target綁定和釋放當前的watcher實例。vue源碼裏面會複雜一點,通過數組targetStack來追蹤記錄watcher,函數仍然是pushTarget和popTarget這一組。
initOptions開始處理data數據了,我們看到最後是調用了util中的observe函數,如果data不是對象,直接返回false。如果是對象那就開始Observer的實例化。下面是observer.js的內容:
class Observer {
constructor (data) {
this.walk(data)
}
walk (data) {
for (let key in data) {
if (typeof data[key] === 'object') {
this.walk(data[key])
continue
}
defineReactive(data, key, data[key])
}
}
}
Observer實例化的時候通過walk函數遍歷了data的屬性,通過defineReactive函數進行數據劫持,屬性值爲對象的則繼續遞歸遍歷。
function defineReactive (obj, key, value) {
let dep = new Dep()
Reflect.defineProperty(obj, key, {
get () {
if (Dep.target) {
dep.addDepend()
}
return value
},
set (newVal) {
value = newVal
dep.notify()
}
})
}
整個數據雙向綁定的靈魂就在於數據的獲取和設置是如何被監聽的,一旦監聽到變化我們就可以做相應的操作,defineProperty定義的getter和setter天然就提供了這麼一種監聽的能力。當我們訪問一個對象屬性的時候,getter就會被觸發,當我們設置對象屬性的時候setter就會被觸發。defineProperty 定義getter和setter的過程就是所謂的數據劫持了。我們看到defineReactive函數先實例化了一個Dep(發佈者),接着開始進行數據劫持,當Dep.target有綁定watcher(訂閱者)的時候就開始進行依賴收集(將當前訂閱者添加到發佈者的sub數組中,詳情可見下文Dep對象的介紹)。當數據更新時會觸發setter,這時將新值賦值給value用於getter的返回值,同時dep開始通知(notify)他的訂閱者(sub數組中的所有watcher實例)進行相應的操作(dom更新,或者是執行相關的回調函數)。下面我們來看發佈者Dep對象的內容,以下是dep.js的內容:
class Dep {
constructor () {
// 存放watcher
this.sub = []
}
// 依賴添加
addDepend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
addSub (sub) {
this.sub.push(sub)
}
notify () {
for (let sub of this.sub) {
sub.update()
}
}
}
這裏比較有意思的一點就是依賴添加的時候是從dep的addDepend繞到了watcher的addDep,最後又繞到了dep的addSub去進行最終的添加,而不是在addDepend中直接把Dep.target所綁定的watcher直接加到sub中去,這樣做的目的是watcher需要記錄發佈者的信息,以防重複添加相同的發佈者,在vue源碼中有所體現,而本文做極簡處理就不考慮dep的記錄了。以下是vue源碼的關於addDep函數的代碼,位置在源碼src\core\observer\watcher.js處:
/**
* Add a dependency to this directive.
*/
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
最後我們看一下notify函數,這裏就是通知sub中所有的watcher進行更新操作了。
到這裏爲止發佈-訂閱的模式就已經都實現了,接下來就是考慮如何更新{{}}中的內容了,也是就實例化Compiler,開始編譯$el的內容。讓我們看一下compiler.js的內容:
class Compiler {
constructor (el, vm) {
vm.$el = document.querySelector(el)
this.replace(vm.$el, vm)
}
replace (el, vm) {
let childNodes = [...el.childNodes]
let self = this
childNodes.forEach(node => {
let txt = node.textContent
// 正則匹配{{}}
let reg = /\{\{(.*?)\}\}/g
if (node.nodeType === Node.TEXT_NODE && reg.test(txt)) {
// 考慮文本內容出現多次{{}}
let moustache = txt.match(reg)
moustache.forEach(subMoustache => {
// 爲{{}}中的屬性綁定watcher
this.watch(node, subMoustache, vm, moustache, txt)
})
}
// 如果是元素節點
if (node.nodeType === Node.ELEMENT_NODE) {
let nodeAttr = [...node.attributes]
nodeAttr.forEach(attr => {
let name = attr.name
let exp = attr.value
switch (name) {
case 'v-model':
node.value = this.getExpValue(exp, vm)
vm.$watch(exp, function(newVal) {
node.value = newVal
})
node.addEventListener('input', e => {
let newVal = e.target.value
self.setExpValue(exp, newVal, vm)
})
break
case '@click':
node.addEventListener('click', e => {
let tep = /(.+)\((.*)\)/.exec(exp)
let [func, params] = [tep[1], tep[2].split(',')]
// 判斷參數是否來自data選項,若否則直接以字符形式作爲入參
params = params.map(key => {
return self.getExpValue(key, vm) || key
})
vm.$options.methods[func].apply(vm, params)
})
break
}
})
}
// 如果還有子節點,繼續遞歸replace
if (node.childNodes && node.childNodes.length) {
this.replace(node, vm);
}
})
}
watch (node, content, vm, moustache, txt) {
let self = this
let prop = (/\{\{(\S*)\}\}/).exec(content)[1]
self.replaceContent(node, moustache, vm, txt)
vm.$watch(prop, function () {
self.replaceContent(node, moustache, vm, txt)
})
}
replaceContent (node, moustache, vm, txt) {
for (let mkey of moustache) {
let prop = (/\{\{(\S*)\}\}/).exec(mkey)[1]
let value = this.getExpValue(prop, vm)
txt = txt.replace(mkey, value).trim()
}
node.textContent = txt
}
getExpValue (exp, vm) {
if (/^\'(.*)\'$/.test(exp) || /^(\d+)$/.test(exp)) return RegExp.$1
let arr = exp.trim().split('.')
let val = vm
for (let key of arr) {
val = val[key]
}
return val
}
setExpValue (exp, value, vm) {
let arr = exp.split('.')
let val = vm
arr.forEach((key, i)=> {
if (i === arr.length - 1) {
val[key] = value
return
}
val = val[key]
})
}
}
首先獲取el標識的dom,然後開始遞歸遍歷dom的子節點(childNodes),如果是元素節點那就遍歷節點的屬性開始實現自定義的指令比如v-model, @click等。如果是文本節點那就匹配文本中{{}}出現的屬性,在watch函數裏面用屬性值先替換掉{{}}中的內容,然後通過$watch監聽這些屬性變化,在回調函數裏面放置replaceContent以便更新節點的textContent(文本內容)。到此爲止一個基礎版的es數據雙向綁定框架Hue就初步完成了。其中compiler裏的難點就是同一個文本節點出現多個{{}}的時候如何替換,更進一步的話還需要考慮{{}}中是表達式的情況,這個時候就需要進一步的解析。這裏就不再進行更多的實現了,大家有興趣的話可以自行實現。判斷節點類型的時候筆者用到了Node.ELEMENT_NODE而不是1,用常量去代替具體的值,這是一個比較好的習慣,一方面讓代碼更具可讀性,另一方面也可以使之更加容易維護。
仿vue實現數據雙向綁定的分享到此就結束了,如果對於本文有任何疑問可以在下方留言。