Vue2.5源码阅读笔记02—虚拟DOM的创建与渲染
Write By CS逍遥剑仙
我的主页: csxiaoyao.com
GitHub: github.com/csxiaoyaojianxian
Email: sunjianfeng@csxiaoyao.com
QQ: 1724338257
1. 数据驱动与虚拟DOM
Vue是数据驱动的MVVM框架,视图是由数据驱动生成的,因此对视图的修改不是通过操作 DOM,而是通过修改数据,相比传统使用jQuery的前端开发,能够大大简化代码量,尤其在交互逻辑复杂的情况下,减少DOM操作,直接操作数据会让代码的逻辑变的非常清晰、利于维护。
真实DOM存储的节点信息非常多,频繁的DOM操作会带来明显的性能问题,虚拟DOM能有效解决性能问题。在Vue中,虚拟DOM由Vue中$mount
实例方法调用mountComponent
函数生成,vm._render
负责创建虚拟DOM,vm._update
负责渲染虚拟DOM。
2. 虚拟DOM渲染流程
虚拟DOM的渲染是按照下面的流程运行的,后面会详细介绍。
(1) new Vue ==> (2) init ==> (3) $mount ==> (4) compile ==> (5) render ==> (6) vnode ==> (7) patch ==> (8) DOM
3. Vue实例挂载
Vue通过 $mount
实例方法挂载 vm
,$mount
方法的实现和平台、构建方式都相关,因此在项目中有多处实现,其中,带 compiler
版本的 $mount
可以在浏览器中使用,有利于对源码进行调试分析,作为学习,应该从带 compiler
的版本入手,具体的实现在 src/platform/web/entry-runtime-with-compiler.js 中。
const mount = Vue.prototype.$mount Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean ): Component { el = el && query(el) /* istanbul ignore if */ if (el === document.body || el === document.documentElement) { process.env.NODE_ENV !== 'production' && warn( `Do not mount Vue to <html> or <body> - mount to normal elements instead.` ) return this } const options = this.$options // resolve template/el and convert to render function if (!options.render) { ... const { render, staticRenderFns } = compileToFunctions(template, { shouldDecodeNewlines, shouldDecodeNewlinesForHref, delimiters: options.delimiters, comments: options.comments }, this) options.render = render options.staticRenderFns = staticRenderFns ... } return mount.call(this, el, hydrating) }
首先对原型上的 $mount
方法进行缓存,目的是为了重新定义该方法,以便在 $mount
方法执行前执行平台差异的代码。以web平台为例,传入两个参数:el
、hydrating
(服务端渲染相关,此处无需传入),重新定义的$mount
执行了一些平台相关的额外操作,首先限制 el
不能为 body
、html
这类根节点,接着,检查是否有 render
方法,如果没有则会把 el
或者 template
字符串转换成 render
方法,最后调用 compileToFunctions
方法实现render在线编译。
原型上的 $mount
方法在 src/platform/web/runtime/index.js 中定义,$mount
方法实际会调用定义在 src/core/instance/lifecycle.js 中的 mountComponent
方法进行挂载。
export function mountComponent ( vm: Component, el: ?Element, hydrating?: boolean ): Component { vm.$el = el if (!vm.$options.render) { vm.$options.render = createEmptyVNode ... } callHook(vm, 'beforeMount') let updateComponent if (process.env.NODE_ENV !== 'production' && config.performance && mark) { updateComponent = () => { const name = vm._name const id = vm._uid const startTag = `vue-perf-start:${id}` const endTag = `vue-perf-end:${id}` mark(startTag) const vnode = vm._render() mark(endTag) measure(`vue ${name} render`, startTag, endTag) mark(startTag) vm._update(vnode, hydrating) mark(endTag) measure(`vue ${name} patch`, startTag, endTag) } } else { updateComponent = () => { vm._update(vm._render(), hydrating) } } new Watcher(vm, updateComponent, noop, { before () { if (vm._isMounted) { callHook(vm, 'beforeUpdate') } } }, true /* isRenderWatcher */) hydrating = false if (vm.$vnode == null) { vm._isMounted = true callHook(vm, 'mounted') } return vm }
mountComponent
先调用 vm._render
方法先生成虚拟 Node,再实例化一个Watcher
,由此看出,渲染最核心的 2 个方法:vm._render
和 vm._update
。
4. vm._render创建VDOM
Vue 的 _render
方法是实例的一个私有方法,可以把实例渲染成一个虚拟 Node,定义在 src/core/instance/render.js 中。平时开发工作中很少手写 render
,大多是写 template
模板,在上面的 mounted
方法中会把 template
编译成 render
方法。VDOM是由VNODE组成的树形结构,_render
函数中创建VNODE的实现是通过调用 createElement
方法,定义在 src/core/vdom/create-elemenet.js 中
4.1 createElement创建VNODE
Virtual DOM 的节点定义的描述在 src/core/vdom/vnode.js 中,vnode.js 详细描述了VNODE的结构,比真实DOM结构简化了很多,Vue 的 Virtual DOM 是借鉴了开源库 snabbdom 的实现。除自身的数据结构的定义,映射到真实 DOM 要经历 VNode 的 create、diff、patch 等过程。
VNode 的创建通过 createElement
方法创建,定义在 src/core/vdom/create-elemenet.js 中。
export function createElement ( context: Component, tag: any, data: any, children: any, normalizationType: any, alwaysNormalize: boolean ): VNode | Array<VNode> { if (Array.isArray(data) || isPrimitive(data)) { normalizationType = children children = data data = undefined } if (isTrue(alwaysNormalize)) { normalizationType = ALWAYS_NORMALIZE } return _createElement(context, tag, data, children, normalizationType) }
createElement 方法的最后调用了 _createElement 私有方法。
exporte function _createElement ( context: Component, tag?: string | Class<Component> | Function | Object, data?: VNodeData, children?: any, normalizationType?: number ): VNode | Array<VNode> { ... }
_createElement 方法有 5 个参数,context
表示 VNode 的上下文环境;tag
表示标签;data
表示 VNode 的数据,它是一个 VNodeData
类型,定义在 flow/vnode.js 中;children
表示当前 VNode 的子节点,将会被规范为标准的 VNode 数组;normalizationType
表示子节点规范的类型,类型不同规范的方法不同,由 render
函数是编译生成的还是用户手写决定。createElement 中最关键的两个流程是 normalizeChildren 和 VNODE 创建。
4.2 normalizeChildren子节点规范化
Virtual DOM 是树状结构,每一个 VNode 可能会有若干个子节点,并且这些子节点也为 VNode 类型,因此需要在 createElement 过程中将传入的 any 类型的 children 参数规范化为 VNODE。
_createElement 会根据传入的 normalizationType
参数的不同,分别调用 normalizeChildren(children)
和 simpleNormalizeChildren(children)
方法,二者都定义在 src/core/vdom/helpers/normalzie-children.js 中。simpleNormalizeChildren
是当render
由函数是编译生成时调用,大部分编译生成的 children
已是 VNode 类型的,除了 functional component
函数式组件返回的是一个数组而不是一个根节点,所以需要通过 Array.prototype.concat
方法把 children
数组变成深度只有一层的一维数组。normalizeChildren
方法存在两种调用场景,一是 render
函数由用户手写,当 children
只有一个节点时,Vue调用 createTextVNode
创建一个文本节点的 VNode;另一场景是当编译 slot
、v-for
的时候会产生嵌套数组的情况,会调用 normalizeArrayChildren
方法进行处理。
export function simpleNormalizeChildren (children: any) { for (let i = 0; i < children.length; i++) { if (Array.isArray(children[i])) { return Array.prototype.concat.apply([], children) } } return children } export function normalizeChildren (children: any): ?Array<VNode> { return isPrimitive(children) ? [createTextVNode(children)] : Array.isArray(children) ? normalizeArrayChildren(children) : undefined }
4.3 VNODE创建
规范化 children
后便可以创建 VNode 的实例。如果是内置节点,则直接创建普通 VNode,如果是为已注册的组件名,则通过 createComponent
创建一个组件类型的 VNode,否则创建一个未知的标签的 VNode。
let vnode, ns if (typeof tag === 'string') { let Ctor ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag) if (config.isReservedTag(tag)) { vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined, undefined, context ) } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) { vnode = createComponent(Ctor, data, context, children, tag) } else { vnode = new VNode( tag, data, children, undefined, undefined, context ) } } else { vnode = createComponent(tag, data, context, children) }
5. vm._update渲染VDOM
Vue 的 _update
是实例的私有方法,它只在首次渲染和数据更新两种情况下被调用,_update
方法把 VNode 渲染成真实的 DOM,定义在 src/core/instance/lifecycle.js 中。
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { const vm: Component = this const prevEl = vm.$el const prevVnode = vm._vnode const prevActiveInstance = activeInstance activeInstance = vm vm._vnode = vnode if (!prevVnode) { vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */) } else { vm.$el = vm.__patch__(prevVnode, vnode) } activeInstance = prevActiveInstance if (prevEl) { prevEl.__vue__ = null } if (vm.$el) { vm.$el.__vue__ = vm } if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) { vm.$parent.$el = vm.$el } }
_update
的核心是调用 vm.__patch__
方法,定义在 src/platforms/web/runtime/index.js
中,不同平台的定义不同,浏览器端渲染的 patch
方法定义在 src/platforms/web/runtime/patch.js
中。
import * as nodeOps from 'web/runtime/node-ops' 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 })
patch
方法的定义是调用 createPatchFunction
方法的返回值,传入 nodeOps
参数和 modules
参数。其中,nodeOps
封装了一系列 DOM 操作方法,modules
定义了一些模块的钩子函数的实现。
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
首次渲染执行 patch
函数的时候,传入的 vm.$el
是例子中形如<div id="app">
的 DOM 对象, vm.$el
的赋值在之前 mountComponent
函数中完成,vnode
是调用 render
函数的返回值,hydrating
在非服务端渲染时为 false,removeOnly
为 false。patch的关键操作如下:
const isRealElement = isDef(oldVnode.nodeType) if (!isRealElement && sameVnode(oldVnode, vnode)) { patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) } else { if (isRealElement) { if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) { oldVnode.removeAttribute(SSR_ATTR) hydrating = true } ... oldVnode = emptyNodeAt(oldVnode) } const oldElm = oldVnode.elm const parentElm = nodeOps.parentNode(oldElm) createElm( vnode, insertedVnodeQueue, oldElm._leaveCb ? null : parentElm, nodeOps.nextSibling(oldElm) ) }
emptyNodeAt
方法把 oldVnode
转换成 VNode
对象,然后再调用 createElm
方法。createElm
的作用是通过虚拟节点创建真实的 DOM 并插入到它的父节点中。 对于创建真实DOM子元素,调用了createChildren
方法。
function createChildren (vnode, children, insertedVnodeQueue) { if (Array.isArray(children)) { if (process.env.NODE_ENV !== 'production') { checkDuplicateKeys(children) } for (let i = 0; i < children.length; ++i) { createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i) } } else if (isPrimitive(vnode.text)) { nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text))) } }
createChildren
遍历子虚拟节点,递归调用 createElm
实现深度优先遍历。接着再调用 invokeCreateHooks
方法执行所有的 create 的钩子并把 vnode
push 到 insertedVnodeQueue
队列中。
function invokeCreateHooks (vnode, insertedVnodeQueue) { for (let i = 0; i < cbs.create.length; ++i) { cbs.create[i](emptyNode, vnode) } i = vnode.data.hook // Reuse variable if (isDef(i)) { if (isDef(i.create)) i.create(emptyNode, vnode) if (isDef(i.insert)) insertedVnodeQueue.push(vnode) } }
最后调用 insert
方法把 DOM
插入到父节点中,因为是递归调用,子元素会优先调用 insert
。insert
方法定义在 src/core/vdom/patch.js
上,最终使用原生DOM操作进行了渲染,实际上整个过程就是递归创建了一个完整的 DOM 树并插入到 Body 上。在 createElm
过程中,如果 vnode
节点不包含 tag
,可能是注释或者纯文本节点,可以直接插入到父元素中。
function insert (parent, elm, ref) { if (isDef(parent)) { if (isDef(ref)) { if (ref.parentNode === parent) { nodeOps.insertBefore(parent, elm, ref) } } else { nodeOps.appendChild(parent, elm) } } } export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) { parentNode.insertBefore(newNode, referenceNode) } export function appendChild (node: Node, child: Node) { node.appendChild(child) }
至此,虚拟DOM渲染为真实DOM。