對類Vue的MVVM前端庫的實現
關於實現MVVM,網上實在是太多了,本文為個人總結,結合源碼以及一些別人的實現
關於雙向綁定
- vue 數據劫持 + 訂閱 - 發布
- ng 臟值檢查
- backbone.js 訂閱-發布(這個沒有使用過,並不是主流的用法)
雙向綁定,從最基本的實現來說,就是在defineProperty綁定的基礎上在綁定input事件,達到v-model的功能
代碼思路圖
兩個版本:
- 簡單版本: 非常簡單,但是因為是es6,並且代碼極度簡化,所以不談功能,思路還是很清晰的
- 標準版本: 參照了Vue的部分源碼,代碼的功能高度向上抽取,閱讀稍微有點困難,實現了基本的功能,包括計算屬性,watch,核心功能都實現沒問題,但是不支持數組
簡單版本
簡單版本的地址: 簡單版本
? 這個MVVM也許代碼邏輯上面實現的並不完美,並不是正統的MVVM, 但是代碼很精簡,相對於源碼,要好理解很多,並且實現了v-model以及v-on methods的功能,代碼非常少,就100多行
class MVVM { constructor(options) { const { el, data, methods } = options this.methods = methods this.target = null this.observer(this, data) this.instruction(document.getElementById(el)) // 獲取掛載點 } // 數據監聽器 攔截所有data數據 傳給defineProperty用於數據劫持 observer(root, data) { for (const key in data) { this.definition(root, key, data[key]) } } // 將攔截的數據綁定到this上面 definition(root, key, value) { // if (typeof value === ‘object‘) { // 假如value是對象則接著遞歸 // return this.observer(value, value) // } let dispatcher = new Dispatcher() // 調度員 Object.defineProperty(root, key, { set(newValue) { value = newValue dispatcher.notify(newValue) }, get() { dispatcher.add(this.target) return value } }) } //指令解析器 instruction(dom) { const nodes = dom.childNodes; // 返回節點的子節點集合 // console.log(nodes); //查看節點屬性 for (const node of nodes) { // 與for in相反 for of 獲取叠代的value值 if (node.nodeType === 1) { // 元素節點返回1 const attrs = node.attributes //獲取屬性 for (const attr of attrs) { if (attr.name === ‘v-model‘) { let value = attr.value //獲取v-model的值 node.addEventListener(‘input‘, e => { // 鍵盤事件觸發 this[value] = e.target.value }) this.target = new Watcher(node, ‘input‘) // 儲存到訂閱者 this[value] // get一下,將 this.target 給調度員 } if (attr.name == "@click") { let value = attr.value // 獲取點擊事件名 node.addEventListener(‘click‘, this.methods[value].bind(this) ) } } } if (node.nodeType === 3) { // 文本節點返回3 let reg = /\{\{(.*)\}\}/; //匹配 {{ }} let match = node.nodeValue.match(reg) if (match) { // 匹配都就獲取{{}}裏面的變量 const value = match[1].trim() this.target = new Watcher(node, ‘text‘) this[value] = this[value] // get set更新一下數據 } } } } } //調度員 > 調度訂閱發布 class Dispatcher { constructor() { this.watchers = [] } add(watcher) { this.watchers.push(watcher) // 將指令解析器解析的數據節點的訂閱者存儲進來,便於訂閱 } notify(newValue) { this.watchers.map(watcher => watcher.update(newValue)) // 有數據發生,也就是觸發set事件,notify事件就會將新的data交給訂閱者,訂閱者負責更新 } } //訂閱發布者 MVVM核心 class Watcher { constructor(node, type) { this.node = node this.type = type } update(value) { if (this.type === ‘input‘) { this.node.value = value // 更新的數據通過訂閱者發布到dom } if (this.type === ‘text‘) { this.node.nodeValue = value } } }
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>MVVM</title> </head> <body> <div id="app"> <input type="text" v-model="text">{{ text }} <br> <button @click="update">重置</button> </div> <script src="./index.js"></script> <script> let mvvm = new MVVM({ el: ‘app‘, data: { text: ‘hello MVVM‘ }, methods: { update() { this.text = ‘‘ } } }) </script> </body> </html>
這個版本的MVVM因為代碼比較少,並且是ES6的原因,思路非常清晰
我們來看看從new MVVM開始,他都做了什麽
解讀簡單版本
new MVVM
首先,通過解構獲取所有的new MVVM傳進來的對象
class MVVM {
constructor(options) {
const {
el,
data,
methods
} = options
this.methods = methods // 提取methods,便於後面將this給methods
this.target = null // 後面有用
this.observer(this, data)
this.instruction(document.getElementById(el)) // 獲取掛載點
}
屬性劫持
開始執行this.observer observer是一個數據監聽器,將data的數據全部攔截下來
observer(root, data) {
for (const key in data) {
this.definition(root, key, data[key])
}
}
在this.definition裏面把data數據都劫持到this上面
definition(root, key, value) {
if (typeof value === ‘object‘) { // 假如value是對象則接著遞歸
return this.observer(value, value)
}
let dispatcher = new Dispatcher() // 調度員
Object.defineProperty(root, key, {
set(newValue) {
value = newValue
dispatcher.notify(newValue)
},
get() {
dispatcher.add(this.target)
return value
}
})
}
此時data的數據變化我們已經可以監聽到了,但是我們監聽到後還要與頁面進行實時相應,所以這裏我們使用調度員,在頁面初始化的時候get(),這樣this.target,也就是後面的指令解析器解析出來的v-model這樣的指令儲存到調度員裏面,主要請看後面的解析器的代碼
指令解析器
指令解析器通過執行 this.instruction(document.getElementById(el))
獲取掛載點
instruction(dom) {
const nodes = dom.childNodes; // 返回節點的子節點集合
// console.log(nodes); //查看節點屬性
for (const node of nodes) { // 與for in相反 for of 獲取叠代的value值
if (node.nodeType === 1) { // 元素節點返回1
const attrs = node.attributes //獲取屬性
for (const attr of attrs) {
if (attr.name === ‘v-model‘) {
let value = attr.value //獲取v-model的值
node.addEventListener(‘input‘, e => { // 鍵盤事件觸發
this[value] = e.target.value
})
this.target = new Watcher(node, ‘input‘) // 儲存到訂閱者
this[value] // get一下,將 this.target 給調度員
}
if (attr.name == "@click") {
let value = attr.value // 獲取點擊事件名
node.addEventListener(‘click‘,
this.methods[value].bind(this)
)
}
}
}
if (node.nodeType === 3) { // 文本節點返回3
let reg = /\{\{(.*)\}\}/; //匹配 {{ }}
let match = node.nodeValue.match(reg)
if (match) { // 匹配都就獲取{{}}裏面的變量
const value = match[1].trim()
this.target = new Watcher(node, ‘text‘)
this[value] = this[value] // get set更新一下數據
}
}
}
}
這裏代碼首先解析出來我們自定義的屬性然後,我們將@click的事件直接指向methods,methds就已經實現了
現在代碼模型是這樣
調度員Dispatcher與訂閱者Watcher
我們需要將Dispatcher和Watcher聯系起來
於是我們之前創建的變量this.target開始發揮他的作用了
正執行解析器裏面使用this.target將node節點,以及觸發關鍵詞存儲到當前的watcher 訂閱,然後我們獲取一下數據
this.target = new Watcher(node, ‘input‘) // 儲存到訂閱者
this[value] // get一下,將 this.target 給調度員
在執行this[value]的時候,觸發了get事件
get() {
dispatcher.add(this.target)
return value
}
這get事件裏面,我們將watcher訂閱者告知到調度員,調度員將訂閱事件存儲起來
//調度員 > 調度訂閱發布
class Dispatcher {
constructor() {
this.watchers = []
}
add(watcher) {
this.watchers.push(watcher) // 將指令解析器解析的數據節點的訂閱者存儲進來,便於訂閱
}
notify(newValue) {
this.watchers.map(watcher => watcher.update(newValue))
// 有數據發生,也就是觸發set事件,notify事件就會將新的data交給訂閱者,訂閱者負責更新
}
}
與input不太一樣的是文本節點不僅需要獲取,還需要set一下,因為要讓訂閱者更新node節點
this.target = new Watcher(node, ‘text‘)
this[value] = this[value] // get set更新一下數據
所以在訂閱者就添加了該事件,然後執行set
set(newValue) {
value = newValue
dispatcher.notify(newValue)
},
notfiy執行,訂閱發布者執行update更新node節點信息
class Watcher {
constructor(node, type) {
this.node = node
this.type = type
}
update(value) {
if (this.type === ‘input‘) {
this.node.value = value // 更新的數據通過訂閱者發布到dom
}
if (this.type === ‘text‘) {
this.node.nodeValue = value
}
}
}
頁面初始化完畢
更新數據
node.addEventListener(‘input‘, e => { // 鍵盤事件觸發
this[value] = e.target.value
})
this[value]也就是data數據發生變化,觸發set事件,既然觸發notfiy事件,notfiy遍歷所有節點,在遍歷的節點裏面根據頁面初始化的時候訂閱的觸發類型.進行頁面的刷新
現在可以完成的看看new MVVM的實現過程了
最簡單版本的MVVM完成
標準版本
標準版本額外實現了component,watch,因為模塊化代碼很碎的關系,看起來還是有難度的
從理念上來說,實現的思想基本是一樣的,可以參照上面的圖示,都是開始的時候都是攔截屬性,解析指令
代碼有將近300行,所以就貼一個地址標準版本MVVM
執行順序
- new MVVM
- 獲取$options = 所以參數
- 獲取data,便於後面劫持
- 因為是es5,後面forEach內部指向window,這不是我們想要的,所以存儲當前this 為me
- _proxyData劫持所有data數據
- 初始化計算屬性
- 通過Object.key()獲取計算屬性的屬性名
- 初始化計算屬性將計算屬性掛載到vm上
- 開始observer監聽數據
- 判斷data是否存在
- 存在就new Observer(創建監聽器)
- 數據全部進行進行defineProperty存取監聽處理,讓後面的數據變動都觸發這個的get/set
- 開始獲取掛載點
- 使用querySelector對象解析el
- 創建一個虛擬節點,並存儲當前的dom
- 解析虛擬dom
- 使用
childNodes
解析對象 - 因為是es5,所以使用
[].slice.call
將對象轉數組 - 獲取到後進行
{{ }}匹配
指令的匹配
以及遞歸子節點
- 指令的匹配: 匹配到指令因為不知道多少個指令名稱,所以這裏還是使用
[].slice.call
循環遍歷 - 解析到有
v-
的指令使用substring(2)
截取後面的屬性名稱 - 再判斷是不是指令
v-on
這裏就是匹配on
關鍵字,匹配到了就是事件指令,匹配不到就是普通指令 - 普通指令解析{{ data }}
_getVMVal
get會觸發MVVM的_proxyData事件 在_proxyData事件裏面觸發data的get事件 - 這時候到了observer的defineReactive的get裏面獲取到了數據,因為沒有Dispatcher.target,所以不進行會觸發調度員
- 至此
_getVMVal
獲取到了數據 modelUpdater
進行Dom上面的數據更新- 數據開始進行訂閱,在訂閱裏面留一個回調函數用於更新dom
- 在watcher(訂閱者)獲取
this
,訂閱的屬性
,回調
- 在this.getter這個屬性上面返回一個匿名函數,用於獲取data的值
- 觸發get事件,將當前watcher的this存儲到Dispatcher.garget上面
- 給this.getters,callvm的的this,執行匿名函數,獲取劫持下來的data,又觸發了MVVM的_proxyData的get事件,繼而有觸發了observer的defineReactive的get事件,不過這一次Dispatcher.target有值,執行了depend事件
- 在
depend
裏面執行了自己的addDep事件,並且將Observer自己的this傳進去 addDep
裏面執行了Dispatcher
的addSub
事件,- 在
addUsb
事件裏面將訂閱存儲到Dispatcher
裏面的this.watchers
裏面的 - 訂閱完成,後面將這些自定義的指令進行移除
- 重復操作,解析所有指令,v-on:click = "data"直接執行methods[data].bind(vm)
更新數據:
- 觸發input事件
- 觸發_setVMVal事件
- 觸發MVVM的set事件
- 觸發observer的set事件
- 觸發dep.notify()
- 觸發watcher的run方法
- 觸發new Watcher的回調 this.cb
- 觸發compile裏面的updaterFn 事件
- 更新視圖
component的實現
計算屬性的觸發 查看這個例子
computed: {
getHelloWord: function () {
return this.someStr + this.child.someStr;
}
},
其實計算屬性就是defineproperty的一個延伸
- 首先compile裏面解析獲取到{{ getHelloword }}‘
- 執行updater[textUpdater]
- 執行
_getVMVal
獲取計算屬性的返回值 - 獲取
vm[component]
就會執行下面的get事件
Object.defineProperty(me, key, {
get: typeof computed[key] === ‘function‘ ? computed[key] : computed[key].get,
set: function () {}
})
是function執行computed[getHelloword],也就是return 的 函數
this.someStr + this.child.someStr;
- 依次獲取data,觸發mvvm的get 以及observer的get,
初始化完成,到這裏還沒有綁定數據,僅僅是初始化完成了
- 開始訂閱該事件
new Watcher()
- component不是函數所以不是function 執行
this.parseGetter(expOrFn);
- 返回一個覆蓋expOrrn的匿名函數
- 開始初始化 執行get()
- 存儲當前this,開始獲取
vm[getHelloword]
- 觸發
component[getHelloword]
- 開始執行MVVM的get
this.someStr
- 到MVVM的get 到 observer的get 因為
Dispatcher.target
存著 getHelloWord 的this.depend ()
所以執行 - Dispatcher的
depend()
,執行watcher的addDep(),執行 Dispatcher的addSub()
將當前的watcher存儲到監聽器 - 開始get第二個數據 this.child.someStr,同理也將getHelloWord的this存入了當前的Dispatcher
- 開始get第三個數據 this.child,同理也將getHelloWord的this存入了當前的Dispatcher
這個執行順序有點迷,第二第三方反來了
this.parseGetter(expOrFn);
就執行完畢了
目前來看為什麽component會實時屬性數據?
因為component的依賴屬性一旦發生變化都會更新 getHelloword 的 watcher ,隨之執行回調更新dom
watch的實現
watch的實現相對來說要簡單很多
- 我們只要將watch監聽的數據告訴訂閱者就可以了
- 這樣,wacth更新了
- 觸發set,set觸發notify
- notify更新watcher
- watcher執行run
- run方法去執行watch的回調
- 即完成了watch的監聽
watch: function (key, cb) {
new Watcher(this, key, cb)
},
對類Vue的MVVM前端庫的實現