Vue2響應式原理與實現html
上一篇文章中已經實現了Vue的響應式系統,接下來就是要將Vue的模板進行掛載並渲染出對應的界面。node
渲染的入口就是調用Vue實例的$mount()方法,其會接收一個選擇器名做爲參數,Vue進行模板渲染的時候,所使用的模板是有必定優先級的:
① 若是用戶傳遞的options對象中包含render屬性,那麼就會優先使用用戶配置的render()函數中包含的模板進行渲染;
② 若是用戶傳遞的options對象中不包含render屬性,可是包含template屬性,那麼會使用用戶配置的template屬性中對應的模板進行渲染;
③ 若是用戶傳遞的options對象中不包含render屬性,也不包含template屬性,那麼會使用掛載點el對應的DOM做爲模板進行渲染。segmentfault
實現上一步中遺留的的$mount()方法:數組
// src/init.js import {compileToFunction} from "./compile/index"; import {mountComponent} from "./lifecyle"; export function initMixin(Vue) { Vue.prototype.$mount = function(el) { // 傳入el選擇器 const vm = this; const options = vm.$options; el = document.querySelector(el); // 根據傳入的el選擇器拿到Vue實例的掛載點 if (!options.render) { // 若是沒有配置render渲染函數 let template = options.template; // 看看用戶有沒有配置template模板屬性 if (!template) { // 若是也沒有配置模板 template = el.outerHTML; // 那麼就用掛載點知道DOM做爲模板 } options.render = compileToFunction(template); // 拿到模板以後將其編譯爲渲染函數 } mountComponent(vm, el); // 傳入vm實例和el開始掛載組件 } }
因此$mount()函數主要就是要初始化對應的渲染函數,有了渲染函數就能夠開始渲染了。渲染屬於生命週期的一部分,咱們將mountComponent()放到lifecycle.js中。app
mountComponent方法中主要作的事情就是建立一個渲染Watcher。在建立渲染Watcher的時候會傳入一個函數,這個函數就是用於更新組件的。而組件的更新須要作的就是執行render渲染函數拿到對應的虛擬DOM,而後與舊的虛擬DOM進行比較,找到變化的部分並應用到真實DOM。
新增一個watcher.js,watcher屬於數據觀測的一部分,因此須要放到src/observer下,如:dom
// src/observer/watcher.js let id = 0; export default class Watcher { constructor(vm, exprOrFn, cb, options, isRenderWatcher) { this.vm = vm; this.id = id++; if (typeof exprOrFn === "function") { // 建立Watcher的時候傳遞的是一個函數,這個函數會當即執行 this.getter = exprOrFn; } this.cb = cb; this.options = options; this.isRenderWatcher = isRenderWatcher; this.get(); // 讓傳入的Watcher的函數或表達式當即執行 } get() { this.getter.call(this.vm, this.vm); } }
// src/lifecycle.js export function mountComponent(vm, el) { vm.$el = el; // 將掛載點保存到Vue實例的$el屬性上 // beforeMount let updateComponent = () => { vm._update(vm._render()); } // 建立一個渲染Watcher並傳入updateComponent()函數,在建立渲染Watcher的時候會當即執行 new Watcher(vm, updateComponent, () => {}, {}, true); // mounted }
隨着渲染Watcher的建立,updateComponent()函數也跟着執行,即執行vm._render(),拿到虛擬DOM,須要給Vue的原型上添加一個_render()方法,和以前同樣經過renderMixin()將Vue混入,如:函數
// src/render.js export function renderMixin(Vue) { Vue.prototype._render = function() { const vm = this; const render = vm.$options.render; // 取出render渲染函數 return render.call(vm); // 讓render渲染函數執行返回虛擬DOM節點 } }
// src/index.js 在其中引入renderMixin並傳入Vue,以便在Vue原型上混入_render()方法 + import {renderMixin} from "./render"; function Vue() { } + renderMixin(Vue);
假設咱們建立Vue實例的時候配置了一個template屬性,值爲:this
<div id='app' style='background:red;height:300px;' key='haha'> hello {{name}} <span style='color:blue;'>{{name}} {{arr}}</span> </div>
那麼這個模板通過compileToFunction()函數編譯後就會變成一個render渲染函數,以下所示:spa
(function anonymous( ) { with(this) {return _c("div", {id: "app",style: {"background":"red","height":"300px"},key: "haha"},_v("hello"+_s(name)),_c("span", {style: {"color":"blue"}},_v(_s(name)+_s(arr))) ) } })
渲染函數內部使用了with(this){},在執行渲染函數的時候會傳入Vue實例,因此這個this就是指Vue實例,對於其中的_c()、_v()、_s()其實就是vm._c()、vm._v()、vm._s()。
因此咱們還須要在renderMixin()內給Vue原型混入_c()、_v()、_s()這幾個方法。prototype
① _s()方法主要是解析template模板中用到的數據,即Vue中的data數據,用戶可能會在模板中使用Vue中不包含的數據,此時變量的值就是null,用戶也可能使用到Vue中的對象數據,對於這些數據咱們須要進行stringify()一下轉換爲字符串形式顯示在模板中。
② v()方法主要就是解析傳入的文本字符串,並將其解析爲一個虛擬文本節點對象。
③ c()方法主要就是接收多個參數(標籤名、屬性對象、子節點數組),並解析爲一個虛擬元素節點對象
// src/render.js import {createTextNode, createElementNode} from "./vdom/index"; export function renderMixin(Vue) { Vue.prototype._c = function(...args) { // 建立虛擬元素節點對象 return createElementNode(...args); } Vue.prototype._v = function(text) { // 建立虛擬文本節點對象 return createTextNode(text); } Vue.prototype._s = function(val) { // 解析變量的值 return val === null ? "" : typeof val === "object" ? JSON.stringify(val) : val; } }
接下來開始建立虛擬DOM,虛擬DOM包括建立、比較等各類操做,也是一個獨立複雜的過程,須要將對虛擬DOM的操做獨立成一個單獨的模塊,主要就是對外暴露createElementNode()、createTextNode()兩個方法,如:
// src/vdom/index.js function vnode(tag, key, attrs, children, text) { // 建立虛擬DOM return { tag, // 標籤名,元素節點專屬 key, // 標籤對應的key屬性,元素節點專屬 attrs, // 標籤上的非key屬性,元素節點專屬 children, // 標籤內的子節點,元素節點專屬 text // 文本節點專屬,非文本節點爲undefined } } // 建立虛擬文本節點,文本節點其餘都爲undefined,僅text有值 export function createTextNode(text) { return vnode(undefined, undefined, undefined, undefined, text); } // 建立虛擬元素節點 export function createElementNode(tag, attrs, ...children) { const key = attrs.key; if (key) { delete attrs.key; } return vnode(tag, key, attrs, children); }
拿到虛擬DOM以後,就開始執行vm._update(vnode)方法,因此須要給Vue原型上混入一個_update()方法,_update屬於lifecycle的一部分,以下:
// src/lifecycle.js import {patch} from "./vdom/patch"; export function lifecycleMixin(Vue) { // 更新的時候接受一個虛擬DOM節點,而後與掛載點或舊DOM節點進行比較 Vue.prototype._update = function(vnode) { const vm = this; const prevVnode = vm._vnode; // 拿到以前的虛擬節點 vm._vnode = vnode; // 將當前最新的虛擬節點保存起來,以便下次比較的時候能夠取出來做爲舊的虛擬節點 if (!prevVnode) { // 第一次沒有舊節點,因此爲undefined,須要傳入真實節點 vm.$el = patch(vm.$el, vnode); } else { vm.$el = patch(prevVnode, vnode); } } }
第一次渲染的時候,舊節點爲undefined,因此咱們直接傳入真實的DOM掛載節點便可。接下來咱們實現patch()方法。
patch()方法要作的事情就是,將傳入的新的虛擬DOM節點渲染成真實的DOM節點,而後用新建立的真實DOM節點替換掉掛載點對應的DOM。
// src/vdom/patch.js export function patch(oldVnode, vnode) { // 接收新舊虛擬DOM節點進行比較 const isRealElement = oldVnode.nodeType; // 看看舊節點是否有nodeType屬性,若是有則是真實DOM節點 if (isRealElement) { // 若是舊的節點是一個真實的DOM節點,直接渲染出最新的DOM節點並替換掉舊的節點便可 const parentElm = oldVnode.parentNode; // 拿到舊節點即掛載點的父節點,這裏爲<body>元素 const oldElm = oldVnode; const el = createElm(vnode); // 根據新的虛擬DOM建立出對應的真實DOM parentElm.insertBefore(el, oldElm.nextSibling);// 將建立出來的新的真實DOM插入 parentElm.removeChild(oldElm); // 移除掛載點對應的真實DOM return el; // 返回最新的真實DOM,以便保存到Vue實例的$el屬性上 } else { // 舊節點也是虛擬DOM,這裏進行新舊虛擬DOMDIFF比較 } }
實現將虛擬DOM轉換成真實的DOM,主要就是根據虛擬DOM節點上保存的真實DOM節點信息,經過DOM API建立出真實的DOM節點便可。
// src/vdom/patch.js function createElm(vnode) { if (vnode.tag) { // 若是虛擬DOM上存在tag屬性,說明是元素節點 vnode.el = document.createElement(vnode.tag); // 根據tag標籤名建立出對應的真實DOM節點 updateProperties(vnode); // 更新DOM節點上的屬性 vnode.children.forEach((child) => { // 遍歷虛擬子節點,將其子節點也轉換成真實DOM並加入到當前節點下 vnode.el.appendChild(createElm(child)); }); } else { // 若是不存在tag屬性,說明是文本節點 vnode.el = document.createTextNode(vnode.text); // 建立對應的真實文本節點 } return vnode.el; }
實現DOM節點上屬性和樣式的更新,如:
// src/vdom/patch.js function updateProperties(vnode, oldAttrs = {}) { // 傳入新的虛擬DOM和舊DOM的屬性對象 const el = vnode.el; // 更新屬性前已經根據元素標籤名建立出了對應的真實元素節點,並保存到vnode的el屬性上 const newAttrs = vnode.attrs || {}; // 取出新虛擬DOM的屬性對象 const oldStyles = oldAttrs.style || {}; // 取出舊虛擬DOM上的樣式 const newStyles = newAttrs.style || {}; // 取出新虛擬DOM上的樣式 // 移除新節點中再也不使用的樣式 for (let key in oldStyles) { // 遍歷舊的樣式 if (!newStyles[key]) { // 若是新的節點已經沒有這個樣式了,則直接移除該樣式 el.style[key] = ""; } } // 移除新節點中再也不使用的屬性 for (let key in oldAttrs) { if (!newAttrs[key]) { // 若是新的節點已經沒有這個屬性了,則已經移除該屬性 el.removeAttribute(key); } } // 遍歷新的屬性對象,開始更新樣式和屬性 for (let key in newAttrs) { if (key === "style") { for (let styleName in newAttrs.style) { el.style[styleName] = newAttrs.style[styleName]; } } else if (key === "class") { el.className = newAttrs[key]; } else { el.setAttribute(key, newAttrs[key]); } } }
由於新舊虛擬DOM節點上的樣式style屬性也是一個對象,因此必須將樣式style對象單獨拿出來進行遍歷才能知道新的樣式中有沒有以前舊的樣式了。移除老的樣式和屬性以後,再遍歷一下新的屬性對象,更新一下最新的樣式和屬性。
所謂響應式更新,就是當咱們修改Vue中的data數據的時候,模板可以自動從新渲染出最新的界面。目前咱們只是渲染出了界面,當咱們去修改Vue實例中的數據的時候,發現模板並無進行從新渲染,由於咱們雖然對Vue中的數據進行了劫持,可是模板的更新(從新渲染)是由渲染Watcher來執行的,或者確切的說是在建立渲染Watcher的時候傳入的updateComponent()函數決定的,updateComponent()函數從新執行就會致使模板從新渲染,因此咱們須要在數據發生變化的時候通知渲染Watcher更新(調用updateComponent()函數)。因此這裏的關鍵就是要通知渲染Watcher數據發生了變化。而通知機制,咱們能夠經過發佈訂閱模式來實現。實現方式以下:
① 將Vue中data數據的每個key映射成一個發佈者對象;
② 當Watcher去取數據的時候,用到了哪一個key對應的值,那麼就將當前Watcher對象加入到該key對應的發佈者對象的訂閱者列表中;
③ 當哪一個key對應的值被修改的時候,就拿到該key對應的發佈者對象,調用其發佈通知的方法,通知訂閱者列表中的watcher對象執行更新操做。
// src/observer/dep.js let id = 0; export default class Dep { constructor() { this.id = id++; this.subs = []; // 訂閱者列表,存放當前dep對象中須要通知的watcher對象 } addSub(watcher) { // 添加訂閱者 this.subs.push(watcher); } notify() { // 發佈通知,通知訂閱者更新 this.subs.forEach((watcher) => { watcher.update(); }); } depend() { // 收集依賴,主要讓watcher對象中記錄一下依賴哪些dep對象 if (Dep.target) { // 若是存在當前Watcher對象 Dep.target.addDep(this); // 通知watcher對象將當前dep對象加入到watcher中 } } } let stack = []; // 存放取值過程當中使用到的watcher對象 // 取值前將當前watcher放到棧頂 export function pushTarget(watcher) { Dep.target = watcher; // 記錄當前Watcher stack.push(watcher); // 將當前watcher放到棧中 } // 取完值後將當前watcher對象從棧頂移除 export function popTarget() { stack.pop(); // 移除棧頂的watcher Dep.target = stack[stack.length - 1]; // 更新當前watcher }
這裏每次watcher對象取值以前,都會調用pushTarget()方法將當前watcher對象保存到全局的Dep.target上,同時將當前watcher放到一個數組中,之因此要放到數組中,是由於計算屬性也是一種wacther對象,當咱們執行渲染watcher對象的時候,此時Dep.target的值爲渲染watcher對象,若是模板中使用到了計算屬性,那麼就要執行計算watcher去取值,此時就會將計算watcher保存到Dep.target中,當計算屬性取值完成後,渲染Watcher可能還須要繼續取值,因此還須要將Dep.target還原成渲染Watcher,爲了可以還原回來,須要將watcher放到棧中保存起來。
修改watcher.js的get()方法,在取值前將當前Watcher對象保存到全局的Dep.target上,如:
// src/observer/watcher.js import {pushTarget, popTarget} from "./dep"; export default class Watcher { constructor(vm, exprOrFn, cb, options, isRenderWatcher) { this.deps = []; // 當前Watcher依賴了哪些key this.depIds = new Set(); // 避免重複 } get() { pushTarget(this); // 取值前將當前watcher放到全局的Dep.target中 this.getter.call(this.vm, this.vm); popTarget(); // 取值完成後將當前watcher從棧頂移除 } // 讓watcher記錄下其依賴的dep對象 addDep(dep) { const id = dep.id; if (!this.depIds.has(id)) { // 若是不存在該dep的id this.deps.push(dep); this.depIds.add(id); dep.addSub(this); } } update() { // watcher進行更新操做,以便頁面可以更新 this.get(); } }
這裏之因此先調用dep.depend()方法讓當前watcher對象將其依賴的dep加入到其deps數組中,主要是爲計算watcher設計的,假如渲染watcher中僅僅使用到了一個計算屬性,因爲渲染watcher並無直接依賴Vue中data對象中的數據,因此data對象中各個key對應的dep對象並不會將渲染watcher加入到訂閱者列表中,而是僅僅會將計算watcher放到訂閱者列表中,此時用戶去修改Vue中的數據,渲染watcher就不會收到通知,致使沒法更新。後面實現計算watcher的時候會進一步解釋。
此時取值前已經將Wacher放到了全局的Dep.target中,而取值的時候會被響應式數據系統的get()攔截,咱們能夠在get中收集依賴,在修改值的時候會被響應式數據系統的set()攔截,咱們能夠在set中進行發佈通知,如:
// src/observer/index.js function defineReactive(data, key, value) { let ob = observe(value); let dep = new Dep(); // 給每一個key建立一個對應的Dep對象 dep.name = key; Object.defineProperty(data, key, { get() { if (Dep.target) { // 若是已經將當前Watcher對象保存到Dep.target上 dep.depend(); // 執行當前key對應的dep對象的depend()方法收集依賴 } return value; }, set(newVal) { if (newVal === value) { return; } observe(newVal); value = newVal; dep.notify(); // 數據會被修改後,經過對應key的dep對象給訂閱者發佈通知 } }); }
通過上面的操做,咱們已經實現了對對象的依賴收集,修改對象的某個key的值,能夠通知到渲染watcher進行更新。
若是Vue中data數據中有某個key的值爲數組,好比,data: {arr: [1, 2, 3]},那麼當咱們經過vm.arr.push(4)去修改數組的時候,會發現模板並無更新,由於咱們目前僅僅對對象進行了依賴收集,也就是說,arr對應的dep對象中有渲染Watcher的依賴,可是arr的值[1, 2, 3]這對象並無對應的dep對象,因此沒辦法通知渲染watcher對象執行更新操做。
在前面響應式數據系統中,咱們進行了數據的遞歸觀測,若是對象的key對應的值也是一個對象或者數組,那麼會對這個值也進行觀測,而一旦觀測就會建立一個對應的Observer對象,因此咱們能夠在Observer對象中添加一個dep對象用於收集數組收集依賴。
// src/observer/index.js class Observer { constructor(data) { this.dep = new Dep(); // 爲了觀察數組收集依賴用,直接觀察數組自己,而不是數組對應的key,如{arr: [1, 2, 3]}, 直接觀察[1, 2, 3]而不是觀察arr } } function defineReactive(data, key, value) { let ob = observe(value); // 對值進行觀測 get() { if (Dep.target) { if (ob) { // 若是被觀測的值也是一個對象或者數組,則會返回一個Observer對象,不然爲null ob.dep.depend(); // 對數組收集依賴 } } } }
對數組收集依賴後,咱們還須要在數組發生變化的時候進行通知,以前響應式系統中已經對可以改變數組的幾個方法進行了重寫,因此咱們能夠在這些方法被調用的時候發起通知,如:
// src/observer/array.js methods.forEach((method) => { arrayMethods[method] = function(...args) { ... if (inserted) { ob.observeArray(inserted); } ob.dep.notify(); // 在可以改變數組的方法中發起通知 } })
此時還存在一個問題,仍是以data: {arr: [1, 2, 3]}爲例,雖然咱們如今經過vm.arr.push(4)能夠看到頁面會更新,可是若是咱們push的是一個數組呢?好比,執行vm.arr.push([4, 5]),那麼當咱們執行vm.arr[3].push(6)的時候發現頁面並無更新,由於咱們沒有對arr中的[4,5]這個數組進行依賴收集,因此咱們須要對數組進行遞歸依賴收集。
// src/observer/index.js function defineReactive(data, key, value) { let ob = observe(value); // 對值進行觀測 get() { if (Dep.target) { if (ob) { // 若是被觀測的值也是一個對象或者數組,則會返回一個Observer對象,不然爲null ob.dep.depend(); // 對數組收集依賴 if (Array.isArray(value)) { // 若是這個值是一個數組 dependArray(value); } } } } } // 遍歷數組中的每一項進行遞歸依賴收集 function dependArray(value) { for (let i = 0; i < value.length; i++) { // 遍歷數組中的每一個元素 let current = value[i]; // 若是數組中的值是數組或者對象,那麼該值也會被觀察,即就會有觀察者對象 current.__ob__ && current.__ob__.dep.depend(); // 對於其中的對象或者數組收集依賴,即給其加一個Watcher對象 if (Array.isArray(current)) { // 若是值仍是數組,則遞歸收集依賴 dependArray(current) } } }