vue:虚拟dom的实现
那么为什么要用 VDOM
:现代 Web
页面的大多数逻辑的本质就是不停地修改DOM
,但是 DOM
操作太慢了,直接导致整个页面掉帧、卡顿甚至失去响应。然而仔细想一想,很多 DOM
操作是可以打包(多个操作压成一个)和合并(一个连续更新操作只保留最终结果)的,同时 JS
引擎的计算速度要快得多,能不能把 DOM
操作放到 JS
里计算出最终结果来一发终极 DOM
操作?答案——当然可以!
Vitual DOM
是一种虚拟DOM
技术,本质上是基于javascript
实现的,相对于DOM
对象,javascript
对象更简单,处理速度更快,DOM
树的结构,属性信息都可以很容易的用javascript
对象来表示:
let element={ tagName:'ul',//节点标签名 props:{//dom的属性,用一个对象存储键值对 id:'list' }, children:[//该节点的子节点 {tagName:'li',props:{class:'item'},children:['aa']}, {tagName:'li',props:{class:'item'},children:['bb']}, {tagName:'li',props:{class:'item'},children:['cc']} ] } 对应的html写法是: <ul id='list'> <li class='item'>aa</li> <li class='item'>aa</li> <li class='item'>aa</li> </ul>
Virtual DOM
并没有完全实现DOM
,Virtual DOM
最主要的还是保留了Element
之间的层次关系和一些基本属性. 你给我一个数据,我根据这个数据生成一个全新的Virtual DOM
,然后跟我上一次生成的Virtual DOM
去 diff
,得到一个Patch
,然后把这个Patch
打到浏览器的DOM
上去。
我们可以通过javascript
对象表示的树结构来构建一棵真正的DOM
树,当数据状态发生变化时,可以直接修改这个javascript
对象,接着对比修改后的javascript
对象,记录下需要对页面做的DOM
操作,然后将其应用到真正的DOM
树,实现视图的更新,这个过程就是Virtual DOM
的核心思想。
VNode
的数据结构图:
VNode
生成最关键的点是通过render
有2种生成方式,第一种是直接在vue
对象的option
中添加render
字段。第二种是写一个模板或指定一个el
根元素,它会首先转换成模板,经过html
语法解析器生成一个ast
抽象语法树,对语法树做优化,然后把语法树转换成代码片段,最后通过代码片段生成function
添加到option
的render
字段中。
ast
语法优的过程,主要做了2件事:
- 会检测出静态的class名和attributes,这样它们在初始化渲染后就永远不会再被比对了。
- 会检测出最大的静态子树(不需要动态性的子树)并且从渲染函数中萃取出来。这样在每次重渲染时,它就会直接重用完全相同的vnode,同时跳过比对。
src/core/vdom/create-element.js const SIMPLE_NORMALIZE = 1 const ALWAYS_NORMALIZE = 2 function createElement (context, tag, data, children, normalizationType, alwaysNormalize) { // 兼容不传data的情况 if (Array.isArray(data) || isPrimitive(data)) { normalizationType = children children = data data = undefined } // 如果alwaysNormalize是true // 那么normalizationType应该设置为常量ALWAYS_NORMALIZE的值 if (alwaysNormalize) normalizationType = ALWAYS_NORMALIZE // 调用_createElement创建虚拟节点 return _createElement(context, tag, data, children, normalizationType) } function _createElement (context, tag, data, children, normalizationType) { /** * 如果存在data.__ob__,说明data是被Observer观察的数据 * 不能用作虚拟节点的data * 需要抛出警告,并返回一个空节点 * 被监控的data不能被用作vnode渲染的数据的原因是: * data在vnode渲染过程中可能会被改变,这样会触发监控,导致不符合预期的操作 */ if (data && data.__ob__) { process.env.NODE_ENV !== 'production' && warn( `Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n` + 'Always create fresh vnode data objects in each render!', context ) return createEmptyVNode() } // 当组件的is属性被设置为一个falsy的值 // Vue将不会知道要把这个组件渲染成什么 // 所以渲染一个空节点 if (!tag) { return createEmptyVNode() } // 作用域插槽 if (Array.isArray(children) && typeof children[0] === 'function') { data = data || {} data.scopedSlots = { default: children[0] } children.length = 0 } // 根据normalizationType的值,选择不同的处理方法 if (normalizationType === ALWAYS_NORMALIZE) { children = normalizeChildren(children) } else if (normalizationType === SIMPLE_NORMALIZE) { children = simpleNormalizeChildren(children) } let vnode, ns // 如果标签名是字符串类型 if (typeof tag === 'string') { let Ctor // 获取标签名的命名空间 ns = config.getTagNamespace(tag) // 判断是否为保留标签 if (config.isReservedTag(tag)) { // 如果是保留标签,就创建一个这样的vnode vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined, undefined, context ) // 如果不是保留标签,那么我们将尝试从vm的components上查找是否有这个标签的定义 } else if ((Ctor = resolveAsset(context.$options, 'components', tag))) { // 如果找到了这个标签的定义,就以此创建虚拟组件节点 vnode = createComponent(Ctor, data, context, children, tag) } else { // 兜底方案,正常创建一个vnode vnode = new VNode( tag, data, children, undefined, undefined, context ) } // 当tag不是字符串的时候,我们认为tag是组件的构造类 // 所以直接创建 } else { vnode = createComponent(tag, data, context, children) } // 如果有vnode if (vnode) { // 如果有namespace,就应用下namespace,然后返回vnode if (ns) applyNS(vnode, ns) return vnode // 否则,返回一个空节点 } else { return createEmptyVNode() } }
方法的功能是给一个VNode
对象对象添加若干个子VNode
,因为整个Virtual DOM
是一种树状结构,每个节点都可能会有若干子节点。然后创建一个VNode
对象,如果是一个reserved tag
(比如html
,head
等一些合法的html
标签)则会创建普通的DOM VNode
,如果是一个component tag
(通过vue
注册的自定义component
),则会创建Component VNode
对象,它的VnodeComponentOptions
不为Null
.
创建好VNode
,下一步就是要把Virtual DOM
渲染成真正的DOM
,是通过Patch
来实现的,源码如下:
src/core/vdom/patch.js return function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) { // oldVnoe:dom||当前vnode,vnode:vnoder=对象类型,hydration是否直接用服务端渲染的dom元素 if (isUndef(vnode)) { if (isDef(oldVnode)) invokeDestroyHook(oldVnode) return } let isInitialPatch = false const insertedVnodeQueue = [] if (isUndef(oldVnode)) { // 空挂载(可能是组件),创建新的根元素。 isInitialPatch = true createElm(vnode, insertedVnodeQueue, parentElm, refElm) } else { const isRealElement = isDef(oldVnode.nodeType) if (!isRealElement && sameVnode(oldVnode, vnode)) { // patch 现有的根节点 patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) } else { if (isRealElement) { // 安装到一个真实的元素。 // 检查这是否是服务器渲染的内容,如果我们可以执行。 // 成功的水合作用。 if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) { oldVnode.removeAttribute(SSR_ATTR) hydrating = true } if (isTrue(hydrating)) { if (hydrate(oldVnode, vnode, insertedVnodeQueue)) { invokeInsertHook(vnode, insertedVnodeQueue, true) return oldVnode } else if (process.env.NODE_ENV !== 'production') { warn( 'The client-side rendered virtual DOM tree is not matching ' + 'server-rendered content. This is likely caused by incorrect ' + 'HTML markup, for example nesting block-level elements inside ' + '<p>, or missing <tbody>. Bailing hydration and performing ' + 'full client-side render.' ) } } // 不是服务器呈现,就是水化失败。创建一个空节点并替换它。 oldVnode = emptyNodeAt(oldVnode) } // 替换现有的元素 const oldElm = oldVnode.elm const parentElm = nodeOps.parentNode(oldElm) // create new node createElm( vnode, insertedVnodeQueue, // 极为罕见的边缘情况:如果旧元素在a中,则不要插入。 // 离开过渡。只有结合过渡+时才会发生。 // keep-alive + HOCs. (#4590) oldElm._leaveCb ? null : parentElm, nodeOps.nextSibling(oldElm) ) // 递归地更新父占位符节点元素。 if (isDef(vnode.parent)) { let ancestor = vnode.parent const patchable = isPatchable(vnode) while (ancestor) { for (let i = 0; i < cbs.destroy.length; ++i) { cbs.destroy[i](ancestor) } ancestor.elm = vnode.elm if (patchable) { for (let i = 0; i < cbs.create.length; ++i) { cbs.create[i](emptyNode, ancestor) } // #6513 // 调用插入钩子,这些钩子可能已经被创建钩子合并了。 // 例如使用“插入”钩子的指令。 const insert = ancestor.data.hook.insert if (insert.merged) { // 从索引1开始,以避免重新调用组件挂起的钩子。 for (let i = 1; i < insert.fns.length; i++) { insert.fns[i]() } } } else { registerRef(ancestor) } ancestor = ancestor.parent } } // destroy old node if (isDef(parentElm)) { removeVnodes(parentElm, [oldVnode], 0, 0) } else if (isDef(oldVnode.tag)) { invokeDestroyHook(oldVnode) } } } invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch) return vnode.elm }
Patch
支持的3个参数,其中oldVnode
是一个真实的DOM
或者一个VNode
对象,它表示当前的VNode
,VNode
是VNode
对象类型,它表示待替换的VNode
,hydration
是bool
类型,它表示是否直接使用服务器端渲染的DOM
元素,下面流程图表示Patch
的运行逻辑:
Patch
运行逻辑看上去比较复杂,有2个方法createElm
和patchVnode
是生成DOM
的关键,源码如下:
/** * @param vnode根据vnode的数据结构创建真实的dom节点,如果vnode有children则会遍历这些子节点,递归调用createElm方法, * @param insertedVnodeQueue记录子节点创建顺序的队列,每创建一个dom元素就会往队列中插入当前的vnode,当整个vnode对象全部转换成为真实的dom 树时,会依次调用这个队列中vnode hook的insert方法 */ let inPre = 0 function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested) { vnode.isRootInsert = !nested // 过渡进入检查 if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) { return } const data = vnode.data const children = vnode.children const tag = vnode.tag if (isDef(tag)) { if (process.env.NODE_ENV !== 'production') { if (data && data.pre) { inPre++ } if ( !inPre && !vnode.ns && !( config.ignoredElements.length && config.ignoredElements.some(ignore => { return isRegExp(ignore) ? ignore.test(tag) : ignore === tag }) ) && config.isUnknownElement(tag) ) { warn( 'Unknown custom element: <' + tag + '> - did you ' + 'register the component correctly? For recursive components, ' + 'make sure to provide the "name" option.', vnode.context ) } } vnode.elm = vnode.ns ? nodeOps.createElementNS(vnode.ns, tag) : nodeOps.createElement(tag, vnode) setScope(vnode) /* istanbul ignore if */ if (__WEEX__) { // in Weex, the default insertion order is parent-first. // List items can be optimized to use children-first insertion // with append="tree". const appendAsTree = isDef(data) && isTrue(data.appendAsTree) if (!appendAsTree) { if (isDef(data)) { invokeCreateHooks(vnode, insertedVnodeQueue) } insert(parentElm, vnode.elm, refElm) } createChildren(vnode, children, insertedVnodeQueue) if (appendAsTree) { if (isDef(data)) { invokeCreateHooks(vnode, insertedVnodeQueue) } insert(parentElm, vnode.elm, refElm) } } else { createChildren(vnode, children, insertedVnodeQueue) if (isDef(data)) { invokeCreateHooks(vnode, insertedVnodeQueue) } insert(parentElm, vnode.elm, refElm) } if (process.env.NODE_ENV !== 'production' && data && data.pre) { inPre-- } } else if (isTrue(vnode.isComment)) { vnode.elm = nodeOps.createComment(vnode.text) insert(parentElm, vnode.elm, refElm) } else { vnode.elm = nodeOps.createTextNode(vnode.text) insert(parentElm, vnode.elm, refElm) } }
方法会根据VNode
的数据结构创建真实的DOM
节点,如果VNode
有children
,则会遍历这些子节点,递归调用createElm
方法,InsertedVnodeQueue
是记录子节点创建顺序的队列,每创建一个DOM
元素就会往这个队列中插入当前的VNode
,当整个VNode
对象全部转换成为真实的DOM
树时,会依次调用这个队列中的VNode hook
的insert
方法。
/** * 比较新旧vnode节点,根据不同的状态对dom做合理的更新操作(添加,移动,删除)整个过程还会依次调用prepatch,update,postpatch等钩子函数,在编译阶段生成的一些静态子树,在这个过程 * @param oldVnode 中由于不会改变而直接跳过比对,动态子树在比较过程中比较核心的部分就是当新旧vnode同时存在children,通过updateChildren方法对子节点做更新, * @param vnode * @param insertedVnodeQueue * @param removeOnly */ function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) { if (oldVnode === vnode) { return } const elm = vnode.elm = oldVnode.elm if (isTrue(oldVnode.isAsyncPlaceholder)) { if (isDef(vnode.asyncFactory.resolved)) { hydrate(oldVnode.elm, vnode, insertedVnodeQueue) } else { vnode.isAsyncPlaceholder = true } return } // 用于静态树的重用元素。 // 注意,如果vnode是克隆的,我们只做这个。 // 如果新节点不是克隆的,则表示呈现函数。 // 由热重加载api重新设置,我们需要进行适当的重新渲染。 if (isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic) && vnode.key === oldVnode.key && (isTrue(vnode.isCloned) || isTrue(vnode.isOnce)) ) { vnode.componentInstance = oldVnode.componentInstance return } let i const data = vnode.data if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) { i(oldVnode, vnode) } const oldCh = oldVnode.children const ch = vnode.children 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) } if (isUndef(vnode.text)) { if (isDef(oldCh) && isDef(ch)) { if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) } else if (isDef(ch)) { if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '') addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) } else if (isDef(oldCh)) { removeVnodes(elm, oldCh, 0, oldCh.length - 1) } else if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, '') } } else if (oldVnode.text !== vnode.text) { nodeOps.setTextContent(elm, vnode.text) } if (isDef(data)) { if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode) } }
updateChildren
方法解析在此:vue:虚拟DOM的patch