一:什么是Virtual Dom
1.浏览器的渲染流程
当浏览器接收到一个html文件时,js引擎和浏览器的渲染引擎便开始工作了。从渲染引擎的角度,它首先会将html文件解析成一个dom树,与此同时,浏览器将识别并加载css样式,并和dom树一起合并为一个渲染树。有了渲染树后,渲染引擎将计算所有元素的位置信息,最后通过绘制,在屏幕上打印最终的内容。
js引擎和渲染引擎虽然是两个独立的线程,但是js引擎却可以触发渲染引擎工作,当我们通过脚本去修改元素位置或外观时,js引擎会利用dom相关的方法去操作dom对象,此时渲染引擎变开始工作,渲染引擎会触发回流或者重绘。
2.回流
当我们对dom的修改引发了元素尺寸的变化时,浏览器需要重新计算元素的大小和位置,最后将重新计算的结果绘制出来,这个过程称为回流。
3.重绘
当我们对dom的修改只单纯改变元素的颜色时,浏览器此时并不需要重新计算元素的大小和位置,而只要重新绘制新样式。这个过程称为重绘。
很显然回流比重绘更加耗费性能。
4.虚拟DOM
虚拟dom是为了解决频繁操作dom引发性能问题的产物。Virtual DOM是对dom的抽象,本质上是js对象,通过js对象描述dom信息和结构。当数据更新影响视图时,会优先作用于Virtual DOM这个js对象,最后通过对比将要改动的部分通知并更新到真实的dom。
1.真实dom:
<div class="content"> <h3>h3标题</h3> <ul> <li style="color: #fff">选项一</li> <li>选项二</li> </ul> </div>
2.通过js对象表示dom:
{ tag: 'div', data: { class: 'content' }, children: [ { tag: 'h3', data: {}, children: ['h3标题'] }, { tag: 'ul', data: {}, children: [ { tag: 'li', data: { style: 'color: #f00' }, children: ['选项一'] }, { tag: 'li', data: {}m children: ['选项二'] } ] } ] }
二:VNode
vue中存在一个VNode类,可以实例化不同类型的vnode实例,不同类型的vnode实例可以表示不同类型的DOM元素。
export default class VNode { constructor ( tag?: string, data?: VNodeData, children?: ?Array<VNode>, text?: string, elm?: Node, context?: Component, componentOptions?: VNodeComponentOptions, asyncFactory?: Function ) { this.tag = tag // 当前节点的标签名 this.data = data // 当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息 this.children = children // 当前节点的子节点,是一个数组 this.text = text // 当前节点的文本 this.elm = elm // 当前虚拟节点对应的真实dom节点 this.ns = undefined // 当前节点的名字空间 this.context = context // 当前组件节点对应的Vue实例 this.fnContext = undefined // 函数式组件对应的Vue实例 this.fnOptions = undefined this.fnScopeId = undefined this.key = data && data.key // 节点的key属性,被当作节点的标志,用以优化 this.componentOptions = componentOptions // 组件的option选项 this.componentInstance = undefined // 当前节点对应的组件的实例 this.parent = undefined // 当前节点的父节点 this.raw = false // 是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false this.isStatic = false // 静态节点标志 this.isRootInsert = true // 是否作为跟节点插入 this.isComment = false // 是否为注释节点 this.isCloned = false // 是否为克隆节点 this.isOnce = false // 是否有v-once指令 this.asyncFactory = asyncFactory this.asyncMeta = undefined this.isAsyncPlaceholder = false } get child (): Component | void { return this.componentInstance } }
VNode有很多属性用来描述节点的关键属性,其中比较重要的属性包括tag、data、children等,tag表示元素节点名称,data表示元素属性,children表示子节点。我们可以通过以下代码示例实现一个简易版的虚拟dom示例:
创建vnode.js:
class VNode { constructor(tag, data, children) { this.tag = tag; this.data = data; this.children = children; } }
通过属性之间不同的搭配,VNode类可以描述出各种类型的真实DOM节点。通过阅读源码(源码位置:src/core/vdom/vnode.js),可以描述出以下几种类型的节点:
- 注释节点
- 文本节点
- 元素节点
- 组件节点
- 函数式组件节点
- 克隆节点
VNode的作用
在视图渲染之前,先将模板编译成VNode并缓存下来,等到数据发生变化页面需要重新渲染的时候,我们把数据发生变化后生成的VNode与前一次缓存下来的VNode进行对比,找出差异,然后有差异的VNode对应的真实DOM节点就是需要重新渲染的节点,最后根据有差异的VNode创建出真实的DOM节点再插入到视图中,最终完成一次视图更新。
三:创建虚拟dom对象
创建虚拟dom对象:
class Create { constructor(tag, data, children) { return new VNode(tag, data, children); } } const oldDom = new Create('div', { 'class': 'content' }, [ new Create('h3', {}, ['h3标题']), new Create('ul', {}, [ new Create('li', { 'style': 'color: #f00' }, ['选项一']), new Create('li', {}, ['选项二']) ]) ])
四:渲染真实dom
创建render.js:将虚拟DOM对象渲染成真实DOM
class Render { constructor(vdom) { this.tag = vdom.tag; this.data = vdom.data; this.children = vdom.children; // 根据tag创建真实的Dom节点 this.element = document.createElement(this.tag); // 为创建的Dom节点设置属性 this.setProps(this.element, this.data); // 为创建的Dom节点设置children子节点 this.setChildren(this.element, this.children); // 返回最终的节点 return this.element; } setProps(element, data) { for (let key in data) { element.setAttribute(key, data[key]); } } setChildren(element, children) { let childElement; if (Array.isArray(children)) { children.forEach(child => { // 子节点是VNode的实例,递归创建子节点,设置属性 if (child instanceof VNode) { childElement = new Render(child); } else { // 子节点是文本节点 childElement = document.createTextNode(child); } element.appendChild(childElement); }) } } }
五:比较两棵虚拟DOM树的差异
比较两棵dom树的差异用到的算法就是diff算法:
diff算法的核心在于在尽可能小变动的前提下找到需要更新的节点,直接调用原生相关dom方法修改视图。不管是真实dom还是虚拟dom,都可以理解为一棵dom树,算法比较节点不同时,只会进行同层节点的比较,不会跨层进行比较,这也大大减少了算法复杂度。
1.对比逻辑:
第一步:判断新的虚拟dom是否存在
第二步:判断节点类型是否相同,如果不同,直接更新当前节点及所有子节点
第三步:节点类型相同时,比较节点上的属性是否相同
第四步:如果是文本类型,则直接比较文本内容是否相同
从根节点开始,递归的按照上面的步骤进行对比。
2.节点的差异类型包括:
- 替换原来的节点 replace类型
- 修改节点属性 props类型
- 文本节点,文本内容改变 text类型
- 删除节点 remove类型
通过diff算法得到的新旧虚拟dom的差异,将差异更新在dom上,完成页面的重新渲染。
3.简易版diff实现,创建diff.js:
class Diff { constructor(oldDom, newDom) { // 保存旧的虚拟dom对象和新的虚拟dom对象之间的差异 this.patches = {}; // 节点的编号 let o = { nid: 0 }; // 遍历oldDom,newDom,将差异的结果保存在patches中 this.traversal(oldDom, newDom, o, this.patches); return this.patches; } traversal(oldDom, newDom, o, patches) { let currentPatches = []; // newDom不存在,表示新的虚拟dom对象或虚拟dom对象中的节点被删除 if (!newDom) { currentPatches.push({ type: 'remove', }) patches[o.nid] = currentPatches; } // 如果都是VNode类型 else if (oldDom instanceof VNode && newDom instanceof VNode) { // 如果节点类型不相同,那么content字段的值为 替换后的整个虚拟DOM节点 if (oldDom.tag !== newDom.tag) { currentPatches.push({ type: 'replace', content: newDom, }) patches[o.nid] = currentPatches; } // 如果节点相同时,需要比较节点上的属性是否发生变化 else { // resultProps表示新旧节点上的属性变化 let resultProps = this.diffProps(oldDom, newDom); // 属性存在差异 if (Object.keys(resultProps).length !== 0) { currentPatches.push({ type: 'props', props: resultProps, }) patches[o.nid] = currentPatches; } } if (oldDom.children) { oldDom.children.forEach((item, index) => { o.nid++; this.traversal(item, newDom.children[index], o, patches); }) } } // 如果是文本类型 else { if (!this.diffText(oldDom, newDom)) { currentPatches.push({ type: 'text', content: newDom, }) patches[o.nid] = currentPatches; } } } /** * 比较属性是否发生变化,并返回差异对象 */ diffProps(oldDom, newDom) { let oldProps = oldDom.data; let newProps = newDom.data; // 记录属性的差异 let propsPatches = {}; for (let key in newProps) { if (!oldProps.hasOwnProperty(key) || oldProps[key] !== newProps[key]) { propsPatches[key] = newProps[key]; } } return propsPatches; } /** * 比较文本的差异,如果相同,返回true,否则返回false */ diffText(oldDom, newDom) { if (oldDom === newDom) { return true; } else { return false; } } }
4.patch
通过diff过程得到新的vnode和旧的vnode之间的差异,然后我们将有差异的vnode创建出真实的dom节点,更新到页面中,完成视图的更新。
patch.js:
class Patch { constructor(realDom, o, patches) { // 获取节点差异 let currentPatch = patches[o.nid]; if (currentPatch) { // 将差异应用到真实节点上 // 先修改根节点上的属性 this.applyPatch(realDom, currentPatch) } // 递归的将差异用于dom中 for (let i = 0; i < realDom.childNodes.length; i++) { // console.log(o, realDom.childNodes); let child = realDom.childNodes[i]; o.nid++; new Patch(child, o, patches) } } applyPatch(realDom, currentPatch) { currentPatch.forEach(item => { let type = item['type']; switch (type) { case 'props': { let props = item ['props']; for (let key in props) { realDom.setAttribute(key, props[key]); } break; } case 'replace': { let content = item['content']; let newEle = null; if (typeof newEle === 'string') { newEle = content } else { // 使用Render类将content渲染成真实的dom newEle = new Render(content) } realDom.parentNode.replaceChild(newEle, realDom); break; } case 'text': { let content = item['content']; realDom.textContent = content; break; } case 'remove': { realDom.parentNode.removeChild(realDom); break; } } }) } }
通过以上的代码,就可以理解虚拟dom的核心思想,然后在阅读源码时,就更容易理解了。
5.源码解析:DOM-Diff,比较两颗DOM树的差异
VNode最大的用途就是在数据变化前后生成真实DOM对应的虚拟DOM节点,然后就可以对比新旧两份VNode,找出差异所在,然后更新有差异的DOM节点,最终达到以最少操作真实DOM更新视图的目的。而对比新旧两份VNode并找出差异的过程就是所谓的DOM-Diff过程。
旧的VNode:就是数据变化之前视图所对应的虚拟DOM节点;
新的VNode:是数据变化之后将要渲染的新的视图所对应的虚拟DOM节点;
以生成的新的VNode为基准,对比旧的VNode,如果新的VNode上有的节点而旧的oldVNode上没有,那么就在旧的oldVNode上加上去;如果新的VNode上没有的节点而旧的oldVNode上有,那么就在旧的oldVNode上去掉;如果某些节点在新的VNode和旧的oldVNode上都有,那么就以新的VNode为准,更新旧的oldVNode,从而让新旧VNode相同。
6.patch的过程:
- 创建节点:新的VNode中有而旧的oldVNode中没有,就在旧的oldVNode中创建。
- 删除节点:新的VNode中没有而旧的oldVNode中有,就从旧的oldVNode中删除。
- 更新节点:新的VNode和旧的oldVNode中都有,就以新的VNode为准,更新旧的oldVNode。
(1)创建节点:
function createElm (vnode, parentElm, refElm) { const data = vnode.data const children = vnode.children const tag = vnode.tag if (isDef(tag)) { vnode.elm = nodeOps.createElement(tag, vnode) // 创建元素节点 createChildren(vnode, children, insertedVnodeQueue) // 创建元素节点的子节点 insert(parentElm, vnode.elm, refElm) // 插入到DOM中 } else if (isTrue(vnode.isComment)) { vnode.elm = nodeOps.createComment(vnode.text) // 创建注释节点 insert(parentElm, vnode.elm, refElm) // 插入到DOM中 } else { vnode.elm = nodeOps.createTextNode(vnode.text) // 创建文本节点 insert(parentElm, vnode.elm, refElm) // 插入到DOM中 } }
第一步:判断是否为元素节点只需判断该VNode节点是否有tag标签。如果有tag属性即认为是元素节点,则调用createElement方法创建元素节点,通常元素节点还会有子节点,那就递归遍历创建所有子节点,将所有子节点创建好之后insert插入到当前元素节点里面,最后把当前元素节点插入到DOM中。
第二步:判断是否为注释节点,只需判断VNode的isComment属性是否为true即可,若为true则为注释节点,则调用createComment方法创建注释节点,再插入到DOM中。
第三步:如果既不是元素节点,也不是注释节点,那就认为是文本节点,则调用createTextNode方法创建文本节点,再插入到DOM中。
(2)删除节点:
如果某些节点在新的VNode中没有而在旧的VNode中有,那么就需要把这些节点从旧的VNode中删除。删除节点非常简单,只需在要删除节点的父元素上调用removeChild方法即可。
function removeNode (el) { const parent = nodeOps.parentNode(el) // 获取父节点 if (isDef(parent)) { nodeOps.removeChild(parent, el) // 调用父节点的removeChild方法 } }
(3)更新节点:
更新节点就是当某些节点在新的VNode和旧的VNode中都有时,我们就需要细致比较一下,找出不一样的地方进行更新。
第一步:如果VNode和VNode均为静态节点,静态节点无论数据发生任何变化都与它无关,所以都为静态节点的话则直接跳过,无需处理。
第二步:如果VNode是文本节点,即表示这个节点内只包含纯文本,那么只需看VNode是否也是文本节点:
第三步:如果是,那就比较两个文本是否不同,如果不同则把旧的VNode里的文本改成跟VNode的文本一样。
第四步:如果旧的VNode不是文本节点,那么不论它是什么,直接调用setTextNode方法把它改成文本节点,并且文本内容跟VNode相同。
如果VNode是元素节点:
(1)该节点包含子节点:
- 如果新的节点内包含了子节点,那么此时要看旧的节点是否包含子节点,如果旧的节点里也包含了子节点,那就需要递归对比更新子节点;
- 如果旧的节点里不包含子节点,那么这个旧节点有可能是空节点或者是文本节点,如果旧的节点是空节点就把新的节点里的子节点创建一份然后插入到旧的节点里面,如果旧的节点是文本节点,则把文本清空,然后把新的节点里的子节点创建一份然后插入到旧的节点里面。
(2)该节点不包含子节点
- 如果该节点不包含子节点,同时它又不是文本节点,那就说明该节点是个空节点,那就好办了,不管旧节点之前里面都有啥,直接清空即可。
// 更新节点 function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) { // vnode与oldVnode是否完全一样?若是,退出程序 if (oldVnode === vnode) { return } const elm = vnode.elm = oldVnode.elm // vnode与oldVnode是否都是静态节点?若是,退出程序 if (isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic) && vnode.key === oldVnode.key && (isTrue(vnode.isCloned) || isTrue(vnode.isOnce)) ) { return } const oldCh = oldVnode.children const ch = vnode.children // vnode有text属性?若没有: if (isUndef(vnode.text)) { // vnode的子节点与oldVnode的子节点是否都存在? if (isDef(oldCh) && isDef(ch)) { // 若都存在,判断子节点是否相同,不同则更新子节点 if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) } // 若只有vnode的子节点存在 else if (isDef(ch)) { /** * 判断oldVnode是否有文本? * 若没有,则把vnode的子节点添加到真实DOM中 * 若有,则清空Dom中的文本,再把vnode的子节点添加到真实DOM中 */ if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '') addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) } // 若只有oldnode的子节点存在 else if (isDef(oldCh)) { // 清空DOM中的子节点 removeVnodes(elm, oldCh, 0, oldCh.length - 1) } // 若vnode和oldnode都没有子节点,但是oldnode中有文本 else if (isDef(oldVnode.text)) { // 清空oldnode文本 nodeOps.setTextContent(elm, '') } // 上面两个判断一句话概括就是,如果vnode中既没有text,也没有子节点,那么对应的oldnode中有什么就清空什么 } // 若有,vnode的text属性与oldVnode的text属性是否相同? else if (oldVnode.text !== vnode.text) { // 若不相同:则用vnode的text替换真实DOM的文本 nodeOps.setTextContent(elm, vnode.text) } }
如果新旧VNode里都包含了子节点,那么对于子节点的更新在代码里调用了updateChildren方法。
六:虚拟Dom和innerHTML
virtual dom的重绘性能真的比innerHTML好吗?:添加链接描述
innerHTML: 重新创建所有 DOM 元素
Virtual DOM: VirtualDom:diff+patch,进行必要的dom更新