深刻剖析:Vue核心之虛擬DOM

前言

使用 Vue 作項目也有兩年時間了,對 Vueapi也用的比較駕輕就熟了,雖然對 Vue 的一些實現原理也耳有所聞,例如 虛擬DOMflow、數據驅動、路由原理等等,可是本身並無特地去探究這些原理的基礎以及 Vue 源碼是如何利用這些原理進行框架實現的,因此利用空閒時間,進行 Vue 框架相關技術原理和 Vue 框架的具體實現的整理。若是你對 Vue 的實現原理很感興趣,那麼就能夠開始這系列文章的閱讀,將會爲你打開 Vue 的底層世界大門,對它的實現細節一探究竟。 本文爲 Virtual DOM的技術原理和 Vue 框架的具體實現。javascript

辛苦編寫良久,還望手動點贊鼓勵~前端

github地址爲:github.com/fengshi123/…,上面彙總了做者全部的博客文章,若是喜歡或者有所啓發,請幫忙給個 star ~,對做者也是一種鼓勵。vue

1、真實DOM和其解析流程

​ 本節咱們主要介紹真實 DOM 的解析過程,經過介紹其解析過程以及存在的問題,從而引出爲何須要虛擬DOM。一圖勝千言,以下圖爲 webkit 渲染引擎工做流程圖java

​ 全部的瀏覽器渲染引擎工做流程大體分爲5步:建立 DOM 樹 —> 建立 Style Rules -> 構建 Render 樹 —> 佈局 Layout -—> 繪製 Paintingnode

  • 第一步,構建 DOM 樹:用 HTML 分析器,分析 HTML 元素,構建一棵 DOM 樹;
  • 第二步,生成樣式表:用 CSS 分析器,分析 CSS 文件和元素上的 inline 樣式,生成頁面的樣式表;
  • 第三步,構建 Render 樹:將 DOM 樹和樣式表關聯起來,構建一棵 Render 樹(Attachment)。每一個 DOM 節點都有 attach 方法,接受樣式信息,返回一個 render 對象(又名 renderer),這些 render 對象最終會被構建成一棵 Render 樹;
  • 第四步,肯定節點座標:根據 Render 樹結構,爲每一個 Render 樹上的節點肯定一個在顯示屏上出現的精確座標;
  • 第五步,繪製頁面:根據 Render 樹和節點顯示座標,而後調用每一個節點的 paint 方法,將它們繪製出來。

注意點:git

一、DOM 樹的構建是文檔加載完成開始的? 構建 DOM 樹是一個漸進過程,爲達到更好的用戶體驗,渲染引擎會盡快將內容顯示在屏幕上,它沒必要等到整個 HTML 文檔解析完成以後纔開始構建 render 樹和佈局。github

二、Render 樹是 DOM 樹和 CSS 樣式表構建完畢後纔開始構建的? 這三個過程在實際進行的時候並非徹底獨立的,而是會有交叉,會一邊加載,一邊解析,以及一邊渲染。web

三、CSS 的解析注意點? CSS 的解析是從右往左逆向解析的,嵌套標籤越多,解析越慢。算法

四、JS 操做真實 DOM 的代價? 用咱們傳統的開發模式,原生 JSJQ 操做 DOM 時,瀏覽器會從構建 DOM 樹開始從頭至尾執行一遍流程。在一次操做中,我須要更新 10 個 DOM 節點,瀏覽器收到第一個 DOM 請求後並不知道還有 9 次更新操做,所以會立刻執行流程,最終執行10 次。例如,第一次計算完,緊接着下一個 DOM 更新請求,這個節點的座標值就變了,前一次計算爲無用功。計算 DOM 節點座標值等都是白白浪費的性能。即便計算機硬件一直在迭代更新,操做 DOM 的代價仍舊是昂貴的,頻繁操做仍是會出現頁面卡頓,影響用戶體驗segmentfault

2、Virtual-DOM 基礎

2.一、虛擬 DOM 的好處

​ 虛擬 DOM 就是爲了解決瀏覽器性能問題而被設計出來的。如前,若一次操做中有 10 次更新 DOM 的動做,虛擬 DOM 不會當即操做 DOM,而是將這 10 次更新的 diff 內容保存到本地一個 JS 對象中,最終將這個 JS 對象一次性 attchDOM 樹上,再進行後續操做,避免大量無謂的計算量。因此,用 JS 對象模擬 DOM 節點的好處是,頁面的更新能夠先所有反映在 JS 對象(虛擬 DOM )上,操做內存中的 JS 對象的速度顯然要更快,等更新完成後,再將最終的 JS 對象映射成真實的 DOM,交由瀏覽器去繪製。

2.二、算法實現

2.2.一、用 JS 對象模擬 DOM

(1)如何用 JS 對象模擬 DOM

例如一個真實的 DOM 節點以下:

<div id="virtual-dom">
<p>Virtual DOM</p>
<ul id="list">
  <li class="item">Item 1</li>
  <li class="item">Item 2</li>
  <li class="item">Item 3</li>
</ul>
<div>Hello World</div>
</div> 
複製代碼

咱們用 JavaScript 對象來表示 DOM 節點,使用對象的屬性記錄節點的類型、屬性、子節點等。

element.js 中表示節點對象代碼以下:

/** * Element virdual-dom 對象定義 * @param {String} tagName - dom 元素名稱 * @param {Object} props - dom 屬性 * @param {Array<Element|String>} - 子節點 */
function Element(tagName, props, children) {
    this.tagName = tagName
    this.props = props
    this.children = children
    // dom 元素的 key 值,用做惟一標識符
    if(props.key){
       this.key = props.key
    }
    var count = 0
    children.forEach(function (child, i) {
        if (child instanceof Element) {
            count += child.count
        } else {
            children[i] = '' + child
        }
        count++
    })
    // 子元素個數
    this.count = count
}

function createElement(tagName, props, children){
 return new Element(tagName, props, children);
}

module.exports = createElement;
複製代碼

根據 element 對象的設定,則上面的 DOM 結構就能夠簡單表示爲:

var el = require("./element.js");
var ul = el('div',{id:'virtual-dom'},[
  el('p',{},['Virtual DOM']),
  el('ul', { id: 'list' }, [
	el('li', { class: 'item' }, ['Item 1']),
	el('li', { class: 'item' }, ['Item 2']),
	el('li', { class: 'item' }, ['Item 3'])
  ]),
  el('div',{},['Hello World'])
]) 
複製代碼

如今 ul 就是咱們用 JavaScript 對象表示的 DOM 結構,咱們輸出查看 ul 對應的數據結構以下:

(2)渲染用 JS 表示的 DOM 對象

可是頁面上並無這個結構,下一步咱們介紹如何將 ul 渲染成頁面上真實的 DOM 結構,相關渲染函數以下:

/** * render 將virdual-dom 對象渲染爲實際 DOM 元素 */
Element.prototype.render = function () {
    var el = document.createElement(this.tagName)
    var props = this.props
    // 設置節點的DOM屬性
    for (var propName in props) {
        var propValue = props[propName]
        el.setAttribute(propName, propValue)
    }

    var children = this.children || []
    children.forEach(function (child) {
        var childEl = (child instanceof Element)
            ? child.render() // 若是子節點也是虛擬DOM,遞歸構建DOM節點
            : document.createTextNode(child) // 若是字符串,只構建文本節點
        el.appendChild(childEl)
    })
    return el
} 
複製代碼

咱們經過查看以上 render 方法,會根據 tagName 構建一個真正的 DOM 節點,而後設置這個節點的屬性,最後遞歸地把本身的子節點也構建起來。

咱們將構建好的 DOM 結構添加到頁面 body 上面,以下:

ulRoot = ul.render();
document.body.appendChild(ulRoot); 
複製代碼

這樣,頁面 body 裏面就有真正的 DOM 結構,效果以下圖所示:

2.2.二、比較兩棵虛擬 DOM 樹的差別 — diff 算法

diff 算法用來比較兩棵 Virtual DOM 樹的差別,若是須要兩棵樹的徹底比較,那麼 diff 算法的時間複雜度爲O(n^3)。可是在前端當中,你不多會跨越層級地移動 DOM 元素,因此 Virtual DOM 只會對同一個層級的元素進行對比,以下圖所示, div 只會和同一層級的 div 對比,第二層級的只會跟第二層級對比,這樣算法複雜度就能夠達到 O(n)

(1)深度優先遍歷,記錄差別

在實際的代碼中,會對新舊兩棵樹進行一個深度優先的遍歷,這樣每一個節點都會有一個惟一的標記:

dfs-walk

在深度優先遍歷的時候,每遍歷到一個節點就把該節點和新的的樹進行對比。若是有差別的話就記錄到一個對象裏面。

// diff 函數,對比兩棵樹
function diff(oldTree, newTree) {
  var index = 0 // 當前節點的標誌
  var patches = {} // 用來記錄每一個節點差別的對象
  dfsWalk(oldTree, newTree, index, patches)
  return patches
}

// 對兩棵樹進行深度優先遍歷
function dfsWalk(oldNode, newNode, index, patches) {
  var currentPatch = []
  if (typeof (oldNode) === "string" && typeof (newNode) === "string") {
    // 文本內容改變
    if (newNode !== oldNode) {
      currentPatch.push({ type: patch.TEXT, content: newNode })
    }
  } else if (newNode!=null && oldNode.tagName === newNode.tagName && oldNode.key === newNode.key) {
    // 節點相同,比較屬性
    var propsPatches = diffProps(oldNode, newNode)
    if (propsPatches) {
      currentPatch.push({ type: patch.PROPS, props: propsPatches })
    }
    // 比較子節點,若是子節點有'ignore'屬性,則不須要比較
    if (!isIgnoreChildren(newNode)) {
      diffChildren(
        oldNode.children,
        newNode.children,
        index,
        patches,
        currentPatch
      )
    }
  } else if(newNode !== null){
    // 新節點和舊節點不一樣,用 replace 替換
    currentPatch.push({ type: patch.REPLACE, node: newNode })
  }

  if (currentPatch.length) {
    patches[index] = currentPatch
  }
} 
複製代碼

從以上能夠得出,patches[1] 表示 ppatches[3] 表示 ul ,以此類推。

(2)差別類型

DOM 操做致使的差別類型包括如下幾種:

  • 節點替換:節點改變了,例如將上面的 div 換成 h1;
  • 順序互換:移動、刪除、新增子節點,例如上面 div 的子節點,把 pul 順序互換;
  • 屬性更改:修改了節點的屬性,例如把上面 liclass 樣式類刪除;
  • 文本改變:改變文本節點的文本內容,例如將上面 p 節點的文本內容更改成 「Real Dom」;

以上描述的幾種差別類型在代碼中定義以下所示:

var REPLACE = 0 // 替換原先的節點
var REORDER = 1 // 從新排序
var PROPS = 2 // 修改了節點的屬性
var TEXT = 3 // 文本內容改變 
複製代碼

(3)列表對比算法

​ 子節點的對比算法,例如 p, ul, div 的順序換成了 div, p, ul。這個該怎麼對比?若是按照同層級進行順序對比的話,它們都會被替換掉。如 pdivtagName 不一樣,p 會被 div 所替代。最終,三個節點都會被替換,這樣 DOM 開銷就很是大。而其實是不須要替換節點,而只須要通過節點移動就能夠達到,咱們只需知道怎麼進行移動。

​ 將這個問題抽象出來其實就是字符串的最小編輯距離問題(Edition Distance),最多見的解決方法是 Levenshtein Distance , Levenshtein Distance 是一個度量兩個字符序列之間差別的字符串度量標準,兩個單詞之間的 Levenshtein Distance 是將一個單詞轉換爲另外一個單詞所需的單字符編輯(插入、刪除或替換)的最小數量。Levenshtein Distance 是1965年由蘇聯數學家 Vladimir Levenshtein 發明的。Levenshtein Distance 也被稱爲編輯距離(Edit Distance),經過動態規劃求解,時間複雜度爲 O(M*N)

定義:對於兩個字符串 a、b,則他們的 Levenshtein Distance 爲:

示例:字符串 aba=「abcde」 ,b=「cabef」,根據上面給出的計算公式,則他們的 Levenshtein Distance 的計算過程以下:

本文的 demo 使用插件 list-diff2 算法進行比較,該算法的時間複雜度偉 O(n*m),雖然該算法並不是最優的算法,可是用於對於 dom 元素的常規操做是足夠的。該算法具體的實現過程這裏再也不詳細介紹,該算法的具體介紹能夠參照:github.com/livoras/lis…

(4)實例輸出

兩個虛擬 DOM 對象以下圖所示,其中 ul1 表示原有的虛擬 DOM 樹,ul2 表示改變後的虛擬 DOM

var ul1 = el('div',{id:'virtual-dom'},[
  el('p',{},['Virtual DOM']),
  el('ul', { id: 'list' }, [
	el('li', { class: 'item' }, ['Item 1']),
	el('li', { class: 'item' }, ['Item 2']),
	el('li', { class: 'item' }, ['Item 3'])
  ]),
  el('div',{},['Hello World'])
]) 
var ul2 = el('div',{id:'virtual-dom'},[
  el('p',{},['Virtual DOM']),
  el('ul', { id: 'list' }, [
	el('li', { class: 'item' }, ['Item 21']),
	el('li', { class: 'item' }, ['Item 23'])
  ]),
  el('p',{},['Hello World'])
]) 
var patches = diff(ul1,ul2);
console.log('patches:',patches);
複製代碼

咱們查看輸出的兩個虛擬 DOM 對象之間的差別對象以下圖所示,咱們能經過差別對象獲得,兩個虛擬 DOM 對象之間進行了哪些變化,從而根據這個差別對象(patches)更改原先的真實 DOM 結構,從而將頁面的 DOM 結構進行更改。

2.2.三、將兩個虛擬 DOM 對象的差別應用到真正的 DOM

(1)深度優先遍歷 DOM

​ 由於步驟一所構建的 JavaScript 對象樹和 render 出來真正的 DOM 樹的信息、結構是同樣的。因此咱們能夠對那棵 DOM 樹也進行深度優先的遍歷,遍歷的時候從步驟二生成的 patches 對象中找出當前遍歷的節點差別,以下相關代碼所示:

function patch (node, patches) {
  var walker = {index: 0}
  dfsWalk(node, walker, patches)
}

function dfsWalk (node, walker, patches) {
  // 從patches拿出當前節點的差別
  var currentPatches = patches[walker.index]

  var len = node.childNodes
    ? node.childNodes.length
    : 0
  // 深度遍歷子節點
  for (var i = 0; i < len; i++) {
    var child = node.childNodes[i]
    walker.index++
    dfsWalk(child, walker, patches)
  }
  // 對當前節點進行DOM操做
  if (currentPatches) {
    applyPatches(node, currentPatches)
  }
} 
複製代碼

(2)對原有 DOM 樹進行 DOM 操做

咱們根據不一樣類型的差別對當前節點進行不一樣的 DOM 操做 ,例如若是進行了節點替換,就進行節點替換 DOM 操做;若是節點文本發生了改變,則進行文本替換的 DOM 操做;以及子節點重排、屬性改變等 DOM 操做,相關代碼如 applyPatches 所示 :

function applyPatches (node, currentPatches) {
  currentPatches.forEach(currentPatch => {
    switch (currentPatch.type) {
      case REPLACE:
        var newNode = (typeof currentPatch.node === 'string')
          ? document.createTextNode(currentPatch.node)
          : currentPatch.node.render()
        node.parentNode.replaceChild(newNode, node)
        break
      case REORDER:
        reorderChildren(node, currentPatch.moves)
        break
      case PROPS:
        setProps(node, currentPatch.props)
        break
      case TEXT:
        node.textContent = currentPatch.content
        break
      default:
        throw new Error('Unknown patch type ' + currentPatch.type)
    }
  })
} 
複製代碼

(3)DOM結構改變

經過將第 2.2.2 獲得的兩個 DOM 對象之間的差別,應用到第一個(原先)DOM 結構中,咱們能夠看到 DOM 結構進行了預期的變化,以下圖所示:

2.三、結語

相關代碼實現已經放到 github 上面,有興趣的同窗能夠clone運行實驗,github地址爲:github.com/fengshi123/…

Virtual DOM 算法主要實現上面三個步驟來實現:

  • JS 對象模擬 DOM 樹 — element.js

    <div id="virtual-dom">
    <p>Virtual DOM</p>
    <ul id="list">
      <li class="item">Item 1</li>
      <li class="item">Item 2</li>
      <li class="item">Item 3</li>
    </ul>
    <div>Hello World</div>
    </div> 
    複製代碼
  • 比較兩棵虛擬 DOM 樹的差別 — diff.js

  • 將兩個虛擬 DOM 對象的差別應用到真正的 DOM 樹 — patch.js

    function applyPatches (node, currentPatches) {
      currentPatches.forEach(currentPatch => {
        switch (currentPatch.type) {
          case REPLACE:
            var newNode = (typeof currentPatch.node === 'string')
              ? document.createTextNode(currentPatch.node)
              : currentPatch.node.render()
            node.parentNode.replaceChild(newNode, node)
            break
          case REORDER:
            reorderChildren(node, currentPatch.moves)
            break
          case PROPS:
            setProps(node, currentPatch.props)
            break
          case TEXT:
            node.textContent = currentPatch.content
            break
          default:
            throw new Error('Unknown patch type ' + currentPatch.type)
        }
      })
    } 
    複製代碼

3、Vue 源碼 Virtual-DOM 簡析

咱們從第二章節(Virtual-DOM 基礎)中已經掌握 Virtual DOM 渲染成真實的 DOM 實際上要經歷 VNode 的定義、diffpatch 等過程,因此本章節 Vue 源碼的解析也按這幾個過程來簡析。

3.一、VNode 模擬 DOM

3.1.一、VNode 類簡析

Vue.js 中,Virtual DOM 是用 VNode 這個 Class 去描述,它定義在 src/core/vdom/vnode.js 中 ,從如下代碼塊中能夠看到 Vue.js 中的 Virtual DOM 的定義較爲複雜一些,由於它這裏包含了不少 Vue.js 的特性。實際上 Vue.jsVirtual DOM 是借鑑了一個開源庫  snabbdom 的實現,而後加入了一些 Vue.js 的一些特性。

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
  devtoolsMeta: ?Object; // used to store functional render context for devtools
  fnScopeId: ?string; // functional scope id support

  constructor (
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
  ) {
    this.tag = tag
    this.data = data
    this.children = children
    this.text = text
    this.elm = elm
    this.ns = undefined
    this.context = context
    this.fnContext = undefined
    this.fnOptions = undefined
    this.fnScopeId = undefined
    this.key = data && data.key
    this.componentOptions = componentOptions
    this.componentInstance = undefined
    this.parent = undefined
    this.raw = false
    this.isStatic = false
    this.isRootInsert = true
    this.isComment = false
    this.isCloned = false
    this.isOnce = false
    this.asyncFactory = asyncFactory
    this.asyncMeta = undefined
    this.isAsyncPlaceholder = false
  }
}
複製代碼

這裏千萬不要由於 VNode 的這麼屬性而被嚇到,或者咬緊牙去摸清楚每一個屬性的意義,其實,咱們主要了解其幾個核心的關鍵屬性就差很少了,例如:

  • tag 屬性即這個vnode的標籤屬性
  • data 屬性包含了最後渲染成真實dom節點後,節點上的classattributestyle以及綁定的事件
  • children 屬性是vnode的子節點
  • text 屬性是文本屬性
  • elm 屬性爲這個vnode對應的真實dom節點
  • key 屬性是vnode的標記,在diff過程當中能夠提升diff的效率

3.1.二、源碼建立 VNode 過程

(1)初始化vue

咱們在實例化一個 vue 實例,也即 new Vue( ) 時,其實是執行 src/core/instance/index.js 中定義的 Function 函數。

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}
複製代碼

經過查看 Vuefunction,咱們知道 Vue 只能經過 new 關鍵字初始化,而後調用 this._init 方法,該方法在 src/core/instance/init.js 中定義。

Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
      
    // 省略一系列其它初始化的代碼
      
    if (vm.$options.el) {
      console.log('vm.$options.el:',vm.$options.el);
      vm.$mount(vm.$options.el)
    }
  }
複製代碼

(2)Vue 實例掛載

Vue 中是經過 $mount 實例方法去掛載 dom 的,下面咱們經過分析 compiler 版本的 mount 實現,相關源碼在目錄 src/platforms/web/entry-runtime-with-compiler.js 文件中定義:。

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean ): Component {
  el = el && query(el)
  
   // 省略一系列初始化以及邏輯判斷代碼 
 
  return mount.call(this, el, hydrating)
}
複製代碼

咱們發現最終仍是調用用原先原型上的 $mount 方法掛載 ,原先原型上的 $mount 方法在 src/platforms/web/runtime/index.js 中定義 。

Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean ): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}
複製代碼

咱們發現$mount 方法實際上會去調用 mountComponent 方法,這個方法定義在 src/core/instance/lifecycle.js 文件中

export function mountComponent ( vm: Component, el: ?Element, hydrating?: boolean ): Component {
  vm.$el = el
  // 省略一系列其它代碼
  let updateComponent
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    updateComponent = () => {
      // 生成虛擬 vnode 
      const vnode = vm._render()
      // 更新 DOM
      vm._update(vnode, hydrating)
     
    }
  } else {
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }

  // 實例化一個渲染Watcher,在它的回調函數中會調用 updateComponent 方法 
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  return vm
}
複製代碼

從上面的代碼能夠看到,mountComponent 核心就是先實例化一個渲染Watcher,在它的回調函數中會調用 updateComponent 方法,在此方法中調用 vm._render 方法先生成虛擬 Node,最終調用 vm._update 更新 DOM

(3)建立虛擬 Node

Vue 的 _render 方法是實例的一個私有方法,它用來把實例渲染成一個虛擬 Node。它的定義在 src/core/instance/render.js 文件中:

Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render, _parentVnode } = vm.$options
    let vnode
    try {
      // 省略一系列代碼 
      currentRenderingInstance = vm
      // 調用 createElement 方法來返回 vnode
      vnode = render.call(vm._renderProxy, vm.$createElement)
    } catch (e) {
      handleError(e, vm, `render`){}
    }
    // set parent
    vnode.parent = _parentVnode
    console.log("vnode...:",vnode);
    return vnode
  }
複製代碼

Vue.js 利用 _createElement 方法建立 VNode,它定義在 src/core/vdom/create-elemenet.js 中:

export function _createElement ( context: Component, tag?: string | Class<Component> | Function | Object, data?: VNodeData, children?: any, normalizationType?: number ): VNode | Array<VNode> {
    
  // 省略一系列非主線代碼
  
  if (normalizationType === ALWAYS_NORMALIZE) {
    // 場景是 render 函數不是編譯生成的
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    // 場景是 render 函數是編譯生成的
    children = simpleNormalizeChildren(children)
  }
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // 建立虛擬 vnode
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    vnode = createComponent(tag, data, context, children)
  }
  if (Array.isArray(vnode)) {
    return vnode
  } else if (isDef(vnode)) {
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    return createEmptyVNode()
  }
}
複製代碼

_createElement 方法有 5 個參數,context 表示 VNode 的上下文環境,它是 Component 類型;tag表示標籤,它能夠是一個字符串,也能夠是一個 Componentdata 表示 VNode 的數據,它是一個 VNodeData 類型,能夠在 flow/vnode.js 中找到它的定義;children 表示當前 VNode 的子節點,它是任意類型的,須要被規範爲標準的 VNode 數組;

3.1.三、實例查看

爲了更直觀查看咱們平時寫的 Vue 代碼如何用 VNode 類來表示,咱們經過一個實例的轉換進行更深入瞭解。

例如,實例化一個 Vue 實例:

var app = new Vue({
    el: '#app',
    render: function (createElement) {
      return createElement('div', {
        attrs: {
          id: 'app',
          class: "class_box"
        },
      }, this.message)
    },
    data: {
      message: 'Hello Vue!'
    }
  })
複製代碼

咱們打印出其對應的 VNode 表示:

3.二、diff 過程

3.2.一、Vue.js 源碼的 diff 調用邏輯

Vue.js 源碼實例化了一個 watcher,這個 ~ 被添加到了在模板當中所綁定變量的依賴當中,一旦 model 中的響應式的數據發生了變化,這些響應式的數據所維護的 dep 數組便會調用 dep.notify() 方法完成全部依賴遍歷執行的工做,這包括視圖的更新,即 updateComponent 方法的調用。watcherupdateComponent方法定義在  src/core/instance/lifecycle.js 文件中 。

export function mountComponent ( vm: Component, el: ?Element, hydrating?: boolean ): Component {
  vm.$el = el
  // 省略一系列其它代碼
  let updateComponent
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    updateComponent = () => {
      // 生成虛擬 vnode 
      const vnode = vm._render()
      // 更新 DOM
      vm._update(vnode, hydrating)
     
    }
  } else {
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }

  // 實例化一個渲染Watcher,在它的回調函數中會調用 updateComponent 方法 
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  return vm
}
複製代碼

完成視圖的更新工做事實上就是調用了vm._update方法,這個方法接收的第一個參數是剛生成的Vnode,調用的vm._update方法定義在 src/core/instance/lifecycle.js中。

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    const restoreActiveInstance = setActiveInstance(vm)
    vm._vnode = vnode
    if (!prevVnode) {
      // 第一個參數爲真實的node節點,則爲初始化
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // 若是須要diff的prevVnode存在,那麼對prevVnode和vnode進行diff
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    restoreActiveInstance()
    // update __vue__ reference
    if (prevEl) {
      prevEl.__vue__ = null
    }
    if (vm.$el) {
      vm.$el.__vue__ = vm
    }
    // if parent is an HOC, update its $el as well
    if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
      vm.$parent.$el = vm.$el
    }
  }
複製代碼

在這個方法當中最爲關鍵的就是 vm.__patch__ 方法,這也是整個 virtual-dom 當中最爲核心的方法,主要完成了prevVnodevnodediff 過程並根據須要操做的 vdom 節點打 patch,最後生成新的真實 dom 節點並完成視圖的更新工做。

接下來,讓咱們看下 vm.__patch__的邏輯過程, vm.__patch__ 方法定義在 src/core/vdom/patch.js 中。

function patch (oldVnode, vnode, hydrating, removeOnly) {
    ......
    if (isUndef(oldVnode)) {
      // 當oldVnode不存在時,建立新的節點
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    } else {
      // 對oldVnode和vnode進行diff,並對oldVnode打patch 
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } 
	......
  }
}
複製代碼

patch 方法中,咱們看到會分爲兩種狀況,一種是當 oldVnode 不存在時,會建立新的節點;另外一種則是已經存在 oldVnode ,那麼會對 oldVnodevnode 進行 diffpatch 的過程。其中 patch 過程當中會調用 sameVnode 方法來對對傳入的2個 vnode 進行基本屬性的比較,只有當基本屬性相同的狀況下才認爲這個2個vnode 只是局部發生了更新,而後纔會對這2個 vnode 進行 diff,若是2個 vnode 的基本屬性存在不一致的狀況,那麼就會直接跳過 diff 的過程,進而依據 vnode 新建一個真實的 dom,同時刪除老的 dom節點。

function sameVnode (a, b) {
  return (
    a.key === b.key &&
    a.tag === b.tag &&
    a.isComment === b.isComment &&
    isDef(a.data) === isDef(b.data) &&
    sameInputType(a, b)
  )
}
複製代碼

diff 過程當中主要是經過調用 patchVnode 方法進行的:

function patchVnode (oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly) {
    ...... 
    const elm = vnode.elm = oldVnode.elm
    const oldCh = oldVnode.children
    const ch = vnode.children
    // 若是vnode沒有文本節點
    if (isUndef(vnode.text)) {
      // 若是oldVnode的children屬性存在且vnode的children屬性也存在 
      if (isDef(oldCh) && isDef(ch)) {
        // updateChildren,對子節點進行diff 
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        if (process.env.NODE_ENV !== 'production') {
          checkDuplicateKeys(ch)
        }
        // 若是oldVnode的text存在,那麼首先清空text的內容,而後將vnode的children添加進去 
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        // 刪除elm下的oldchildren
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        // oldVnode有子節點,而vnode沒有,那麼就清空這個節點 
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      // 若是oldVnode和vnode文本屬性不一樣,那麼直接更新真是dom節點的文本元素
      nodeOps.setTextContent(elm, vnode.text)
    }
    ......
  }
複製代碼

從以上代碼得知,

diff 過程當中又分了好幾種狀況,oldCholdVnode的子節點,chVnode的子節點:

  • 首先進行文本節點的判斷,若 oldVnode.text !== vnode.text,那麼就會直接進行文本節點的替換;
  • vnode 沒有文本節點的狀況下,進入子節點的 diff
  • oldChch 都存在且不相同的狀況下,調用 updateChildren 對子節點進行 diff
  • oldCh不存在,ch 存在,首先清空 oldVnode 的文本節點,同時調用 addVnodes 方法將 ch 添加到elm真實 dom 節點當中;
  • oldCh存在,ch不存在,則刪除 elm 真實節點下的 oldCh 子節點;
  • oldVnode 有文本節點,而 vnode 沒有,那麼就清空這個文本節點。

3.2.二、子節點 diff 流程分析

(1)Vue.js 源碼

​ 這裏着重分析下updateChildren方法,它也是整個 diff 過程當中最重要的環節,如下爲 Vue.js 的源碼過程,爲了更形象理解 diff 過程,咱們給出相關的示意圖來說解。

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    // 爲oldCh和newCh分別創建索引,爲以後遍歷的依據
    let oldStartIdx = 0
    let newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    // 直到oldCh或者newCh被遍歷完後跳出循環
    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, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        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, newCh, newStartIdx)
        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, newCh, newStartIdx)
            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]
      }
    }
    if (oldStartIdx > oldEndIdx) {
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
  }
複製代碼

在開始遍歷 diff 前,首先給 oldChnewCh 分別分配一個 startIndexendIndex 來做爲遍歷的索引,當oldCh 或者 newCh 遍歷完後(遍歷完的條件就是 oldCh 或者 newChstartIndex >= endIndex ),就中止oldChnewChdiff 過程。接下來經過實例來看下整個 diff 的過程(節點屬性中不帶 key 的狀況)。

(2)無 keydiff 過程

咱們經過如下示意圖對以上代碼過程進行講解:

(2.1)首先從第一個節點開始比較,無論是 oldCh 仍是 newCh 的起始或者終止節點都不存在 sameVnode ,同時節點屬性中是不帶 key標記的,所以第一輪的 diff 完後,newChstartVnode 被添加到 oldStartVnode的前面,同時 newStartIndex前移一位;

图片描述

(2.2)第二輪的 diff中,知足 sameVnode(oldStartVnode, newStartVnode),所以對這2個 vnode 進行diff,最後將 patch 打到 oldStartVnode 上,同時 oldStartVnodenewStartIndex 都向前移動一位 ;

图片描述

(2.3)第三輪的 diff 中,知足 sameVnode(oldEndVnode, newStartVnode),那麼首先對 oldEndVnodenewStartVnode 進行 diff,並對 oldEndVnode進行 patch,並完成 oldEndVnode 移位的操做,最後newStartIndex前移一位,oldStartVnode 後移一位;

图片描述

(2.4)第四輪的 diff中,過程同步驟3;

图片描述

(2.5)第五輪的 diff 中,同過程1;

图片描述

(2.6)遍歷的過程結束後,newStartIdx > newEndIdx,說明此時 oldCh 存在多餘的節點,那麼最後就須要將這些多餘的節點刪除。

图片描述

(3)有 keydiff 流程

vnode 不帶 key 的狀況下,每一輪的 diff 過程中都是起始結束節點進行比較,直到 oldCh 或者newCh 被遍歷完。而當爲 vnode 引入 key 屬性後,在每一輪的 diff 過程當中,當起始結束節點都沒有找到sameVnode 時,而後再判斷在 newStartVnode 的屬性中是否有 key,且是否在 oldKeyToIndx 中找到對應的節點 :

  • 若是不存在這個 key,那麼就將這個 newStartVnode做爲新的節點建立且插入到原有的 root 的子節點中;
  • 若是存在這個 key,那麼就取出 oldCh 中的存在這個 keyvnode,而後再進行 diff 的過;

經過以上分析,給vdom上添加 key屬性後,遍歷 diff 的過程當中,當起始點結束點搜尋diff 出現仍是沒法匹配的狀況下時,就會用 key 來做爲惟一標識,來進行 diff,這樣就能夠提升 diff 效率。

帶有 Key屬性的 vnodediff 過程可見下圖:

(3.1)首先從第一個節點開始比較,無論是 oldCh 仍是 newCh 的起始或者終止節點都不存在 sameVnode,但節點屬性中是帶 key 標記的, 而後在 oldKeyToIndx 中找到對應的節點,這樣第一輪 diff 事後 oldCh 上的B節點被刪除了,可是 newCh 上的B節點elm 屬性保持對 oldChB節點elm引用。

图片描述

(3.2)第二輪的 diff 中,知足 sameVnode(oldStartVnode, newStartVnode),所以對這2個 vnode 進行diff,最後將 patch 打到 oldStartVnode上,同時 oldStartVnodenewStartIndex 都向前移動一位 ;

图片描述

(3.3)第三輪的 diff中,知足 sameVnode(oldEndVnode, newStartVnode),那麼首先對 oldEndVnodenewStartVnode 進行 diff,並對 oldEndVnode 進行 patch,並完成 oldEndVnode 移位的操做,最後newStartIndex 前移一位,oldStartVnode後移一位;

图片描述

(3.4)第四輪的diff中,過程同步驟2;

图片描述

(3.5)第五輪的diff中,由於此時 oldStartIndex 已經大於 oldEndIndex,因此將剩餘的 Vnode 隊列插入隊列最後。

图片描述

3.三、patch 過程

經過3.2章節介紹的 diff 過程當中,咱們會看到 nodeOps 相關的方法對真實 DOM 結構進行操做,nodeOps 定義在 src/platforms/web/runtime/node-ops.js 中,其爲基本 DOM 操做,這裏就不在詳細介紹。

export function createElementNS (namespace: string, tagName: string): Element {
  return document.createElementNS(namespaceMap[namespace], tagName)
}

export function createTextNode (text: string): Text {
  return document.createTextNode(text)
}

export function createComment (text: string): Comment {
  return document.createComment(text)
}

export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
  parentNode.insertBefore(newNode, referenceNode)
}

export function removeChild (node: Node, child: Node) {
  node.removeChild(child)
}
複製代碼

3.四、總結

經過前三小節簡析,咱們從主線上把模板和數據如何渲染成最終的 DOM 的過程分析完畢了,咱們能夠經過下圖更直觀地看到從初始化 Vue 到最終渲染的整個過程。

4、總結

本文從經過介紹真實 DOM 結構其解析過程以及存在的問題,從而引出爲何須要虛擬 DOM;而後分析虛擬DOM 的好處,以及其一些理論基礎和基礎算法的實現;最後根據咱們已經掌握的基礎知識,再一步步去查看Vue.js 的源碼如何實現的。從存在問題 —> 理論基礎 —> 具體實踐,一步步深刻,幫助你們更好的瞭解什麼是Virtual DOM、爲何須要 Virtual DOM、以及 Virtual DOM的具體實現,但願本文對您有幫助。

**辛苦編寫良久,若是對你有幫助,還望手動點贊鼓勵~~~~~~**

github地址爲:github.com/fengshi123/…,上面彙總了做者全部的博客文章,若是喜歡或者有所啓發,請幫忙給個 star ~,對做者也是一種鼓勵。

參考文獻

一、Vue 技術揭祕:ustbhuangyi.github.io/vue-analysi…

二、深度剖析:如何實現一個 Virtual DOM 算法:segmentfault.com/a/119000000…

三、vue核心之虛擬DOM(vdom):www.jianshu.com/p/af0b39860…

四、virtual-dom(Vue實現)簡析:segmentfault.com/a/119000001…

相關文章
相關標籤/搜索