衆所周知,Vue.js的響應式就是用了數據劫持 + 發佈-訂閱模式,然而深其意,身爲小白,往往感受本身能回答上來,最後去有欲言又止以失敗了結;做爲經典的面試題之一,大多數狀況下,也都只能答到「用Object.defineProperty
...」這種地步html
因此寫下這篇來爲本身梳理一下響應式的思路vue
Model,View,View-Model就是mvvm的的含義;node
View
經過View-Model
的 DOM Listeners
將事件綁定到 Model
上Model
則經過 Data Bindings
來管理 View
中的數據View-Model
從中起到一個鏈接橋的做用依照mvvm模型說的,當model(data)改變時,對應的view也會自動改變,這就是響應式
舉個🌰git
// html <div id="app"> <input type="text" v-model='c'> <p>{{a.b}}</p> <div>my message is {{c}}</div> </div>
// js let mvvm = new Mvvm({ el: '#app', data: { a: { b: '這是個例子' }, c: 10, } });
當一個Vue
實例建立時,vue
會遍歷data
選項的屬性,用Object.defineProperty
將它們轉爲getter/setter
而且在內部追蹤相關依賴,在屬性被訪問和修改時通知變化。
每一個組件實例 / 元素都有相應的watcher
程序實例,它會在組件渲染的過程當中把屬性記錄爲依賴,以後當依賴項的setter
被調用時,會通知watcher
從新計算,從而導致它關聯的組件得以更新
總結,最重要就是三個步驟github
Object.defineProperty
爲每一個數據設置 getter/setter
watcher
dep
,並將對應的觀察者添加進依賴列表,每當數據更新時,訂閱者(依賴收集器)通知全部對應觀察者(依賴)自動更新對應頁面經過以上,咱們知道了大概的mvvm運做原理,對應以上分別實現其功能便可
一、一個數據監聽器 Observer
,對數據的全部屬性進行監聽,若有變更就通知訂閱者dep
二、一個指令解析/渲染器 Compile
,對每一個元素節點的指令進行掃描和解析,對應替換數據,以及綁定相應的更新函數
三、一個依賴 Watcher
類和一個依賴收集器 dep
類
四、一個mvvm
類面試
咱們要打造一個Mvvm,根據以前咱們mvvm的例子數組
class Mvvm { constructor(option) { this.$option = option; // 初始化 this.init(); } init() { // 數據監控 observe(this.$option.data); // 編譯 new Compile(this.$option.el); } }
這裏我只寫了一個函數,用類寫也是能夠的app
/* observe監聽函數,監聽data中的全部數據並進行數據劫持 * @params * $data - mvvm實例中的data */ function observe(data) { // 判斷是否是對象 if (typeof data !== 'object') return // 循環數據 Object.keys(data).forEach(key => { defineReactive(data, key, data[key]); }) /* 數據劫持 defineReactive * @param * obj - 監聽對象; key - 遍歷對象的key; val - 遍歷對象的val */ function defineReactive(obj, key, val) { // 遞歸子屬性 observe(val); // 數據劫持 Object.defineProperty(obj, key, { enumerable: true, // 可枚舉 configurable: true, // 可修改 // 設置getter 和 setter 函數來對數據劫持 get() { console.log('get!', key, val); return val }, set(newVal) { // 監聽新數據 observe(newVal); console.log('set!', key, newVal); val = newVal; // 賦值 }, }) } }
然而單純這樣寫是不夠的,由於有數組這樣的特例:Object.defineProperty
嚴格上來講是能夠監聽數組的變化, 但對於數組增長length
而形成的的變化(原型方法)沒法監聽到的;
簡單來講就是當使用數組原型方法來改寫數組的時候,雖然數據被改寫了,可是咱們沒法監聽到數組自己的改寫;
因此,在Vue
中重寫了數組的原型方法;
咱們也來實現這個改寫:框架
// 先獲取原型上的方法, 而後創造原型重寫 let methods = ['pop', 'shift', 'unshift', 'sort', 'reverse', 'splice', 'push']; let arrProto = Array.prototype; let newArrProto = Object.create(arrProto); methods.forEach(method => { newArrProto[method] = function (...args) { console.log('arr change!') // 用 function 定義該函數使得 this 指向調用的數組;若是用箭頭函數 this 會指向 window arrProto[method].call(this, ...args) } }) // 數據劫持 function observe(data) { // 判斷是不是數組類型 + if (Array.isArray(data)) { + // 將數組數據原型指針指向本身定義好的原型對象 + data.__proto__ = newArrProto; + return + } ... }
然而,這樣還存在限制,那就是Vue
沒法檢測到對象屬性的添加或刪除;
因此在Vue
中使用了Vue.set
和Vue.delete
來彌補響應式;
這個咱們就略過了,之後有空再補dom
/* Compile類,解析dom中全部節點上的指令 * @params * $el - 須要渲染的標籤 * $vm - mvvm實例 */ class Compile { constructor(el, vm) { this.vm = vm; this.$el = document.querySelector(el); // 掛載到編譯實例方便操做 this.frag = document.createDocumentFragment(); // 運用fragment類進行dom操做以節省開銷 this.reg = /\{\{(.*?)\}\}/g; // 將全部dom節點移入frag中 while (this.$el.firstChild) { let child = this.$el.firstChild; this.frag.appendChild(child); } // 編譯元素節點 this.compile(this.frag); this.$el.appendChild(this.frag); } }
這樣一個編譯函數框架就寫好了,而後須要對裏面的詳細函數功能進行補充;
由於咱們須要在循環節點的時候識別文字節點上的{{xxx}}
插值。。。
class Compile { ... // 編譯 compile(frag) { // 遍歷 frag node節點 Array.from(frag.childNodes).forEach(node => { let txt = node.textContent; // 編譯文本 {{}} if (node.nodeType === 3 && this.reg.test(txt)) { this.compileTxt(node, RegExp.$1); } // 遞歸子節點 if (node.childNodes && node.childNodes.length) this.compile(node) }) } // 編譯文字節點 compileTxt(node, key) { node.textContent = typeof val === 'undefined' ? '' : val; } ... }
到這裏,初次渲染頁面的時候,mvvm
已經能夠把實例裏面的數據渲染出來了,可是還不夠,由於咱們須要她能夠實時自動更新
當一個數據在node上有多個節點/組件同時引用的時候,該數據更新時,咱們如何一個個的去自動更新頁面?這就須要用到發佈訂閱模式了;
咱們能夠在編譯的時候爲頁面使用到數據的每一個組件都添加一個觀察者(依賴) watcher
;
再爲每一個數據添加一個訂閱者(依賴收集器)dep
,並將對應的觀察者(依賴) watcher
添加進依賴列表,每當數據更新時,訂閱者(依賴收集器)通知全部對應觀察者(依賴)自動更新對應頁面
因此須要建立一個Dep,它能夠用來收集依賴、刪除依賴和向依賴發送消息
class Dep { constructor() { // 建立一個數組,用來保存全部的依賴的路徑 this.subs = []; } // 添加依賴 @sub - 依賴(watcher實例) addSub(sub) { this.subs.push(sub); } // 提醒發佈 notify() { this.subs.forEach(el => el.update()) } }
// 觀察者 / 依賴 class Watcher { constructor(vm, key, cb) { this.vm = vm; this.key = key; this.cb = cb; // 初始化時獲取當前數據值 this.value = this.get(); } /* 獲取當前值 * @param $boolean: true - 數據更新 / false - 初始化 * @return 當前的 vm[key] */ get(boolean) { Dep.target = boolean ? null : this; // 觸發getter,將本身添加到 dep 中 let value = UTIL.getVal(this.vm, this.key); Dep.target = null; return value; } update() { // 取得最新值; // 只有初始化的時候觸發,更新的時候不觸發getter let nowVal = this.get(true); // 對比舊值 if (this.value !== nowVal) { console.log('update') this.value = nowVal; this.cb(nowVal); } } }
再回到Compile
中,咱們須要在第一遍渲染的時候還將爲該組件建立一個wacther
實例;
而後再將渲染更新的函數放到watcher
的cb
中;
class Compile{ ... // 編譯文字節點 compileTxt(node, key) { + this.bind(node, this.vm, key, 'text'); } + // 綁定依賴 + bind(node, vm, key, dir) { + let updateFn = this.update(dir); + // 第一次渲染 + updateFn && updateFn(node, UTIL.getVal(vm, key)); + // 設置觀察者 + new Watcher(vm, key, (newVal) => { + // cb 之後的渲染 + updateFn && updateFn(node, newVal); + }); + } + // 更新 + update(dir) { + switch (dir) { + case 'text': // 文本更新 + return (node, val) => node.textContent = typeof val === 'undefined' ? '' : val; + break; + } + } ... }
完成這些,回到原來defineReactive
中,對其進行修改,爲每一個數據都增添一個dep
實例;
並在getter
中爲dep
實例添加依賴;在setter
中添加dep
實例的發佈函數;
function observe(data) { ... function defineReactive(obj, key, val) { // 遞歸子屬性 observe(val); // 添加依賴收集器 + let dep = new Dep(); // 數據劫持 Object.defineProperty(obj, key, { enumerable: true, // 可枚舉 configurable: true, // 可修改 get() { console.log('get!', key, val); // 添加訂閱 + Dep.target && dep.addSub(Dep.target); return val }, set(newVal) { observe(newVal); console.log('set!', key, newVal); val = newVal; // 發佈更新 + dep.notify(); // 觸發更新 }, }) } }
至此,一個簡易的響應式Mvvm
已經實現了,每當咱們修改數據的時候,其對應的頁面內容也會自動從新渲染更新;
那麼雙向綁定又是如何實現的呢?
雙向綁定就是在Compile
的時候,對node
的元素節點進行識別,若是有v-model
指令,則對該元素的value
值和響應數據進行綁定,並在update
函數中添加對應的value
更新方法
class Compile { // 編譯 compile(frag) { // 遍歷 frag node節點 Array.from(frag.childNodes).forEach(node => { let txt = node.textContent; // 編譯元素節點 + if (node.nodeType === 1) { + this.compileEl(node); + // 編譯文本 {{}} } else if (node.nodeType === 3 && this.reg.test(txt)) { this.compileTxt(node, RegExp.$1); } // 遞歸子節點 if (node.childNodes && node.childNodes.length) this.compile(node) }) } ... + compileEl(node) { + // 查找指令 v-xxx + let attrList = node.attributes; + if (!attrList.length) return; + [...attrList].forEach(attr => { + let attrName = attr.name; + let attrVal = attr.value; + // 判斷是否帶有 ‘v-’ 指令 + if (attrName.includes('v-')) { + // 編譯指令 / 綁定 標籤value和對應data + this.bind(node, this.vm, attrVal, 'model'); + let oldVal = UTIL.getVal(this.vm, attrVal); // 獲取 vm實例 當前值 + // 增添input事件監聽 + node.addEventListener('input', e => { + let newVal = e.target.value; // 獲取輸入的新值 + if (newVal === oldVal) return; + UTIL.setVal(this.vm, attrVal, newVal); + oldVal = newVal; + }) + } + }); + } ... // 更新 update(dir) { switch (dir) { case 'text': // 文本更新 return (node, val) => node.textContent = typeof val === 'undefined' ? '' : val; break; + case 'model': // model指令更新 + return (node, val) => node.value = typeof val === 'undefined' ? '' : val; + break; } } }
簡單來講,雙向數據綁定就是給有v-xxx
指令組件添加addEventListner
的監聽函數,一旦事件發生,就調用setter
,從而調用dep.notify()
通知全部依賴watcher
調用watcher.update()
進行更新
動手實現Mvvm的過程以下
Object.defineProperty
的get
和set
進行數據劫持observe
遍歷data數據來進行監聽,併爲數據建立dep
實例來收集依賴Compile
對dom
中的全部節點進行編譯,併爲組件添加wathcer
實例dep
&watcher
發佈訂閱模式實現數據與視圖同步歡迎移步項目源碼
感謝閱讀歡迎指正、探討😀 各位喜歡的看官,歡迎 star 🌟