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属性,可以另节点重用,避免大量的重绘
- 首次渲染的时候会增加开销,不如直接操作DOM性能好
代码演示
演示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创建的整体过程:
- 首次渲染过程
- vm._init()
- vm.$mount()
- mountComponent
- 创建 Watcher 对象
- updateComponent()
- 调用了 vm._update(vm._render(), hydrating)
- 和虚拟DOM相关的过程(本次学习内容)
- _render 创建虚拟DOM并返回,最终传入 _update
- vnode = render.call(vm._renderProxy, vm.$createElement)
- 调用了用户传入的render或编译生成的render函数
- vm.$createElement
- 就是render中调用的 h 函数
- 内部调用了vm._createElement
- vm._createElement
- new VNode创建虚拟节点并返回
- vnode = render.call(vm._renderProxy, vm.$createElement)
- _update 调用__patch__,负责把虚拟DOM,渲染成真实DOM
- 首次执行
- vm.__patch__(vm.$el, vnode, hydrating, false)
- 数据更新
- vm.__patch__(preVnode, vnode)
- 首次执行
- vm.__patch__
- patchVnode
- updateChildren
- _render 创建虚拟DOM并返回,最终传入 _update
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
- platformModules 和平台相关的模块
- 它拼接了 platformModules 和 baseModules
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>
断点位置:
-
src/core/vdom/patch.js
:updateChildren定义的位置。 - while 循环:对比新旧节点差异的位置
调试结果:
- 没有设置key的情况下
- 修改了3次DOM,插入了1次DOM
- 由于新旧节点的key都相同(undefined),以及其他条件判断得到新旧节点是相同节点,所以对它们都进行了文本修改
- b -> x
- c -> b
- d -> c
- 最后旧节点遍历结束,调用addVnodes,插入文本为 d 的节点
- 由于新旧节点的key都相同(undefined),以及其他条件判断得到新旧节点是相同节点,所以对它们都进行了文本修改
- 修改了3次DOM,插入了1次DOM
- 设置key的情况下
- 插入了1次DOM,没有修改任何DOM
- 用于新增节点影响了后面元素的索引
- 先对比开始节点为不同节点,然后对比结束节点是相同节点
- 由于内容相同,不做处理
- 最后旧节点遍历结束,调用addVnodes,插入文本为 x 的节点
- 插入了1次DOM,没有修改任何DOM
设置key的DOM操作要比没有设置key的操作少很多。
总结 - 和虚拟DOM相关的过程
- _render 创建虚拟DOM并返回,最终传入 _update
- vnode = render.call(vm._renderProxy, vm.$createElement)
- 调用了用户传入的render或编译生成的render函数
- 如果是用户传入的render就调用 vm.$createElement
- 如果是template编译的render,内部编译成调用 vm._c 的函数
- $createElement 和 _c 都是调用 vm._createElement
- vm._createElement
- new VNode创建虚拟节点并返回
- _render结束,返回vnode,交给_update去处理
- vnode = render.call(vm._renderProxy, vm.$createElement)
- _update 调用__patch__,负责把虚拟DOM,渲染成真实DOM
- 首次执行
- vm.__patch__(vm.$el, vnode, hydrating, false)
- 第一个参数是真实DOM
- 数据更新
- vm.__patch__(preVnode, vnode)
- 第一个参数是上一次渲染时保存的虚拟DOM
- 首次执行
- vm.__patch__
- 在
src\platforms\web\runtime\index.js
中初始化- 挂载到Vue.prototype.__patch__
- 它其实是
src\core\vdom\patch.js
中createPatchFunction 导出的 patch 函数
- createPatchFunction
- 设置并缓存了
- modules 和平台相关以及和平台无关的模块
- nodeOps 定义了操作DOM的方法
- 初始化了 cbs
- 存储了所有模块中定义的钩子函数
- 这些钩子函数的作用是用来处理节点的属性/事件/样式等
- 返回patch函数
- 设置并缓存了
- 在
- patch
- 判断第一个参数是否是真实DOM
- 如果是真实DOM,那就是首次渲染
- 将新节点转化成真实DOM,更新到视图
- 调用 createElm
- 将旧的DOM元素转换成一个空的vnode,直接从界面移除,触发相关钩子函数
- 将新节点转化成真实DOM,更新到视图
- 如果不是真实DOM,并且新旧节点是相同节点,说明是数据更新
- 调用patchVnode对比差异,将差异更新到视图
- 如果是真实DOM,那就是首次渲染
- 最后触发insert钩子函数
- 判断第一个参数是否是真实DOM
- createElm 把虚拟节点及子节点,转换成真实DOM,并插入到DOM树,还会触发相应的钩子函数
- patchVnode 对比新旧vnode,以及子节点的差异,并更新到视图
- 如果新旧vnode都有子节点,并且子节点不同的话就调用 updateChildren 对比子节点差异
- updateChildren diff算法对比新旧子节点的差异,并更新到视图
- 循环遍历判断
- 循环遍历判断1:从头和尾开始依次找到相同的子节点进行比较 patchVnode,总共有四种比较方式
- 头头对比
- 尾尾对比
- 头尾对比
- 尾头对比
- 循环遍历判断2:在老节点的子节点中查找与 newStartVnode 相同key的节点,辅以 sameVnode判断是否是相同节点,并进行处理
- 循环遍历判断1:从头和尾开始依次找到相同的子节点进行比较 patchVnode,总共有四种比较方式
- 循环结束后处理
- 批量添加新增节点
- 批量删除旧节点中多余的节点
- 循环遍历判断
上一篇: 电脑常用的30个操作小技巧知识总结介绍
下一篇: 开机按f1进入系统是怎么回事如何解决