# 虚拟dom和虚拟节点VNode

# 什么是虚拟dom

通过js对象模拟出一个我们需要渲染到页面上的dom树的结构,实现了一个修改js对象即可修改页面dom的快捷途径,避免了我们手动再去一次次操作dom-api的繁琐,而且其提供了diff算法可以使得用最少的dom操作进行修改。(虚拟DOM最终也要挂到浏览器上成为真实DOM节点的,因此使用虚拟DOM并不能使操作DOM次数减少,但能够精确地获取最小的,最必要的操作DOM的集合)

  1. 虚拟DOM解决方式就是通过状态生成一个虚拟节点树,然后使用虚拟节点树进行渲染。在渲染之前,会使用新生成的虚拟节点树和上一次生成的虚拟节点树进行对比,只渲染不同的部分。

  2. 虚拟节点树其实是由组件树建立起来的整个虚拟节点(Virtual Node,简写vnode)树

  3. 虚拟DOM除了它的数据结构定义,映射到真实的DOM实际上要经历VNodecreate,diff,patch等过程。VNodecreate是通过createElement方法创建的。

# vue.js中的虚拟 dom

在vue.js中,我们使用模板来描述状态与DOM之间的映射关系。Vue.js通过编译将模板转换成渲染函数createElement,执行渲染函数就可以得到一个虚拟节点树,使用这个虚拟节点树就可以渲染页面。(通过patch把虚拟节点渲染成视图)

为了避免不必要的DOM操作,虚拟DOM在虚拟节点映射到视图的过程中,将虚拟节点与上一次渲染视图所使用的旧虚拟节点(oldVnode)做对比,找出真正需要更新的节点来进行DOM操作,从而避免操作不用修改的DOM

虚拟DOM在 vue.js 中所做的事情其实并没有想象中那么复杂,主要做两件事:1. 提供与真实DOM节点所对应的虚拟节点 vnode。2. 将虚拟节点 vnode 和旧虚拟节点 oldVnode 进行对比,然后更新视图。

# createElement方法

Vue.js利用createElement方法创建VNode img

createElement方法实际上是对_createElement的封装,允许传入的参数更加灵活,处理这些参数之后,调用真正创建VNode的函数就是_createElement img

五个参数类型

  • context表示VNode的上下文环境,是Component的类型;
  • tag表示标签,可以是一个字符串,也可以是一个Component
  • data表示VNode的数据;
  • children表示当前的VNode的子节点,任意类型;
  • normalizationType表示子节点规范的类型,主要参考render函数是编译生成的还是用户手动生成的。

以下分析createElement两个重点流程,children的规范化和VNode如何创建。

# children的规范化

由于 Virtual DOM 实际上是⼀个树状结构,每⼀个VNode可能会有若⼲个⼦节点,这些⼦节点应该也是VNode的类型。 _createElement接收的第 4 个参数 children 是任意类型的,因此我们需要把它们规范成VNode类型。这⾥根据normalizationType的不同,调⽤了normalizeChildren(children)simpleNormalizeChildren(children)⽅法。

export function simpleNormalizeChildren (children: any) {
  for (let i = 0; i < children.length; i++) {
    if (Array.isArray(children[i])) {
      return Array.prototype.concat.apply([], children)
    }
  }
  return children
}
1
2
3
4
5
6
7
8

simpleNormalizeChildren⽅法调⽤场景是render函数当函数是编译⽣成的。理论上编译⽣成的children都已经是VNode类型的,但这⾥有⼀个例外,就是 functional component函数式组件返回的是⼀个数组⽽不是⼀个根节点,所以会通过Array.prototype.concat⽅法把整个children数组打平,让它的深度只有⼀层。

export function normalizeChildren (children: any): ?Array<VNode> {
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}
1
2
3
4
5
6
7

normalizeChildren⽅法的调⽤场景有2种,⼀个场景是render函数是⽤户⼿写的,当children只有⼀个节点的时候,Vue.js从接⼝层⾯允许⽤户把children写成基础类型⽤来创建单个简单的⽂本节点,这种情况会调⽤createTextVNode创建⼀个⽂本节点的VNode;另⼀个场景是当编译slotv-for的时候会产⽣嵌套数组的情况,会调⽤normalizeArrayChildren⽅法,接下来看⼀下它的实现:

function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> {
  const res = []
  let i, c, lastIndex, last
  for (i = 0; i < children.length; i++) {
    c = children[i]
    if (isUndef(c) || typeof c === 'boolean') continue
    lastIndex = res.length - 1
    last = res[lastIndex]
    //  nested
    if (Array.isArray(c)) {
      if (c.length > 0) {
        c = normalizeArrayChildren(c, `${nestedIndex || ''}_${i}`)
        // merge adjacent text nodes
        if (isTextNode(c[0]) && isTextNode(last)) {
          res[lastIndex] = createTextVNode(last.text + (c[0]: any).text)
          c.shift()
        }
        res.push.apply(res, c)
      }
    } else if (isPrimitive(c)) {
      if (isTextNode(last)) {
        // merge adjacent text nodes
        // this is necessary for SSR hydration because text nodes are
        // essentially merged when rendered to HTML strings
        res[lastIndex] = createTextVNode(last.text + c)
      } else if (c !== '') {
        // convert primitive to vnode
        res.push(createTextVNode(c))
      }
    } else {
      if (isTextNode(c) && isTextNode(last)) {
        // merge adjacent text nodes
        res[lastIndex] = createTextVNode(last.text + c.text)
      } else {
        // default key for nested array children (likely generated by v-for)
        if (isTrue(children._isVList) &&
          isDef(c.tag) &&
          isUndef(c.key) &&
          isDef(nestedIndex)) {
          c.key = `__vlist${nestedIndex}_${i}__`
        }
        res.push(c)
      }
    }
  }
  return res
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

normalizeArrayChildren接收2个参数,children表⽰要规范的⼦节点,nestedIndex表⽰嵌套的索引,因为单个child可能是⼀个数组类型。 normalizeArrayChildren主要的逻辑就是遍历children,获得单个节点c,然后对c的类型判断,如果是⼀个数组类型,则递归调⽤normalizeArrayChildren; 如果是基础类型,则通过createTextVNode⽅法转换成VNode类型; 否则就已经是VNode类型了,如果children是⼀个列表并且列表还存在嵌套的情况,则根据 nestedIndex去更新它的key。这⾥需要注意⼀点,在遍历的过程中,对这3种情况都做了如下处理:如果存在两个连续的text节点,会把它们合并成⼀个text节点。经过对children的规范化,children变成了⼀个类型为VNodeArray

# VNode

在Vue.js中存在一个 VNode 类,使用它可以实例化不同类型的vnode实例,而不同类型的vnode实例各自表示不同类型的DOM元素。

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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

Vue.js对状态采取中等粒度的侦测策略。当状态发生变化,只通知到组件级别,然后组件内使用虚拟DOM来渲染视图。Vue1采取细粒度,这样一个细小的状态发生变化,都会利用watcher侦测,大大消耗了内存。但如果一个组件只有一个状态发生变化,整个组件就要重新渲染,这样也会造成很大的性能损耗,所以对vnode进行缓存就尤为重要了。

# VNode类型

  • vnode类型
    • 注释节点
    • 文本节点
    • 元素节点
    • 组件节点
    • 函数式组件
    • 克隆节点

因为通过VNode类实例的vnode实例对象,只是有效属性不同罢了。因为通过参数为实例设置属性时,无效的属性默认被赋值为undefinedfalse,无效属性直接忽略就好。

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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

# 注释节点

export const createEmptyVNode = (text: string = '') => {
  const node = new VNode()
  node.text = text
  node.isComment = true
  return node
}
1
2
3
4
5
6

可以看出一个注释节点只有textisComment两个有效属性。 例如:

<!-- 注释节点 -->
1

所以vnode对应

{
  text: '注释节点',
  isComment: true
}
1
2
3
4

# 文本节点

export function createTextVNode (val: string | number) {
  return new VNode(undefined, undefined, undefined, String(val))
}
1
2
3

可以看出文本节点只有text属性

# 克隆节点

克隆节点是将现有节点的属性复制到新节点,让新创建的节点和被克隆节点的属性保持一致,从而实现克隆效果。它的作用是优化静态节点和插槽节点

export function cloneVNode (vnode: VNode): VNode {
  const cloned = new VNode(
    vnode.tag,
    vnode.data,
    // #7975
    // clone children array to avoid mutating original in case of cloning
    // a child.
    vnode.children && vnode.children.slice(),
    vnode.text,
    vnode.elm,
    vnode.context,
    vnode.componentOptions,
    vnode.asyncFactory
  )
  cloned.ns = vnode.ns
  cloned.isStatic = vnode.isStatic
  cloned.key = vnode.key
  cloned.isComment = vnode.isComment
  cloned.fnContext = vnode.fnContext
  cloned.fnOptions = vnode.fnOptions
  cloned.fnScopeId = vnode.fnScopeId
  cloned.asyncMeta = vnode.asyncMeta
  cloned.isCloned = true
  return cloned
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

以静态节点为例子,当组件内的某个状态发生改变后,当前组件会通过虚拟DOM重新渲染视图,静态节点因为内容没有改变所以就没必要执行渲染函数重新生成vnode。因此这个时候就可以使用克隆节点将vnode克隆一份,使用克隆节点进行渲染。这样就不用重新执行渲染函数生成新的静态节点vnode,从而提升一定性能

克隆节点和被克隆节点唯一区别就是isCloned属性,前者为true,后者为false

# 元素节点

tag: 顾名思义,一个节点名称 data:该属性包含一些节点上的数据 children: 当前节点的子节点列表 context: 它是当前组件的Vue.js实例

# 组件节点

组件节点和元素节点类似,有以下两个独有的属性: componentOptions:顾名思义,就是组件节点的选项参数,其中包含propsDatatagchildren等信息。 componentInstance:组件的实例,也就是vue.js的实例。事实上,在vue.js中,每个组件都是一个vue.js实例。

# 函数式组件

函数式组件和组件节点类似,它有两个独有的属性fnContextfnOptions

# 创建VNode

回到createElement函数,规范化children之后,接下来就会创建一个VNode实例

if (typeof tag === 'string') {
  let Ctor
  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
    )
  } 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
  vnode = createComponent(tag, data, context, children)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

这⾥先对tag做判断,如果是string类型,则接着判断如果是内置的⼀些节点,则直接创建⼀个普通VNode,如果是为已注册的组件名,则通过createComponent创建⼀个组件类型的VNode,否则创建⼀个未知的标签的VNode。如果是tag⼀个Component类型,则直接调⽤createComponent创建⼀个组件类型的VNode节点。

TIP

之所以需要使用状态生成VNode,是因为如果直接用状态生成真实DOM,会有一定程度的性能浪费。而先创建虚拟节点再渲染视图,就可以将虚拟节点缓存,然后使用新创建的虚拟节点和上一次渲染时缓存的虚拟节点进行对比,然后根据对比结果只更新需要更新的真实DOM节点,从而避免不必要的DOM操作,节省一定的性能开销。

# 虚拟DOM到真实DOM的一个伪代码过程

学习《前端开发核心进阶》积累

// 首先实现一个setAttribute方法。使用该方法来对DOM节点进行属性设置
const setAttribute = (node,key,value) => {
  switch(key) {
    case 'style':
      node.style.cssText = value
      break;
    case 'value':
      let tagName = node.tagName || ''
      tagName = tagName.toLowerCase()
      if(tagName === 'input' || tagName === 'textarea') {
        node.value = value
      } else {
        node.setAttribute(key,value)
      }
      break;
    default:
      node.setAttribute(key,value)
      break;
  }
}

// 实现一个用于生成虚拟DOM的类
class Element {
  constructor(tagName, attributes={}, children = []) {
    this.tagName = tagName
    this.attributes = attributes
    this.children = children
  }
  // 加入render函数,根据虚拟DOM生成真实DOM片段
  render() {
    let element = document.createElement(this.tagName)
    let attributes = this.attributes
    for(let key in attributes) {
      setAttribute(element,key,attributes[key])
    }
    let children = this.children
    children.forEach(child => {
      let childElement = child instanceof Element
        ? child.render()
        : document.createTextNode(child) // 若是字符串直接创建文本节点
      element.appendChild(childElement)
    })
    return element
  }
}

function element(tagName,attributes,children) {
  return new Element(tagName,attributes,children)
}

//将真实DOM节点渲染到浏览器上
const renderDom = (element,target) => {
  target.appendChild(element)
}

// 测试例子
const chap = element('ul',{id: 'list'},[
  element('li',{class: 'chapter'},['chapter1']),
  element('li',{class: 'chapter'},['chapter2']),
  element('li',{class: 'chapter'},['chapter3'])
])
const dom = chap.render()
renderDom(dom,document.body)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

# diff算法伪代码

《前端开发核心知识进阶》一书积累所得

由上可以产出一份虚拟DOM,并渲染在浏览器中。用户进行特定操作后,会产出一份新的虚拟DOM,如何得出前后两份虚拟DOM的差异,并交给浏览器需要更新的结果呢?

const diff = (oldVirtualDom,newVirtualDom) => {
  let patches = {}
  // 递归树,将比较后的结果存储到patches中
  walkToDiff(oldVirtualDom,newVirtualDom,0,patches)
  return patches
}

let initialIndex = 0;
const walkToDiff = (oldVirtualDom,newVirtualDom,index,patches) => {
  let diffResult = []
  // 如果newVirtualDom不存在,则说明节点已经移除,接着可以将type为REMOVE的对象推进diffResult并记录index
  if(!newVirtualDom) {
      diffResult.push({
        type: 'REMOVE',
        index
      })
  } else if(typeof oldVirtualDom === 'string' && typeof newVirtualDom === 'string') {
      // 如果新旧节点都是文本节点
      if(oldVirtualDom !== newVirtualDom) {
        diffResult.push({
          type: 'MODIFY_TEXT',
          index,
          data: newVirtualDom
        })
      }
  } else if(oldVirtualDom.tagName === newVirtualDom.tagName) {
      // 新旧节点类型相同
      // 比较属性是否相同
      let diffAttributeResult = {}
      for(let key in oldVirtualDom) {
        if(oldVirtualDom[key] !== newVirtualDom[key]) {
          // 同一个key下,旧新节点的属性不一致,则直接替换掉旧节点的属性
          diffAttributeResult[key] = newVirtualDom[key]
        }
      }
      for(let key in newVirtualDom) {
        //旧节点不存在的新属性
        if(!oldVirtualDom.hasOwnProperty(key)) {
          diffAttributeResult[key] = newVirtualDom[key]
        }
      }
      if(Object.keys(diffAttributeResult).length > 0) {
        diffResult.push({
          type: 'MODIFY_ATTRIBUTES',
          diffAttributeResult
        })
      }
      // 如果有子节点则遍历子节点
      oldVirtualDom.children.forEach((child,index) => {
        walkToDiff(child,newVirtualDom.children[index],++initialIndex,patches)
      })
  } else {
    // 如果节点类型不同,已经被直接替换了,则直接将新的结果放入diffResult数组中
    diffResult.push({
      type: 'REPLACE',
      newVirtualDom
    })
  }
  if(!oldVirtualDom) {
    diffResult.push({
      type: 'REPLACE',
      newVirtualDom
    })
  }
  if(diffResult.length) {
    patches[index] = diffResult
  }
}

// 测试案例
const chap = element('ul',{id: 'list'},[
  element('li',{class: 'chapter'},['chapter1']),
  element('li',{class: 'chapter'},['chapter2']),
  element('li',{class: 'chapter'},['chapter3'])
])
const chap2 = element('ul',{id: 'list2'},[
  element('li',{class: 'chapter2'},['chapter4']),
  element('li',{class: 'chapter2'},['chapter5']),
  element('li',{class: 'chapter2'},['chapter6'])
])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

img

walkToDiff前两个参数是需要比较的虚拟DOM对象;第三个参数是用来记录nodeIndex,在删除节点时会使用。初始值为0;第四个参数为一个闭包变量,用来记录diff的结果。

那么如何将这个差异更新到真实的DOM上呢,需要使用patch方法来完成了。

const patch = (node, patches) => {
    let walker = {index: 0}
    walk(node,walker,patches)
}

const walk = (node,walker,patches) => {
    let currentPatch = patches[walker.index]
    let childrenNodes = node.childrenNodes
    childrenNodes.forEach(child => {
        walker.index++
        walk(child,walker,patches)
    })
    if(currentPatch) {
        doPatch(node,currentPatch)
    }
}

// walk函数自身递归,对当前节点的差异调用doPatch方法进行更新
const doPatch = (node,patches) => {
    patches.forEach(patch => {
        switch(patch.type) {
            case 'MODIFY_ATRIBUTES':
                const attributes = patch.diffAttributeResult.attributes
                for(let key in attributes) {
                    if(node.nodeType !== 1) return
                    const value = attributes[key]
                    if(value) {
                        setAttribute(node,key,value)
                    } else {
                        node.removeAttribute(key)
                    }
                }
                break;
            case 'MODIFY_TEXT':
                node.textContent = patches.data
                break;
            case 'REPLACE':
                let newNode = (patch.newNode instanceof Element) ? render(patch.newNode) : document.createTextNode(patch.newNode)
                node.parentNode.replaceChild(newNode,node)
                break;
            case 'REMOVE':
                node.parentNode.removeChild(node)
                break;
            default:
                break;
        }
    })
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49