vue中的虚拟dom
目录vue为什么引入虚拟DOM?vue中虚拟DOM干了啥?vue中的虚拟DOM如何创建的?vue中的Vnode类patch创建新增节点删除废弃节点修改需要更新的节点vue中虚拟DOM的优化具体思路具体实现vue为什么引入虚拟DOM?vue1.0响应式粒度太细,Object.defineProperty()每个数据的修改都会通知watcher,进而通知do...
目录
- 虚拟dom是什么?它的引入解决了什么问题
- vue为什么引入虚拟DOM?
- vue中虚拟DOM干了啥?
- vue中的虚拟DOM如何创建的?
- vue中的Vnode类
- patch
- 创建新增节点
- 删除废弃节点
- 修改需要更新的节点
- vue中虚拟DOM的优化
- 具体思路
- 具体实现
虚拟dom是什么?它的引入解决了什么问题
虚拟DOM就是使用js的object模拟真实的dom,当状态发生变化,更新之前做diff,达到最少操作dom的效果
<body>
<!-- 真实dom -->
<div id="app">
<p>节点1</p>
</div>
<script>
<!--虚拟dom -->
const vnode = {
tag: 'div',
data: {id: 'app'},
children: {
tag: 'p',
data: {},
children: '节点1'
}
}
</script>
</body>
我们知道访问DOM是非常昂贵的,会造成相当多得性能浪费,所以我们试想,当某个状态发生变化时,只更新与这个状态相关联的DOM节点。虚拟DOM就是解决方案之一。
三大主流框架Vue.js、angular.js、React.js中,Angular使用脏值检测,React使用虚拟DOM,Vue.js1.0通过细粒度绑定,2.0开始引入虚拟DOM。
所以虚拟DOM旨在避免不必要的DOM操作。
vue为什么引入虚拟DOM?
- vue1.0响应式粒度太细,Object.defineProperty()每个数据的修改都会通知watcher,进而通知dom去改变,对大型项目来说是一个噩梦!内存开销非常大。
- vue2.0引入虚拟dom,通过diff之后再通知dom去改变,相对于1.0响应式的级别修改了,watcher只到组件,组件内部使用虚拟dom
vue中虚拟DOM干了啥?
虚拟dom没有想象中的那么复杂,它只做两件事:
- 提供与真实dom节点对应的虚拟节点vnode
- 状态发生变化时,对比新旧两个vnode,更新视图。
vue中的虚拟DOM如何创建?
vue-loader
允许我们直接在template中编写模板字符串,将内容提取出来给vue-template-compiler,Vue.js通过编译器将模板转换成渲染函数中的内容,执行这个函数可以得到一个vnode树,使用这个虚拟节点树就可以渲染页面了。
例如:
<template>
<img src="../image.png">
</template>
将会被编译成:
createElement('img', {
attrs: {
src: require('../image.png')
}
})
注意,实际过程中编译器最终生成的是一个代码字符串,类似with(this){return _c('div',{attr:{"id":"el"}},[_v("hello"+ _s(name))])}
,这个代码字符串被包装在渲染函数执行,生成一份Vnode。
类似的,render函数中的h就相当于上面提到的createElement()函数
new Vue({
router,
render: h => h(App)
}).$mount('#app')
具体的Vnode的生成过程可以参考 Vue源码之模板编译原理
vue中的Vnode类
vnode类型有:
- 注释节点
- 文本节点
- 元素节点
- 组件节点
- 函数是组件
- 克隆节点
不同类型的vnode表示不同类型的真实DOM元素。
var VNode = function VNode (
tag,
data,
children,
text,
elm,
context,
componentOptions,
asyncFactory
) {
this.tag = tag;
this.data = data;
this.children = children;
this.text = text;
this.elm = elm;
this.ns = undefined;
this.context = context; // 当前组件的Vue实例
this.fnContext = undefined;// 函数式组件实例
this.fnOptions = undefined;// 函数式组件选项参数
this.fnScopeId = undefined;
this.key = data && data.key;
this.componentOptions = componentOptions; // 组件节点的选项参数,propsData、tag、children
this.componentInstance = undefined;// 组件实例
this.parent = undefined;
this.raw = false;
this.isStatic = false;
this.isRootInsert = true;
this.isComment = false; // true 代表注释节点
this.isCloned = false; // true 代表克隆节点
this.isOnce = false;
this.asyncFactory = asyncFactory;
this.asyncMeta = undefined;
this.isAsyncPlaceholder = false;
};
patch
patch对比新旧vnode之间的差异,目的其实是修改DOM节点。
对DOM进行修改需要做三件事:
- 创建新增的节点
- oldVnode不存在而vnode存在时
- 当vnode和oldVnode完全不是同一个节点时
- 删除已废弃的节点
- vnode 中不存在的节点
- 修改需要更新的节点
- vnode和oldVnode是同一个节点,进行详细对比
事实上,只有三种类型的节点会被创建并插入到DOM中:元素、文本、注释。
判断节点类型:
- 判断vnode是否为元素节点,只需要看它是否具有tag属性。
- 不具有tag属性则是文本或者注释节点,根据vnode的isComment属性是true可以判断vnode是注释节点,反之是文本节点。
创建新增节点
我们在patch的过程中,发现有变化(如:需要新增、删除或者修改),则根据最新的vnode,比对oldVnode,对真实DOM进行更新操作。要知道,patch的目的就是修改DOM。
当前环境下,为创建不同类型真实DOM节点定义了不同的方法
- createElement ——元素
- createTextNode ——文本
- createComment ——注释
判断vnode的类型,并使用各自的方法创建真实的DOM节点,最后将DOM节点插入到指定的父节点中(parentNode.appendChild)。
元素通常还有子节点,创建子节点是一个递归的过程,vnode的children属性保存了所有子vnode。
删除废弃的节点
removeVnodes用于删除vnode数组中从startIdx到endIdx的一组节点。这里精简了源码
function removeVnodes (vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
var ch = vnodes[startIdx];
if (isDef(ch)) {
removeNode(ch.elm);
}
}
}
var nodeOps = {
removeChild: removeChild,
appendChild: appendChild,
parentNode: parentNode
}
function removeNode (el) {
var parent = nodeOps.parentNode(el);
// element may have already been removed due to v-html / v-text
if (isDef(parent)) {
nodeOps.removeChild(parent, el);
}
}
修改需要更新的节点
如果节点是静态节点,则直接跳过更新步骤。例如这样的
<p>我是静态节点,不需要发生变化</p>
我们以新的虚拟节点vnode为标准,分以下几种情况,(注意前提是新旧vnode是相同的节点类型或者标签一样):
- vnode有文本属性,则无论oldVnode子节点是什么,直接setTextContent方法更新文本
- vnode是无children的元素,则oldVnode中无论有子节点还是文本,直接都删除即可。
- vnode是有children的元素
- oldVnode没有children,那么oldVnode要么是空标签,要么有文本子节点,则直接清空,并将vnode中的children直接插入到视图中。
- oldVnode有children,则需要详细的对比,可能会新增、删除、或者移动。
在源码中,真实的过程如下图所示:
这里主要讨论最后一种,当newChildren和oldChildren都存在时的情况。
更新策略
对比两个子节点列表,首先要循环newChildren,每循环到一个新的子节点,就去oldChildren中找到相同的旧子节点,
- 找不到,则新增子节点,并插入到oldChildren中所有未处理节点对应的DOM前面。
看下面这张图:
- 找到,并且位置相同。直接更新DOM
- 找到,但是位置变了。这时需要移动子节点(Node.insertBefore()),只需要将节点移动到所有未处理节点对应DOM的最前面。
看下面这张图
当newChildren循环结束后,如果oldChildren中还剩下没有处理的节点,那就删除这些节点即可。
vue中虚拟DOM的优化
新旧节点都是拥有多个子节点的节点时,这里的diff操作,是框架之间优化点的区别之处,也是影响性能的关键之处。
具体思路
最直接的对比差异的方法时嵌套两层循环,但是针对某些简单的数据操作,可能不需要循环就知道哪个节点被修改了。
Vue的优化策略是:
尝试几种可能的变化,快速查找差异,减少不必要的循环。
主要运用了4中查找方式,并按从上到下的顺序查找:
- 新前与旧前
- 新后与旧后
- 新后与旧前
- 新前与旧后
- 新前:newChildren中所有未处理的第一个节点
- 新后:newChildren中所有未处理的最后一个节点
- 旧前:oldChildren中所有未处理的第一个节点
- 旧后:oldChildren中所有未处理的最后一个节点
你会发现,循环对比都是针对“未处理节点”的
通过定义四个变量oldStartIdx、oldEndIdx、newStartIdx、newEndIdx来指向oldChildren和newChildren的开始位置下标和结束位置下标。每处理一个节点,就将下标向前或向后移动一个位置。
那么,通过如下条件可以确保循环体内的节点都是未处理的。
while(oldStartIdx <= oldChildren && newStartIdx <= newEndIdx){
//...
}
只要oldChildren或者newChildren中的一个循环完毕,循环就结束。
- 如果oldChildren先循环结束,那么newChildren中还剩的节点都是需要新增的节点,直接插入DOM就行。
- 如果newChildren先循环结束,那么oldChildren中还剩的节点都是需要删除的节点,直接从DOM中移除。
由此可以看出,这种优化手段减少了循环次数,提升了性能。
具体实现
这里将源码精简,主要知晓其实现的过程,细节可以直接看源码。
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) {
// ... 变量定义
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 新前与旧前是同一个节点
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 新后与旧后是同一个节点
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 新后与旧前是同一个节点
// 将节点移动到所有未处理节点的最后面,即nodeOps.nextSibling(oldEndVnode.elm的前面
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 新前与旧后是同一个节点
// 将节点移动到所有未处理节点的前面,也就是oldStartVnode.elm的前面
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
//如果以上四种情况都不是,那么根据key进行查找,所以我们在列表中设置key是一种最佳实战
if (isUndef(oldKeyToIdx)) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
if (isUndef(idxInOld)) {
// 1.如果未找到相同key,则新增子节点
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
} else {
// 2.找到后,对比是否为相同类型的节点或标签
vnodeToMove = oldCh[idxInOld];
if (sameVnode(vnodeToMove, newStartVnode)) {
// 相同则更新,并对比位置进行移动,移动到所有未处理节点DOM的前面
// 为了防止重复处理同一个节点,将oldChildren中对应的该节点设置为undefined,用来标记该节点已处理
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
oldCh[idxInOld] = undefined;
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
} else {
// 相同key,但是不同类型或者标签,则直接新增节点
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
}
}
newStartVnode = newCh[++newStartIdx];
}
}
// 循环结束,创建newChildren中剩余的节点,或者删除oldChildren中剩余的节点
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(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
最后,简单梳理下这四种查找的原理:
新前与旧前
通过对比新前和旧前两个节点,
- 如果相同,直接进行更新操作。将新旧start位置下标向后移动一个位置,进入下一个循环
- 如果不同,执行新后与旧后对比
新后与旧后
通过对比新后和旧后两个节点
- 如果相同,直接进行更新操作。将新旧end位置下标向前移动一个位置,进入下一个循环
- 如果不同,执行新后与旧前对比
新后与旧前
通过对比新后和旧前两个节点
- 如果相同,直接进行更新操作。并将旧前对应的DOM移动到所有未处理节点最后面(可以根据oldEndIdx来找到最后一个未处理节点对应的DOM),将新后位置下标向前移动一个位置,旧前位置下标向后移动一个位置,进入下一个循环
- 如果不同,执行新前与旧后对比
新前与旧后
通过对比新前与旧后前两个节点
- 如果相同,直接进行更新操作。并将旧后对应的DOM移动到所有未处理节点最前面(可以根据oldStartIdx来找到最前一个未处理节点对应的DOM),将旧后位置下标向前移动一个位置,新前位置下标向后移动一个位置,进入下一个循环
- 如果不同,执行key比较
更多推荐
所有评论(0)