Vue實現數據雙向綁定主要利用的就是: 數據劫持和 發佈訂閱模式。
所謂發佈訂閱模式就是,定義了對象間的一種 一對多的關係, 讓多個觀察者對象同時監聽某一個主題對象,當一個對象發生改變時,全部依賴於它的對象都將獲得通知。
所謂數據劫持,就是 利用JavaScript的訪問器屬性,即 Object.defineProperty()方法,當對對象的屬性進行賦值時,Object.defineProperty就能夠 經過set方法劫持到數據的變化,而後 通知發佈者(主題對象)去通知全部觀察者,觀察者收到通知後,就會對視圖進行更新。
如上圖所示,View模板首先通過 Compiler(編譯器對象)進行編譯,在編譯的過程當中, 會分析模板中哪裏使用到了Vue數據(Model中的數據), 一旦使用到了Vue數據(Model中的數據),就會建立一個Water(觀察者對象),而且將這個觀察者對象添加到發佈者對象的數組中,同時獲取到Vue中的數據替換編譯生成一個新的View視圖。
在建立Vue實例的過程當中,會對Vue data中的數據進行數據劫持操做,即將data上的屬性都經過Object.definePropery()的方式代理到Vue實例上, 當View視圖或者Vue Model中發生數據變化的時候,就會被劫持,而後通知Dep發佈者對象進行視圖的更新,從而實現數據的雙向綁定。
// index.htmlhtml
<body> <div id="app"> <input type="text" v-model="scholl.name"> <div>{{scholl.name}} {{scholl.age}}</div> </div> </body> <script src="./vue.js"></script> <script> let vm = new Vue({ el: "#app", data : { scholl : { name: "zf", age: 10 } } }); </script>
咱們使用Vue的時候,是直接new一個Vue對象,並傳入一個options配置對象,裏面有el和data,先簡單點只配置el和data兩個屬性,因此 vue.js中存在一個Vue類,如:
// vue.js
class Vue { constructor(options) { this.$el = options.el; // 保存傳遞的el屬性 this.$data = options.data; // 保存傳入的data屬性 } }
要實現一個簡易Vue,第一步就是要編譯模板,那麼咱們該什麼時候發起模板的編譯操做呢?咱們應該在建立Vue實例的時候,在 其構造函數中就應該開始發起模板編譯操做,如:
// vue.js
class Vue { constructor(options) { this.$el = options.el; // 保存傳遞的el屬性 this.$data = options.data; // 保存傳入的data屬性 new Complier(this.$el, this); // 在建立Vue實例的過程當中當即發起模板編譯操做 } }
從上面能夠看出Compiler也是一個類,傳入了el和Vue實例對象, 編譯的第一步就是將View模板中的內容所有轉換爲文檔片斷進行操做,由於模板可能會很是的複雜,而模板的編譯是一個頻繁操做DOM的過程,若是直接操做真實的DOM會很是影響頁面性能,由於 文檔片斷存在於內存中, 並不在DOM樹中,因此將子元素插入到文檔片斷時不會引發頁面迴流,從而能夠提高頁面性能, document.createDocumentFragment()方法能夠建立文檔片斷,如:
class Complier { constructor(el, vm) { // 由於配置options.el的時候el能夠傳入選擇器還能夠直接傳入DOM元素 this.el = this.isElementNode(el) ? el : document.querySelector(el); this.vm = vm; // 將Vue實例保存到編譯器對象上 // 傳入this.el,即el對應的DOM元素,也就是根節點DOM let fragment = this.node2fragment(this.el); // 編譯模板,將真實DOM劫持到文檔片斷中後,就能夠開始進行模板編譯了,用Vue中的數據進行替換等 this.compile(fragment); // 將編譯好的模板添加回到頁面中,以便在頁面中顯示出來 this.el.appendChild(fragment); } isElementNode(node) { // 判斷是不是DOM元素節點 return node.nodeType === 1; } node2fragment(node) { // 將真實DOM劫持到內存中 let fragment = document.createDocumentFragment(); // 建立一個文檔片斷 let firstChild; while(firstChild = node.firstChild) { // 遍歷傳入節點中的全部子節點,而後依次添加到文檔片斷中 // appendChild具備移動性,能夠劫持頁面中的真實DOM到內存中 fragment.appendChild(firstChild); } return fragment; } }
將el中的因此子節點劫持到內存中後,就能夠開始在內存中進行編譯操做了,從上面能夠看到,是直接調用Compiler中的compile方法,因此接下來咱們須要實現這個compile()方法,編譯過程就是 遍歷文檔片斷中的全部子節點, 而後根據子節點的類型進行區分,若是是元素節點,那麼進行元素節點編譯,若是是文本節點,那麼進行文本節點編譯,而且, 若是是元素節點,那麼還有對該元素節點繼續遞歸編譯,即 繼續遍歷該元素節點的子節點,如:
class Compiler { compile(node) { let childNodes = node.childNodes; // 獲取傳遞節點的全部子節點 [...childNodes].forEach((child) => { // 遍歷傳遞節點的全部子節點 if (this.isElementNode(child)) { // 若是是元素節點 this.compileElement(child); // 編譯元素節點,好比元素上面的指令等 this.compile(child); // 遞歸編譯元素節點 } else { this.compileText(child); // 編譯文本節點,即{{}}mustache表達式 } }); } }
接下來就是要 實現對元素節點和文本節點的編譯,即實現compileElement()和compileText()方法,對於元素節點,首先 獲取到元素節點上的全部屬性,而後開始 遍歷屬性, 判斷是否有帶"v-"的屬性,若是有那麼就是一個指令,而後對指令進行處理,指令的做用就是操做DOM,因此須要傳入DOM節點,vm、指令表達式,如:
// 在Complier中添加一個compileElement()方法 class Complier { compileElement(node){ let attributes = node.attributes; // 取出元素節點上的全部屬性 [...attributes].forEach((attr) => { let {name, value:expr} = attr; // 獲取到帶v-的指令名和指令表達式 if (this.isDirective(name)) { // 若是該屬性名是vue指令,即以v-開頭 let [, directive] = name.split("-"); // 去除v-,獲取帶參數和修飾符的指令名 let [directiveName, eventName] = directive.split(":"); // 將指令名和事件名拆開,如v-on:click, 則分別爲 on click CompileUtil[directiveName](node, expr, this.vm, eventName); // 傳遞DOM元素和指令表達式以及vm進行指令處理 } }); } }
上面使用到了CompileUtil編譯工具對象專門進行各類指令的具體處理,添加一個CompileUtil對象裏面有各類工具方法,如model、text,因爲指令的做用,主要就是操做DOM,因此裏面主要就是根據指令表達式從vm中獲取到數據,而後操做DOM進行值的設置,如:
// 添加一個CompileUtil工具對象 var CompileUtil = { getVal(vm, expr) { // 根據vm和指令表達式從vm中獲取數據 return expr.split(".").reduce((data, current) => { return data[current]; }, vm.$data); }, model(node, expr, vm) { const value = this.getVal(vm, expr); // 獲取表達式的值 node.value = value; // 對於v-model指令,直接給DOM的value屬性賦值便可 } }
這裏主要理解getVal()方法便可,這裏用到了 reduce()進行累加操做,主要是由於表達式,若是是多個點的形式,如"scholl.name",那麼能夠以vm中的data做爲最初數據,而後遍歷每一個屬性名, 進行"."的累加操做,即vm.$data.scholl.name進行獲取值。
能夠經過 /\{\{(.+?)\}\}/正則表達式檢測是否存在{{}},而後對{{}}表達式進行替換便可,如:
// 在Complier中添加一個compileText()方法 class Complier { compileText(node){ const content = node.textContent; if(/\{\{(.+?)\}\}/.test(content)) { // 檢測文本節點中是否含有{{}}表達式 CompileUtil["text"](node, content, this.vm); } } }
將整個文本內容交給CompileUtil中的text方法進行處理,即將{{}}替換掉而後用替換後的值再替換DOM的文本內容,如:
var CompileUtil = { text(node, expr, vm) { let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => { return this.getVal(vm, args[1]); }); node.textContent = content; // 替換文本節點的內容 } }
至此,編譯已經完成,已經能夠在頁面上看到 vue指令和 {{}}表達式編譯後的數據了。
此時模板雖然編譯成功了,可是當vue中data裏的數據發生變化的時候,整個Vue對象並不能檢測到數據發生了變化,由於vue中的data尚未添加數據劫持,即 尚未經過Object.defineProperty()方法進行從新定義,因此 須要在編譯模板前對vue中data進行觀察即數據劫持。
class Vue { constructor(options) { this.$el = options.el; // 保存傳遞的el屬性 this.$data = options.data; // 保存傳入的data屬性 // 添加數據劫持,將數據所有轉化成Object.defineProperty()來定義 new Observer(this.$data); new Complier(this.$el, this); // 在建立Vue實例的過程當中當即發起模板編譯操做 } }
上面是直接建立Observer對象並傳入data進行數據劫持的,因此須要建立一個Observer類,在其構造函數中進行數據劫持,如:
class Observer { constructor(data) { this.observer(data); } observer(data) { if (data && typeof data \=== "object") { // 若是傳入的data是一個對象,遍歷data對象中的全部屬性改爲Object.defineProperty的形式 for (let key in data) { this.defineReactive(data, key, data[key]); } } } defineReactive(obj, key, value) { this.observer(value); // 遞歸觀察數據,若是data中的某個屬性的屬性值爲對象,則也要進行觀察 Object.defineProperty(obj, key, { get() { return value; }, set: (newValue) => { if (newValue != value) { this.observer(newValue); // 若是賦值的是對象那麼也進行新數據監控 value = newValue; } } }); } }
這樣,當vue中data數據發生變化的時候就會被get()和set()劫持到,從而能夠進行視圖的更新。
此時雖然已經能夠劫持到vue中data的數據變化了,可是還不能進行頁面的更新,由於 目前還不知道頁面上有哪些地方用到了該數據,因此必須在編譯的時候,若是發現有某個地方用到了vue中的數據,那麼就註冊一個Watcher觀察者,而後檢測到數據發生變化的時候,經過發佈者去通知全部觀察者,觀察者收到通知後進行頁面的更新便可實現數據的雙向綁定。
// 添加Watcher觀察者類 class Watcher { constructor(vm, expr, cb) { Dep.target = this; // 每次建立Watcher對象的時候,將建立的Watcher對象在獲取值的時候添加到dep中 this.vm = vm; this.expr = expr; this.cb = cb; // 默認先存放舊值 this.oldValue = this.get(); Dep.target = null; // 添加Watcher對象後清空,防止每次獲取數據的時候都添加Watcher對象 } get() { let value = CompileUtil.getVal(this.vm, this.expr); return value; } update() { let newValue = CompileUtil.getVal(this.vm, this.expr); if (newValue !== this.oldValue) { this.cb(newValue); } } }
// 添加Dep發佈者類 class Dep { constructor() { this.subs = []; // 存放全部的watcher } // 訂閱 addSub(watcher) { // 添加watcher this.subs.push(watcher); } // 發佈,遍歷全部的觀察者,調用觀察者的update進行頁面的更新 notify() { this.subs.forEach((watcher) => { watcher.update(); }); } }
建立Watcher對象的時候,須要傳遞vm和表達式,爲了獲取到表達式的值,同時傳遞了一個回調函數,主要是爲了把變化後的值傳遞出去以便更新視圖。那麼應該在何時建立Watcher對象呢?應該在模板編譯的時候,當檢測到元素上使用了vue指令綁定data中的數據或者使用mustache表達式綁定data中的數據的時候,就須要建立一個Watcher對象了,如:
CompileUtil = { model(node, expr, vm) { new Watcher(vm, expr, (newValue) => { node.value = newValue; }); const value = this.getVal(vm, expr); // 獲取表達式的值 node.value = value; // 對於v-model指令,直接給DOM的value屬性賦值便可 }, getContentValue(vm, expr) { return expr.replace(/\{\{(.+?)\}\}/g,(...args) => { return this.getVal(vm, args\[1\]); // 從新獲取最新的值 }); }, text(node, expr, vm) { let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => { new Watcher(vm, args[1], () => { //每次匹配到一個就建立一個Watcher對象 node.textContent = this.getContentValue(vm, expr); }); return this.getVal(vm, args[1]); }); node.textContent = content; // 替換文本節點的內容 } }
Watcher對象建立好以後,那麼又須要在何時添加到對應的發佈對象中呢?當Watcher對象建立好以後,會當即去獲取對應的值,從而會觸發對應數據的getter方法,因此在調用getter方法的時候將建立的Watcher對象添加到發佈者對象中,如:
class Observer { defineReactive(obj, key, value) { // 每一個key對應一個發佈者對象 let dep = new Dep(); // 爲data中的每個屬性建立一個發佈者對象 Object.defineProperty(obj, key, { get() { Dep.target && dep.addSub(Dep.target); // 將建立的Watcher對象添加到發佈者中 } }); } }
至此,已經實現了Vue的數據雙向綁定,但還不支持計算屬性。
好比有計算屬性{{getNewName}}和普通表達式{{scholl.name}},那麼兩者有什麼共同點呢?就是不給是計算屬性仍是普通表達式,都是要從vm.\$data中去取值,當咱們給{{getNewName}}建立Watcher的時候,咱們但願獲取到vm.\$data.getNewName的值,要想從vm.\$data中獲取到值,那麼必須將getNewName代理到vm.$data,而後獲取getNewName的值時,直接執行計算屬性函數便可。如:
class Vue { this.$el = options.el; this.$data = options.data; let computed = options.computed; let methods = options.methods; new Observer(this.$data); for (let key in computed) { // 計算屬性代理到data上 Object.defineProperty(this.$data, key, { // 須要從$data中取值,因此須要將計算屬性定義到this.$data上而不是vm上 get: () => { return computed[key].call(this); } } } for (let key in methods) { // 將methods上的數據代理到vm上 Object.defineProperty(this, key, { get() { return methods[key]; } }); } // 爲了方便,把數據獲取操做,將data上的數據都代理到vm上 this.proxyVm(this.$data); proxyVm(data) { for (let key in data) { Object.defineProperty(this, key, { get() { return data[key]; }, set(newValue) { data[key] = newValue; } }); } } }
總之就是,在建立Vue實例的時候給傳入的data進行數據劫持,同時視圖編譯的時候,對於使用到data中數據的地方進行建立Watcher對象,而後在數據劫持的getter中添加到發佈者對象中,當劫持到數據發生變化的時候,就經過發佈訂閱模式以回調函數的方式通知全部觀察者操做DOM進行更新,從而實現數據的雙向綁定。