前言
在看vue源碼的時候,覺得這幾個vue的核心理念需要總結一下,遂寫篇文章,自己忘記的時候再回來看看。
模板渲染
Vue採用的是聲明式渲染,與命令式渲染不同,聲明式渲染只需要告訴程序,我們想要的什麼效果,其他的事情讓程序自己去做。而命令式渲染,需要命令程序一步一步根據命令執行渲染。例如:
let arr = [1, 2, 3, 4, 5];
// 命令式渲染,關心每一步、關心流程。用命令去實現
let newArr = [];
for (let i = 0; i < arr.length; i++) {
newArr.push(arr[i] * 2);
}
// 聲明式渲染,不用關心中間流程,只需要關心結果和實現的條件
let newArr1 = arr.map(function (item) {
return item * 2;
});
Vue實現了if、for、事件、數據綁定等指令,允許採用簡潔的模板語法來聲明式地將數據渲染出視圖。
為什麼要進行模板編譯?實際組件中的template語法是無法被瀏覽器解析的,因為它不是正確的HTML語法,而模板編譯,就是將組件的template編譯成可執行的JavaScript代碼,即將template轉化為真正的渲染函數。
模板編譯分三個階段,parse、optimize、generate,最終生成render函數。
parse階段:使用正在表達式將template進行字符串解析,得到指令、class、style等數據,生成抽象語法樹AST。optimize階段:尋找 AST 中的靜態節點進行標記,為後面VNode的patch過程中對比做優化。被標記為static的節點在後面的diff算法中會被直接忽略,不做詳細的比較。generate階段:根據AST結構拼接生成render函數的字符串。
預編譯
對於 Vue 組件來説,模板編譯只會在組件實例化的時候編譯一次,生成 渲染函數 之後在也不會進行編譯。因此,編譯對組件的 runtime 是一種性能損耗。而模板編譯的目的僅僅是將template轉化為render function,而這個過程,正好可以在項目構建的過程中完成。
比如webpack的vue-loader依賴了vue-template-compiler模塊,在 webpack 構建過程中,將template預編譯成 render 函數,在 runtime 可直接跳過模板編譯過程。
/*回過頭看,runtime 需要是僅僅是 render 函數,而我們有了預編譯之後,我們只需要保證構建過程中
生成 render 函數。與 React 類似,在添加JSX的語法糖編譯器babel-plugin-transform-vue-jsx
之後,我們可以在 Vue 組件中使用JSX語法直接書寫 render 函數。*/
<script>
export default {
data() {
return {
msg: 'Hello JSX.'
}
},
render() {
const msg = this.msg;
return <div>
{msg}
</div>;
}
}
</script>
當然,假如同時聲明瞭 template 標籤和 render 函數,構建過程中,template 編譯的結果將覆蓋原有的 render 函數,即 template 的優先級高於直接書寫的 render 函數。
響應式系統
Vue是一款MVVM的JS框架,當對數據模型data進行修改時,視圖會自動得到更新,即框架幫我們完成了更新DOM的操作,而不需要我們手動的操作DOM。可以這麼理解,當我們對數據進行賦值的時候,Vue告訴了所有依賴該數據模型的組件,你依賴的數據有更新,你需要進行重渲染了,這個時候,組件就會重渲染,完成了視圖的更新。
整個流程梳理
- 首先實例化
Vue類; - 在實例化時,先觸發
observe,遞歸地對所有data中的變量進行訂閲; - 每次訂閲之前,都會生成一個
dep實例,dep是指依賴; - 每一個只要是
Object類型的變量都有一個dep實例; - 這個
dep是閉包產生的,因此所有與dep有關的操作,都要放到defineReactive函數內部執行;
window.myapp = new Vue({
el: "#app",
data: {
number: {
big: 999
},
},
});
export default class Vue {
constructor (options: any = {}) {
this.$options = options;
this.$el = options.el;
this.$data = options.data;
this.$methods = options.methods;
this.observe(this.$data);
new Compiler(this.$el, this);
}
observe (data) {
if (!data || typeof data !== "object") {
return;
}
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key]);
})
}
defineReactive(data, key, value) {
this.observe(value);
let dep = new Dep();
this.$dps.push(dep);
Object.defineProperty(data, key, {
enumerable: true,
configurable: false,
get () {
// 由於需要在閉包內添加watcher,所以通過Dep定義一個全局target屬性,
// 暫存watcher, 添加完移除
if (Dep.target)
// dep.addSub(Dep.target);
dep.depend();
/**
* dep.depend();
* 兩種寫法一致
*/
return value;
},
})
}
}
Dep類
- 先定義一個全局的
uid,便於分別每一個dep實例,在創建dep的時候綁定並自加1,每一個dep,都會有一個subs隊列,裏面存放的是watcher。 - 每一個
data以及其中凡是對象的變量,都唯一對應一個dep。 - 如果想要實現從
model->View的綁定,只需要這樣做,把所有的發佈者watcher都放到一個dep中。 -
當改變一個變量時,只需要拿到這個變量對應的
dep即可,因為dep有一個subs隊列,存放的全是相關的發佈者watcher,只需要遍歷subs並且調用其中發佈者的update方法即可更新頁面,這就是設計dep類的思想。let guid = 0; export default class Dep { static target: any = null; subs: Array<any>; uid: number; constructor() { this.subs = []; this.uid = guid ++; } addSub(sub) { this.subs.push(sub); } depend () { Dep.target.addDep(this); } notify() { this.subs.forEach(sub => { sub.update(); }) } } Dep.target = null;
Dep.target是一個靜態變量,所有的dep實例的target都指向同一個東西,也就是説這個target是全局唯一的,理解為全局變量即可,其實就是一個watcher。在defineProperty的get事件被觸發時會進行依賴收集。
編譯模板compiler
compiler的主要作用是把html節點和watcher關聯起來,至於html的內容如何更新,都由watcher的callback/updater函數決定。這裏暫時不做深入,這裏只需要知道它是watcher來更新DOM的。
watcher和dep綁定
到了這一步,model已經閉包地擁有了自己的dep,html節點也和watcher關聯了起來,就差把watcher推到對應的dep裏了。然後先看看Watcher類
export default class Watcher {
private vm;
private exp;
private cb;
private value;
private depIds = {};
constructor (vm, exp, cb) {
this.vm = vm;
this.exp = exp;
this.cb = cb;
// 創建時必須觸發一次依賴收集
this.triggerDepCollection();
}
update () {
this.triggerDepCollection();
this.cb(this.value);
}
addDep (dep) {
if (!this.depIds.hasOwnProperty(dep.uid)) {
dep.addSub(this);
this.depIds[dep.uid] = dep;
}
}
// 收集依賴,因為觸發了definePropreity的get()
// or re-collection
triggerDepCollection () {
Dep.target = this;
this.value = this.getDeepValue();
Dep.target = null;
}
getDeepValue () {
let data = this.vm.$data;
this.exp.split(".").forEach(key => {
data = data[key];
})
return data;
}
}
當編譯html代碼時,我們碰到了一個需要收集的變量,現在為其創建一個watcher,並在watcher內部與dep建立聯繫。我們稱這步為依賴收集,我們可以看到,在構造函數的最後一行,triggerDepCollection()意味這個watcher自己觸發依賴收集,這個函數先把我們先前提到的Dep.target設為watcher自身,然後getDeepValue()這裏你只需要知道去訪問了一次exp變量,這就觸發了exp變量的get事件,就是提醒exp的dep,“你可以收集我了”,get事件的主要內容就是收集這個依賴,然後再結合最開始提到的代碼,觸發dep.depend()。
前文的 defineReactive方法裏面的 get方法中的 if (Dep.target) dep.depend(),它又調用了dep的Dep.target.addDep(this),也就是當前的watcher的addDep(this),watcher的addDep(this)又調用了這個dep的addSub()。
本來要收集依賴,只需要dep調用自己的addSub(watcher),把watcher推到自己的subs隊列就完事了,但現在,dep把自己傳給watcher,然後watcher再把自己傳給dep,dep再把watcher加到自己的隊列,這樣豈不是多此一舉?其實不然。就在於watcher的addDep這一步,關鍵在於判斷這個dep的uid是不是自己加入過的dep,也可以用defineReactive方法裏面的set實現。
每次調用update()的時候會觸發相應屬性的getDeepvalue,getDeepvalue裏面會觸發dep.depend(),繼而觸發這裏的addDep。
1、假如相應屬性的dep.id已經在當前watcher的depIds裏,説明不是一個新的屬性,僅僅是改變了其值而已,則不需要將當前watcher添加到該屬性的dep裏。
2、假如相應屬性是新的屬性,則將當前watcher添加到新屬性的dep裏,,因為新屬性之前的setter、dep 都已經失效,如果不把 watcher 加入到新屬性的dep中,通過 obj.xxx = xxx 賦值的時候,對應的 watcher 就收不到通知,等於失效了。因此每次更新都要重新收集依賴。
3、每個子屬性的watcher在添加到子屬性的dep的同時,也會添加到父屬性的dep,監聽子屬性的同時監聽父屬性的變更,這樣,父屬性改變時,子屬性的watcher也能收到通知進行update,這一步是在 this.get() --> this.getVMVal() 裏面完成,forEach時會從父級開始取值,間接調用了它的getter,觸發了addDep(), 在整個forEach過程,當前wacher都會加入到每個父級過程屬性的dep,例如:當前watcher的是child.child.name, 那麼child,child.child, child.child.name這三個屬性的dep都會加入當前watcher。
至此,所有的內容就完成了,watcher也和dep綁定完畢。
Virtual DOM
在Vue中,template被編譯成瀏覽器可執行的render function,然後配合響應式系統,將render function掛載在render-watcher中,當有數據更改的時候,調度中心Dep通知該render-watcher執行render function,完成視圖的渲染與更新。
整個流程看似通順,但是當執行render function時,如果每次都全量刪除並重建DOM,這對執行性能來説,無疑是一種巨大的損耗,因為我們知道,瀏覽器的DOM很“昂貴”的,當我們頻繁的更新DOM,會產生一定的性能問題。
為了解決這個問題,Vue使用JS對象將瀏覽器的 DOM 進行的抽象,這個抽象被稱為VirtualDOM。Virtual DOM的每個節點被定義為VNode,當每次執行render function時,Vue對更新前後的VNode進行Diff對比,找出儘可能少的需要更新的真實DOM節點,然後只更新需要更新的節點,從而解決頻繁更新DOM產生的性能問題。
VNode
VNode,全稱virtual node,即虛擬節點,對真實 DOM 節點的虛擬描述,在 Vue 的每一個組件實例中,會掛載一個$createElement函數,所有的VNode都是由這個函數創建的。
比如創建一個 div:
// 聲明 render function
render: function (createElement) {
// 也可以使用 this.$createElement 創建 VNode
return createElement('div', 'hellow world');
}
// 以上 render 方法返回html片段 <div>hellow world</div>
render 函數執行後,會根據VNode Tree將 VNode 映射生成真實 DOM,從而完成視圖的渲染.
Diff
Diff 將新老 VNode 節點進行比對,然後將根據兩者的比較結果進行最小單位地修改視圖,而不是將整個視圖根據新的 VNode 重繪,進而達到提升性能的目的。
patch
Vue內部的 diff 被稱為patch。其 diff 算法的是通過同層的樹節點進行比較,而非對樹進行逐層搜索遍歷的方式,所以時間複雜度只有O(n),是一種相當高效的算法。
首先定義新老節點是否相同判定函數sameVnode:滿足鍵值key和標籤名tag必須一致等條件,返回true,否則false。
在進行patch之前,新老 VNode 是否滿足條件sameVnode(oldVnode, newVnode),滿足條件之後,進入流程patchVnode,否則被判定為不相同節點,此時會移除老節點,創建新節點。
patchVnode
patchVnode 的主要作用是判定如何對子節點進行更新,
如果新舊VNode都是靜態的,同時它們的key相同(代表同一節點),並且新的 VNode 是 clone 或者是標記了 once(標記v-once屬性,只渲染一次),那麼只需要替換 DOM 以及 VNode 即可。
新老節點均有子節點,則對子節點進行 diff 操作,進行updateChildren,這個 updateChildren 也是 diff 的核心。
1.如果老節點沒有子節點而新節點存在子節點,先清空老節點DOM的文本內容,然後為當前DOM節點加入子節點。
2.當新節點沒有子節點而老節點有子節點的時候,則移除該DOM節點的所有子節點。
3.當新老節點都無子節點的時候,只是文本的替換。
updateChildren
Diff 的核心,對比新老子節點數據,判定如何對子節點進行操作,在對比過程中,由於老的子節點存在對當前真實 DOM 的引用,新的子節點只是一個 VNode 數組,所以在進行遍歷的過程中,若發現需要更新真實 DOM 的地方,則會直接在老的子節點上進行真實 DOM 的操作,等到遍歷結束,新老子節點則已同步結束。
1、updateChildren內部定義了4個變量,分別是oldStartIdx、oldEndIdx、newStartIdx、newEndIdx,分別表示正在Diff對比的新老子節點的左右邊界點索引,在老子節點數組中,索引在oldStartIdx與oldEndIdx中間的節點,表示老子節點中為被遍歷處理的節點,所以小於oldStartIdx或大於oldEndIdx的表示未被遍歷處理的節點。
2、同理,在新的子節點數組中,索引在newStartIdx與newEndIdx中間的節點,表示老子節點中為被遍歷處理的節點,所以小於newStartIdx或大於newEndIdx的表示未被遍歷處理的節點。
3、每一次遍歷,oldStartIdx和oldEndIdx與newStartIdx和newEndIdx之間的距離會向中間靠攏。當oldStartIdx>oldEndIdx或者newStartIdx>newEndIdx時結束循環。
4、在遍歷中,取出4索引對應的 Vnode節點:
(1). oldStartIdx:oldStartVnode
(2). oldEndIdx:oldEndVnode
(3). newStartIdx:newStartVnode
(4). newEndIdx:newEndVnode
5、diff過程中,如果存在key,並且滿足sameVnode,會將該DOM節點進行復用,否則則會創建一個新的DOM節點。
比較過程
首先,oldStartVnode、oldEndVnode與newStartVnode、newEndVnode兩兩比較,一共有 2*2=4 種比較方法。
- 情況一:當
oldStartVnode與newStartVnode滿足sameVnode,則oldStartVnode與newStartVnode進行patchVnode,並且oldStartIdx與newStartIdx右移動。 - 情況二:與情況一類似,當
oldEndVnode與newEndVnode滿足sameVnode,則oldEndVnode與newEndVnode進行patchVnode,並且oldEndIdx與newEndIdx左移動。 - 情況三:當
oldStartVnode與newEndVnode滿足sameVnode,則説明oldStartVnode已經跑到了oldEndVnode後面去了,此時oldStartVnode與newEndVnode進行patchVnode的同時,還需要將oldStartVnode的真實DOM節點移動到oldEndVnode的後面,並且oldStartIdx右移,newEndIdx左移。 - 情況四:與情況三類似,當
oldEndVnode與newStartVnode滿足sameVnode,則説明oldEndVnode已經跑到了oldStartVnode前面去了,此時oldEndVnode與newStartVnode進行patchVnode的同時,還需要將oldEndVnode的真實DOM節點移動到oldStartVnode的前面,並且oldStartIdx右移,newEndIdx左移。 - 若不存在,説明
newStartVnode為新節點,創建新節點放在oldStartVnode前面即可。 - 當
oldStartIdx>oldEndIdx或者newStartIdx>newEndIdx,循環結束,這個時候我們需要處理那些未被遍歷到的VNode。 - 當
oldStartIdx>oldEndIdx時,説明老的節點已經遍歷完,而新的節點沒遍歷完,這個時候需要將新的節點創建之後放在oldEndVnode後面。 - 當
newStartIdx>newEndIdx時,説明新的節點已經遍歷完,而老的節點沒遍歷完,這個時候要將沒遍歷的老的節點全都刪除。
借用網上的一個動圖
説明
以上部分內容來源與自己複習時的網絡查找,也主要用於個人學習,相當於記事本的存在,暫不列舉鏈接文章。如果有作者看到,可以聯繫我將原文鏈接貼出。