由Vitrual Dom想到的

本文闡述的內容:

  • Dom操作之重繪重排
  • 結合vue源碼理解Vitrual Dom原理

理解這一部分是為了的目的:

  • 學習思想進階的第一部分
  • 很多東西不能只是會用,用的時候要理解其原理,知其所以然

1.前言

經常看到有人說某某操作多么浪費性能,有些操作又相對來說好點;包括現在的vuereactVitrual Dom,有些人覺得性能并沒有得到很大提升,依據是dom操作的本質就那些api。

每每問到這些,我并不能講出本質。所以不能盲目的跟風。

2.Dom操作之重繪重排

曾經看到這樣一句話覺得很好的形容了js與dom之間的關系

把DOM和JavaScript(這里指ECMScript)各自想象為一個島嶼,它們之間用收費橋梁連接,ECMAScript每次訪問DOM,都要途徑這座橋,并交納“過橋費”,訪問DOM的次數越多,費用也就越高。因此,推薦的做法是盡量減少過橋的次數,努力待在ECMAScript島上。

假設已經知道瀏覽器渲染頁面的過程,不了解請戳:

2.1 概念

  • 重排(reflow)
    DOM的變化影響了元素的幾何屬性(寬或高),瀏覽器需要重新計算元素的幾何屬性,同樣其他元素的幾何屬性和位置也會因此受到影響。瀏覽器會使渲染樹中受到影響的部分失效,并重新構造渲染樹。

  • 重繪(repaint)
    js操作僅僅影響Dom的顏色,字體等等,或者完成重排后,瀏覽器會重新繪制受影響的部分到屏幕,該過程稱為重繪。也就是說,每次重排,必然會導致重繪

2.2 導致頁面重排的一些操作

  • DOM樹的結構變化
    增刪改
  • DOM元素的幾何屬性的變化
    寬高等尺寸
  • 內容改變
    文本改變或圖片尺寸改變
  • 用戶事件
    鼠標懸停、頁面滾動、輸入框鍵入文字、改變窗口大小等等

2.3 瀏覽器的渲染機制

瀏覽器會盡量把所有的變動集中在一起,排成一個隊列,然后一次性執行,盡量避免多次重新渲染。

以下操作會迫使瀏覽器強制刷新渲染隊列:

  • offsetTop/offsetLeft/offsetWidth/offsetHeight
  • scrollTop/scrollLeft/scrollWidth/scrollHeight
  • clientTop/clientLeft/clientWidth/clientHeight
  • getComputedStyle(),(currentStyle in IE)

2.4 優化的辦法

  • 我們知道html是一個文檔流,從上到下渲染;如果脫離了文檔流后,(例如:position, float),js操作其dom,受影響的dom將減少,從而減少重排的dom數量。
  • display:none;的元素不會出現在渲染樹中,而重排重繪是根據 渲染樹來進行的,我們可以先講dom隱藏,然后再進行無數次dom操作,最后顯示,這樣只進行了2次重排重繪操作。

** 2.5 參考**
網頁性能管理詳解
高性能JavaScript 重排與重繪
關于DOM的操作以及性能優化問題-重繪重排


3.結合vue源碼來理解Vitrual Dom原理

我們首先要知道的概念

  • Vitrual Dom就是一個樹形的object,與真實的dom保持映射關系
  • js語法的執行速度要遠遠快于操作dom的速度
  • ReactVue的核心之一就是Vitrual Dom
  • 深度優先搜索算法

3.1 Vitrual Dom實現的步驟

  • 3.1.1 用JS對象模擬DOM樹

有如下的html結構

<div id="app">
    <h1>header</h1>
    <p>footer</p>
</div>

用對象表示(省略了部分屬性)

var element = {
  tagName: 'div', // 節點標簽名
  el: null,    // vue中映射的真實dom
  props: { // DOM的屬性,用一個對象存儲鍵值對
    id: 'app'
  },
  children: [ // 該節點的子節點
    {tagName: 'h1', props: null, children:['header']},
    {tagName: 'p', props: null, children: ['footer']}
  ]
}

真實 DOM 元素屬性多達 228 個,而這些屬性有 90% 多對我們來說都是無用的,所以將我們需要的屬性拿過來,會簡化很多。

let div = document.createElement('div');
for(let k in div) {
    console.log(k);
}

vue和react是由數據驅動視圖,當數據改變的時候,它們會重新生成Vitrual Dom,然后將新舊Vitrual Dom做比較,也就是接下來要說的部分。

  • 3.1.2 比較新舊Vitrual Dom樹的差異

比較兩棵DOM樹的差異是 Virtual DOM 算法最核心的部分,這也是所謂的 Virtual DOM 的 diff 算法

8C56DD7E-C6A6-43BB-97B7-D2AFA2D3B862.png

比較只會對新舊Virtual DOM同級的元素進行對比,利用遞歸實現深度優先遍歷


CDB0ED79-8308-4F55-8DD1-96397AE86D1C.png
  • 3.1.3 對比較的差異逐個進行處理

vue和react所做的操作是一樣的,對比較后 的結果逐個進行原生api操作,并不是直接將根元素替換。

// 原生api列表 
// https://github.com/vuejs/vue/blob/dev/src/platforms/web/runtime/node-ops.js

export function createElement
export function createElementNS
export function createTextNode
export function createComment
export function insertBefore
export function removeChild 
export function appendChild
export function parentNode 
export function nextSibling
export function tagName
export function setTextContent
export function setAttribute

另外它們操作的時機也是不一樣的,具體的步驟需要結合框架本身的算法來詳細闡述。

3.2 結合vue源碼來理解

我們要弄清楚什么時候利用了virtual dom的diff算法,patch的時候做了哪些操作,
接下來結合vue的源碼進行簡單的解讀。

// 一切從Vue構造函數實例化開始
new Vue({
    // options
})

在源碼結構中,找不到入口文件,我們先從構建工具目錄開始,在build/config中找到了入口文件,用Webpack & Browserify打包的入口文件:

// Runtime only (CommonJS). Used by bundlers e.g. Webpack & Browserify
  'web-runtime-cjs': {
    entry: resolve('web/entry-runtime.js'),
    dest: resolve('dist/vue.runtime.common.js'),
    format: 'cjs',
    banner
  }

進而我們找到runtime/index.js文件,其核心部分:

// 引入Vue構造函數
import Vue from 'core/index'
// 渲染組建的函數
import { mountComponent } from 'core/instance/lifecycle'

// virtual dom的核心,比較算法
import { patch } from './patch'
// install platform patch function
// 在瀏覽器端用patch方法作為比較算法
Vue.prototype.__patch__ = inBrowser ? patch : noop

// $mount的實質就是調用mountComponent
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 此處獲取真實dom,傳遞給mountComponent
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

export default Vue

接著看Vue構造函數的定義和mountComponent方法

我們找到src/core/instance目錄,在index.js中,就是Vue構造函數的初始化

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)
}

當我們調用構造函數new Vue({/* options */})時,就會調用this._init(options)

我們在init.js中看到_init定義,然后做了一系列初始化操作

    vm._self = vm
    // 生命周期初始化
    initLifecycle(vm)
    // 事件
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')

最后調用了$mount(vm.$options.el),由上可知,此處的$mount === mountComponent此時的el參數為真實的dom元素。

/lifecycle.js中,我們看到定義了3個周期函數

Vue.prototype._update
Vue.prototype.$forceUpdate
Vue.prototype.$destroy 

然后找到了mountComponent函數

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // 我們發現$el就是真實的dom
  vm.$el = el
  callHook(vm, 'beforeMount')

  let updateComponent  
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    //....
  } else {
    updateComponent = () => {
      // 當數據更新時,調用此方法
      vm._update(vm._render(), hydrating)
    }
  }
  // 添加數據雙向綁定等
  vm._watcher = new Watcher(vm, updateComponent, noop)
  hydrating = false

  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }

  // 返回一個完整的vue實例
  return vm
}

當數據發生改變時,調用_update函數更新dom

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevVnode = vm._vnode
    if (!prevVnode) {
      // initial render
      vm.$el = vm.__patch__(
        vm.$el, vnode, hydrating, false /* removeOnly */,
        vm.$options._parentElm,
        vm.$options._refElm
      )
    } else {
      // updates
      // 根據傳參,vnode === vm._render() 也就是新的virtual dom
      // 說明新的virtual dom是由render函數生成,后面再來了解rendered函數做了哪些事情
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
  }

當第一次渲染時,vm._vnode === null不會做update操作,盡管如此,都是調用__patch__方法實現更新。

由上面我們可以知道,在瀏覽器環境,__patch__ === patch.js/patch方法

終于來到了我們這次要講的Virtual Dom關鍵部分了,開心。

查看patch.js

return function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
  // 如果不是真實dom,并且新舊虛擬dom是同一個節點的
  if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // 開始比較
        patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
  } else {
    // 如果傳遞的是真實dom
    if (isRealElement) {
       // 第一次,由真實dom初始化虛擬dom對象 new Vnode()
       oldVnode = emptyNodeAt(oldVnode)
    }
 
    // 接著由虛擬dom創建真實dom
    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)
    )
  }
}   

非初始化更新數據就會調用patchVnode方法,也就是diff算法的核心部分,先看詳解再解讀 。

// 核心部分

if (isUndef(vnode.text)) {
    if (isDef(oldCh) && isDef(ch)) {
      
      // 如果2者的子節點都存在則優先比較子節點,此處就是上面提到的深度優先搜索
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
    } else if (isDef(ch)) {
      
      // 新虛擬dom的子節點存在,則做appendChild操作
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) {

      // 僅僅是舊虛擬dom的子節點存在,做removeChild操作
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {

      // 將舊虛擬dom的文本置為空
      nodeOps.setTextContent(elm, '')
    }
} else if (oldVnode.text !== vnode.text) {

    // 僅僅是修改文案
    nodeOps.setTextContent(elm, vnode.text)
}

一張圖來表示這個過程

B8C4071A-0388-4EDD-BF47-D023A4130808.png

深度優先遍歷會最先比較其子節點updateChildren源碼很長,也是理解中的難點,折疊其代碼后,發現其比較的過程也就分為6種情況,這里的diff算法其實就是對一個列表的比較,vue只做了這幾種情況的比較,也就滿足了同層級的dom操作的絕大部分情況。

5F7F241A-1C6D-4284-998C-BFDA74135A87.png

借用別人的一張圖來表述這個比較過程:

E32FC6E0-5823-44AB-B5EB-D7E77E430DF9.png

從圖中我們可以看出比較是從首尾開始的,while一個循環,索引分為別遞增和遞減。

最簡單的情況是列表沒有發生改變,僅僅是它們的子節點改變,就只會出現下面2種情況

1 . sameVnode(oldStartVnode, newStartVnode)
2 . sameVnode(oldEndVnode, newEndVnode)

對于以上情況,不需要對改層級dom進行移動操作,最多修改其文本,只需要對其子節點進行對比

patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)

但是,如果該層級列表發生了改變,順序或增刪情況,就會滿足以下情況了。

判斷節點是否進行了移動操作

3 . sameVnode(oldStartVnode, newEndVnode)

說明將前面的節點移動到了后面的位置,同樣要比較其子節點,然后做同層級的移動操作,這里用insertBefore實現

// 將該節點移動到oldEndVnode的位置,而不是newEndVnode,
// 因為要取nextSibling,有可能新的節點中nextSibling不存在了
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))

4 . sameVnode(oldEndVnode, newStartVnode)

// 同理
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)

用一張圖來舉一個最簡單的例子

FD2B546E-255A-488B-83F6-27E9AAB4CF2F.png

由上圖我們發現,當該層級dom發生改變需要移動時,操作的是真實的dom節點,而新舊的virtual dom并沒有發生變化,包括insertBefore操作,它的referenceNode都是在oldEndIdx.elm中獲取的,因為可能newEndIdx的下一個節點不存在了,當跳出循環后,舊virtual dom中多余的節點(例如上圖中的e)會被remove;新的virtual dom中新增的會被addVnode。

我們再來看最后一種情況,當這些條件都不滿足的時候

// else部分

// 獲取舊虛擬dom中的key
if (isUndef(oldKeyToIdx)) 
    oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)

// 如果新虛擬dom中定義了key,則取出舊虛擬dom中相對的元素
idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null

if (isUndef(idxInOld)) {
    // 舊虛擬dom中key 不存在,說明是新創建的節點
    // 此處我們會發現,同級別的節點最好設置屬性key,這樣少一步dom操作,否則會先創建,再插入
    createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
} else {
    elmToMove = oldCh[idxInOld]
    // key 存在,且這個key對應的2個節點值得比較,
    if (sameVnode(elmToMove, newStartVnode)) {
        patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)

        // 將原值置為undefined,目的是將key存在的節點不做比較
        oldCh[idxInOld] = undefined
        canMove && nodeOps.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm)
    } else {
        // 不值得比較說明是不同的元素,需要創建新節點
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
    }

最后,循環結束的標志是

oldStartIdx > oldEndIdx 或者 newStartIdx > newEndIdx

所以分為2種情況,當oldStartIdx > oldEndIdx,也就是舊虛擬dom先比較完成

if (oldStartIdx > oldEndIdx) {
    // 如果新虛擬dom中存在新節點,則創建并插入
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    }

反之,如果新虛擬dom先比較完成,則移除舊虛擬dom中的節點

removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)

整個過程就結束了,virtual dom核心的diff部分,其實就是一個列表和相同節點的比較部分,里面又一個方法判斷這些節點值不值得比較,就是判斷新舊virtual dom中對應的元素是否相等即sameVnode

3.3 總結

3.3.1 虛擬dom的diff算法沒有跨層級判斷,如果我們進行了跨層級操作dom,這是很浪費的,所以盡量要避免。

3.3.2 同一層級的元素最好設置key,因為設置了key后,相同key做比較時,最多只會進行insertBefore操作(當然如果2個不相同的元素有相同的key就會先createElminsertBefore了)

3.3.3 從vue虛擬domdiff算法的源碼來看,所做的事情還是很多的,有遞歸的存在,所以我們要盡量避免多層級嵌套div,減少遞歸層級。如果頁面不是很頻繁的更新和操作dom,我們根本不需要使用virtual dom

3.3.4 單獨的使用virtual dom我覺得意義不大,vue和react存在的意義,吸引我的地方在于解放雙手,不用管如何增刪改dom,因為它們的數據狀態機制,也就是mvvm中的viewModal層面,不管是react的單向數據流,還是vue的雙向數據綁定,都是利用數據驅動視圖,利用virtual dom,不用直接操作dom,使得我們有了當初使用jquery般方便的感覺。總之,結合state數據狀態和virtual dom去管理頁面我覺得是非常好用的。

3.4 參考
深度剖析:如何實現一個 Virtual DOM 算法
Vue2.0 源碼閱讀:模板渲染

解析vue2.0的diff算法
如何理解虛擬DOM?
全面理解虛擬DOM,實現虛擬DOM
刺破vue的心臟之——詳解render function code

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容