Vue2总结虚拟DOM
Posted 程序员思语
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Vue2总结虚拟DOM相关的知识,希望对你有一定的参考价值。
目前社区有很多 Vue.js 的源码解读文章,但是质量层次不齐,不够系统和全面,最近我将从各个细枝末节解读 Vue.js 的实现原理,针对 vue3 版本之前,让同学们可以彻底掌握 Vue.js。
友情提示:阅读本文大概需要 65分钟
前言
今天的主题是研究DOM和虚拟DOM之间的关系,在介绍虚拟DOM之前,先复习一下浏览器渲染机制,了解浏览器是如何处理原生DOM,从用户发起请求,浏览器根据URL发送请求,服务器响应并正常返回数据之后,浏览器首先
①.处理 html 并构建 DOM 树;
②.处理 CSS 构建 CSSOM 树;
③.将 DOM 与 CSSOM 合并成一个渲染树;
④.根据渲染树来布局,计算每个节点的位置;
⑤.调用 GPU 绘制,合成图层,显示在屏幕上。
在渲染过程中,在构建 CSSOM 树的时候会阻塞渲染,直至 CSSOM 树构建完成。并且构建 CSSOM 树是一个十分消耗性能的过程,所以应该尽量保证层级扁平,减少过度层叠,越是具体的 CSS 选择器,执行速度越慢。
当 HTML 解析到 script 标签时,会暂停构建 DOM,直到 script 里的内容解析完成后才会从暂停的地方重新开始。也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件(当然你可以使用异步加载)。并且CSS 也会影响 JS 的执行,只有当解析完样式表才会执行 JS,所以也可以认为这种情况下,CSS 也会暂停构建DOM。从浏览器最基础的DOM渲染过程我们可以了解到原生DOM渲染是十分消耗性能的一种操作,同时也表明了在大型项目中使用更高效渲染方式的必要性,比较流行的前端 JS 框架也渐渐使用了 虚拟DOM这一手段。
DOM
在理解虚拟DOM之前先复习一下什么是DOM,DOM是什么?文档对象模型(Document Object Model),个人觉得是一种对于在浏览器运行的HTML文件的解析出来的一个API文档,(可以结合上次讲到Vue的事件运行机制)DOM树的构建则是浏览器在呈现一个页面中一个重要的步骤,这就意味着每一个浏览器中必然有着DOM,但是由于浏览器种类很多,不同浏览器对DOM的执行是不一样的,尤其像IE浏览器这种非主流的存在。直接使用真实DOM的损耗计算:
总损耗 = 真实DOM完全增删改 + (可能较多的节点)排版与重绘
Virtual Dom
虚拟 DOM 是同样操作 DOM,不过是把 DOM 树抽象成数据对象,Virtual Dom 它可以使我们操作这块的数据对象,用数据对象来呈现 DOM 树,在每次视图渲染的时候 patch 取得最优,这种做法使我们最小量地去修改 DOM,此外 Virtual DOM 还有一个很大的作用是简化 DOM 操作,让数据与 DOM 之间的关系更直观更简单。
当然在不选定场景的讨论都是耍流氓,但当我们需要更新大量的数据的时候,不见得能快很多,甚至要慢(网上都说操作真实 DOM 慢,但测试结果却比 React 更快,为什么?答案:https://www.zhihu.com/question/31809713),针对原生、Vue、React等渲染速度的对比,在性能优化专题再讨论。
针对Vue2的大环境下,虚拟DOM不同于原生DOM操作,虚拟DOM与真实DOM的具体区别:
虚拟DOM不会进行造成排版与重绘操作;
虚拟DOM进行频繁修改,然后一次性比较并修改真实DOM中需要改的部分(DIFF算法),最后并在真实DOM中进行排版与重绘,减少过多DOM节点排版与重绘损耗;
真实DOM频繁排版与重绘的效率是相当低的;
虚拟DOM有效降低大面积(真实DOM节点)的重绘与排版,因为最终与真实DOM比较差异,可以只渲染局部使用虚拟DOM的损耗计算;
总损耗 = 虚拟DOM增删改 + (与Diff算法效率有关)真实DOM差异增删改 + (较少的节点)排版与重绘
为何需要VDom
综上所述,操作 真实DOM 是很耗费性能的一件事情,既然如此,我们可以考虑通过 JS对象来模拟 DOM 对象(虚拟DOM),毕竟操作 JS 对象比操作 DOM 省时的多。
// 假设这里模拟一个 ul,其中包含了 5 个 li
[1, 2, 3, 4, 5]
// 这里替换上面的 li
[1, 2, 5, 4]
从上面这个栗子,我们可以看出先前的 ul 中的第三个 li 被移除了,元素 4 5 互相替换了位置。如果以上操作对应到 DOM 中,那么就是以下代码。
// 删除第三个 li
ul.childNodes[2].remove()
// 将第四个 li 和第五个交换位置
let fromNode = ul.childNodes[4]
let toNode = node.childNodes[3]
let cloneFromNode = fromNode.cloneNode(true)
let cloenToNode = toNode.cloneNode(true)
ul.replaceChild(cloneFromNode, toNode)
ul.replaceChild(cloenToNode, fromNode)
当然在实际操作中,还需要给每个节点一个标识作为判断是同一个节点的依据。所以这也是 Vue 和 React 中官方推荐列表里的节点使用唯一的 key 来保证性能。既然 DOM 对象可以通过 JS 对象来模拟,反之也可以通过 JS 对象来渲染出对应的 DOM以下是一个 JS 对象模拟 DOM 对象的简单实现。
export default class Element {
/**
* @param {String} tag 'div'
* @param {Object} props { class: 'item' }
* @param {Array} children [ Element1, 'text']
* @param {String} key option
*/
constructor(tag, props, children, key) {
this.tag = tag
this.props = props
if (Array.isArray(children)) {
this.children = children
} else if (isString(children)) {
this.key = children
this.children = null
}
if (key) this.key = key
}
// 渲染
render() {
let root = this._createElement(
this.tag,
this.props,
this.children,
this.key
)
document.body.appendChild(root)
return root
}
create() {
return this._createElement(this.tag, this.props, this.children, this.key)
}
// 创建节点
_createElement(tag, props, child, key) {
// 通过 tag 创建节点
let el = document.createElement(tag)
// 设置节点属性
for (const key in props) {
if (props.hasOwnProperty(key)) {
const value = props[key]
el.setAttribute(key, value)
}
}
if (key) {
el.setAttribute('key', key)
}
// 递归添加子节点
if (child) {
child.forEach(element => {
let child
if (element instanceof Element) {
child = this._createElement(
element.tag,
element.props,
element.children,
element.key
)
} else {
child = document.createTextNode(element)
}
el.appendChild(child)
})
}
return el
}
}
Vue VDom算法
在JQuery时代,我们需要在各个事件方法中直接操作DOM来达到修改视图的目的。综上所述,我们可以将真实DOM树抽象成一棵以javascript 对象构成的抽象树,在修改抽象树数据后将抽象树转化成真实DOM重绘到页面上,于是 虚拟DOM 出现了,它是真实DOM的一层抽象,用属性描述真实DOM的各个特性,当它发生变化的时候,就会去修改视图。
可以想象,最简单粗暴的方法就是将整个DOM结构用innerHTML修改到页面上,但是这样进行重绘整个视图层是相当消耗性能的,我们是不是可以每次只更新它的修改呢?所以Vue.js将DOM抽象成一个以JavaScript对象为节点的虚拟DOM树,以VNode节点模拟真实DOM,可以对这颗抽象树进行创建节点、删除节点以及修改节点等操作,在这过程中都不需要操作真实DOM,只需要操作JavaScript对象后只对差异修改(这一点Vue和React类似),相对于对整块的 innerHTML 进行修改,大大提升了性能。修改以后经过 DIFF 算法得出一些需要修改的最小单位,再将这些小单位的视图进行更新。这样减少了很多不需要的DOM操作,大大提高了性能。
现在又出现了一个问题,我们只是将虚拟DOM映射成了真实的DOM。那如何给这些DOM加入attr、class、style等DOM属性呢?这要依赖于虚拟DOM的生命钩子函数。里面有对 attr、class、props、events、style以及transition(过渡状态)的DOM属性进行操作。以attr为例,代码很简单。attr只需要在create以及update钩子被调用时更新DOM的 attr 属性即可。
Vue VNode对象
一个Vue VNode的实例对象包含了以下属性
tag: 当前节点的标签名
data: 当前节点的数据对象
children: 数组类型,包含了当前节点的子节点
text: 当前节点的文本
elm: 当前虚拟节点对应的真实的dom节点
ns: 节点的namespace
context: 编译作用域
functionalContext: 函数化组件的作用域
key: 节点的key属性,用于作为节点的标识,有利于patch的优化
componentOptions: 创建组件实例时会用到的选项信息
child: 当前节点对应的组件实例
parent: 组件的占位节点
raw: raw html
isStatic: 静态节点的标识
isRootInsert: 是否作为根节点插入,被transition 包裹的节点,该属性的值为false
isComment: 当前节点是否是注释节点
isCloned: 当前节点是否为克隆节点
isOnce: 当前节点是否有v-once指令
Vue VNode分类
VNode可以理解为vue框架的虚拟dom的基类,通过new实例化的VNode大致可以分为几类:
EmptyVNode: 没有内容的注释节点
TextVNode: 文本节点
ElementVNode: 普通元素节点
ComponentVNode: 组件节点
CloneVNode: 克隆节点,可以是以上任意类型的节点,唯一的区别在于isCloned属性为true
// createElement解析
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)}
` +
'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()
}
}
patch原理
patch函数的定义在官方源码的src/core/vdom/patch.js中,我们先来看下这个函数的逻辑。
patch函数接收6个参数:
oldVnode: 旧的虚拟节点或旧的真实dom节点
vnode: 新的虚拟节点
hydrating: 是否要跟真是dom混合
removeOnly: 特殊flag,用于组件
parentElm: 父节点
refElm: 新节点将插入到refElm之前
patch的策略是:
1.如果vnode不存在但是oldVnode存在,说明意图是要销毁老节点,那么就调invokeDestroyHook(oldVnode)来进行销毁
2.如果oldVnode不存在但是vnode存在,说明意图是要创建新节点,那么就调用createElm来创建新节点
3.当vnode和oldVnode都存在时
①.如果oldVnode和vnode是同一个节点,就调用patchVnode来进行patch;
②.当vnode和oldVnode不是同一个节点时,如果oldVnode是真实dom节点或hydrating设置为true,需要用hydrate函数将虚拟dom和真是dom进行映射,然后将oldVnode设置为对应的虚拟dom,找到oldVnode.elm的父节点,根据vnode创建一个真实dom节点并插入到该父节点中oldVnode.elm的位置;
③.这里面值得一提的是patchVnode函数,因为真正的patch算法是由它来实现的(patchVnode中更新子节点的算法其实是在updateChildren函数中实现的,为了便于理解,我统一放到patchVnode中来解释)。
patchVnode算法是:
1.如果oldVnode跟vnode完全一致,那么不需要做任何事情;
2.如果oldVnode跟vnode都是静态节点,且具有相同的key,当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elm和oldVnode.child都复制到vnode上,也不用再有其他操作;
3.否则,如果vnode不是文本节点或注释节点
Ⅰ.如果oldVnode和vnode都有子节点,且2方的子节点不完全一致,就执行更新子节点的操作(这一部分其实是在updateChildren函数中实现),算法如下
①分别获取oldVnode和vnode的firstChild、lastChild,赋值给oldStartVnode、oldEndVnode、newStartVnode、newEndVnode
②.如果oldStartVnode和newStartVnode是同一节点,调用patchVnode进行patch,然后将oldStartVnode和newStartVnode都设置为下一个子节点,重复上述流程
③.如果oldEndVnode和newEndVnode是同一节点,调用patchVnode进行patch,然后将oldEndVnode和newEndVnode都设置为上一个子节点,重复上述流程
④.如果oldStartVnode和newEndVnode是同一节点,调用patchVnode进行patch,如果removeOnly是false,那么可以把oldStartVnode.elm移动到oldEndVnode.elm之后,然后把oldStartVnode设置为下一个节点,newEndVnode设置为上一个节点,重复上述流程
⑤.如果newStartVnode和oldEndVnode是同一节点,调用patchVnode进行patch,如果removeOnly是false,那么可以把oldEndVnode.elm移动到oldStartVnode.elm之前,然后把newStartVnode设置为下一个节点,oldEndVnode设置为上一个节点,重复上述流程;
⑥.如果以上都不匹配,就尝试在oldChildren中寻找跟newStartVnode具有相同key的节点,如果找不到相同key的节点,说明newStartVnode是一个新节点,就创建一个,然后把newStartVnode设置为下一个节点;
⑦.如果上一步找到了跟newStartVnode相同key的节点,那么通过其他属性的比较来判断这2个节点是否是同一个节点,如果是,就调用patchVnode进行patch,如果removeOnly是false,就把newStartVnode.elm插入到oldStartVnode.elm之前,把newStartVnode设置为下一个节点,重复上述流程;
⑧.如果在oldChildren中没有寻找到newStartVnode的同一节点,那就创建一个新节点,把newStartVnode设置为下一个节点,重复上述流程;
⑨.如果oldStartVnode跟oldEndVnode重合了,并且newStartVnode跟newEndVnode也重合了,这个循环就结束了;
Ⅱ.如果只有oldVnode有子节点,那就把这些节点都删除;
Ⅲ.如果只有vnode有子节点,那就创建这些子节点;
Ⅳ.如果oldVnode和vnode都没有子节点,但是oldVnode是文本节点或注释节点,就把vnode.elm的文本设置为空字符串;
4.如果vnode是文本节点或注释节点,但是vnode.text != oldVnode.text时,只需要更新vnode.elm的文本内容就可以;
生命周期
patch提供了5个生命周期钩子,分别是:
create: 创建patch时
activate: 激活组件时
update: 更新节点时
remove: 移除节点时
destroy: 销毁节点时
这些钩子是提供给Vue内部的directives/ref/attrs/style等模块使用的,方便这些模块在patch的不同阶段进行相应的操作,这里模块定义在src/core/vdom/modules和src/platforms/web/runtime/modules2个目录中vnode也提供了生命周期钩子,分别是:
init: vdom初始化时
create: vdom创建时
prepatch: patch之前
insert: vdom插入后
update: vdom更新前
postpatch: patch之后
remove: vdom移除时
destroy: vdom销毁时
vue组件的生命周期底层其实就依赖于vnode的生命周期,在src/core/vdom/create-component.js中我们可以看到,vue为自己的组件vnode已经写好了默认的init/prepatch/insert/destroy,而vue组件的mounted/activated就是在insert中触发的,deactivated就是在destroy中触发的。
React VDOM
既然讨论了Vue的虚拟DOM,那么连同React一同比较了。React 号称组件式开发,中最神奇的部分莫过于虚拟 DOM,以及其高效的 Diff 算法(经过多次改良)。这让我们可以无需担心性能问题而“毫无顾忌”的随时“刷新”整个页面,由虚拟 DOM 来确保只对界面上真正变化的部分进行实际的 DOM 操作。React 在这一部分已经做到足够透明,在实际开发中我们基本无需关心虚拟 DOM 是如何运作的。然而,作为有态度的程序员,我们总是对技术背后的原理充满着好奇。理解其运行机制不仅有助于更好的理解 React 组件的生命周期,而且对于进一步优化 React 程序也会有很大帮助。Web 界面由 DOM 树来构成,当其中某一部分发生变化时,其实就是对应的某个 DOM 节点发生了变化。在 React 中,构建 UI 界面的思路是由当前状态决定界面。前后两个状态就对应两套界面,然后由 React 来比较两个界面的区别,这就需要对 DOM 树进行 Diff 算法分析。即给定任意两棵树,找到最少的转换步骤。但是标准的的 Diff 算法复杂度需要 O(n^3),这显然无法满足性能要求。要达到每次界面都可以整体刷新界面的目的,势必需要对算法进行优化。这看上去非常有难度,然而 Facebook 工程师却做到了,他们结合 Web 界面的特点做出了两个简单的假设,使得 Diff 算法复杂度直接降低到 O(n)。
①.两个相同组件产生类似的 DOM 结构,不同的组件产生不同的 DOM 结构;
②.对于同一层次的一组子节点,它们可以通过唯一的 id 进行区分。算法上的优化是 React 整个界面 Render 的基础,事实也证明这两个假设是合理而精确的,保证了整体界面构建的性能。
Facebook团队经过多次改良将 Diff 算法复杂度控制在了 O(n),这让我们考虑 UI 时可以完全基于状态来每次 render 整个界面而无需担心性能问题,简化了 UI 开发的复杂度。而算法优化的基础是刚刚提到的2个假设,以及 React 的 UI 基于组件这样的一个机制。理解虚拟 DOM Diff 算法不仅能够帮助我们理解组件的生命周期,而且也对我们实现自定义组件时如何进一步优化性能具有指导意义。
此外Diff算法只是为了虚拟DOM比较替换效率更高,通过D iff 算法得到 Diff 算法结果数据表(需要进行哪些操作记录表)。原本要操作的DOM在 vue 这边还是要操作的,只不过用到了 js 的DOM Fragment来操作 DOM(统一计算出所有变化后统一更新一次DOM)进行浏览器DOM一次性更新。其实DOM Fragment我们不用平时发开也能用,但是这样程序员写业务代码就用把DOM操作放到fragment里,这就是框架的价值,程序员才能专注于写业务代码。(据尤大大在 Vue3 的公开演讲中介绍 Vue3 将在此环节优化提升更多,使框架更高效更便捷)。
<div id="real-container">
<p>Real DOM</p>
<div>cannot update</div>
<ul>
<li className="item">Item 1</li>
<li className="item">Item 2</li>
<li className="item">Item 3</li>
</ul>
</div>
const tree = Element('div', { id: 'virtual-container' }, [
Element('p', {}, ['Virtual DOM']),
Element('div', {}, ['before update']),
Element('ul', {}, [
Element('li', { class: 'item' }, ['Item 1']),
Element('li', { class: 'item' }, ['Item 2']),
Element('li', { class: 'item' }, ['Item 3']),
]),
]);
const root = tree.render();
document.getElementById('virtualDom').appendChild(root);
// 用js对象模拟DOM节点的好处是,页面的更新可以先全部反映在js对象上,
// 操作内存中的js对象的速度显然要快多了。
// 等更新完后,再将最终的js对象映射成真实的DOM,交由浏览器去绘制。
// 那具体怎么实现呢?看一下Element方法的具体实现:
function Element(tagName, props, children) {
if (!(this instanceof Element)) {
return new Element(tagName, props, children);
}
this.tagName = tagName;
this.props = props || {};
this.children = children || [];
this.key = props ? props.key : undefined;
let count = 0;
this.children.forEach((child) => {
if (child instanceof Element) {
count += child.count;
}
count++;
});
this.count = count;
}
// 第一个参数是节点名(如div),第二个参数是节点的属性(如class),第三个参数是子节点(如ul的li)
// 除了这三个参数会被保存在对象上外,还保存了key和count。
Virtual Dom 算法实现
既然我们已经简单分析了 Vue 和 React 的 VDOM 思路,并且通过 JS 来模拟实现了 真实DOM,那么接下来的难点就在于如何判断旧的对象和新的对象之间的差异。DOM 是多叉树的结构,如果需要完整的对比两颗树的差异,那么需要的时间复杂度会是 O(n ^ 3),这个复杂度肯定是不能接受的。于是 React 团队优化了算法,实现了 O(n) 的复杂度来对比差异。实现 O(n) 复杂度的关键就是只对比同层的节点,而不是跨层对比,这也是考虑到在实际业务中很少会去跨层的移动 DOM 元素。所以判断差异的算法就分为了两步? 首先从上至下,从左往右遍历对象,也就是树的深度遍历,这一步中会给每个节点添加索引,便于最后渲染差异,一旦节点有子元素,就去判断子元素是否有不同。
1.树的递归
首先来实现树的递归算法,先来考虑下两个节点对比会有几种情况新的节点的 tagName 或者 key 和旧的不同,这种情况代表需要替换旧的节点,并且也不再需要遍历新旧节点的子元素了,因为整个旧节点都被删掉了新的节点的 tagName 和 key(可能都没有)和旧的相同,开始遍历子树没有新的节点,则无需操作。
import { StateEnums, isString, move } from './util'
import Element from './element'
export default function diff(oldDomTree, newDomTree) {
// 用于记录差异
let pathchs = {}
// 一开始的索引为 0
dfs(oldDomTree, newDomTree, 0, pathchs)
return pathchs
}
function dfs(oldNode, newNode, index, patches) {
// 用于保存子树的更改
let curPatches = []
// 需要判断三种情况
// 1.没有新的节点,那么什么都不用做
// 2.新的节点的 tagName 和 `key` 和旧的不同,就替换
// 3.新的节点的 tagName 和 key(可能都没有) 和旧的相同,开始遍历子树
if (!newNode) {
} else if (newNode.tag === oldNode.tag && newNode.key === oldNode.key) {
// 判断属性是否变更
let props = diffProps(oldNode.props, newNode.props)
if (props.length) curPatches.push({ type: StateEnums.ChangeProps, props })
// 遍历子树
diffChildren(oldNode.children, newNode.children, index, patches)
} else {
// 节点不同,需要替换
curPatches.push({ type: StateEnums.Replace, node: newNode })
}
if (curPatches.length) {
if (patches[index]) {
patches[index] = patches[index].concat(curPatches)
} else {
patches[index] = curPatches
}
}
}
2.判断属性的更改
判断属性的更改也分三个步骤:
遍历旧的属性列表,查看每个属性是否还存在于新的属性列表中;
遍历新的属性列表,判断两个列表中都存在的属性的值是否有变化;
在第二步中同时查看是否有属性不存在与旧的属性列列表中;
function diffProps(oldProps, newProps) {
// 判断 Props 分以下三步骤
// 先遍历 oldProps 查看是否存在删除的属性
// 然后遍历 newProps 查看是否有属性值被修改
// 最后查看是否有属性新增
let change = []
for (const key in oldProps) {
if (oldProps.hasOwnProperty(key) && !newProps[key]) {
change.push({
prop: key
})
}
}
for (const key in newProps) {
if (newProps.hasOwnProperty(key)) {
const prop = newProps[key]
if (oldProps[key] && oldProps[key] !== newProps[key]) {
change.push({
prop: key,
value: newProps[key]
})
} else if (!oldProps[key]) {
change.push({
prop: key,
value: newProps[key]
})
}
}
}
return change
}
3.判断列表差异算法实现
这个算法是整个 Virtual Dom 中最核心的算法,对节点作差异遍历处理,也是分为三步:
遍历旧的节点列表,查看每个节点是否还存在于新的节点列表中;
遍历新的节点列表,判断是否有新的节点;
在第二步中同时判断节点是否有移动;
function listDiff(oldList, newList, index, patches) {
// 为了遍历方便,先取出两个 list 的所有 keys
let oldKeys = getKeys(oldList)
let newKeys = getKeys(newList)
let changes = []
// 用于保存变更后的节点数据
// 使用该数组保存有以下好处
// 1.可以正确获得被删除节点索引
// 2.交换节点位置只需要操作一遍 DOM
// 3.用于 `diffChildren` 函数中的判断,只需要遍历
// 两个树中都存在的节点,而对于新增或者删除的节点来说,完全没必要
// 再去判断一遍
let list = []
oldList &&
oldList.forEach(item => {
let key = item.key
if (isString(item)) {
key = item
}
// 寻找新的 children 中是否含有当前节点
// 没有的话需要删除
let index = newKeys.indexOf(key)
if (index === -1) {
list.push(null)
} else list.push(key)
})
// 遍历变更后的数组
let length = list.length
// 因为删除数组元素是会更改索引的
// 所有从后往前删可以保证索引不变
for (let i = length - 1; i >= 0; i--) {
// 判断当前元素是否为空,为空表示需要删除
if (!list[i]) {
list.splice(i, 1)
changes.push({
type: StateEnums.Remove,
index: i
})
}
}
// 遍历新的 list,判断是否有节点新增或移动
// 同时也对 `list` 做节点新增和移动节点的操作
newList &&
newList.forEach((item, i) => {
let key = item.key
if (isString(item)) {
key = item
}
// 寻找旧的 children 中是否含有当前节点
let index = list.indexOf(key)
// 没找到代表新节点,需要插入
if (index === -1 || key == null) {
changes.push({
type: StateEnums.Insert,
node: item,
index: i
})
list.splice(i, 0, key)
} else {
// 找到了,需要判断是否需要移动
if (index !== i) {
changes.push({
type: StateEnums.Move,
from: index,
to: i
})
move(list, index, i)
}
}
})
return { changes, list }
}
function getKeys(list) {
let keys = []
let text
list &&
list.forEach(item => {
let key
if (isString(item)) {
key = [item]
} else if (item instanceof Element) {
key = item.key
}
keys.push(key)
})
return keys
}
4.遍历子元素打标识
对于这个函数来说,主要功能就两个:
判断两个列表差异;
给节点打上标记;
function diffChildren(oldChild, newChild, index, patches) {
let { changes, list } = listDiff(oldChild, newChild, index, patches)
if (changes.length) {
if (patches[index]) {
patches[index] = patches[index].concat(changes)
} else {
patches[index] = changes
}
}
// 记录上一个遍历过的节点
let last = null
oldChild &&
oldChild.forEach((item, i) => {
let child = item && item.children
if (child) {
index =
last && last.children ? index + last.children.length + 1 : index + 1
let keyIndex = list.indexOf(item.key)
let node = newChild[keyIndex]
// 只遍历新旧中都存在的节点,其他新增或者删除的没必要遍历
if (node) {
dfs(item, node, index, patches)
}
} else index += 1
last = item
})
}
5.渲染差异
通过前面的算法,可以得出两个状态树的差异,随后就可以根据需要局部去更新 DOM 了,下面就是 Virtual Dom 算法的最后步骤,这个函数主要两个功能:
深度遍历树,将需要做变更操作的取出来;
局部更新 DOM;
let index = 0
export default function patch(node, patchs) {
let changes = patchs[index]
let childNodes = node && node.childNodes
// 这里的深度遍历和 diff 中是一样的
if (!childNodes) index += 1
if (changes && changes.length && patchs[index]) {
changeDom(node, changes)
}
let last = null
if (childNodes && childNodes.length) {
childNodes.forEach((item, i) => {
index =
last && last.children ? index + last.children.length + 1 : index + 1
patch(item, patchs)
last = item
})
}
}
function changeDom(node, changes, noChild) {
changes &&
changes.forEach(change => {
let { type } = change
switch (type) {
case StateEnums.ChangeProps:
let { props } = change
props.forEach(item => {
if (item.value) {
node.setAttribute(item.prop, item.value)
} else {
node.removeAttribute(item.prop)
}
})
break
case StateEnums.Remove:
node.childNodes[change.index].remove()
break
case StateEnums.Insert:
let dom
if (isString(change.node)) {
dom = document.createTextNode(change.node)
} else if (change.node instanceof Element) {
dom = change.node.create()
}
node.insertBefore(dom, node.childNodes[change.index])
break
case StateEnums.Replace:
node.parentNode.replaceChild(change.node.create(), node)
break
case StateEnums.Move:
let fromNode = node.childNodes[change.from]
let toNode = node.childNodes[change.to]
let cloneFromNode = fromNode.cloneNode(true)
let cloenToNode = toNode.cloneNode(true)
node.replaceChild(cloneFromNode, toNode)
node.replaceChild(cloenToNode, fromNode)
break
default:
break
}
})
}
6.最后
最终 Virtual Dom 算法的实现也就是以下三步:
通过 JS 来模拟创建 DOM 对象;
判断两个对象的差异;
渲染差异;
let test4 = new Element('div', { class: 'my-div' }, ['test4'])
let test5 = new Element('ul', { class: 'my-div' }, ['test5'])
let test1 = new Element('div', { class: 'my-div' }, [test4])
let test2 = new Element('div', { id: '11' }, [test5, test4])
let root = test1.render()
let pathchs = diff(test1, test2)
console.log(pathchs)
setTimeout(() => {
console.log('开始更新')
patch(root, pathchs)
console.log('结束更新')
}, 1000)
经过以上六个步骤就完成了一个轻量版的虚拟DOM算法,可以帮助更好地理解DIFF算法和虚拟DOM的实现过程。
参考
总结
虚拟DOM的目的是将所有操作累加起来,统计计算出所有的变化后,统一更新一次DOM。其实即使不懂原理,业务代码照样写,像JQuery这种就属于以选择器为导向的框架,Vue和React属于有明确分层架构的MV*框架,而虚拟DOM属于框架中的节点模块,也是核心模块,理解了虚拟DOM,理解类似框架也就相对轻松,今后出现类型框架也不犯愁了。前端就是这样痛并快乐着。
最后
以上是关于Vue2总结虚拟DOM的主要内容,如果未能解决你的问题,请参考以下文章