Vue 2.0源碼學習
Vue2.0介紹
從去年9月份了解到Vue后,就被他簡潔的API所吸引。1.0版本正式發布后,就在業務中開始使用,將原先jQuery的功能逐步的進行遷移。
今年的10月1日,Vue的2.0版本正式發布了,其中核心代碼都進行了重寫,于是就專門花時間,對Vue 2.0的源碼進行了學習。本篇文章就是2.0源碼學習的總結。
先對Vue 2.0的新特性做一個簡單的介紹:
-
大小 & 性能。Vue 2.0的線上包gzip后只有12Kb,而1.0需要22Kb,react需要44Kb。而且,Vue 2.0的性能在react等幾個框架中,性能是最快的。
-
VDOM。實現了Virtual DOM, 并且將靜態子樹進行了提取,減少界面重繪時的對比。與1.0對比性能有明顯提升。
-
template & JSX。眾所周知,Vue 1.0使用的是template來實現模板,而React使用了JSX實現模板。關于template和JSX的爭論也很多,很多人不使用React就是因為沒有支持template寫法。Vue 2.0對template和JSX寫法都做了支持。使用時,可以根據具體業務細節進行選擇,可以很好的發揮兩者的優勢。就這一點,Vue已經超過React了。
-
Server Render。2.0還對了Server Render做了支持。這一點并沒有在業務中使用,不做評價。
Vue的最新源碼可以去 https://github.com/vuejs/vue 獲得。本文講的是 2.0.3版本,2.0.3可以去 https://github.com/vuejs/vue/... 這里獲得。
下面開始進入正題。首先從生命周期開始。
生命周期
上圖就是官方給出的Vue 2.0的生命周期圖,其中包含了Vue對象生命周期過程中的幾個核心步驟。了解了這幾個過程,可以很好的幫助我們理解Vue的創建與銷毀過程。
從圖中我們可以看出,生命周期主要分為4個過程:
-
create。 new Vue 時,會先進行create,創建出Vue對象。
-
mount。根據el, template, render方法等屬性,會生成DOM,并添加到對應位置。
-
update。當數據發生變化后,會重新渲染DOM,并進行替換。
-
destory。銷毀時運行。
那么這4個過程在源碼中是怎么實現的呢?我們從 new Vue 開始。
new Vue
為了更好的理解new的過程,我整理了一個序列圖:
new Vue的過程主要涉及到三個對象:vm、compiler、watcher。其中,vm表示Vue的具體對象;compiler負責將template解析為AST render方法;watcher用于觀察數據變化,以實現數據變化后進行re-render。
下面來分析下具體的過程和代碼:
首先,運行 new Vue() 的時候,會進入代碼 src/core/instance/index.js 的Vue構造方法中,并執行 this._init() 方法。在 _init 中,會對各個功能進行初始化,并執行 beforeCreate 和 created 兩個生命周期方法。核心代碼如下:
initLifecycle(vm)
initEvents(vm)
callHook(vm, 'beforeCreate')
initState(vm)
callHook(vm, 'created')
initRender(vm)
這個過程有一點需要注意:
beforeCreate和created之間只有initState,和官方給出的生命周期圖并不完全一樣。這里的initState是用于初始化data,props等的監聽的。
在 _init 的最后,會運行 initRender 方法。在該方法中,會運行 vm.$mount 方法,代碼如下:
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
這里的 vm.$mount 可以在業務代碼中調用,這樣,new 過程和 mount過程就可以根據業務情況進行分離。
這里的 $mount 在 src/entries/web-runtime-with-compiler.js 中,主要邏輯是根據el, template, render三個屬性來獲得AST render方法。代碼如下:
if (!options.render) { // 如果有render方法,直接運行mount
let template = options.template
if (template) { // 如果有template, 獲取template參數對于的HTML作為模板
if (typeof template === 'string') {
if (template.charAt(0) === '#') {
template = idToTemplate(template)
}
} else if (template.nodeType) {
template = template.innerHTML
} else {
if (process.env.NODE_ENV !== 'production') {
warn('invalid template option:' + template, this)
}
return this
}
} else if (el) { // 如果沒有template, 且存在el,則獲取el的outerHTML作為模板
template = getOuterHTML(el)
}
if (template) { // 如果獲取到了模板,則將模板轉化為render方法
const { render, staticRenderFns } = compileToFunctions(template, {
warn,
shouldDecodeNewlines,
delimiters: options.delimiters
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
}
}
return mount.call(this, el, hydrating)
這個過程有三點需要注意:
compile時,將最大靜態子樹提取出來作為單獨的AST渲染方法,以提升后面vNode對比時的性能。所以,當存在多個連續的靜態標簽時,可以在外邊添加一個靜態父節點,這樣,staticRenderFns數目可以減少,從而提升性能。
Vue 2.0中的模板有三種引用寫法:el, template, render(JSX)。其中的優先級是render > template > el。
el, template兩種寫法,最后都會通過compiler轉化為render(JSX)來運行,也就是說,直接寫成render(JSX)是性能最優的。當然,如果使用了構建工具,最終生成的包就是使用的render(JSX)。這樣子,在源碼上就可以不用過多考慮這一塊的性能了,直接用可維護性最好的方式就行。
將模板轉化為render,用到了 compileToFunctions方法 ,該方法最后會通過 src/compiler/index.js 文件中的 compile 方法,將模板轉化為AST語法結構的render方法,并對靜態子樹進行分離。
完成render方法的生成后,會進入 _mount (src/core/instance.lifecycle.js)中進行DOM更新。該方法的核心邏輯如下:
vm._watcher = new Watcher(vm, () => {
vm._update(vm._render(), hydrating)
}, noop)
首先會new一個watcher對象,在watcher對象創建后,會運行傳入的方法 vm._update(vm._render(), hydrating) (watcher的邏輯在下面的watcher小節中細講)。其中的 vm._render() 主要作用就是運行前面compiler生成的render方法,并返回一個vNode對象。這里的vNode就是一個虛擬的DOM節點。
拿到vNode后,傳入 vm._update() 方法,進行DOM更新。
VDOM
上面已經講完了 new Vue 過程中的主要步驟,其中涉及到template如何轉化為DOM的過程,這里單獨拿出來講下。先上序列圖:
從圖中可以看出,從template到DOM,有三個過程:
-
template -> AST render(compiler解析template)
-
AST render -> vNode(render方法運行)
-
vNode -> DOM(vdom.patch)
首先是template在compiler中解析為AST render方法的過程。上一節中有說到, initRender 后,會調用到 src/entries/web-runtime-with-compiler.js 中的 Vue.prototype.$mount 方法。在 $mount 中,會獲取template,然后調用 src/platforms/web/compiler/index.js 的 compileToFunctions 方法。在該方法中,會運行 compile 將template解析為多個render方法,也就是AST render。這里的 compile 在文件 src/compiler/index.js 中,代碼如下:
const ast = parse(template.trim(), options) // 解析template為AST
optimize(ast, options) // 提取static tree
const code = generate(ast, options) // 生成render 方法
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
可以看出, compile 方法就是將template以AST的方式進行解析,并轉化為render方法進行返回。
再看第二個過程:AST render -> vNode。這個過程很簡單,就是將AST render方法進行運行,獲得返回的vNode對象。
最后一步,vNode -> DOM。該過程中,存在vNode的對比以及DOM的添加修改操作。
在上一節中,有講到 vm._update() 方法中對DOM進行更新。 _update 的主要代碼如下:
// src/core/instance/lifecycle.js
if (!prevVnode) {
// Vue.prototype.__patch__ is injected in entry points
// based on the rendering backend used.
vm.$el = vm.__patch__(vm.$el, vnode, hydrating) // 首次添加
} else {
vm.$el = vm.__patch__(prevVnode, vnode) // 數據變化后觸發的DOM更新
}
可以看出,無論是首次添加還是后期的update,都是通過 __patch__ 來更新的。這里的 __patch__ 核心步驟是在 src/core/vdom/patch.js 中的 patch 方法進行實現,源碼如下:
function patch (oldVnode, vnode, hydrating, removeOnly) {
if (!oldVnode) {
...
} else {
...
if (!isRealElement && sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) // diff并更新DOM。
} else {
elm = oldVnode.elm
parent = nodeOps.parentNode(elm)
...
if (parent !== null) {
nodeOps.insertBefore(parent, vnode.elm, nodeOps.nextSibling(elm)) // 添加element到DOM。
removeVnodes(parent, [oldVnode], 0, 0)
}
...
}
}
...
}
首次添加很簡單,就是通過insertBefore將轉化好的element添加到DOM中。如果是update,則會調動 patchVnode() 。最后來看下 patchVnode 的代碼:
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
...
const elm = vnode.elm = oldVnode.elm
const oldCh = oldVnode.children
const ch = vnode.children
...
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) { // 當都存在時,更新Children
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) { // 只存在新節點時,即添加節點
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) { // 只存在老節點時,即刪除節點
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) { // 刪除了textContent
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) { // 修改了textContent
nodeOps.setTextContent(elm, vnode.text)
}
}
其中有調用了 updateChildren 來更新子節點,代碼如下:
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
...
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 {
...
}
}
...
}
可以看到 updateChildren 中,又通過 patchVnode 來更新當前節點。梳理一下, patch 通過 patchVnode 來更新根節點,然后通過 updateChildren 來更新子節點,具體子節點,又通過 patchVnode 來更新,通過一個類似于遞歸的方式逐個節點的完成對比和更新。
Vue 2.0中對如何去實現VDOM的思路是否清晰,通過4層結構,很好的實現了可維護性,也為實現server render, weex等功能提供了可能。拿server render舉例,只需要將最后的 vNode -> DOM 改成 vNode -> String 或者 vNode -> Stream , 就可以實現server render。剩下的compiler和Vue的核心邏輯都不需要改。
Watcher
我們都知道MVVM框架的特征就是當數據發生變化后,會自動更新對應的DOM節點。使用MVVM之后,業務代碼中就可以完全不寫DOM操作代碼,不僅可以將業務代碼聚焦在業務邏輯上,還可以提高業務代碼的可維護性和可測試性。那么Vue 2.0中是怎么實現對數據變化的監聽的呢?照例,先看序列圖:
可以看出,整個Watcher的過程可以分為三個過程。
-
對state設置setter/getter
-
對vm設置好Watcher,添加好state 觸發 setter時的執行方法
-
state變化觸發執行
前面有說過,在生命周期函數 beforeCreate 和 created 直接,會運行方法 initState() 。在 initState 中,會對Props, Data, Computed等屬性添加Setter/Getter。拿Data舉例,設置setter/getter的代碼如下:
function initData (vm: Component) {
let data = vm.$options.data
...
// proxy data on instance
const keys = Object.keys(data)
let i = keys.length
while (i--) {
...
proxy(vm, keys[i]) // 設置vm._data為代理
}
// observe data
observe(data)
}
通過調用 observe 方法,會對data添加好觀察者,核心代碼為:
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend() // 處理好依賴watcher
...
}
return value
},
set: function reactiveSetter (newVal) {
...
childOb = observe(newVal) // 對新數據重新observe
dep.notify() // 通知到dep進行數據更新
}
})
這個時候,對data的監聽已經完成。可以看到,當data發生變化的時候,會運行 dep.notify() 。在 notify 方法中,會去運行watcher的 update 方法,內容如下:
update () {
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
run () {
if (this.active) {
const value = this.get()
}
...
}
update 方法中,queueWatcher方法的目的是通過 nextTicker 來執行 run 方法,屬于支線邏輯,就不分析了,這里直接看 run 的實現。 run 方法其實很簡單,就是調用 get 方法,而 get 方法會通過執行 this.getter() 來更新DOM。
那么 this.getter 是什么呢?本文最開始分析 new Vue 過程時,有講到運行 _mount 方法時,會運行如下代碼:
vm._watcher = new Watcher(vm, () => {
vm._update(vm._render(), hydrating)
}, noop)
那么 this.getter 就是這里Watcher方法的第二個參數。來看下 new Watcher 的代碼:
export default class Watcher {
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: Object = {}
) {
...
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
}
...
this.value = this.lazy
? undefined
: this.get()
}
}
可以看出,在 new Vue 過程中,Watcher會在構造完成后主動調用 this.get() 來觸發 this.getter() 方法的運行,以達到更新DOM節點。
總結一下這個過程:首先 _init 時,會對Data設置好setter方法,setter方法中會調用 dep.notify() ,以便數據變化時通知DOM進行更新。然后 new Watcher 時,會將更新DOM的方法進行設置,也就是 Watcher.getter 方法。最后,當Data發生變化的時候, dep.notify() 運行,運行到 watcher.getter() 時,就會去運行render和update邏輯,最終達到DOM更新的目的。
總結與收獲
剛開始覺得看源碼,是因為希望能了解下Vue 2.0的實現,看看能不能得到一些從文檔中無法知道的細節,用于提升運行效率。把主要流程理清楚后,的確了解到一些,這里做個整理:
-
el屬性傳入的如果不是element,最后會通過 document.querySelector 來獲取的,這個接口性能較差,所以,el傳入一個element性能會更好。
-
$mount 方法中對 html , body 標簽做了過濾,這兩個不能用來作為渲染的根節點。
-
每一個組件都會從 _init 開始重新運行,所以,當存在一個長列表時,將子節點作為一個組件,性能會較差。
-
*.vue 文件會在構建時轉化為 render 方法,而 render 方法的性能比指定 template 更好。所以,源碼使用 *.vue 的方式,性能更好。
-
如果需要自定義 delimiters ,每一個組件都需要單獨指定。
-
如果是 *.vue 文件,指定 delimiters 是失效的,因為 vue-loader 對 *.vue 文件進行解析時,并沒有將 delimiters 傳遞到 compiler.compile() 中。(這一點不確定是bug還是故意這樣設計的)。
來自:https://segmentfault.com/a/1190000007484936