最近利用空閑時間又翻看了一遍Vue的源碼,只不過這次不同的是看了Flow版本的源碼。說來慚愧,最早看的第一遍時對Flow不了解,因此閱讀的是打包之后的vue文件,大家可以想象這過程的痛苦,沒有類型的支持,看代碼時摸索了很長時間,所以我們這次對Vue源碼的剖析是Flow版本的源碼,也就是從Github上下載下來的源碼中src目錄下的代碼。不過,在分析之前,我想先說說閱讀Vue源碼所需要的一些知識點,掌握這些知識點之后,相信再閱讀源碼會較為輕松。
1. 前置知識點
我個人認為要想深入理解Vue的源碼,至少需要以下知識點:
下面咱們一一介紹
1.1 Flow基本語法
相信大家都知道,javascript是弱類型的語言,在寫代碼灰常爽的同時也十分容易犯錯誤,所以Facebook搞了這么一個類型檢查工具,可以加入類型的限制,提高代碼質量,舉個例子:
function sum(a, b) {
return a + b;
}
可是這樣,我們如果這么調用這個函數sum('a', 1) 甚至sum(1, [1,2,3])這么調用,執行時會得到一些你想不到的結果,這樣編程未免太不穩定了。那我們看看用了Flow之后的結果:
function sum(a: number, b:number) {
return a + b;
}
我們可以看到多了一個number的限制,標明對a和b只能傳遞數字類型的,否則的話用Flow工具檢測會報錯。其實這里大家可能有疑問,這么寫還是js嗎? 瀏覽器還能認識執行嗎?當然不認識了,所以需要翻譯或者說編譯。其實現在前端技術發展太快了,各種插件層出不窮--Babel、Typescript等等,其實都是將一種更好的寫法編譯成瀏覽器認識的javascript代碼(我們以前都是寫瀏覽器認識的javascript代碼的)。我們繼續說Flow的事情,在Vue源碼中其實出現的Flow語法都比較好懂,比如下面這個函數的定義:
export function renderList (
val: any,
render: (
val: any,
keyOrIndex: string | number,
index?: number
) => VNode
): ?Array<VNode>{
...
}
val是any代表可以傳入的類型是任何類型, keyOrIndex是string|number類型,代表要不是string類型,要不是number,不能是別的;index?:number這個我們想想正則表達式中?的含義---0個或者1個,這里其實意義也是一致的,但是要注意?的位置是在冒號之前還是冒號之后--因為這兩種可能性都有,上面代碼中問號是跟在冒號前面,代表index可以不傳,但是傳的話一定要傳入數字類型;如果問號是在冒號后面的話,則代表這個參數必須要傳遞,但是可以是數字類型也可以是空。這樣是不是頓時感覺嚴謹多了?同時,代碼意義更明確了。為啥這么說呢? 之前看打包后的vue源碼,其中看到觀察者模式實現時由于沒有類型十分難看懂,但是看了這個Flow版本的源碼,感覺容易懂。 當然,如果想學習Flow更多的細節, 可以看看下面這個學習文檔:
Flow學習資料
1.2 原型與原型繼承
Vue中的組件相信大家都使用過,并且組件之中可以有子組件,那么這里就涉及到父子組件了。組件其實初始化過程都是一樣的,顯然有些方法是可以繼承的。Vue代碼中是使用原型繼承的方式實現父子組件共享初始化代碼的。所以,要看懂這里,需要了解js中原型的概念;這里不多談,只是提供幾個學習資料供大家參考:
廖雪峰js教程
js原型理解
1.3 Object.defineProperty
這個方法在js中十分強大,Vue正是使用了它實現了響應式數據功能。我們先瞄一眼Vue中定義響應式數據的代碼:
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
.....
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal)
dep.notify()
}
})
}
其中我們看到Object.defineProperty這個函數的運用,其中第一個參數代表要設置的對象,第二個參數代表要設置的對象的鍵值,第三個參數是一個配置對象,對象里面可以設置參數如下:
value: 對應key的值,無需多言
configurable:是否可以刪除該key或者重新配置該key
enumerable:是否可以遍歷該key
writable:是否可以修改該key
get: 獲取該key值時調用的函數
set: 設置該key值時調用的函數
我們通過一個例子來了解一下這些屬性:
let x = {}
x['name'] = 'vue'
console.log(Object.getOwnPropertyDescriptor(x,'name'))
Object.getOwnPropertyDescriptor可以獲取對象某個key的描述對象,打印結果如下:
{
value: "vue",
writable: true,
enumerable: true,
configurable: true
}
從上可知,該key對應的屬性我們可以改寫(writable:true),可以重新設置或者刪除(configurable: true),同時可以遍歷(enumerable:true)。那么讓我們修改一下這些屬性,比如configurable,代碼如下:
Object.defineProperty(x, 'name', {
configurable: false
})
執行成功之后,如果你再想刪除該屬性,比如delete x['name'],你會發現返回為false,即無法刪除了。
那enumerable是什么意思呢?來個例子就明白了,代碼如下:
let x = {}
x[1] = 2
x[2] = 4
Object.defineProperty(x, 2, {
enumerable: false
})
for(let key in x){
console.log("key:" + key + "|value:" + x[key])
}
結果如下:
key:1|value:2
為什么呢? 因為我們把2設置為不可遍歷了,那么我們的for循環就取不到了,當然我們還是可以用x[2]去取到2對應的值得,只是for循環中取不到而已。這個有什么用呢?Vue源碼中Observer類中有下面一行代碼:
def(value, '__ob__', this);
這里def是個工具函數,目的是想給value添加一個key為__ob__,值為this,但是為什么不直接 value.__ob__ = this 反而要大費周章呢?
因為程序下面要遍歷value對其子內容進行遞歸設置,如果直接用value.__ob__這種方式,在遍歷時又會取到造成,這顯然不是本意,所以def函數是利用Object.defineProperty給value添加的屬性,同時enumerable設置為false。
至于get和set嘛?這個就更強大了,類似于在獲取對象值和設置對象值時加了一個代理,在這個代理函數中可以做的東西你就可以想象了,比如設置值時再通知一下View視圖做更新。也來個例子體會一下吧:
let x = {}
Object.defineProperty(x, 1, {
get: function(){
console.log("getter called!")
},
set: function(newVal){
console.log("setter called! newVal is:" + newVal)
}
})
當我們訪問x[1]時便會打印getter called,當我們設置x[1] = 2時,打印setter called。Vue源碼正是通過這種方式實現了訪問屬性時收集依賴,設置屬性時源碼有一句dep.notify,里面便是通知視圖更新的相關操作。
1.4 Vnode概念
Vnode,顧名思義,Virtual node,虛擬節點,首先聲明,這不是Vue自己首創的概念,其實Github上早就有一個類似的項目:Snabbdom。我個人認為,Vue應該也參考過這個庫的實現,因為這個庫包含了完整的Vnode以及dom diff算法,甚至實現的具體代碼上感覺Vue和這個庫也是有點相像的。為啥要用Vnode呢?其實原因主要是原生的dom節點對象太大了,我們運行一下代碼:
let dom = document.createElement('div');
for(let key in dom){
console.log(key)
}
打印的結果灰常長?。?!說明這個dom對象節點有點重量級,而我們的html網頁經常數以百計個這種dom節點,如果采用之前的Jquery這種方式直接操作dom,性能上確實稍微low一點。所以snabbdom或者Vue中應用了Vnode,Vnode對象啥樣呢? 看看Vue源碼對Vnode的定義:
export default class VNode {
tag: string | void;
data: VNodeData | void;
children: ?Array<VNode>;
text: string | void;
elm: Node | void;
ns: string | void;
context: Component | void; // rendered in this component's scope
key: string | number | void;
componentOptions: VNodeComponentOptions | void;
componentInstance: Component | void; // component instance
parent: VNode | void; // component placeholder node
// strictly internal
raw: boolean; // contains raw HTML? (server only)
isStatic: boolean; // hoisted static node
isRootInsert: boolean; // necessary for enter transition check
isComment: boolean; // empty comment placeholder?
isCloned: boolean; // is a cloned node?
isOnce: boolean; // is a v-once node?
asyncFactory: Function | void; // async component factory function
asyncMeta: Object | void;
isAsyncPlaceholder: boolean;
ssrContext: Object | void;
fnContext: Component | void; // real context vm for functional nodes
fnOptions: ?ComponentOptions; // for SSR caching
fnScopeId: ?string;
....
}
相比之下, Vnode對象的屬性確實少了很多;其實光屬性少也不見得性能就能高到哪兒去,另一個方面便是針對新舊Vnode的diff算法了。這里其實有一個現象:其實大多數場景下即便有很多修改,但是如果從宏觀角度觀看,其實修改的點不多。舉個例子:
比如有以下三個dom節點A B C
我們的操作中依次會改成 B C D
如果采用Jquery的改法,當碰到第一次A改為B時,修改了一次,再碰到B改為C,又修改了一次,再次碰到C改為D,又又修改了一次,顯然其實從宏觀上看,只需要刪除A,然后末尾加上D即可,修改次數得到減少;但是這種優化是有前提的,也就是說能夠從宏觀角度看才行。以前Jquery的修改方法在碰到第一次修改的時候,需要把A改為B,這時代碼還沒有執行到后面,它是不可能知道后面的修改的,也就是無法以全局視角看問題。所以從全局看問題的方式就是異步,先把修改放到隊列中,然后整成一批去修改,做diff,這個時候從統計學意義上來講確實可以優化性能。這也是為啥Vue源碼中出現下述代碼的原因:
queueWatcher(this);
1.5 函數柯里化
函數柯里化是什么鬼呢?其實就是將多參數的函數化作多個部分函數去調用。舉個例子:
function getSum(a,b){
return a+b;
}
這是個兩個參數的函數,可以直接getSum(1,2)調用拿到結果;然而,有時候并不會兩個參數都能確定,只想先傳一個值,另外一個在其他時間點再傳入,那我們把函數改為:
function getSum(a){
return function(b){
return a+b;
}
}
那我們如何調用這個柯里化之后的函數呢?
let f = getSum(2)
console.log(f(3))
console.log(getSum(2)(3)) //結果同上
可見,柯里化的效果便是之前必須同時傳入兩個參數才能調用成功而現在兩個參數可以在不同時間點傳入。那為毛要這么做嘛?Vue源碼是這么應用這個特性的,Vue源碼中有一個platform目錄,專門存放和平臺相關的源碼(Vue可以在多平臺上運行 比如Weex)。那這些源碼中肯定有些操作是和平臺相關的,比如會有些以下偽代碼所表示的邏輯:
if(平臺A){
....
}else if(平臺B){
....
}
可是如果這么寫會有個小不舒服的地方,那就是其實代碼運行時第一次走到這里根據當前平臺就已經知道走哪一個分支了,而現在這么寫必當導致代碼再次運行到這里的時候還會進行平臺判斷,這樣總感覺會多一些無聊的多余判斷,因此Vue解決此問題的方式就是應用了函數柯里化技巧,類似聲明了以下一個函數:
function ...(平臺相關參數){
return function(平臺不相關參數){
處理邏輯
}
}
在Vue的patch以及編譯環節都應用了這種方式,講到那部分代碼時我們再細致的看,讀者提前先了解一下可以幫助理解Vue的設計。
1.6 Macrotask與Microtask
可能有的讀者第一次聽到這兩個詞,實際上這個和js的事件循環機制息息相關。在上面我們也提到,Vue更新不是數據一改馬上同步更新視圖的,這樣肯定會有性能問題,比如在一個事件處理函數里先this.data = A 然后再this.data=B,如果要渲染兩次,想想都感覺很low。Vue源碼實際上是將更改都放入到隊列中,同一個watcher不會重復(不理解這些概念不要緊,后面源碼會重點介紹),然后異步處理更新邏輯。在實現異步的方式時,js實際提供了兩種task--Macrotask與Microtask。兩種task有什么區別呢?先從一個例子講起:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
Promise.resolve().then(function() {
console.log('promise3');
}).then(function() {
console.log('promise4');
});
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
以上代碼運行結果是什么呢?讀者可以思考一下,答案應該是:
script start
script end
promise1
promise2
setTimeout
promise3
promise4
簡單可以這么理解,js事件循環中有兩個隊列,一個叫MacroTask,一個MircroTask,看名字就知道Macro是大的,Micro是小的(想想宏觀經濟學和微觀經濟學的翻譯)。那么大任務隊列跑大任務--比如主流程程序了、事件處理函數了、setTimeout了等等,小任務隊列跑小任務,目前讀者記住一個就可以--Promise。js總是先從大任務隊列拿一個執行,然后再把所有小任務隊列全部執行再循環往復。以上面示例程序,首先整體上個這個程序是一個大任務先執行,執行完畢后要執行所有小任務,Promise就是小任務,所以又打印出promise1和promise2,而setTimeout是大任務,所以執行完所有小任務之后,再取一個大任務執行,就是setTimeout,這里面又往小任務隊列扔了一個Promise,所以等setTimeout執行完畢之后,又去執行所有小任務隊列,所以最后是promise3和promise4。說的有點繞,把上面示例程序拷貝到瀏覽器執行一下多思考一下就明白了,關鍵是要知道上面程序本身也是一個大任務。一定要理解了之后再去看Vue源碼,否則不會理解Vue中的nextTick函數。
推薦幾篇文章吧(我都認真讀完了,受益匪淺)
Macrotask Vs Microtask
理解js中Macrotask和Microtask
阮一峰 Eventloop理解
1.7 遞歸編程算法
很多程序員比較害怕遞歸,但是遞歸真的是一種灰常灰常強大的算法。Vue源碼中大量使用了遞歸算法--比如dom diff算法、ast的優化、目標代碼的生成等等....很多很多。而且這些遞歸不僅僅是A->A這么簡單,大多數源碼中的遞歸是A->B->C...->A等等這種復雜遞歸調用。比如Vue中經典的dom diff算法:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
if (isUndef(oldKeyToIdx)) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); }
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
} else {
vnodeToMove = oldCh[idxInOld];
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined;
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
}
}
newStartVnode = newCh[++newStartIdx];
}
上面代碼是比較新舊Vnode節點更新孩子節點的部分源碼,調用者是patchVnode函數,我們發現這部分函數中又會調用會patchVnode,調用鏈條為:patchVnode->updateChildren->patchVnode。同時,即便沒有直接應用遞歸,在將模板編譯成AST(抽象語法樹)的過程中,其使用了棧去模擬了遞歸的思想,由此可見遞歸算法的重要性。這也難怪,畢竟不管是真實dom還是vnode,其實本質都是樹狀結構,本來就是遞歸定義的東西。我們也會單獨拿出一篇文章講講遞歸,比如用遞歸實現一下JSON串的解析。希望讀者注意查看。
1.8 編譯原理基礎知識
這恐怕比遞歸更讓某些程序員蛋疼,但是我相信只要讀者認真把Vue這部分代碼看懂,絕對比看N遍編譯原理的課本更能管用。我們看看Vue源碼這里的實現:
const ast = parse(template.trim(), options)
if (options.optimize !== false) {
optimize(ast, options)
}
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
上述代碼首先通過parse函數將template編譯為抽象語法樹ast,然后對ast進行代碼優化,最后生成render函數。其實這個過程就是翻譯,比如gcc把c語言翻譯為匯編、又比如Babel把ES6翻譯為ES5等等,這里面的流程十分都是十分地相似。Vue也玩了這么一把,把模板html編譯為render函數,什么意思呢?
<li v-for="record in commits">
<span class="date">{{record.commit.author.date}}</span>
</li>
比如上面的html,你覺得瀏覽器會認識嘛?顯然v-for不是html原生的屬性,上述代碼如果直接在瀏覽器運行,你會發現{{record.commit.author.date}}就直接展示出來了,v-for也沒有起作用,當然還是會出現html里面(畢竟html容錯性很高的);但是經過Vue的編譯系統一編譯生成一些函數,這些函數一執行就是瀏覽器認識的html元素了,神奇吧? 其實僅僅是應用了編譯原理課本的部分知識罷了,這部分我們后面會灰?;页T敿毜慕榻B源碼,只要跟著看下來,必定會對編譯過程有所理解?,F在可以這么簡單理解一下AST(抽象語法樹),比如java可以寫一個if判斷,C語言也可以寫,js、python等等也可以(如下所示):
java:
if(x > 5){
....
}
python:
if x>5:
....
雖然從語法形式上寫法不太一致,但是抽象出共同點其實都是一個if語句跟著一個x>5 的條件,那么ast就是一種表現大家共同點的一種結構。得到ast是翻譯的基礎。
綜上,Vue源碼其實代碼行數并不是很多,但是其簡約凝練的風格深深吸引了我。我會重點分析Vue源碼中觀察者模式的實現、Vnode以及dom diff算法的實現以及模板編譯為render函數的實現。這三者我感覺就是Vue源碼中最精彩的地方,希望你我都可以從中汲取養分,不斷提高!
最后送上一個視頻連接,希望大家可以先設置VSCode調試Vue源碼的環境,只要可以調試的代碼沒有啥讀不懂的,視頻介紹很詳細,給其點贊。
VSCode搭建Vue源碼調試環境