由Vitrual Dom想到的

663 阅读12分钟

本文阐述的内容:

  • 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