九、虚拟DOM

Vue

# 知识链接

# 概念

虚拟 DOM(Virtual DOM) 是使用 JavaScript 对象来描述 DOM,虚拟 DOM 的本质就是 JavaScript 对象,使用 JavaScript 对象来描述 DOM 的结构。应用的各种状态变化首先作用于虚拟 DOM,最终映射到 DOM。Vue的MVVM框架会帮我们屏蔽DOM操作

Vue.js 中的虚拟 DOM 借鉴了Snabbdom(模块机制,钩子函数,diff算法),并添加了一些 Vue.js 中的特性,例如:指令和组件机制。

Vue 1.x 中细粒度监测数据的变化,每一个属性对应一个watcher,开销太大Vue 2.x 中每个组件对应一个 watcher,状态变化通知到组件,再引入虚拟 DOM 进行比对和渲染

# 为什么要使用虚拟 DOM ?

  • 使用虚拟 DOM,可以避免用户直接操作DOM,开发过程关注在业务代码的实现,不需要关注如何操作 DOM,从而提高开发效率
  • 作为一个中间层可以跨平台,除了 Web 平台外,还支持 SSR服务端渲染、Weex框架。
  • 关于性能方面
    • 在首次渲染的时候肯定不如直接操作DOM,因为要维护一层额外的虚拟 DOM
    • 如果后续有频繁操作DOM的操作,这个时候可能会有性能的提升,虚拟DOM在更新真实DOM之前会通过Diff算法对比新旧两个虚拟DOM树的差异,最终把差异更新到真实 DOM
    • 添加key属性,会让DOM尽可能重用,比不加key的性能要优化的多

# 代码演示虚拟DOM

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Vue.js 01 component example</title>
  </head>
  <body>
    <div id="app">
    </div>

    <script src="../../dist/vue.runtime.js"></script>
    <script>
      const vm = new Vue({
        el: '#app',
        render (h) { 
          // return h('h1', this.msg) 
          // h1 DOM对象的属性
          // return h('h1', { domProps: { innerHTML: this.msg } }) 
          // attrs:标签属性
          // return h('h1', { attrs: { id: 'title' } }, this.msg) 
          const vnode = h(
            'h1',
            {
              attrs: { id: 'title' }
            },
            this.msg 
          )
          // 返回结果是一个VNode对象
          console.log(vnode) 
          return vnode
        },
        data: {
          msg: 'Hello Vue'
        }
      })
    </script>
  </body>
</html>
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

打开浏览器可以看到返回的虚拟DOM

image

# h函数详解

渲染函数render,参数h是一个函数,对应源码中的createElement.h函数的作用是为了创建一个虚拟节点VNode,调用h函数的时候需要传入四个参数,最后一个参数需要看源码解释

vm.$createElement(tag,data,children,normalizeChildren)

  • 第一个参数tag是一个标签的名称(字符串),或者是组件的选项对象
  • 第二个参数data是用来描述tag的,如果tag是标签,那么data可以是设置tag的属性,或者是其对应dom的元素属性,还可以注册事件
  • 第三个参数children可以是字符串,设置标签tag中的内容,如果是数组就是设置标签中的子节点

Vue和snabbdom类似,vue的支持组件和slots插槽

  • 返回值 VNode对象
    • 虽然属性很多,但是相对于真实DOM来说属性还是少了很多的
    • 几个核心属性
      • tag:h函数传入的第一个参数
      • data:调用h函数传来的选项
      • children:子节点,如果第三个参数是字符串,那么会将其自动转化为VNode对象,描述一个文本节点
      • elm:VNode转换成真实DOM之后,对应存储的真实DOM
      • key:复用当前元素
      • text

# 整体过程分析

image

# VNode的创建过程

在core/instance/render.js中有Vue.prototype._render的定义,里面调用了vm.$createElement,在当前文件中找到这个函数,这里有两个方法,调用的情况不同,但是里面都使用了createElement

// 重点方法 _c/$createElement
// 对手动传入template属性,其编译生成的 render 进行渲染的方法,其调用的也是createElement,最后一个参数不一样
// 当把template编译成render函数的时候,其内部会调用_c
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.
// 对手动传入的 render 函数进行渲染的方法
// $createElement就是new Vue的时候传入render(h)的h函数,作用是把虚拟DOM转换成真实DOM
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
1
2
3
4
5
6
7
8
9

进入createElement函数

const SIMPLE_NORMALIZE = 1
const ALWAYS_NORMALIZE = 2

// wrapper function for providing a more flexible interface
// without getting yelled at by flow
export function createElement (
  // 传入的是vue实例
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  // 处理参数,如果data是数组或者是原始值,其实data是children
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    // 将data赋值给children,将自己变成undefined
    children = data
    data = undefined
  }
  // 用户传入的render函数,这个值是false
  // 用来处理children参数
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE // 常量 2
  }
  // 这个函数中创建了VNode
  return _createElement(context, tag, data, children, normalizationType)
}
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

下面看_createElement

// 创建VNode
// 这里和snabbdom有所不同,因为里面处理了组件和其他的内容
export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  // 如果是data存在且是响应式数据会警告避免使用响应式数据,并且返回一个空的VNode
  if (isDef(data) && isDef((data: any).__ob__)) {
    process.env.NODE_ENV !== 'production' && warn(
      `Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n` +
      'Always create fresh vnode data objects in each render!',
      context
    )
    return createEmptyVNode()
  }
  // object syntax in v-bind
  // 如果data中有is属性,会记录到tag中
  // 这个会把is后面的组件名称,找到对应组件渲染到component中
  // <component v-bind:is="currentTabComponent"></component>
  if (isDef(data) && isDef(data.is)) {
    tag = data.is
  }
  // tag变量如果是false,is指令就是false,会返回一个空的VNode节点
  if (!tag) {
    // in case of component :is set to falsy value
    return createEmptyVNode()
  }
  // warn against non-primitive key
  // 判断是否有key,或者key不是原始值就会报警告,key应该是字符串和数字
  if (process.env.NODE_ENV !== 'production' &&
    isDef(data) && isDef(data.key) && !isPrimitive(data.key)
  ) {
    if (!__WEEX__ || !('@binding' in data.key)) {
      warn(
        'Avoid using non-primitive value as key, ' +
        'use string/number value instead.',
        context
      )
    }
  }
  // support single function children as default scoped slot
  // 这里处理作用域插槽,跳过
  if (Array.isArray(children) &&
    typeof children[0] === 'function'
  ) {
    data = data || {}
    data.scopedSlots = { default: children[0] }
    children.length = 0
  }

  // 判断render函数类型,分别将多维数组转化为一维数组
  if (normalizationType === ALWAYS_NORMALIZE) {
    // 如果是用户传递的render函数就调用normalizeChildren,对children变量进行处理
    children = normalizeChildren(children)
    // 如果是渲染器生成的render就调用simpleNormalizeChildren
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }
  // 核心 创建VNode对象
  let vnode, ns
  // 1. 判断tag是否是字符串
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    // 1.1 判断是否是html的保留标签,直接创建VNode
    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
      // config.parsePlatformTagName(tag)是tag标签
      // context是vue实例
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    // 1.2 如果tag存在,是字符串,且不是html保留标签
    //     就判断data是否存在,或者data的pre是否存在,如果存在,就通过一个函数获取对应的组件
    //     会获取选项中的components,所有组件,通过组件的名称取得当前组件
    //     调用这个函数的目的是对当前组件的,名称进行处理
    //     这里主要是判断是否是自定义组件,这里先跳过
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      // 获取组件,通过createComponent创建组件对应的VNode对象
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
    // 1.3 如果tag不是html保留标签,其实是自定义标签,直接创建其VNode对象
    // 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 {
    // 2. 如果tag不是字符串,那他应该是一个组件,通过createComponent创建组件对应的VNode对象
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }

  // 判断VNode是否是数组,是的话直接返回VNode对象
  if (Array.isArray(vnode)) {
    return vnode
  // 如果不是数组且定义好了,就对VNode进行简单的初始化处理  
  } else if (isDef(vnode)) {
    // 处理VNode的命名空间
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    // 如果上面都不满足,就返回一个空的注释节点
    return createEmptyVNode()
  }
}
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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122

这里面我们可以关注几个函数,创建空节点createEmptyVNode

export const createEmptyVNode = (text: string = '') => {
  // 设置VNode对象
  const node = new VNode()
  node.text = text
  // 标识这个节点是注释节点
  node.isComment = true
  return node
}
1
2
3
4
5
6
7
8

normalizeChildren函数

export function normalizeChildren (children: any): ?Array<VNode> {
  // 如果children是原始值,就调用createTextVNode创建文本VNode节点,并包装成数组形式
  // 如果childrend是数组,那么就要将children降维成一维数组,方便后续处理
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}
1
2
3
4
5
6
7
8
9

simpleNormalizeChildren函数

export function simpleNormalizeChildren (children: any) {
  // 将二维数组转换成一维数组,如果children中有组件,并且组件是函数组件(二维数组)的话就会做这样的处理
  for (let i = 0; i < children.length; i++) {
    if (Array.isArray(children[i])) {
      // concat将两个数组转化成一个数组,还有一个作用是如果children是一个二维数组,那么就会将其展开成一维数组合并到前面的数组中
      return Array.prototype.concat.apply([], children)
    }
  }
  return children
}
1
2
3
4
5
6
7
8
9
10

# VNode的处理过程

vm._update(vm._render(), hydrating) 中的update方法,这个方法在core/instance/lifecycle.js中定义

  // 更新,最最核心的是里面调用了patch方法
  // patch的作用是把虚拟DOM转换成真实DOM挂载到$el中
  Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    ...
    // 从实例vm中获取_vnode,_vnode是之前处理过的对象
    const prevVnode = vm._vnode
    ...
    vm._vnode = vnode
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    // 判断是不是第一次调用,如果prevVnode不存在就是首次调用,如果是就调用上面的patch,如果不是就调用下面的patch
    if (!prevVnode) {
      // initial render
      // 调用vm.__patch__方法
      // 第一个参数:传入真实DOM,vm.$el
      // 第二个参数:刚才创建好的vnode
      // 这个方法是将真实dom转换成虚拟dom然后和vnode进行比较,将比较后的结果转换成真实DOM,更新给vm.$el
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // updates
      // 如果不是首次调用,将老的vnode和新的vnode进行比较,新的vnode转换成真实DOM,更新给vm.$el
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    ...
  }
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

真正处理vnode的地方是在vue实例的__patch__方法中,接下来找到patch方法定义的位置,

snabbdom中的vnode通过函数返回一个普通对象,这个对象只有6个属性,vue中的vnode比其功能复杂,属性也多.

snabbdom中的patch函数通过init函数返回,用到了柯里化,传入了modules和api两个参数

  • modules:模块,类似插件机制,处理属性、样式和事件等,当然我们也可以自定义模块
  • domAPI:操作DOM的方法

先把这两个参数通过闭包的方式进行缓存,最后返回一个patch函数,将来调用patch的时候就不需要关心模块和操作DOMAPI的方法,VUE也是一样的机制.

# Vue.prototype.patch

vue中定义的patch方法在platforms/web/runtime/index.js中,这个方法中在patch之前,首先去判断是否在浏览器中,是就返回一个patch函数,如果不是浏览器就返回一个空函数.

// 导入了patch模块
import { patch } from './patch'
// 在Vue的原型对象上注册了一个patch函数,虚拟DOM中patch函数的功能是把虚拟DOM转换成真实DOM
// 赋值的时候会判断是否是浏览器,inBrowser是判断window类型是否为undefined
// 如果是就直接返回patch函数,如果不是就返回noop函数,noop是一个空函数
Vue.prototype.__patch__ = inBrowser ? patch : noop
1
2
3
4
5
6

下面我们要看一下patch函数做了什么?

# patch

platforms/web/runtime/patch.js中,可以看出来,patch函数是通过createPatchFunction函数生成的额,这个函数是一个高阶函数,

// 这个函数是通过createPatchFunction函数生成的,这个函数是一个高阶函数,也是一个柯里化函数
// 需要一个对象参数,一个nodeOps,一个modules
export const patch: Function = createPatchFunction({ nodeOps, modules })
1
2
3

# nodeOps

先看一下nodeOps是什么?同级目录下的node-ops.js里面,可以看出来,这里定义的函数都是返回一个DOM对象或者直接进行DOM操作,其中 createElement 与 snabbdom 还是有所区别

// snabbdom中的createElement是直接创建一个DOM对象,把这个对象返回
// 这里面还处理了一些select标签的地方,如果是select标签,判断有没有attrs和mutiple属性,如果有就设置multiple属性,再返回
export function createElement (tagName: string, vnode: VNode): Element {
  const elm = document.createElement(tagName)
  if (tagName !== 'select') {
    return elm
  }
  // false or null will remove the attribute but undefined will not
  if (vnode.data && vnode.data.attrs && vnode.data.attrs.multiple !== undefined) {
    elm.setAttribute('multiple', 'multiple')
  }
  return elm
}
1
2
3
4
5
6
7
8
9
10
11
12
13

接下来再看第二个参数modules

# modules

在patch.js中可以看到modules拼接了两个数组,一个是platformModules,一个是baseModules

// 与平台无关的模块,里面是指令和ref模块
import baseModules from 'core/vdom/modules/index'
// 与平台相关的模块,与snabbdom一致,这些模块的作用是操作属性、样式和事件等,与snabbdom不一样的就是transition,过渡动画,那些个模块中都导出了生命周期钩子函数
import platformModules from 'web/runtime/modules/index'

// the directive module should be applied last, after all
// built-in modules have been applied.
// modules拼接了两个数组,一个是platformModules,一个是baseModules
const modules = platformModules.concat(baseModules)
1
2
3
4
5
6
7
8
9

下面看一下createPatchFunction

# createPatchFunction

在core/vdom/patch.js中,可以看到其函数

// 这个类似snabbdom中的init函数,在最后返回了patch函数
// 高阶函数
export function createPatchFunction (backend) {
  let i, j
  // callbacks,这里面存储的模块定义的钩子函数
  const cbs = {}
  // 接收了两个属性,解构
  const { modules, nodeOps } = backend

  // 初始化cbs
  // 先遍历hooks数组,这里面都是生命周期钩子函数名称
  for (i = 0; i < hooks.length; ++i) {
    // 把这些名称作为cbs的属性,并把值初始化成一个数组(模块有很多,一个钩子函数会对应多个处理形式)
    // cbs['update'] = []
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      // 如果module函数里面定义对应的钩子函数,就取出来放到数组中
      if (isDef(modules[j][hooks[i]])) {
        // cbs['update'] = [updateAttrs, updateClass, update...]
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }
  
  ...
  // 函数柯里化,让一个函数返回一个函数
  // modules和nodeOps是已经初始化好的两个相关数据
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
      ...
  }
}  
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

# patch的内部实现

下面看一下patch内部的执行过程,因为代码比较多,所以只关注核心逻辑,

  1. 接收四个参数,前两个一个是oldVnode,一个是vnode(新vnode)
return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // 先判断新的vnode是否存在,如果不存在判断oldVnode是否存在,如果新的vnode不存在,oldVnode存在就调用invokeDestroyHook钩子函数,这种情况比较少见
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }
}    
1
2
3
4
5
6
7
  1. 定义了常量insertedVnodeQueue
// 常量,新插入vnode节点的队列,存储的目的是把这些新插入的节点对应的DOM元素挂载在DOM树上之后会去触发这些vnode的钩子函数
const insertedVnodeQueue = []
...
// 下面invokeInsertHook就是去触发insertedVnodeQueue队列中的新插入的vnode的钩子函数
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
1
2
3
4
5
  1. 判断老的vnode是否存在,如果不存在
// 判断老的vnode是否存在,
if (isUndef(oldVnode)) {
  // empty mount (likely as component), create new root element
  // 不存在的情况,什么情况下会是undefined或者是null呢?
  // 在组件中的$mount方法,但是没有传入参数的时候,如果传入参数表示我们要把这个挂载到页面上的某个位置,如果没有传参数的话表示我们只是把组件创建出来但并不挂载到视图上
  
  // 这个时候将变量置为true,他vnode也创建好了,DOM元素也创建好了,但是仅仅存在内存中,没有挂到DOM树上来.
  isInitialPatch = true
  // 将vnode转换成真实DOM,但是仅仅存在内存中,没有挂到DOM树上
  createElm(vnode, insertedVnodeQueue)
} else {
    ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13

这里看一下createElm是怎么处理的?上面我们调用的时候只传了两个参数,里面还有接收的第三个参数,parentElm此时为undefined.

function createElm (
    vnode,
    insertedVnodeQueue,
    // 第三个参数,是dom节点挂载到的父节点
    parentElm,
    ...
) {
...
  /* istanbul ignore if */
  if (__WEEX__) {
    ...
    if (!appendAsTree) {
      ...
      // 这里会调用insert,将dom元素挂载到父节点中,如果parentElm传入的是空,就不做处理
      insert(parentElm, vnode.elm, refElm)
    }
    ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

在调用insert的时候,会将这些元素传入,下面看看insert是怎么处理的?insert判断如果parent没有值不做任何的处理,所以如果oldvnode不存在的时候,新的vnode只存在在内存中,并没有挂载到dom树上

function insert (parent, elm, ref) {
    // parent如果有值,就把dom元素挂载到父元素里面
    if (isDef(parent)) {
      ...
    }
}
1
2
3
4
5
6
  1. 如果老的vnode存在

如果老节点存在,就判断老节点是否是dom元素,说明是首次渲染的时候,首次渲染和数据更改的处理情况是有区别的

4.1 判断老节点如果不是真实DOM且与新的vnode是相同节点

// 判断如果不是真实DOM且与新的vnode是相同节点,
// snabbdom中的sameVnode判断了key和sel选择器是否相同
if (!isRealElement && sameVnode(oldVnode, vnode)) {
    // 核心核心
    // 在这里patchVnode比较新老节点的差异,并且将差异更新到DOM上,里面会执行diff算法
    patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
    ...
}
1
2
3
4
5
6
7
8
9

这里面sameVnode的判断比snabbdom中的复杂

function sameVnode (a, b) {
  // 比snabbdom的复杂,判断了key,tag以及别的东西,这里不关心
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

4.2 如果是真实DOM或者与新的vnode不是相同节点,那么走下面else的代码

if (!isRealElement && sameVnode(oldVnode, vnode)) {
    ...
// 如果是真实DOM 或者 与新vnode不是相同节点走这里
} else {
    // 如果是真实DOM,说明首次渲染,创建VNode
    if (isRealElement) {
      // 这的代码是与SSR相关的东西,跳过
      ...
      // 将真实DOM转换成VNode对象存储到了oldVnode节点中
      oldVnode = emptyNodeAt(oldVnode)
    }
    // 获取oldVnode的elm,真实DOM节点,获取这个的目的是找到其真实DOM的父元素,将来要挂载到这个节点下面
    const oldElm = oldVnode.elm
    const parentElm = nodeOps.parentNode(oldElm)
    // 创建 DOM 节点,
    // 将vnode转换成真实dom挂载到parentElm里面,如果传了第四个参数,会将转换的真实dom插入到这个元素之前,并且会把vnode记录到insertedVnodeQueue这个队列中来
    createElm(
      vnode,
      insertedVnodeQueue,
      // 这个判断是如果当时正在执行一个过渡动画,并且是正在消失的话,就处理成null,不挂载
      oldElm._leaveCb ? null : parentElm,
      nodeOps.nextSibling(oldElm)
    )
    // 处理父节点的占位符的问题,与核心逻辑无关,跳过
    ...
    // 判断parentElm是否存在,将oldVnode从界面上移除,并且触发相关的钩子函数
    if (isDef(parentElm)) {
      removeVnodes([oldVnode], 0, 0)
    // 如果没有父节点说明这个节点并不在DOM树上,判断其是否有tag属性,如果有就触发相关的钩子函数
    } else if (isDef(oldVnode.tag)) {
      invokeDestroyHook(oldVnode)
    }
}   
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

其中看一下热moveVnodes里面的函数实现

function removeVnodes (vnodes, startIdx, endIdx) {
    // 遍历所有的vnode节点,
    for (; startIdx <= endIdx; ++startIdx) {
      const ch = vnodes[startIdx]
      // 判断这个节点是否存在,如果存在并且有tag说明是一个tag标签,此时将tag标签从DOM上移除,并且触发对应的钩子函数,如果没有tag说明是一个文本节点,直接将这个文本节点从DOM树上移除掉
      if (isDef(ch)) {
        if (isDef(ch.tag)) {
          removeAndInvokeRemoveHook(ch)
          invokeDestroyHook(ch)
        } else { // Text node
          removeNode(ch.elm)
        }
      }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  1. 最后就是触发队列中的钩子函数
// 定义变量,初始化为false,如果标签没有挂载到DOM树上会修改为true
let isInitialPatch = false
// 判断老的vnode是否存在,
if (isUndef(oldVnode)) {
  isInitialPatch = true
  ...
} else {
  ...
}
// 下面invokeInsertHook就是去触发insertedVnodeQueue队列中的新插入的vnode的钩子函数
// isInitialPatch这个变量是vnode对应的DOM元素并没有挂载到DOM树上,而是存在内存中,如果是这种情况,就不会触发insertedVnodeQueue里面的钩子函数insert
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
// 最后将新的vnode的DOM元素返回
return vnode.elm
1
2
3
4
5
6
7
8
9
10
11
12
13
14

可以看出来里面的两个核心函数:patchVnode和createElm,下面分别详细介绍一下

# createElm

这个函数的作用是把Vnode转换成真实DOM,并挂载到DOM树上

  1. 判断vnode中是否有elm属性,如果有说明之前渲染过
// 判断vnode中是否有elm属性,如果有说明之前渲染过,
// ownerArray代表vnode中有子节点
if (isDef(vnode.elm) && isDef(ownerArray)) {
    // 如果两个东西都有就把vnode克隆一份,子节点也会克隆一份
    // 这样做的原因是为了避免一些潜在的错误
    vnode = ownerArray[index] = cloneVNode(vnode)
}
1
2
3
4
5
6
7
  1. 处理组件,跳过
  2. 判断三种情况,标签节点,注释节点,文本节点的处理
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
// 第一种情况判断vnode中是否有tag,tag是标签名称,即vnode是否是标签节点
if (isDef(tag)) {
    ...
// 第二种情况判断vnode是否是注释节点    
} else if (isTrue(vnode.isComment)) {
    ...
// 第三种情况判断vnode是否是文本节点
} else {
    ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13

2.1 处理标签节点

// 是否是开发环境
if (process.env.NODE_ENV !== 'production') {
    ...
    // 判断标签是否是未知标签,即html中不存在的标签,自定义标签,会发送警告,是否注册了组件,但是不会影响程序的执行.
    if (isUnknownElement(vnode, creatingElmInVPre)) {
      warn(
        'Unknown custom element: <' + tag + '> - did you ' +
        'register the component correctly? For recursive components, ' +
        'make sure to provide the "name" option.',
        vnode.context
      )
    }
  }

  // 判断是否有命名空间,如果有就用createElementNS创建对应的DOM元素(这种情况是针对svg的情况),如果没有就createElement创建DOM元素
  // 当创建好之后会存储到vnode的elm属性中,到这里DOM元素还没有完全处理好
  vnode.elm = vnode.ns
    ? nodeOps.createElementNS(vnode.ns, tag)
    : nodeOps.createElement(tag, vnode)
  
  // 这里会对vnode的DOM元素设置样式的作用域
  // 会给这个DOM元素设置一个scopeId
  setScope(vnode)

  /* istanbul ignore if */
  // 判断环境是否是__WEEX__,跳过直接看else
  if (__WEEX__) {
    ...
  } else {
    // 把vnode中所有的子元素或者文本节点转换成DOM对象
    createChildren(vnode, children, insertedVnodeQueue)
    // 判断data是否有值
    if (isDef(data)) {
      // 如果data有值就调用invokeCreateHooks触发钩子函数
      // 此时vnode已经创建好了对应的DOM对象,此时要去触发created钩子函数
      invokeCreateHooks(vnode, insertedVnodeQueue)
    }
    // 到这里vnode对应的DOM对象就创建完毕了
    // 调用insert将vnode中创建好的DOM对象插入到parentElm中
    insert(parentElm, vnode.elm, refElm)
}
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

看看createChildren中具体做了什么

# createChildren

// 处理子元素和文本节点
function createChildren (vnode, children, insertedVnodeQueue) {
    // 判断children是否是数组,
    if (Array.isArray(children)) {
      if (process.env.NODE_ENV !== 'production') {
        // 如果是开发环境判断子元素是否有相同的key
        checkDuplicateKeys(children)
      }
      // 遍历children,找到其vnode,通过createElm将其转换成真实DOM,并且挂载到DOM树上
      for (let i = 0; i < children.length; ++i) {
        createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
      }
    // 如果vnode.text是原始值,通过String将其转换成字符串,调用createTextNode创建一个文本节点,将这个DOM元素挂载到vnode.elm中
    } else if (isPrimitive(vnode.text)) {
      nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

看看里面的checkDuplicateKeys是怎么判断的?

function checkDuplicateKeys (children) {
    // 定义了一个对象,在对象中存储了子元素的key
    const seenKeys = {}
    // 遍历子元素,每一个子元素都是一个vnode,获取其key属性,如果key有值就判断对象中是否有对应的key,如果有就说明有重复的key,此时会报警告,如果没有就在对象中记录下来key
    for (let i = 0; i < children.length; i++) {
      const vnode = children[i]
      const key = vnode.key
      if (isDef(key)) {
        if (seenKeys[key]) {
          // 当前开发中有相同的key
          warn(
            `Duplicate keys detected: '${key}'. This may cause an update error.`,
            vnode.context
          )
        } else {
          seenKeys[key] = true
        }
      }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

看看invokeCreateHooks中具体做了什么?

# invokeCreateHooks

function invokeCreateHooks (vnode, insertedVnodeQueue) {
    // 调用cbs中的所有create钩子函数,这些是模块中的钩子函数
    for (let i = 0; i < cbs.create.length; ++i) {
      cbs.create[i](emptyNode, vnode)
    }
    // vnode.data.hook这些是vnode上的钩子函数
    i = vnode.data.hook // Reuse variable
    // 判断是否有hook
    if (isDef(i)) {
      // 判断hook上面是否有create,如果有就触发create钩子函数
      if (isDef(i.create)) i.create(emptyNode, vnode)
      // 判断hook上面是否有insert钩子函数,如果有此处不去触发insert,以为此时只是创建了vnode还没有挂载到DOM树上,所以此时只是先添加到了insertedVnodeQueue上
      // 在patch函数最后会遍历insertedVnodeQueue中所有的vnode,触发他们的insert钩子函数
      if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

看看insert函数中具体做了点啥?

# insert

// 将DOM对象挂载到DOM树上
function insert (parent, elm, ref) {
    // parent如果有值,就把dom元素挂载到父元素里面
    if (isDef(parent)) {
      // 判断有没有ref,如果有就判断ref的父节点是不是传入的parent,如果是就插入到ref之前
      if (isDef(ref)) {
        if (nodeOps.parentNode(ref) === parent) {
          nodeOps.insertBefore(parent, elm, ref)
        }
      // 如果没有ref的话,就把elm插入到parent中
      } else {
        nodeOps.appendChild(parent, elm)
      }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

2.2 如果是注释节点

// 调用createComment创建注释节点并且放到elm中
vnode.elm = nodeOps.createComment(vnode.text)
// 插入到DOM树上来
insert(parentElm, vnode.elm, refElm)
1
2
3
4

2.3 如果是文本节点

// 调用createTextNode创建文本节点并且放到elm中
vnode.elm = nodeOps.createTextNode(vnode.text)
// 插入到DOM树上来
insert(parentElm, vnode.elm, refElm)
1
2
3
4

这个函数中把vnode转换成DOM元素,并且挂载到DOM树上,而且会触发对应的钩子函数

# patchVnode

作用是对比新旧vnode,找到差异更新到真实dom,也就是diff算法

将辅助的操作都不看,只看最核心的东西

  1. 先触发了用户传入的prepatch钩子函数
// 这里触发了用户传入的prepatch钩子函数
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
  i(oldVnode, vnode)
}
1
2
3
4
5
6
  1. 判断之后调用update的钩子函数
if (isDef(data) && isPatchable(vnode)) {
  // 先调用cbs中update中的钩子函数,就是模块中的,先更新样式属性事件等
  for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
  // 获取用户自定义的钩子函数并执行
  if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
1
2
3
4
5
6
  1. 判断新老节点有没有text属性

3.1 如果没有text属性,就判断子节点的内容

  • if 新老节点的子节点都存在
// 如果子节点都存在且不相同,那么就调用updateChildren对比新老节点的子节点,把子节点说我差异更新到DOM上
// updateChildren 核心函数
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
1
2
3
  • if 新节点有子节点,老节点没有子节点
setTextContent(elm, '')
// addVnodes这个函数的作用是将新节点中的子节点转换成DOM元素,并且添加到DOM树上
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
1
2
3
  • if 老节点有子节点,新节点没有子节点
removeVnodes(oldCh, 0, oldCh.length - 1)
1
  • if 新老节点都没有子节点,老节点有text属性,清空文本内容
nodeOps.setTextContent(elm, '')
1

3.2 如果有text属性且新旧节点的text属性不同

if (isUndef(vnode.text)) {
...
} else if (oldVnode.text !== vnode.text) {
    // 如果有text且新旧节点的text值不同
    // 将当前DOM对象中的内容设置为新vnode的text值
    nodeOps.setTextContent(elm, vnode.text)
}
1
2
3
4
5
6
7
  1. 执行用户传入的postpatch钩子函数
// 操作完成之后会获取data中的hook里面的postpatch钩子函数并执行
if (isDef(data)) {
  if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
1
2
3
4

这里patchVnode函数就看完了,里面在对比新老子节点的时候用到了updateChildren,下面详细看看updateChildren是怎么实现的

# updateChildren

如果新老节点的子节点是否都存在且不相同,那么就调用updateChildren对比新老节点的子节点,把子节点的差异更新到DOM上.

如果节点没有发生变化,会重用该节点

这个执行过程和snabbdom是一样的.

// 比较新老节点的子节点,更新差异
// 接收参数:第一个是老节点的DOM元素,第二个是老节点的子节点,第三个是新节点的子节点
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    // 新老节点的子节点传过来都是数组的形式,对比两个数组中的所有vnode,找到差异更新
    // 过程会进行优化,先对比两个数组中的开始和结束四个顶点
    // 新老节点的开始和结束索引,新老开始结束的节点本身
    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

    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    const canMove = !removeOnly

    // 判断是否有重复的key,如果有重复的key会报警告
    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh)
    }

    // diff算法
    // 新老子节点都没有遍历完
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      // 先判断老开始节点是否有值
      if (isUndef(oldStartVnode)) {
        // 获取下一个老节点作为老开始节点
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      // 判断老结束节点是否有值  
      } else if (isUndef(oldEndVnode)) {
        // 没有就获取前一个节点作为老结束节点
        oldEndVnode = oldCh[--oldEndIdx]
      // 对比数组中的四个顶点 
      // 老开始和新开始比 
      // sameVnode值判断了key和tag是否相同,里面的内容是否具体相同并不知道,
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 如果这key和tag相同就用patchVnode继续比较这两个节点以及他们的子节点
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        // 当patch完成之后两个都移动到下一个节点
        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 {
        // 如果上面四个都不满足,这个时候要拿着老节点的key去新节点的数组中一次找相同key的老节点
        // 这个找的过程做了优化:
        // 对象oldKeyToId这个变量在没有赋值的时候去调用createKeyToOldIdx函数
        // 他会把老节点的key和索引存储到对象oldKeyToIdx中
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        // 如果新开始节点有key,就用新开始节点的key来oldKeyToIdx中查找老节点的索引
        // 如果没key,就去老节点的数组中依次遍历找到相同老节点对应的索引
        // 这里也提现了,使用key的话会快一点
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

        // 如果没有找到老节点对应的索引  
        if (isUndef(idxInOld)) { // New element
          // 就调用createElm创建新开始节点对应的DOM对象并插入到老开始节点的前面
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          // 如果找到了老节点对应的索引
          // 把老节点取出来存到vnodeToMove里面,即将要移动的节点
          vnodeToMove = oldCh[idxInOld]
          // 如果找到的节点和新节点的key和tag相同,和之前一样的操作,比较当前两个节点和子节点
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            // 把老节点移动到老开始节点之前
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // 如果只是key相同,但是是不同的元素,那么创建新元素
            // 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(oldCh, oldStartIdx, oldEndIdx)
    }
}
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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# 从updateChildren中看使用key的好处

vue文档中说可以在v-for的过程中,给每一个元素设置key属性,以便跟踪每个元素的身份,最大实现重用.

在vue-cli创建的项目中,如果使用v-for的时候没有使用key会有警告.

下面进行调试

# 总结

image

更新时间: 2022-01-13 21:13