vue數據渲染

前言

vue 是如何將編譯器中的代碼轉換爲頁面真實元素的?這個過程涉及到模板編譯成 AST 語法樹,AST 語法樹構建渲染函數,渲染函數生成虛擬 dom,虛擬 dom 編譯成真實 dom 這四個過程。前兩個過程在咱們 vue 源碼解讀系列文章的上一期已經介紹過了,因此本文會接着上一篇文章繼續往下解讀,着重分析後兩個過程。vue

總體流程

解讀代碼以前,先看一張 vue 編譯和渲染的總體流程圖:
屏幕快照 2020-01-04 下午9.29.17.pngnode

vue 會把用戶寫的代碼中的 <template></template> 標籤中的代碼解析成 AST 語法樹,再將處理後的 AST 生成相應的render函數,render 函數執行後會獲得與模板代碼對應的虛擬 dom,最後經過虛擬 dom 中新舊 vnode 節點的對比和更新,渲染獲得最終的真實 dom。 有了這個總體的概念咱們再來結合源碼分析具體的數據渲染過程。git

從vm.$mount開始

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 已經掛載完成的緣由。算法

vm._render()

接下來具體分析 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究竟是什麼瀏覽器

render方法的來源

在 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類

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

有了前面兩點的知識儲備,接下來回到 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 函數的來源不一樣,對子節點的數據結構進行相應處理。

建立vnode節點

這部分邏輯是對tag標籤在不一樣狀況下的處理,梳理一下具體的判斷case以下:

  1. 若是傳入的 tag 標籤是字符串,則進一步進入下列第 2 點和第 3 點判斷,若是不是字符串則建立一個組件類型 vnode 節點。
  2. 若是是內置的標籤,則建立一個相應的內置標籤 vnode 節點。
  3. 若是是一個組件標籤,則建立一個組件類型 vnode 節點。
  4. 其餘狀況下,則建立一個命名空間未定義的 vnode 節點。
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)
  }
vnode建立後的處理

這部分一樣也是一些 if/else 分狀況的處理邏輯:

  1. 若是 vnode 成功建立,且是一個數組類型,則返回建立好的 vnode 節點
  2. 若是 vnode 成功建立,且有命名空間,則遞歸全部子節點應用該命名空間
  3. 若是 vnode 沒有成功建立則建立並返回一個空的 vnode 節點
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._update() 作的事情就是把 vm._render() 生成的虛擬 dom 渲染成真實 dom。_update() 方法內部會調用 vm.__patch__ 方法來完成視圖更新,最終調用的是 createPatchFunction 方法,該方法的代碼量和邏輯都很是多,它定義在 src/core/vdom/patch.js 文件中。下面介紹下具體的 patch 流程和流程中用到的重點方法:

重點方法

  1. createElm:該方法會根據傳入的虛擬 dom 節點建立真實的 dom 並插入到它的父節點中
  2. sameVnode:判斷新舊節點是不是同一節點。
  3. patchVnode:當新舊節點是相同節點時,調用該方法直接修改節點,在這個過程當中,會利用 diff 算法,循環進行子節點的的比較,進而進行相應的節點複用或者替換。
  4. updateChildren方法:diff 算法的具體實現過程

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算法

其餘注意點

sameVnode的實際應用

在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 被判斷爲同一組件。

虛擬DOM如何映射到真實的DOM節點

vue 爲平臺作了一層適配層,瀏覽器平臺的代碼在 /platforms/web/runtime/node-ops.js。不一樣平臺之間經過適配層對外提供相同的接口,虛擬 dom 映射轉換真實 dom 節點的時候,只須要調用這些適配層的接口便可,不須要關心內部的實現。

最後

經過上述的源碼和實例的分析,咱們完成了 Vue 中 數據渲染 的完整解讀。若是想要了解更多的 Vue 源碼。歡迎進入咱們的github進行查看,裏面有Vue源碼分析另外幾篇文章,另外對 Vue 工程的每一行源碼都作了註釋,方便你們的理解。~~~~

相關文章
相關標籤/搜索