Vue 2.6 源码剖析-虚拟DOM

学习目标

分析Vue中虚拟DOM的实现

  • 虚拟DOM创建的过程
  • 与虚拟DOM相关的一些函数,如
    • h 函数
    • patch 函数
    • patchVnode 函数
  • v-for 中使用 key 的好处

虚拟DOM相关回顾

什么是虚拟DOM

  • 虚拟DOM(Virtual DOM) 是使用 JavaScript 对象描述真实 DOM
    • 虚拟DOM的本质就是JavaScript对象
    • 程序的各种变化首先作用于虚拟DOM,最终映射到真实DOM
  • Vue.js 中的虚拟DOM借鉴了 Snabbdom,并添加了 Vue.js 的特性
    • 借鉴如
      • 模块机制、钩子函数、diff 算法
    • 特性如
      • 指令和组件机制

为什么要使用虚拟DOM

  • 避免用户直接操作真实DOM,提高开发效率
    • 开发过程只需关注业务代码的实现,不需要关注如何操作DOM
    • 不需要关注DOM的浏览器兼容性问题
  • 作为一个中间层可以跨平台
    • 服务端渲染
    • weex框架
  • 虚拟DOM不一定可以提高性能
    • 首次渲染的时候会增加开销,不如直接操作DOM性能好
      • 因为要额外的维护一层虚拟DOM,也就是要创建一些额外的JavaScript对象,增加了开销
      • 复杂视图情况下提升渲染性能
        • 如果有频繁DOM操作的化,虚拟DOM在更新真实DOM之前,首先通过Diff算法,对比新旧两个DOM树的差异,最终把差异更新到真实DOM,而不会每次都直接操作真实DOM。
        • 另外通过给节点设置key属性,可以另节点重用,避免大量的重绘

代码演示

演示Vue中的虚拟DOM,明确后面要研究的内容。

<div id="app"></div>
<script src="../../dist/vue.js"></script>
<script>
  const vm = new Vue({
    el: "#app",
    render(h) {
      // h语法:h(tag, data, children)

      // return h('h1', this.msg)
      // return h('h1', { domProps: { innerHTML: this.msg }})
      // return h('h1', { attrs: {id: 'title'}}, this.msg)

      const vnode = h(
        'h1',
        {
          attrs: { id: 'title' }
        },
        this.msg
      )
      console.log(vnode)
      return vnode
    },
    data: {
      msg: 'Hello Vue'
    }
  });
</script>

h函数

官方说明

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

  • tag 标签名称或者组件对象
  • data 描述tag,可以设置 DOM 的属性或者标签的属性
  • children tag中的文本内容或者子节点

vnode核心属性

  • children 存放vnode的子节点

    • 当前示例是一个文本节点
  • data 调用h函数的时候传入的data选项

  • elm vnode转换的真实DOM

    • 当前示例是 h1
  • key 用于复用虚拟DOM

  • tag 调用h函数时传入的第一个参数

  • text

整体过程分析

虚拟DOM创建的整体过程:

  1. 首次渲染过程
    1. vm._init()
    2. vm.$mount()
    3. mountComponent
    4. 创建 Watcher 对象
    5. updateComponent()
      1. 调用了 vm._update(vm._render(), hydrating)
  2. 和虚拟DOM相关的过程(本次学习内容)
    1. _render 创建虚拟DOM并返回,最终传入 _update
      1. vnode = render.call(vm._renderProxy, vm.$createElement)
        1. 调用了用户传入的render或编译生成的render函数
      2. vm.$createElement
        1. 就是render中调用的 h 函数
        2. 内部调用了vm._createElement
      3. vm._createElement
        1. new VNode创建虚拟节点并返回
    2. _update 调用__patch__,负责把虚拟DOM,渲染成真实DOM
      1. 首次执行
        1. vm.__patch__(vm.$el, vnode, hydrating, false)
      2. 数据更新
        1. vm.__patch__(preVnode, vnode)
    3. vm.__patch__
    4. patchVnode
    5. updateChildren

VNode的创建过程 createElement

定义位置

找到调用 updateComponent 的位置(src\core\instance\lifecycle.js),函数内调用了_render函数。

_render是在初始化实例方法时(renderMixin)定义的(src\core\instance\render.js)。

它的核心是调用render:

// 调用render函数
// vm.$createElement -> h函数:生成虚拟DOM
// initProxy 中定义了_renderProxy,它是vm或者vm的代理对象(Proxy)
vnode = render.call(vm._renderProxy, vm.$createElement)

render是从vm.$options中获取:

  • 用户传入的render
  • 或者 编译生成的render

vm.$createElement就是传给用户的h函数:

render(h) {
	return h(/*...*/)
}

$createElement是在当前文件中定义的:

// 对编译生成的 render 进行渲染的方法
// _c是在 template 选项转换成的 render 函数中调用
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)

// 对手写 render 函数进行渲染的方法
// $createElement 是在用户手写的render选项中使用的 h 函数
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

_c 和 $createElement 都是调用的 createElement。

区别是

  • 参数:最后一个参数不同(这个在之后看源码的时候解释)。
  • 调用时机
    • 当render是由用户传入的时候,内部调用 $createElement
    • 当render是由模板编译生成的时候,这个函数内部调用的就是 _c

createElement

它最终返回Vnode,但是Vnode不是它创建的,而是通过 _createElement创建的。

createElement内部主要是处理参数:

  • 判断h函数传递参数的方式:2个参数或3个参数
export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  // 判断data是数组(子节点)或原始值(标签的内容)的时候
  // 其实就是传递的children,省略了data
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    // 把data赋值给children,并且把data设置为undefined
    children = data
    data = undefined
  }
  // 判断使用的是_c(false)还是$createElement(true)
  if (isTrue(alwaysNormalize)) {
    // ALWAYS_NORMALIZE = 2
    // normalizationType 将来用来处理children
    normalizationType = ALWAYS_NORMALIZE
  }
  return _createElement(context, tag, data, children, normalizationType)
}

_createElement

创建Vnode并返回。

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  // 首先判断data是否为空,并且包含 Observer 对象
  // 说明data是响应式的数据
  if (isDef(data) && isDef((data: any).__ob__)) {
    // 发出警告:data应该避免使用响应式数据
    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
    )
    // 返回一个空的vnode
    return createEmptyVNode()
  }
  // object syntax in v-bind
  // 判断data是否为空,并且包含is属性
  // <component v-bind:is="currentTaComponent"></component>
  // is 最终会把 currentTaComponent 渲染到 component的位置
  // vnode选项中的is 和 Vue的is效果一样
  if (isDef(data) && isDef(data.is)) {
    // 替换tag
    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
  // 判断如果data有key,并且key不是一个原始值,发出警告
  if (process.env.NODE_ENV !== 'production' &&
    isDef(data) && isDef(data.key) && !isPrimitive(data.key)
  ) {
    if (!__WEEX__ || !('@binding' in data.key)) {
      // 警告:key应该避免使用非原始值,应该使用string或number类型的值
      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
  }
  // ALWAYS_NORMALIZE = 2
  // SIMPLE_NORMALIZE = 1
  // 处理children:主要就是把数组拍平,转换成一维数组
  // 这是_createElement第一个核心的作用
  if (normalizationType === ALWAYS_NORMALIZE) {
    // 如果是用于传入的render函数,调用normalizeChildren处理children
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    // 把二维数组,转换成一维数组
    children = simpleNormalizeChildren(children)
  }
  // 下面是第二个核心的内容:创建vnode对象
  let vnode, ns
  if (typeof tag === 'string') {
    // 如果tag是字符串
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    // 判断html中的保留标签:HTML和SVG下不能被定义为组件名的标签
    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
        )
      }
      // new VNode创建 Vnode
      // context 是 Vue 实例
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // 如果是自定义组件
      // resolveAsset 从vm.$options.components中获取组件的构造函数
      // createComponent创建组件对应的Vnode
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // 如果是自定义标签,new VNode创建 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 {
    // 如果tag不是字符串,那它应该是一个组件
    // createComponent创建组件对应的Vnode
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  if (Array.isArray(vnode)) {
    // 如果vnode是数组,直接返回
    return vnode
  } else if (isDef(vnode)) {
    // 如果vnode不为空,对vnode做一些处理
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    // 否则返回空的vnode
    return createEmptyVNode()
  }
}
createEmptyVNode
// src\core\vdom\vnode.js
// 创建一个空的vnode(注释节点)
export const createEmptyVNode = (text: string = '') => {
  const node = new VNode()
  node.text = text
  // 标识当前vnode是一个注释节点
  node.isComment = true
  return node
}
VNode 类
export default class VNode {
  // 声明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;
  // ... 其他属性
  
  constructor (
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
  ) {
    // 只是初始化了vnode的属性
    this.tag = tag
    this.data = data
    this.children = children
    this.text = text
    this.elm = elm
    // ...其他属性
  }

  // DEPRECATED: alias for componentInstance for backwards compat.
  /* istanbul ignore next */
  get child (): Component | void {
    return this.componentInstance
  }
}
normalizeChildren

不管children是什么值,都返回一个一维数组,方便后续处理

// src\core\vdom\helpers\normalize-children.js
// normalizeChildren的核心作用:不管children是什么值,都返回一个一维数组,方便后续处理
// <template>, <slot>, v-for情况下children可能是数组,并且可能嵌套了多层
export function normalizeChildren (children: any): ?Array<VNode> {
  // 如果调用的是用户传入的render
  // children可能是字符串或数组

  // 如果children是原始值,把children转换成文本节点,并包装成数组返回
  // 否则(children是数组)调用normalizeArrayChildren把children拍平
  //   normalizeArrayChildren 把一个多为的数组转化成一维数组
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}
simpleNormalizeChildren

把二维数组转化成一维数组

// src\core\vdom\helpers\normalize-children.js
// 如果children中包含组件,并且这个组件是函数式组件的话,就会调用这个方法进行处理
// 因为函数式组件已经进行了一维数组的转化,此时children顶多是一个二维数组
// 把二维数组转化成一维数组
export function simpleNormalizeChildren (children: any) {
  for (let i = 0; i < children.length; i++) {
    if (Array.isArray(children[i])) {
      // concat用于拼接数组,还能把后面的二维数组展开去处理
      return Array.prototype.concat.apply([], children)
    }
  }
  return children
}
isReservedTag

判断是否是HTML保留标签。

规定一些 html 和 svg 下面的不能定义为组件名的标签。

// src\platforms\web\util\element.js
export const isReservedTag = (tag: string): ?boolean => {
  return isHTMLTag(tag) || isSVG(tag)
}

VNode 的处理过程 update

lifecycle.js中定义了updateComponent方法,内部调用了_update和_render。

_render 中 最终调用了_createElement,创建了VNode。

然后传递给 _update 处理创建的VNode。

_update也是在当前文件中定义,核心就是调用了 vm.__patch__。

_update的工作就是:

  • 判断是否有 preVnode(旧vnode)
    • 如果没有,就是首次渲染,调用vm.__patch__传入$el
    • 如果有,就是数据变更渲染,调用vm.__patch__传入旧vnode
// _update 方法的作用是把 VNode 渲染成真实的 DOM
// 首次渲染会调用,数据更新会调用
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  const prevEl = vm.$el
  // 获取之前处理的 vnode 对象
  const prevVnode = vm._vnode
  const restoreActiveInstance = setActiveInstance(vm)
  // 更新_vnode为新vnode
  vm._vnode = vnode
  // Vue.prototype.__patch__ is injected in entry points
  // Vue原型上的__patch__方法是在入口中被注入进来的
  // based on the rendering backend used.、
  // 核心部分:调用__patch__,把虚拟DOM转换成真实DOM,最终挂载到 $el
  if (!prevVnode) {
    // 如果不存在处理过的 vnode,说明是首次渲染
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // 数据变更渲染
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  restoreActiveInstance()
  // update __vue__ reference
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
  // if parent is an HOC, update its $el as well
  if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
    vm.$parent.$el = vm.$el
  }
  // updated hook is called by the scheduler to ensure that children are
  // updated in a parent's updated hook.
}

patch 函数的初始化

从_update源码中看出,真正处理vnode的地方是在 Vue 实例的 __patch__方法中。

回顾 Snabbdom 中的 vnode 和 patch

vnode 创建一个对象,包含了几个参数(比Vue少很多)

export function vnode (sel: string | undefined,
  data: any | undefined,
  children: Array<VNode | string> | undefined,
  text: string | undefined,
  elm: Element | Text | undefined): VNode {
  const key = data === undefined ? undefined : data.key
  return { sel, data, children, text, elm, key }
}

patch函数通过init函数返回(高阶函数函数)。

init中传入了modules模块(插件) 和 domApi(操作DOM的方法)。

它先用必报的方式对这两个参数继续缓存,然后返回一个patch函数。

这样将来在调用patch时,就不需要关心modules 和 domApi的引入。

Vue也是类似的机制。

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
  }
}

Vue.js中 patch 的初始化

Vue中的patch在 src\platforms\web\runtime 目录下定义,它是和平台相关的。

// src\platforms\web\runtime\index.js
import { patch } from './patch'

// install platform patch function
// 在 Vue 的原型上注册 __patch__ 函数
// 类似 Snabbdom 的 patch 函数:把虚拟DOM转化成真实DOM
// inBrowser:判断是否是浏览器环境;noop:空函数
Vue.prototype.__patch__ = inBrowser ? patch : noop

patch函数是通过 createPatchFunction 函数返回的,所以它也是一个高阶函数。

// src\platforms\web\runtime\patch.js
import { createPatchFunction } from 'core/vdom/patch'

export const patch: Function = createPatchFunction({ nodeOps, modules })

它接收两个参数:

  • nodeOps 一些DOM的操作,类似Snabbdom 的 domApi
    • 特殊的地方:对select做了特殊处理(multiple 属性)
  • modules 模块,类似Snabbdom的modules。
    • 它拼接了 platformModules 和 baseModules
      • platformModules 和平台相关的模块 src\platforms\web\runtime\modules\index.js
        • 它和 Snabbdom 中的模块基本一致,都是导出生命周期的钩子函数
        • 多了一个transition,处理过渡动画
      • baseModules 和平台无关的模块 src\core\vdom\modules\index.js
        • 它用来处理 指令 和 ref

createPatchFunction

createPatchFunction 相当于 Snabbdom 的 init。

函数最后返回了 patch 函数。

// src\core\vdom\patch.js
// 类似 Snabbdom 的 init,创建并返回patch函数
export function createPatchFunction (backend) {
  let i, j
  // callbacks 与Snabbdom类似,存储模块中定义的钩子函数
  const cbs = {}

  // modules 模块:用于操作节点的属性/事件/样式
  // nodeOps DOM操作的方法
  const { modules, nodeOps } = backend

  // 遍历hooks,生命周期钩子函数的名称
  for (i = 0; i < hooks.length; ++i) {
    // 把名称作为cbs的属性,初始化为一个数组,收集模块的钩子函数
    // cbs['update'] = []
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      if (isDef(modules[j][hooks[i]])) {
        // cbs['update'] = [updateAttrs, updateClass, update..]
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }

  // ...
  
	// 定义并返回patch
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // ...
  }
}

patch 函数的执行过程

patch 中代码比较多,本节只关注它的核心逻辑。

核心:

  • createElm 将vnode转化为真实DOM,并渲染到视图
    • 处理新增节点
  • patchVnode diff算法对比新旧vnode差异,将差异更新到视图
    • 处理相同节点
/**
 * 定义并返回patch
 * @param {*} oldVnode 旧的vnode
 * @param {*} vnode  新的vnode
 */
return function patch (oldVnode, vnode, hydrating, removeOnly) {
  // 如果新的vnode不存在,判断旧的vnode
  if (isUndef(vnode)) {
    // 如果旧的vnode存在,执行 destroy 钩子函数
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
    return
  }

  let isInitialPatch = false
  // 存储新插入的vnode节点的队列
  // 目的是将新插入的vnode挂载到DOM上后,触发 insert钩子函数(invokeInsertHook)
  const insertedVnodeQueue = []

  if (isUndef(oldVnode)) {
    // 如果旧的vnode不存在
    // 当调用$mount方法,但是没有传参的时候,旧的vnode不存在
    // 此时表示当前只是把组件创建出来,并不挂载到视图

    // isInitialPatch为true,记录vnode创建完成,对应的DOM元素也创建完了。
    // 但是仅仅存储在内存中,没有挂载到DOM树上
    isInitialPatch = true
    // createElm 把vnode转化成真实DOM
    // createElm 的第三个参数是parentElm,当为空的时候,不会把真实DOM挂载到DOM树
    createElm(vnode, insertedVnodeQueue)
  } else {
    // 如果旧vnode存在

    // nodeType是DOM对象的属性,根据它判断vnode是一个真实DOM
    // 如果它是真实DOM,说明是首次渲染
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // 如果oldVnode不是真实DOM,并且新旧vnode是相同节点
      // 调用patchVnode:使用 diff 算法对比新旧vnode差异,更新到视图
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {
      // 如果新旧vnode不是相同节点
      if (isRealElement) {
        // 如果oldVnode是真实DOM,说明是首次渲染

        // 和服务端渲染相关的(暂不关心)
        if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
          //...
        }
        // 和服务端渲染相关的(暂不关心)
        if (isTrue(hydrating)) {
          //...
        }

        // 当前判断的核心目的:把oldVnode(当前是真实DOM)转换成vnode
        oldVnode = emptyNodeAt(oldVnode)
      }

      // 获取oldVnode中的DOM元素
      const oldElm = oldVnode.elm
      // 获取oldVnode对应的DOM元素的父元素,将来用于把vnode转化的真实DOM挂载到父元素
      const parentElm = nodeOps.parentNode(oldElm)

      // 把vnode转化成真实DOM
      // createElm:
      //   1. 创建真实DOM
      //   2. 把vnode记录到insertedVnodeQueue队列
      //   3. 把真实DOM插入到 参数4 对应的DOM节点前
      createElm(
        vnode,
        insertedVnodeQueue,
        // 如果旧元素正处于离开转换 leave transition(从界面消失的过渡动画)
        // 就不把新创建的DOM元素挂载到DOM树上
        oldElm._leaveCb ? null : parentElm,
        nodeOps.nextSibling(oldElm)
      )

      // 处理父节点的占位符问题(与核心无关,暂不关心)
      if (isDef(vnode.parent)) {
        // ...
      }

      // destroy old node
      // 判断parentElm是否存在
      if (isDef(parentElm)) {
        // 如果存在,就把oldVnode 从界面移除,并且触发相关钩子函数
        removeVnodes([oldVnode], 0, 0)
      } else if (isDef(oldVnode.tag)) {
        // 如果不存在 parentElm,说明DOM树上没有这个oldVnode
        // 此时判断如果它有 tag 属性(是一个标签)
        // 触发destroy钩子函数
        invokeDestroyHook(oldVnode)
      }
    }
  }

  // 触发 新插入vnode的insert钩子函数
  // isInitialPatch为true,表示当前vnode创建的DOM元素没有挂载到DOM树上
  // 如果DOM元素没有挂载到DOM树,就不执行insert钩子函数
  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  // 最后返回新vnode对应的真实DOM元素
  return vnode.elm
}

emptyNodeAt

把真实DOM转化成一个空的vnode

// 把真实DOM转化成一个空的vnode
function emptyNodeAt (elm) {
  // nodeOps.tagName获取DOM元素的tagname
  return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
}

removeVnodes

function removeVnodes (vnodes, startIdx, endIdx) {
  // 遍历所有节点
  for (; startIdx <= endIdx; ++startIdx) {
    // 获取节点
    const ch = vnodes[startIdx]
    // 判断节点是否存在
    if (isDef(ch)) {
      // 判断是否有tag属性
      if (isDef(ch.tag)) {
        // 如果有,说明是一个tag标签
        // removeAndInvokeRemoveHook:移除这个标签,并触发remove钩子函数
        // invokeDestroyHook:触发destroy钩子函数
        removeAndInvokeRemoveHook(ch)
        invokeDestroyHook(ch)
      } else { // Text node
        // 如果没有,说明是一个文本节点
        // 直接删除节点
        removeNode(ch.elm)
      }
    }
  }
}

invokeInsertHook

// 触发vnode队列的insert钩子函数
function invokeInsertHook (vnode, queue, initial) {
  // delay insert hooks for component root nodes, invoke them after the
  // element is really inserted
  if (isTrue(initial) && isDef(vnode.parent)) {
    // 如果DOM元素没有挂载到DOM树,并且它有父节点
    // 不调用insert钩子函数
    // 标记当前插入是一个延缓插入的操作(pendingInsert)
    // 把队列(queue)记录到pendingInsert中
    // 将来这些元素真正的插入到DOM上之后,才会触发这个队列中每一个vnode的insert钩子函数
    vnode.parent.data.pendingInsert = queue
  } else {
    // 遍历队列中的每一个vnode,触发insert钩子函数
    for (let i = 0; i < queue.length; ++i) {
      queue[i].data.hook.insert(queue[i])
    }
  }
}

createElm

把vnode转化成真实DOM,然后挂载到DOM树上。

/**
   * 把vnode转化成真实DOM挂载到DOM树,并触发一些相应的钩子函数
   * @param {*} vnode 要转化的vnode
   * @param {*} insertedVnodeQueue 新增vnode的队列,用于触发insert钩子函数
   * @param {*} parentElm vnode的父元素,会将vnode转化的真实DOM,插入到父元素中
   * @param {*} refElm 下一个兄弟节点:将vnode转化的真实DOM,插入到这个元素之前
   */
function createElm (
vnode,
 insertedVnodeQueue,
 parentElm,
 refElm,
 nested,
 ownerArray,
 index
) {
  // vnode.elm存在表示vnode已经被渲染过
  // ownerArray表示vnode是否有子节点
  // 这个判断是为了避免一些潜在的错误(暂不关心)
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    // 克隆vnode及它的子节点
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  vnode.isRootInsert = !nested // for transition enter check
  // 调用 createComponent 处理组件的情况
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }

  // 获取data children tag
  const data = vnode.data
  const children = vnode.children
  const tag = vnode.tag

  // 判断3种情况:
  // 1. vnode是标签:tag存在,表示是标签,因为如果是组件,上面已经处理过了
  // 2. vnode是注释节点
  // 3. vnode是文本节点
  if (isDef(tag)) {
    if (process.env.NODE_ENV !== 'production') {
      // 开发环境
      if (data && data.pre) {
        creatingElmInVPre++
      }
      // 判断tag是否是未知的标签,也就是HTML中不存在的标签
      if (isUnknownElement(vnode, creatingElmInVPre)) {
        // 这是个常见的警告:tag是一个自定义标签,你是否正确的注册了对应的组件
        warn(
          'Unknown custom element: <' + tag + '> - did you ' +
          'register the component correctly? For recursive components, ' +
          'make sure to provide the "name" option.',
          vnode.context
        )
      }
    }

    // 创建DOM元素,存储到vnode的elm属性中
    // 判断ns:
    // 有:创建带命名空间(SVG)的DOM元素
    // 没有:创建对应的DOM元素
    vnode.elm = vnode.ns
      ? nodeOps.createElementNS(vnode.ns, tag)
    : nodeOps.createElement(tag, vnode)
    // setScope会为vnode所对应的DOM元素设置样式的作用域
    setScope(vnode)

    /* istanbul ignore if */
    // 判断是否是weex环境(暂不关心)
    if (__WEEX__) {
      // ...
    } else {
      // createChildren 把 vnode 中所有的子元素转化成 vnode
      createChildren(vnode, children, insertedVnodeQueue)
      if (isDef(data)) {
        // 当前vnode已经创建好了对应的DOM对象
        // 如果data有值,触发create钩子函数
        invokeCreateHooks(vnode, insertedVnodeQueue)
      }
      // 把创建的DOM对象插入到parentElm中
      // insert会判断parent为空,不做处理
      insert(parentElm, vnode.elm, refElm)
    }

    if (process.env.NODE_ENV !== 'production' && data && data.pre) {
      creatingElmInVPre--
    }
  } else if (isTrue(vnode.isComment)) {
    // 如果vnode是注释节点
    // 创建并赋值为一个注释的dom元素
    vnode.elm = nodeOps.createComment(vnode.text)
    // 调用insert 插入到DOM树
    insert(parentElm, vnode.elm, refElm)
  } else {
    // 如果vnode是文本节点
    // 创建并赋值为一个文本的dom元素
    vnode.elm = nodeOps.createTextNode(vnode.text)
    // 调用insert 插入到DOM树
    insert(parentElm, vnode.elm, refElm)
  }
}

createChildren

// src\core\vdom\patch.js
function createChildren (vnode, children, insertedVnodeQueue) {
  if (Array.isArray(children)) {
    // 如果是数组
    if (process.env.NODE_ENV !== 'production') {
      // 开发环境,判断子元素中是否有相同的key
      checkDuplicateKeys(children)
    }
    // 遍历children
    for (let i = 0; i < children.length; ++i) {
      // 把子节点vnode转换成真实DOM,并挂载到DOM树上
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
    }
  } else if (isPrimitive(vnode.text)) {
    // 如果是原始值
    // 将值转化成字符串,通过createTextNode创建一个文本DOM元素
    // 把文本DOM元素插入到vnode.elm中
    nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
  }
}

checkDuplicateKeys

// src\core\vdom\patch.js
function checkDuplicateKeys (children) {
  // seenKeys存储子元素的key
  const seenKeys = {}
  // 遍历子元素
  for (let i = 0; i < children.length; i++) {
    const vnode = children[i]
    const key = vnode.key
    if (isDef(key)) {
      if (seenKeys[key]) {
        // 如果定义了key属性,并且子节点中有重复的key
        // 发出警告
        warn(
          `Duplicate keys detected: '${key}'. This may cause an update error.`,
          vnode.context
        )
      } else {
        // 记录key
        seenKeys[key] = true
      }
    }
  }
}

invokeCreateHooks

// src\core\vdom\patch.js
function invokeCreateHooks (vnode, insertedVnodeQueue) {
  // 遍历cbs中所有的create钩子函数并调用
  // cbs存储的是modules模块的钩子函数
  for (let i = 0; i < cbs.create.length; ++i) {
    cbs.create[i](emptyNode, vnode)
  }
  // 获取用户定义的钩子函数
  i = vnode.data.hook // Reuse variable
  if (isDef(i)) {
    // 如果有create钩子函数,触发
    if (isDef(i.create)) i.create(emptyNode, vnode)
    // 如果有insert钩子函数,添加到队列
    if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
  }
}

insert

// src\core\vdom\patch.js
// 向元素中插入dom元素
function insert (parent, elm, ref) {
  // 如果parent未传入,则不作处理
  if (isDef(parent)) {
    if (isDef(ref)) {
      // 如果ref存在,要判断ref的父节点和parent是否相同
      if (nodeOps.parentNode(ref) === parent) {
        // 如果相同,就把elm插入到 ref 对应的dom元素之前
        nodeOps.insertBefore(parent, elm, ref)
      }
    } else {
      // 如果没有ref,就把elm插入到 parent
      nodeOps.appendChild(parent, elm)
    }
  }
}

patchVnode

使用 diff 算法对比新旧节点,将差异更新到视图

// src\core\vdom\patch.js
function patchVnode (
 oldVnode,
 vnode,
 insertedVnodeQueue,
 ownerArray,
 index,
 removeOnly
) {
  // ... 一些判断(暂不关心)

  let i
  // 获取vnode中的data
  const data = vnode.data
  // 判断如果用户定义了prepatch钩子函数,执行这个钩子函数
  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
    i(oldVnode, vnode)
  }

  // 获取新旧节点的子节点
  const oldCh = oldVnode.children
  const ch = vnode.children
  // 触发update钩子函数
  if (isDef(data) && isPatchable(vnode)) {
    // 调用cbs存储的模块的update钩子函数:操作节点的属性/样式/事件等
    for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    // 调用用户定义的update钩子函数
    if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
  }

  // patchVnode 的核心:对比新旧vnode的差异
  // 判断:
  // 1. 如果新vnode中没有text,就会对比新旧vnode的子节点
  // 2. 如果新vnode中有text,并且不同于旧vnode的text,直接修改dom的文本内容
  if (isUndef(vnode.text)) {
    if (isDef(oldCh) && isDef(ch)) {
      // 如果新旧vnode都存在子节点,并且不相等
      // 调用updateChildren对比新旧节点的差异,将差异更新到DOM
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
    } else if (isDef(ch)) {
      // 如果新vnode有子节点,旧vnode没有
      // 先检查新vnode的子节点中是否有重复的key,开发环境会发出警告
      if (process.env.NODE_ENV !== 'production') {
        checkDuplicateKeys(ch)
      }
      // 如果旧vnode有文本内容,先清空文本
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
      // 为当前DOM节点加入子节点
      // addVnodes:把新vnode下的子节点转化成DOM元素,添加到DOM树
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) {
      // 如果旧vnode中有子节点,新vnode没有
      // 删除旧vnode中的子节点,并触发remove和destroy钩子函数
      removeVnodes(oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      // 如果新旧vnode都没有子节点
      // 并且旧vnode有文本内容,新vnode没有文本内容
      // 清空旧vnode的文本内容
      nodeOps.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    // 新vnode有text,并且与旧的不同,修改文本
    nodeOps.setTextContent(elm, vnode.text)
  }
  // patch过程执行完毕,触发postPatch钩子函数
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
  }
}

addVnodes

// src\core\vdom\patch.js
function addVnodes (parentElm, refElm, vnodes, startIdx, endIdx, insertedVnodeQueue) {
    // 遍历指定范围的子节点,转化成真实DOM,挂载到DOM树
    for (; startIdx <= endIdx; ++startIdx) {
      createElm(vnodes[startIdx], insertedVnodeQueue, parentElm, refElm, false, vnodes, startIdx)
    }
  }

updateChildren

对比相同节点的子节点,找到它们的差异,更新到DOM树。

如果节点没有发生变化,会重用该节点(key的使用)。

查看源码发现 updateChildren 的执行过程和Snabbdom中是一样的。

// diff 算法
// 更新新旧vnode的子节点
// 子节点都是以数组形式传入
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  // 初始化:
  // 1. 新旧子节点的结束索引
  // 2. 新旧子节点的开始索引
  // 3. 新旧开始子节点
  // 4. 新旧结束子节点
  // 5. 其他辅助变量
  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,发出警告
  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]

      // 然后判断顶点节点是否相同的情况
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      // 判断 新旧开始节点 相同
      // 调用patchVnode对比差异,并更新到视图
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      // 更新索引和开始节点
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      // 判断 新旧结束节点 相同
      // 调用patchVnode对比差异,并更新到视图
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
      // 更新索引和结束节点
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      // 判断 旧开始节点和新结束节点 相同
      // 调用patchVnode对比差异,并更新到视图
      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对比差异,并更新到视图
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      // 移动旧结束节点的位置
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]

      // 如果对比顶点节点都不相同
      // 就去旧节点中寻找与新开始节点的key相同的节点
    } else {
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      // 如果新开始节点有key,则用key去寻找
      // 如果新开始节点没有key,则遍历旧节点,调用 sameVnode 寻找与新开始节点相同的节点
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
      : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
      if (isUndef(idxInOld)) { // New element
        // 如果没有找到相同节点
        // 调用 createElm 把新开始节点转换成真实DOM,更新到旧开始节点对应的dom元素前
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
      } else {
        // 如果找到key相同的节点,还要调用 sameVnode 再确认一下是否是相同节点
        // 先拷贝一下旧节点(将要移动的节点),稍后要置空这个oldCh中的这个节点
        vnodeToMove = oldCh[idxInOld]
        if (sameVnode(vnodeToMove, newStartVnode)) {
          // 如果是相同节点,调用patchVnode
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
          // 把旧节点设置为 undefined并移动到旧开始节点前
          oldCh[idxInOld] = undefined
          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // 如果不是相同节点(key相同,但是是不同的元素),表示是新元素,调用createElm
          // 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)
  }
}

key 的作用

Vue 官方文档(维护状态)中讲到,在v-for中为每个节点添加一个key属性,可以跟踪每个节点的身份,从而重用和重新排序现有元素。

在使用 Vue CLI创建的项目中,如果没有给v-for的节点设置key,还会发出警告。

通过调试代码查看没有设置key和设置key的区别。

<div id="app">
  <button @click="handler">按钮</button>
  <ul>
    <!-- 没有设置key -->
    <li v-for="value in arr">{{value}}</li>
    <!-- 设置key -->
    <!-- <li v-for="value in arr" :key="value">{{value}}</li> -->
  </ul>
</div>
<script src="../../dist/vue.js"></script>
<script>
  const vm = new Vue({
    el: "#app",
    data: {
      arr: ['a', 'b', 'c', 'd']
    },
    methods: {
      handler() {
        this.arr.splice(1, 0, 'x')
        // this.arr = ['a', 'x', 'b', 'c', 'd']
      }
    }
  });
</script>

断点位置:

  1. src/core/vdom/patch.js :updateChildren定义的位置。
  2. while 循环:对比新旧节点差异的位置

调试结果:

  • 没有设置key的情况下
    • 修改了3次DOM,插入了1次DOM
      • 由于新旧节点的key都相同(undefined),以及其他条件判断得到新旧节点是相同节点,所以对它们都进行了文本修改
        • b -> x
        • c -> b
        • d -> c
      • 最后旧节点遍历结束,调用addVnodes,插入文本为 d 的节点
  • 设置key的情况下
    • 插入了1次DOM,没有修改任何DOM
      • 用于新增节点影响了后面元素的索引
      • 先对比开始节点为不同节点,然后对比结束节点是相同节点
      • 由于内容相同,不做处理
      • 最后旧节点遍历结束,调用addVnodes,插入文本为 x 的节点

设置key的DOM操作要比没有设置key的操作少很多。

总结 - 和虚拟DOM相关的过程

  1. _render 创建虚拟DOM并返回,最终传入 _update
    1. vnode = render.call(vm._renderProxy, vm.$createElement)
      1. 调用了用户传入的render或编译生成的render函数
    2. 如果是用户传入的render就调用 vm.$createElement
    3. 如果是template编译的render,内部编译成调用 vm._c 的函数
    4. $createElement 和 _c 都是调用 vm._createElement
    5. vm._createElement
      1. new VNode创建虚拟节点并返回
    6. _render结束,返回vnode,交给_update去处理
  2. _update 调用__patch__,负责把虚拟DOM,渲染成真实DOM
    1. 首次执行
      1. vm.__patch__(vm.$el, vnode, hydrating, false)
      2. 第一个参数是真实DOM
    2. 数据更新
      1. vm.__patch__(preVnode, vnode)
      2. 第一个参数是上一次渲染时保存的虚拟DOM
  3. vm.__patch__
    1. src\platforms\web\runtime\index.js 中初始化
      1. 挂载到Vue.prototype.__patch__
      2. 它其实是 src\core\vdom\patch.js中createPatchFunction 导出的 patch 函数
    2. createPatchFunction
      1. 设置并缓存了
        1. modules 和平台相关以及和平台无关的模块
        2. nodeOps 定义了操作DOM的方法
      2. 初始化了 cbs
        1. 存储了所有模块中定义的钩子函数
        2. 这些钩子函数的作用是用来处理节点的属性/事件/样式等
      3. 返回patch函数
  4. patch
    1. 判断第一个参数是否是真实DOM
      1. 如果是真实DOM,那就是首次渲染
        1. 将新节点转化成真实DOM,更新到视图
          1. 调用 createElm
        2. 将旧的DOM元素转换成一个空的vnode,直接从界面移除,触发相关钩子函数
      2. 如果不是真实DOM,并且新旧节点是相同节点,说明是数据更新
        1. 调用patchVnode对比差异,将差异更新到视图
    2. 最后触发insert钩子函数
  5. createElm 把虚拟节点及子节点,转换成真实DOM,并插入到DOM树,还会触发相应的钩子函数
  6. patchVnode 对比新旧vnode,以及子节点的差异,并更新到视图
    1. 如果新旧vnode都有子节点,并且子节点不同的话就调用 updateChildren 对比子节点差异
  7. updateChildren diff算法对比新旧子节点的差异,并更新到视图
    1. 循环遍历判断
      1. 循环遍历判断1:从头和尾开始依次找到相同的子节点进行比较 patchVnode,总共有四种比较方式
        1. 头头对比
        2. 尾尾对比
        3. 头尾对比
        4. 尾头对比
      2. 循环遍历判断2:在老节点的子节点中查找与 newStartVnode 相同key的节点,辅以 sameVnode判断是否是相同节点,并进行处理
    2. 循环结束后处理
      1. 批量添加新增节点
      2. 批量删除旧节点中多余的节点
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值