vue 是如何將編譯器中的代碼轉換爲頁面真實元素的?這個過程涉及到模板編譯成 AST 語法樹,AST 語法樹構建渲染函數,渲染函數生成虛擬 dom,虛擬 dom 編譯成真實 dom 這四個過程。前兩個過程在咱們 vue 源碼解讀系列文章的上一期已經介紹過了,因此本文會接着上一篇文章繼續往下解讀,着重分析後兩個過程。vue
解讀代碼以前,先看一張 vue 編譯和渲染的總體流程圖:
node
vue 會把用戶寫的代碼中的 <template></template> 標籤中的代碼解析成 AST 語法樹,再將處理後的 AST 生成相應的render函數,render 函數執行後會獲得與模板代碼對應的虛擬 dom,最後經過虛擬 dom 中新舊 vnode 節點的對比和更新,渲染獲得最終的真實 dom。 有了這個總體的概念咱們再來結合源碼分析具體的數據渲染過程。git
vue 中是經過 $mount 實例方法去掛載 vm 的,數據渲染的過程就發生在 vm.$mount 階段。在這個方法中,最終會調用 mountComponent 方法來完成數據的渲染。咱們結合源碼看一下其中的幾行關鍵代碼:github
updateComponent = () => { vm._update(vm._render(), hydrating) // 生成虛擬dom,並更新真實dom }
這是在 mountComponent 方法的內部,會定義一個 updateComponent 方法,在這個方法中 vue 會經過 vm._render() 函數生成虛擬 dom,並將生成的 vnode 做爲第一個參數傳入 vm._update() 函數中進而完成虛擬 dom 到真實 dom 的渲染。第二個參數 hydrating 是跟服務端渲染相關的,在瀏覽器中不須要關心。這個函數最後會做爲參數傳入到 vue 的 watch 實例中做爲 getter 函數,用於在數據更新時觸發依賴收集,完成數據響應式的實現。這個過程不在本文的介紹範圍內,在這裏只要明白,當後續 vue 中的 data 數據變化時,都會觸發 updateComponent 方法,完成頁面數據的渲染更新。具體的關鍵代碼以下:web
new Watcher(vm, updateComponent, noop, { before () { if (vm._isMounted && !vm._isDestroyed) { // 觸發beforeUpdate鉤子 callHook(vm, 'beforeUpdate') } } }, true /* isRenderWatcher */) hydrating = false // manually mounted instance, call mounted on self // mounted is called for render-created child components in its inserted hook if (vm.$vnode == null) { vm._isMounted = true // 觸發mounted鉤子 callHook(vm, 'mounted') } return vm }
代碼中還有一點須要注意的是,在代碼結束處,會作一個判斷,當 vm 掛載成功後,會調用 vue 的 mounted 生命週期鉤子函數。這也就是爲何咱們在 mounted 鉤子中執行代碼時,vm 已經掛載完成的緣由。算法
接下來具體分析 vue 生成虛擬 dom 的過程。前面說了這一過程是調用vm._render()方法來完成的,該方法的核心邏輯是調用vm.$createElement方法生成vnode,代碼以下:segmentfault
vnode = render.call(vm._renderProxy, vm.$createElement)
其中vm.renderProxy是個代理,代理vm,作一些錯誤處理,vm.$createElement 是建立vnode的真正方法,該方法的定義以下:數組
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
可見最終調用的是createElement方法來實現生成vnode的邏輯。在進一步介紹createElement方法以前,咱們先理清楚兩個個關鍵點,1.render的函數來源,2.vnode究竟是什麼瀏覽器
在 vue 內部其實定義了兩種 render 方法的來源,一種是若是用戶手寫了 render 方法,那麼 vue 會調用這個用戶本身寫的 render 方法,即下面代碼中的 vm.$createElement;另一種是用戶沒有手寫 render 方法,那麼vue內部會把 template 編譯成 render 方法,即下面代碼中的 vm._c。不過這兩個 render 方法最終都會調用createElement方法來生成虛擬dom數據結構
// bind the createElement fn to this instance // so that we get proper render context inside it. // args order: tag, data, children, normalizationType, alwaysNormalize // internal version is used by render functions compiled from templates vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false) // normalization is always applied for the public version, used in // user-written render functions. vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
vnode 就是用一個原生的 js 對象去描述 dom 節點的類。由於瀏覽器操做dom的成本是很高的,因此利用 vnode 生成虛擬 dom 比建立一個真實 dom 的代價要小不少。vnode 類的定義以下:
export default class VNode { tag: string | void; // 當前節點的標籤名 data: VNodeData | void; // 當前節點對應的對象 children: ?Array<VNode>; // 當前節點的子節點 text: string | void; // 當前節點的文本 elm: Node | void; // 當前虛擬節點對應的真實dom節點 .... /*建立一個空VNode節點*/ export const createEmptyVNode = (text: string = '') => { const node = new VNode() node.text = text node.isComment = true return node } /*建立一個文本節點*/ export function createTextVNode (val: string | number) { return new VNode(undefined, undefined, undefined, String(val)) } ....
能夠看到 vnode 類中仿照真實 dom 定義了不少節點屬性和一系列生成各種節點的方法。經過對這些屬性和方法的操做來達到模仿真實 dom 變化的目的。
有了前面兩點的知識儲備,接下來回到 createElement 生成虛擬 dom 的分析。createElement 方法中的代碼不少,這裏只介紹跟生成虛擬 dom 相關的代碼。該方法整體來講就是建立並返回一個 vnode 節點。 在這個過程當中能夠拆分紅三件事情:1.子節點的規範化處理; 2.根據不一樣的情形建立不一樣的 vnode 節點類型;3.vnode 建立後的處理。下面開始分析這3個步驟:
if (normalizationType === ALWAYS_NORMALIZE) { children = normalizeChildren(children) } else if (normalizationType === SIMPLE_NORMALIZE) { children = simpleNormalizeChildren(children) }
爲何會有這個過程,是由於傳入的參數中的子節點是 any 類型,而 vue 最終生成的虛擬 dom 其實是一個樹狀結構,每個 vnode 可能會有若干個子節點,這些子節點應該也是 vnode 類型。因此須要對子節點處理,將子節點統一處理成一個 vnode 類型的數組。同時還須要根據 render 函數的來源不一樣,對子節點的數據結構進行相應處理。
這部分邏輯是對tag標籤在不一樣狀況下的處理,梳理一下具體的判斷case以下:
let vnode, ns if (typeof tag === 'string') { let Ctor // 獲取tag的名字空間 ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag) // 判斷是不是內置的標籤,若是是內置的標籤則建立一個相應節點 if (config.isReservedTag(tag)) { // platform built-in elements if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) { warn( `The .native modifier for v-on is only valid on components but it was used on <${tag}>.`, context ) } vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined, undefined, context ) // 若是是組件,則建立一個組件類型節點 // 從vm實例的option的components中尋找該tag,存在則就是一個組件,建立相應節點,Ctor爲組件的構造類 } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) { // component vnode = createComponent(Ctor, data, context, children, tag) } else { // unknown or unlisted namespaced elements // check at runtime because it may get assigned a namespace when its // parent normalizes children //其餘狀況,在運行時檢查,由於父組件可能在序列化子組件的時候分配一個名字空間 vnode = new VNode( tag, data, children, undefined, undefined, context ) } } else { // direct component options / constructor // tag不是字符串的時候則是組件的構造類,建立一個組件節點 vnode = createComponent(tag, data, context, children) }
這部分一樣也是一些 if/else 分狀況的處理邏輯:
if (Array.isArray(vnode)) { // 若是vnode成功建立,且是一個數組類型,則返回建立好的vnode節點 return vnode } else if (isDef(vnode)) { // 若是vnode成功建立,且名字空間,則遞歸全部子節點應用該名字空間 if (isDef(ns)) applyNS(vnode, ns) if (isDef(data)) registerDeepBindings(data) return vnode } else { // 若是vnode沒有成功建立則建立空節點 return createEmptyVNode() }
vm._update() 作的事情就是把 vm._render() 生成的虛擬 dom 渲染成真實 dom。_update() 方法內部會調用 vm.__patch__ 方法來完成視圖更新,最終調用的是 createPatchFunction 方法,該方法的代碼量和邏輯都很是多,它定義在 src/core/vdom/patch.js 文件中。下面介紹下具體的 patch 流程和流程中用到的重點方法:
判斷舊節點是否存在,若是不存在就調用 createElm() 建立一個新的 dom 節點,不然進入第二步判斷。
if (isUndef(oldVnode)) { // empty mount (likely as component), create new root element isInitialPatch = true createElm(vnode, insertedVnodeQueue) }
經過 sameVnode() 判斷新舊節點是不是同一節點,若是是同一個節點則調用 patchVnode() 直接修改現有的節點,不然進入第三步判斷
const isRealElement = isDef(oldVnode.nodeType) if (!isRealElement && sameVnode(oldVnode, vnode)) { // patch existing root node /*是同一個節點的時候直接修改現有的節點*/ patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly) }
若是新舊節點不是同一節點,則調用 createElm()建立新的dom,並更新父節點的佔位符,同時移除舊節點。
else { .... createElm( vnode, insertedVnodeQueue, // extremely rare edge case: do not insert if old element is in a // leaving transition. Only happens when combining transition + // keep-alive + HOCs. (#4590) oldElm._leaveCb ? null : parentElm, nodeOps.nextSibling(oldElm) ) // update parent placeholder node element, recursively /*更新父的佔位符節點*/ if (isDef(vnode.parent)) { let ancestor = vnode.parent const patchable = isPatchable(vnode) while (ancestor) { for (let i = 0; i < cbs.destroy.length; ++i) { cbs.destroy[i](ancestor) /*調用destroy回調*/ } ancestor.elm = vnode.elm if (patchable) { for (let i = 0; i < cbs.create.length; ++i) { cbs.create[i](emptyNode, ancestor) /*調用create回調*/ } // #6513 // invoke insert hooks that may have been merged by create hooks. // e.g. for directives that uses the "inserted" hook. const insert = ancestor.data.hook.insert if (insert.merged) { // start at index 1 to avoid re-invoking component mounted hook for (let i = 1; i < insert.fns.length; i++) { insert.fns[i]() } } } else { registerRef(ancestor) } ancestor = ancestor.parent } } // destroy old node if (isDef(parentElm)) { removeVnodes([oldVnode], 0, 0) /* 刪除舊節點 */ } else if (isDef(oldVnode.tag)) { invokeDestroyHook(oldVnode) /* 調用destroy鉤子 */ } }
返回 vnode.elm,即最後生成的虛擬 dom 對應的真實 dom,將 vm.$el 賦值爲這個 dom 節點,完成掛載。
其中重點的過程在第二步和第三步中,特別是 diff 算法對新舊節點的比較和更新頗有意思,diff 算法在另一篇文章來詳細介紹 Vue中的diff算法。
在patch的過程當中,若是兩個節點被判斷爲同一節點,會進行復用。這裏的判斷標準是
1.key相同
2.tag(當前節點的標籤名)相同
3.isComment(是否爲註釋節點)相同
4.data的屬性相同
平時寫 vue 時會遇到一個組件中用到了 A 和 B 兩個相同的子組件,能夠來回切換。有時候會出現改變了 A 組件中的值,切到 B 組件中,發現 B 組件的值也被改變成和 A 組件同樣了。這就是由於 vue 在 patch 的過程當中,判斷出了 A 和 B 是 sameVnode,直接進行復用引發的。根據源碼的解讀,能夠很容易地解決這個問題,就是給 A 和 B 組件分別加上不一樣的 key 值,避免 A 和 B 被判斷爲同一組件。
vue 爲平臺作了一層適配層,瀏覽器平臺的代碼在 /platforms/web/runtime/node-ops.js。不一樣平臺之間經過適配層對外提供相同的接口,虛擬 dom 映射轉換真實 dom 節點的時候,只須要調用這些適配層的接口便可,不須要關心內部的實現。
經過上述的源碼和實例的分析,咱們完成了 Vue 中 數據渲染 的完整解讀。若是想要了解更多的 Vue 源碼。歡迎進入咱們的github進行查看,裏面有Vue源碼分析另外幾篇文章,另外對 Vue 工程的每一行源碼都作了註釋,方便你們的理解。~~~~