# Vue源码解读(二)

# 一、Vue虚拟DOM

虚拟DOM(Virtual DOM)是对DOM的JS抽象表示,它们是JS对象,能够描述DOM结构和关系。应用的各种状态变化会先作用于虚拟DOM,最终映射到DOM上。

iamge

虚拟DOM优点

虚拟DOM轻量、快速,当它们发生变化时通过新旧虚拟DOM比对可以得到最小DOM操作量,从而提升性能和用户体验。本质上是使用JavaScript运算成本替换DOM操作的执行成本,前者运算速度要比后者快得多,这样做很划算,因此才会有虚拟DOM。

vue 1.0中有细粒度的数据变化侦测,它是不需要虚拟DOM的,但是细粒度造成了大量开销,这对于大型项目来说是不可接受的。因此,vue 2.0选择了中等粒度的解决方案,每一个组件对应一个watcher实例, 这样状态变化时只能通知到组件,再通过引入虚拟DOM去进行比对和渲染。

但是有一点需要注意,虽然每一个组件对应一个watcher实例,但是组件的数量和watcher实例的数量不一定是相等的,因为组件内可能还会有用户手动创建的watcher,比如$watch或者watch方法创建的watcher。

# VNode

VNode,就是虚拟DOM,定义在src\core\vdom\vnode.js

// VNode对象,共有6种类型:元素、组件、文本、函数式组件、注释和克隆节点
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
  functionalContext: Component | void; // only for functional component root nodes
  key: string | number | void;
  componentOptions: VNodeComponentOptions | void;
  componentInstance: Component | void; // component instance
  parent: VNode | void; // component placeholder node
  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?

  constructor (
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions
  ) {
    /*当前节点的标签名*/
    this.tag = tag
    /*当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息*/
    this.data = data
    /*当前节点的子节点,是一个数组*/
    this.children = children
    /*当前节点的文本*/
    this.text = text
    /*当前虚拟节点对应的真实dom节点*/
    this.elm = elm
    /*当前节点的名字空间*/
    this.ns = undefined
    /*编译作用域*/
    this.context = context
    /*函数化组件作用域*/
    this.functionalContext = undefined
    /*节点的key属性,被当作节点的标志,用以优化*/
    this.key = data && data.key
    /*组件的option选项*/
    this.componentOptions = componentOptions
    /*当前节点对应的组件的实例*/
    this.componentInstance = undefined
    /*当前节点的父节点*/
    this.parent = undefined
    /*简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false*/
    this.raw = false
    /*静态节点标志*/
    this.isStatic = false
    /*是否作为根节点插入*/
    this.isRootInsert = true
    /*是否为注释节点*/
    this.isComment = false
    /*是否为克隆节点*/
    this.isCloned = false
    /*是否有v-once指令*/
    this.isOnce = false
  }

  get child (): Component | void {
    return this.componentInstance
  }
}
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

这是一个最基础的VNode节点,作为其他派生VNode类的基类,里面定义了下面这些数据。

TIP

tag: 当前节点的标签名

data: 当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息

children: 当前节点的子节点,是一个数组

text: 当前节点的文本

elm: 当前虚拟节点对应的真实dom节点

ns: 当前节点的名字空间

context: 当前节点的编译作用域

functionalContext: 函数化组件作用域

key: 节点的key属性,被当作节点的标志,用以优化

componentOptions: 组件的option选项

componentInstance: 当前节点对应的组件的实例

parent: 当前节点的父节点

raw: 简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false

isStatic: 是否为静态节点

isRootInsert: 是否作为跟节点插入

isComment: 是否为注释节点

isCloned: 是否为克隆节点

isOnce: 是否有v-once指令


打个比方,比如说我现在有这么一个VNode树

{
    tag: 'div'
    data: {
        class: 'test'
    },
    children: [
        {
            tag: 'span',
            data: {
                class: 'demo'
            }
            text: 'hello,VNode'
        }
    ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

渲染之后的结果就是这样的

<div class="test">
    <span class="demo">hello,VNode</span>
</div>
1
2
3

# mountComponent

vdom树首页生成、渲染发生在mountComponent中,src\core\instance\lifecycle.js,核心代码如下

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el  // 挂载的宿主元素
  callHook(vm, 'beforeMount') // 调用beforeMount钩子
  
  // 定义更新函数updateComponent,实际调用vm._update
  let updateComponent = () => {
      // vm._update是更新函数,内部会做diff算法,vm._render()是渲染函数,渲染函数返回虚拟DOM
      vm._update(vm._render(), hydrating)
  }

  // 实例化watcher,并传入更新函数
  new Watcher(vm, updateComponent, noop, {
    before () {
      // 如果没有实例已经挂载并且没有销毁,则调用beforeUpdate钩子
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')  // 调用mounted钩子
  }
  return vm
}
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

从源码可知,实例化watcher时传入了更新函数updateComponentupdateComponent实际调用了vm._updatevm._update是更新函数,内部会做diff算法,vm._render()是渲染函数,渲染函数返回虚拟DOM。所以接下来我们要去研究vm._rendervm._update

# vm._render

vm._render生成虚拟dom,src\core\instance\render.js,核心代码如下

Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render, _parentVnode } = vm.$options  // 从$options里拿出render函数

    vm.$vnode = _parentVnode
    // 调用render方法生成虚拟DOM
    let vnode = render.call(vm._renderProxy, vm.$createElement)
    return vnode
}
1
2
3
4
5
6
7
8
9

# vm._update

vm._update是更新函数,内部会做虚拟DOM的diff算法,src\core\instance\lifecycle.js,核心代码如下

  Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevEl = vm.$el // 拿到DOM元素
    const prevVnode = vm._vnode // 老的Vnode
    const restoreActiveInstance = setActiveInstance(vm)
    vm._vnode = vnode
    
    // 打补丁利用vm.__patch__
    if (!prevVnode) {
      // 如果没有老的Vnode,则将DOM元素和虚拟DOM传入,进行初始化渲染
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // 否则比较新旧虚拟DOM,得出最小差异,然后操作DOM
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    restoreActiveInstance()
    // 更新vue实例的引用__vue__
    if (prevEl) {
      prevEl.__vue__ = null
    }
    if (vm.$el) {
      vm.$el.__vue__ = vm
    }
  }

  Vue.prototype.$forceUpdate = function () {
    const vm: Component = this
    if (vm._watcher) {  // 强制更新
      vm._watcher.update()
    }
  }
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

从源码可知,vm._update负责更新dom,核心是调用vm.__patch__,所以接下来我们要去研究vm.__patch__

# vm.__patch__和patch

vm.__patch__是在平台特有代码中指定的, src\platforms\web\runtime\index.js.

import { patch } from './patch'  // 引入了patch.js里的patch方法

Vue.prototype.__patch__ = inBrowser ? patch : noop
1
2
3

patch方法的定义在 src\platforms\web\runtime\patch.js,核心代码如下

import * as nodeOps from 'web/runtime/node-ops' // 定义各种原生dom基础操作方法
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'

const modules = platformModules.concat(baseModules)

export const patch: Function = createPatchFunction({ nodeOps, modules })
1
2
3
4
5
6
7
8

可见 patch 实际就是createPatchFunction的返回值,需要传递nodeOpsmodules,这里主要是为了跨平台。

# nodeOps和modules

nodeOps定义了各种原生dom基础操作方法,nodeOps定义在src\platforms\web\runtime\node-ops.js

import { namespaceMap } from 'web/util/index'

export function createElement (tagName: string, vnode: VNode): Element {
  const elm = document.createElement(tagName)
  if (tagName !== 'select') {
    return elm
  }
  if (vnode.data && vnode.data.attrs && vnode.data.attrs.multiple !== undefined) {
    elm.setAttribute('multiple', 'multiple')
  }
  return elm
}

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

export function appendChild (node: Node, child: Node) {
  node.appendChild(child)
}

export function parentNode (node: Node): ?Node {
  return node.parentNode
}

export function nextSibling (node: Node): ?Node {
  return node.nextSibling
}

export function tagName (node: Element): string {
  return node.tagName
}

export function setTextContent (node: Node, text: string) {
  node.textContent = text
}

export function setStyleScope (node: Element, scopeId: string) {
  node.setAttribute(scopeId, '')
}
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

modules 定义了虚拟dom更新 => dom操作转换的方法,比如attr、class、events、style、transition等的转换方法。定义在src\platforms\web\runtime\modules\index.js

拿attr更新来说,具体更新方法定义在src\platforms\web\runtime\modules\attrs.js,核心代码如下

function updateAttrs (oldVnode: VNodeWithData, vnode: VNodeWithData) {
  let key, cur, old
  const elm = vnode.elm 
  const oldAttrs = oldVnode.data.attrs || {} // 老的属性
  let attrs: any = vnode.data.attrs || {}  // 新的属性

  // 遍历新的属性,如果老的属性值和新的属性值不等,则更新属性值
  for (key in attrs) {
    cur = attrs[key]
    old = oldAttrs[key]
    if (old !== cur) {
      setAttr(elm, key, cur)  // 更新属性值
    }
  }
  // 遍历旧的属性
  for (key in oldAttrs) {
    // 如果新的属性的属性值没有定义,则把这个属性删除
    if (isUndef(attrs[key])) {
        elm.removeAttribute(key)
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# createPatchFunction

createPatchFunction,创建并返回patch方法,需要传入nodeOpsmodules,该方法定义在src\core\vdom\patch.js里面,patch方法才是真正的打补丁的方法。

# patch(新老VNode节点比对)

patch将新老VNode节点进行比对,然后将根据两者的比较结果进行最小单位地修改视图,而不是将整个视图根据新的VNode重绘。patch的核心在于diff算法,这套算法可以高效地比较virtual DOM的变更,得出变化以修改视图。

diff算法:通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,时间复杂度只有O(n),是一种相当高效的算法。同层级只做三件事:增删改。

具体规则是:new VNode不存在就删;old VNode不存在就增;都存在就比较类型,类型不同直接替换、类型相同执行更新;

img

这两张图代表旧的VNode与新VNode进行patch的过程,他们只是在同层级的VNode之间进行比较得到变化(图中相同颜色的方块代表互相进行比较的VNode节点),然后修改变化的视图,所以十分高效。

  // patch 同层级只做三件事:节点的增删改。
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []

    if (isUndef(oldVnode)) {
      // 如果老的Vnode没有定义,则根据新的VNode创建元素
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    } else {
      // 标记旧的VNode是否有nodeType,如果有,它就是一个真实的dom节点
      const isRealElement = isDef(oldVnode.nodeType)
      // 是同一个节点的时候就做深层次的更新patchVnode
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        // 如果是真实的节点,即传了dom元素进来
        if (isRealElement) {
          // 当旧的VNode是服务端渲染的元素,hydrating记为true
          if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
            oldVnode.removeAttribute(SSR_ATTR)
            hydrating = true
          }
          // 如果不是服务端渲染或者合并到真实DOM失败,则创建一个空的VNode节点替换
          oldVnode = emptyNodeAt(oldVnode)
        }

        // 取代现有元素
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

        // 创建新节点
        createElm(
          vnode,
          insertedVnodeQueue,
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )

        // 移除老节点
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          // 调用Destroy钩子
          invokeDestroyHook(oldVnode)
        }
      }
    }
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }
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

从代码中不难发现,当oldVnode与vnode是sameVnode的时候才会进行patchVnode,也就是新旧VNode节点判定为同一节点的时候才会进行patchVnode这个过程,否则就是创建新的DOM,移除旧的DOM。 怎么样的节点算sameVnode呢?

# sameVnode

该方法定义在src\core\vdom\patch.js里面。

/*
  判断两个VNode节点是否是同一个节点,需要同时满足以下条件
  key相同
  tag(当前节点的标签名)相同
  isComment(是否为注释节点)相同
  是否data(当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息)都有定义
  当标签是<input>的时候,type必须相同
*/
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)
  )
}
// 判断当标签是<input>的时候,type是否相同
// 某些浏览器不支持动态修改<input>类型,所以他们被视为不同节点
function sameInputType (a, b) {
  if (a.tag !== 'input') return true
  let i
  const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
  const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
  return typeA === typeB
}
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

SameVnode:当两个VNode的tag、key、isComment都相同,并且同时定义或未定义data的时候,且如果标签为input则type必须相同。这时候这两个VNode则算sameVnode,可以直接进行patchVnode操作。

# patchVnode

该方法定义在src\core\vdom\patch.js里面。两个VNode相同执行更新操作,包括三种操作:属性更新、文本更新、子节点更新,具体规则如下:

patchVode规则

  1. 如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),并且新的VNode是clone或者是标记了v-once,那么只需要替换elm以及componentInstance即可。
  2. 新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren,这个updateChildren也是diff的核心。
  3. 如果老节点没有子节点而新节点存在子节点,先清空老节点DOM的文本内容,然后为当前DOM节点加入子节点。
  4. 当新节点没有子节点而老节点有子节点的时候,则移除该DOM节点的所有子节点。
  5. 当新老节点都无子节点的时候,只是文本的替换。
  /*patchVode*/
  function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
    /*两个VNode节点相同则直接返回*/
    if (oldVnode === vnode) {
      return
    }
    /*
      如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),
      并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),
      那么只需要替换elm以及componentInstance即可。
    */
    if (isTrue(vnode.isStatic) &&
        isTrue(oldVnode.isStatic) &&
        vnode.key === oldVnode.key &&
        (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))) {
      vnode.elm = oldVnode.elm
      vnode.componentInstance = oldVnode.componentInstance
      return
    }

    /* 如果存在data.hook.prepatch则要先执行 */
    let i
    const data = vnode.data
    if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
      i(oldVnode, vnode)
    }
    const elm = vnode.elm = oldVnode.elm
    const oldCh = oldVnode.children
    const ch = vnode.children

    /* 执行属性、事件、样式等等更新操作 */
    if (isDef(data) && isPatchable(vnode)) {
      /* 调用update回调以及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)
    }
    
    /* 开始判断children的各种情况 */
    /* 如果这个VNode节点没有text文本时*/
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        /*新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren*/
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        /*如果老节点没有子节点而新节点存在子节点,先清空elm的文本内容,然后为当前节点加入子节点*/
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        /*当新节点没有子节点而老节点有子节点的时候,则移除所有ele的子节点*/
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        /*当新老节点都无子节点的时候,只是文本的替换,因为这个逻辑中新节点text不存在,所以直接去除ele的文本*/
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      /*当新老节点text不一样时,直接替换这段文本*/
      nodeOps.setTextContent(elm, vnode.text)
    }
    /*调用postpatch钩子*/
    if (isDef(data)) {
      if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, 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
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

新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren

# updateChildren

该方法定义在src\core\vdom\patch.js里面。updateChildren主要作用是比对新旧两个VNode的children得出具体DOM操作。执行一个双循环是传统方式,vue中针对web场景特点做了特别的算法优化:

  function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    // 定义四个游标和四个开始结束的节点
    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, elmToMove, refElm

    const canMove = !removeOnly

    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)) {
        /*前四种情况其实是指定key的时候,判定为同一个VNode,则直接patchVnode即可,分别比较oldCh以及newCh的两头节点2*2=4种情况*/
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
        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)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        // 如果不是前四种情况,即开始和结束都没有相同的,则循环去找到sameVode
        /*
          生成一个key与旧VNode的key对应的哈希表(只有第一次进来undefined的时候会生成,也为后面检测重复的key值做铺垫)
          比如childre是这样的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}]  beginIdx = 0   endIdx = 2  
          结果生成{key0: 0, key1: 1, key2: 2}
        */
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        /*如果newStartVnode新的VNode节点存在key并且这个key在oldVnode中能找到则返回这个节点的idxInOld(即第几个节点,下标)*/
        idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null
        if (isUndef(idxInOld)) { // New element
          /*newStartVnode没有key或者是该key没有在老节点中找到则创建一个新的节点*/
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
          newStartVnode = newCh[++newStartIdx]
        } else {
          /*获取同key的老节点*/
          elmToMove = oldCh[idxInOld]
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !elmToMove) {
            /*如果elmToMove不存在说明之前已经有新节点放入过这个key的DOM中,提示可能存在重复的key,确保v-for的时候item有唯一的key值*/
            warn(
              'It seems there are duplicate keys that is causing an update error. ' +
              'Make sure each v-for item has a unique key.'
            )
          }
          if (sameVnode(elmToMove, newStartVnode)) {
            /*Github:https://github.com/answershuto*/
            /*如果新VNode与得到的有相同key的节点是同一个VNode则进行patchVnode*/
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
            /*因为已经patchVnode进去了,所以将这个老节点赋值undefined,之后如果还有新节点与该节点key相同可以检测出来提示已有重复的key*/
            oldCh[idxInOld] = undefined
            /*当有标识位canMove实可以直接插入oldStartVnode对应的真实DOM节点前面*/
            canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm)
            newStartVnode = newCh[++newStartIdx]
          } else {
            // same key but different element. treat as new element
            /*当新的VNode与找到的同样key的VNode不是sameVNode的时候(比如说tag不一样或者是有不一样type的input标签),创建一个新的节点*/
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
            newStartVnode = newCh[++newStartIdx]
          }
        }
      }
    }

    /*以下是循环结束后,处理多余或者不够的真实DOM节点*/
    if (oldStartIdx > oldEndIdx) {
      /*全部比较完成以后,发现oldStartIdx > oldEndIdx的话,说明老节点已经遍历完了,新节点比老节点多,所以这时候多出来的新节点需要一个一个创建出来加入到真实DOM中*/
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      /*如果全部比较完成以后发现newStartIdx > newEndIdx,则说明新节点已经遍历完了,老节点多余新节点,这个时候需要将多余的老节点从真实DOM中移除*/
      removeVnodes(parentElm, 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

直接看源码可能比较难以捋清其中的关系,我们通过图来看一下。

img

首先,在新老两个VNode节点的左右头尾两侧都有一个变量标记,在遍历过程中这几个变量都会向中间靠拢。当oldStartIdx > oldEndIdx或者newStartIdx > newEndIdx时结束循环。

索引与VNode节点的对应关系:

  • oldStartIdx => oldStartVnode

  • oldEndIdx => oldEndVnode

  • newStartIdx => newStartVnode

  • newEndIdx => newEndVnode

在遍历中,如果存在key,并且满足sameVnode,会将该DOM节点进行复用,否则则会创建一个新的DOM节点。

# 遍历规则

首先,oldStartVnode、oldEndVnode与newStartVnode、newEndVnode两两交叉比较,一共有2*2=4种比较方法。

1、当新老VNode节点的start或者end满足sameVnode时,也就是sameVnode(oldStartVnode, newStartVnode)或者sameVnode(oldEndVnode, newEndVnode),直接将该VNode节点进行patchVnode即可。

img

2、如果oldStartVnode与newEndVnode满足sameVnode,即sameVnode(oldStartVnode, newEndVnode)。这时候说明oldStartVnode已经跑到了oldEndVnode后面去了,进行patchVnode的同时还需要将真实DOM节点移动到oldEndVnode的后面。

img

3、如果oldEndVnode与newStartVnode满足sameVnode,即sameVnode(oldEndVnode, newStartVnode)。这说明oldEndVnode跑到了oldStartVnode的前面,进行patchVnode的同时真实的DOM节点移动到了oldStartVnode的前面。

img

4、如果以上情况均不符合,则通过createKeyToOldIdx会得到一个oldKeyToIdx,里面存放了一个key为旧的VNode,value为对应index序列的哈希表。从这个哈希表中可以找到是否有与newStartVnode一致key的旧的VNode节点,如果同时满足sameVnode,patchVnode的同时会将这个真实DOM(elmToMove)移动到oldStartVnode对应的真实DOM的前面。

img

5、当然也有可能newStartVnode在旧的VNode节点找不到一致的key,或者是即便key相同却不是sameVnode,这个时候会调用createElm创建一个新的DOM节点。

img

到这里循环已经结束了,那么剩下我们还需要处理多余或者不够的真实DOM节点。

(1)当结束时oldStartIdx > oldEndIdx,这个时候老的VNode节点已经遍历完了,但是新的节点还没有。说明了新的VNode节点实际上比老的VNode节点多,也就是比真实DOM多,需要将剩下的(也就是新增的)VNode节点插入到真实DOM节点中去,此时调用addVnodes(批量调用createElm的接口将这些节点加入到真实DOM中去)。

img

(2)同理,当newStartIdx > newEndIdx时,新的VNode节点已经遍历完了,但是老的节点还有剩余,说明真实DOM节点多余了,需要从文档中删除,这时候调用removeVnodes将这些多余的真实DOM删除。

img

# DOM操作

由于Vue使用了虚拟DOM,所以虚拟DOM可以在任何支持JavaScript语言的平台上操作,譬如说目前Vue支持的浏览器平台或是weex,在虚拟DOM的实现上是一致的。那么最后虚拟DOM如何映射到真实的DOM节点上呢?

Vue为平台做了一层适配层,浏览器平台见/platforms/web/runtime/node-ops.js以及weex平台见/platforms/weex/runtime/node-ops.js。不同平台之间通过适配层对外提供相同的接口,虚拟DOM进行操作真实DOM节点的时候,只需要调用这些适配层的接口即可,而内部实现则不需要关心,它会根据平台的改变而改变。

现在又出现了一个问题,我们只是将虚拟DOM映射成了真实的DOM。那如何给这些DOM加入attr、class、style等DOM属性呢?这要依赖于虚拟DOM的生命钩子。虚拟DOM提供了如下的钩子函数,分别在不同的时期会进行调用。

const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
export function createPatchFunction (backend) {
  const { modules, nodeOps } = backend
  /*构建cbs回调函数,web平台上见/platforms/web/runtime/modules*/
  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      if (isDef(modules[j][hooks[i]])) {
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }
  function patchVnode (...) {
    if (isDef(data) && isPatchable(vnode)) {
      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
7
8
9
10
11
12
13
14
15
16
17
18
19

同理,也会根据不同平台有自己不同的实现,我们这里以Web平台为例。Web平台的钩子函数见/platforms/web/runtime/modules。里面有对attr、class、props、events、style以及transition(过渡状态)的DOM属性进行操作。

# patch算法总结

# 1、啥时候进行patchVnode?

当oldVnode与vnode是sameVnode(同一节点)的时候才会进行patchVnode,也就是新旧VNode节点判定为同一节点的时候才会进行patchVnode这个过程,否则就是创建新的DOM,移除旧的DOM。

SameVnode:当两个VNode的tag、key、isComment都相同,并且同时定义或未定义data的时候,且如果标签为input则type必须相同。这时候这两个VNode则算sameVnode,可以直接进行patchVnode操作。

# 2、patchVnode的规则

1.如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),那么只需要替换elm以及componentInstance即可。

2.新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren,这个updateChildren也是diff的核心。

3.如果老节点没有子节点而新节点存在子节点,先清空老节点DOM的文本内容,然后为当前DOM节点加入子节点。

4.当新节点没有子节点而老节点有子节点的时候,则移除该DOM节点的所有子节点。

5.当新老节点都无子节点的时候,只是文本的替换。

# 3、patchVnode什么情况下进行updateChildren?

当新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren。

# 二、Vue编译器Compile

模板编译的主要目标是将模板(template)转换为渲染函数(render) images

带编译器的版本中,可以使用template或el的方式声明模板.

<div id="demo">
  <h1>Vue.js测试</h1>
  <p>{{foo}}</p>
</div>
<script>
  // 使用el方式
  new Vue({
    data: { foo: 'foo' },
    el: "#demo",
  });
</script>
1
2
3
4
5
6
7
8
9
10
11

然后输出渲染函数

const app = new Vue({});
// 输出render函数
console.log(app.$options.render);
1
2
3

输出结果大致如下

ƒunction anonymous() {
  with (this) {
    return _c('div', { attrs: { "id": "demo" } }, [
      _c('h1', [_v("Vue.js测试")]),
      _v(" "),
      _c('p', [_v(_s(foo))])
    ])
  }
}
1
2
3
4
5
6
7
8
9

别名

元素节点使用createElement创建,别名_c

本文节点使用createTextVNode创建,别名_v

表达式先使用toString格式化,别名_s

实现模板编译共有三个阶段:解析、优化和代码生成。

# 解析 - parse

解析器将模板解析为抽象语法树AST,只有将模板解析成AST后,才能基于它做优化或者生成代码字符串。 调试查看得到的AST,src\compiler\parser\index.js - parse方法,结构如下:

images

解析器内部分了HTML解析器、文本解析器和过滤器解析器,最主要是HTML解析器,核心算法说明: parseHTML, src\compiler\parser\index.js

parseHTML(tempalte, {
  start(tag, attrs, unary){}, // 遇到开始标签的处理
  end(){},// 遇到结束标签的处理
  chars(text){},// 遇到文本标签的处理
  comment(text){}// 遇到注释标签的处理
})
1
2
3
4
5
6

# 优化 - optimize

优化器的作用是在AST中找出静态子树并打上标记。静态子树是在AST中永远不变的节点,如纯文本节点。

标记静态子树的好处:

  • 每次重新渲染,不需要为静态子树创建新节点
  • 虚拟DOM中patch时,可以跳过静态子树

标记过程有两步:

  1. 找出静态节点并标记
  2. 找出静态根节点并标记

代码实现,src\compiler\optimizer.js - optimize

export function optimize (root: ?ASTElement, options: CompilerOptions) {
  if (!root) return
  isStaticKey = genStaticKeysCached(options.staticKeys || '')
  isPlatformReservedTag = options.isReservedTag || no
  // 找出静态节点并标记
  markStatic(root)
  // 找出静态根节点并标记
  markStaticRoots(root, false)
}
1
2
3
4
5
6
7
8
9

标记结束

image

# 代码生成 - generate

将AST转换成渲染函数中的内容,即代码字符串。

generate方法生成渲染函数代码,src\compiler\codegen\index.js - generate

export function generate (
  ast: ASTElement | void,
  options: CompilerOptions
  ): CodegenResult {
  const state = new CodegenState(options)
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}
1
2
3
4
5
6
7
8
9
10
11

生成的code

"_c('div',{attrs:{"id":"demo"}},[_c('h1',[_v("Vue.js测试")]),_v(" "),_c('p',
[_v(_s(foo))])])"
1
2

# 关于v-if、v-for

// 解析v-if,  在src\compiler\parser\index.js
function processIf (el) {
  const exp = getAndRemoveAttr(el, 'v-if') // 获取v-if=“exp"中exp并删除v-if属性
  if (exp) {
    el.if = exp // 为ast添加if表示条件
    addIfCondition(el, { // 为ast添加ifConditions表示各种情况对应结果
      exp: exp,
      block: el
    })
  } else { // 其他情况处理
    if (getAndRemoveAttr(el, 'v-else') != null) {
      el.else = true
    }
    const elseif = getAndRemoveAttr(el, 'v-else-if')
    if (elseif) {
      el.elseif = elseif
    }
  }
}
// 代码生成,在src\compiler\codegen\index.js
function genIfConditions (
  conditions: ASTIfConditions,
  state: CodegenState,
  altGen?: Function,
  altEmpty?: string
  ): string {
  const condition = conditions.shift() // 每次处理一个条件
  if (condition.exp) { // 每种条件生成一个三元表达式
    return `(${condition.exp})?${
      genTernaryExp(condition.block)
      }:${
      genIfConditions(conditions, state, altGen, altEmpty)
      }`
  } else {
      return `${genTernaryExp(condition.block)}`
}
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

# 关于插槽

组件编译的顺序是先编译是父组件,再编译子组件。src\compiler\parser\index.js

普通插槽是在父组件编译和渲染阶段生成 vnodes ,数据的作用域是父组件,子组件渲染的时候直接拿到这些渲染好的 vnodes 。

作用域插槽,父组件在编译和渲染阶段并不会直接生成 vnodes ,而是在父节点保留一个 scopedSlots 对象,存储着不同名称的插槽以及它们对应的渲染函数,只有在编译和渲染子组件阶段才会执行这个渲染函数生成vnodes ,由于是在子组件环境执行的,所以对应的数据作用域是子组件实例。

简单地说,两种插槽的目的都是让子组件 slot 占位符生成的内容由父组件来决定,但数据的作用域会根据它们vnodes 渲染时机不同而不同。

Last Updated: 3/26/2020, 4:20:55 PM