Vue 在 2.x 版本开始,引入了一个非常核心的机制 – 虚拟dom。本文将简单的就下面几点进行分析:
- 什么是虚拟dom?
- Vue数据变动的时候,diff算法是如何运转的?
- 是否设置key对dom更新有什么区别?
一:什么是虚拟dom?
虚拟dom(vnode)是真实dom在js内存上的一种结构映射。比如我们在页面有这样一个真实的dom
<div id="a">
<p>test</p>
</div>
那在vue中,就会有这样一个结构和它对应(vue通过createElement来创建Vnode对象)
// @returns {VNode}
createElement(
'div',
{
id:"a"
},
[
createElement('p', 'test'),
]
)
Vnode的结构如下
我们可以看到一个多层嵌套的js object对象,里面包含了描述dom的完整信息。虚拟dom就是通过这样的结构来实现在数据发生改变的时候,通过在js运行内存中比对新旧虚拟dom,来精确的知道需要改变的是哪些节点,然后进行批量的dom更改。这样的机制带来了几个好处:
1.在复杂应用中,无需手动去一个个操作dom,简化了开发逻辑。用户的关注重点在数据层面。
2.精准的找到需要更改的点,避免了不必要的改动。通过一些复用机制避免在内存在重复的创建virtual dom和更新dom。
3.抽象的virtual dom可以兼容其他的ui平台,比如将virtual dom固化成类似于原生应用,比如weex。
二:Vue数据变动的时候,diff算法是如何运转的?
首先界面是响应数据更新,对于响应式不熟悉的可以看Vue 响应式原理
// 在dom第一次挂载的时候,会创建一个watcher方法,watcher的get函数是一个update操作
// 省略N行代码
} else {
updateComponent = function () {
vm._update(vm._render(), hydrating); // update操作在这里
};
}
// 创建一个watcher实例,第二个参数是对应watcher的expOrFn 这里是传入function,即watcher的getter对应该fuction,参考Watcher构造函数的实现。
new Watcher(vm, updateComponent, noop, {
before: function before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate');
}
}
}, true /* isRenderWatcher */);
当数据发生变化:
// vue响应式的核心代码
// 当我们改变数据的时候触发set,
// set会触发观察者的notify方法
Object.defineProperty(obj, key, {
get: function reactiveGetter () { // 省略N行 },
set: function reactiveSetter (newVal) {
// 省略N行
dep.notify(); // 通知更新
}
});
}
notify实现
// 更新
Dep.prototype.notify = function notify () {
// stabilize the subscriber list first
var subs = this.subs.slice();
if (!config.async) {
// subs aren't sorted in scheduler if not running async
// we need to sort them now to make sure they fire in correct
// order
subs.sort(function (a, b) { return a.id - b.id; });
}
for (var i = 0, l = subs.length; i < l; i++) {
// subs里是观察者watcher实例的集合
subs[i].update();
}
};
vue实例update方法
// 触发run或者queueWatcher方法
Watcher.prototype.update = function update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true;
} else if (this.sync) { // 判断是同步还是异步更新dom
this.run();
} else {
queueWatcher(this);
}
};
// 不管是同步更新还是异步更新,最终总会触发watcher的run方法
Watcher.prototype.run = function run () {
if (this.active) {
var value = this.get();// 触发更新
if (
value !== this.value ||
// Deep watchers and watchers on Object/Arrays should fire even
// when the value is the same, because the value may
// have mutated.
isObject(value) ||
this.deep
) {
// set new value
var oldValue = this.value;
this.value = value;
if (this.user) {
try {
this.cb.call(this.vm, value, oldValue);
} catch (e) {
handleError(e, this.vm, ("callback for watcher \"" + (this.expression) + "\""));
}
} else {
this.cb.call(this.vm, value, oldValue);
}
}
}
};
// watcher 的 get方法最终会调用之前设置的getter对应的function,参考文中第一个代码块
vm._update(vm._render(), hydrating); // update操作在这里
// 这是vue实例的更新方法
Vue.prototype._update = function (vnode, hydrating) {
var vm = this;
var prevEl = vm.$el;
var prevVnode = vm._vnode; // 获取更新前的旧vnode
var restoreActiveInstance = setActiveInstance(vm);
vm._vnode = vnode;
// Vue.prototype.__patch__ is injected in entry points
// based on the rendering backend used.
if (!prevVnode) {
// 如果没有旧node,直接取之前的dom,初始化页面时的root的el
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
} else {
// 更新旧节点
vm.$el = vm.__patch__(prevVnode, vnode);
}
restoreActiveInstance();
// update __vue__ reference
if (prevEl) {
prevEl.__vue__ = null;
}
if (vm.$el) {
// 更新$el 对应的新的vue实例
vm.$el.__vue__ = vm;
}
// if parent is an HOC, update its $el as well
if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
vm.$parent.$el = vm.$el;
}
// updated hook is called by the scheduler to ensure that children are
// updated in a parent's updated hook.
};
//其中的关键方法__patch__
Vue.prototype.__patch__ = inBrowser ? patch : noop;
var patch = createPatchFunction({ nodeOps: nodeOps, modules: modules });
// 开始进入diff算法的核心逻辑createPatchFunction
// 该函数返回一个patch函数,入参为旧的vnode和新的vnode
// 其他流程看代码中的中文注释
function createPatchFunction (backend) {
return function patch (oldVnode, vnode, hydrating, removeOnly) {
if (isUndef(vnode)) {
// 新dom是undefined 旧dom是有内容的,直接触发销毁
if (isDef(oldVnode)) { invokeDestroyHook(oldVnode); }
return
}
var isInitialPatch = false;
var insertedVnodeQueue = [];
if (isUndef(oldVnode)) {
// 如果旧dom不存在,直接用新的vdom创建新的dom
isInitialPatch = true;
createElm(vnode, insertedVnodeQueue);
} else {
var isRealElement = isDef(oldVnode.nodeType);
if (!isRealElement && sameVnode(oldVnode, vnode)) {
//如果不是真实dom,第二次数据更新,直接调用patchVnode
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
} else {
//第一次加载时候,挂载到真实的dom上
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 {
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);
}
// replacing existing element
var oldElm = oldVnode.elm;
var parentElm = nodeOps.parentNode(oldElm);
// 第一次渲染直接创建节点
createElm(
vnode,
insertedVnodeQueue,
// extremely rare edge case: do not insert if old element is in a
// leaving transition. Only happens when combining transition +
// keep-alive + HOCs. (#4590)
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
);
// 省略N行代码
return vnode.elm
}
}
如果存在老节点,更新老节点
// 更新新老节点
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
如果没有变化,直接返回
if (oldVnode === vnode) {
return
}
// 省略N行代码
var data = vnode.data;
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode);
}
var oldCh = oldVnode.children;
var 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)) {
{
checkDuplicateKeys(ch);
}
// 如果老的dom有文本内容,晴空文本内容
if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, ''); }
//如果新node有子节点,老的node没有,直接添加所有新的子节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 如何老node有子节点,新node没有,直接删除所有老的子节点。
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); }
}
}
如果新的和老的vdom 都有有子节点,更新子节点。进入diff处理流程
// 更新子节点
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
var oldStartIdx = 0;
var newStartIdx = 0;
var oldEndIdx = oldCh.length - 1;
var oldStartVnode = oldCh[0];
var oldEndVnode = oldCh[oldEndIdx];
var newEndIdx = newCh.length - 1;
var newStartVnode = newCh[0];
var newEndVnode = newCh[newEndIdx];
var oldKeyToIdx, idxInOld, vnodeToMove, refElm;
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
var canMove = !removeOnly;
{
checkDuplicateKeys(newCh);
}
// 快慢指针算法
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 见下面表格1
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 见下面表格2
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
// 见下面表格3
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
// 见下面表格4
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 如果上面的都不匹配,收集旧的key和索引的map关系
if (isUndef(oldKeyToIdx)) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); }
// 通过上面的map来找到新vnode的key对应老的vnode的索引位置
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
// 如果找不到老vnode的索引,说明是一个新dom,执行createElm
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
} else {
vnodeToMove = oldCh[idxInOld];
// 找到当前新vnode key对应的老vnode
// 判断newStartIds 对应的vnode是否可复用该老的vnode
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
oldCh[idxInOld] = undefined;
//调换dom位置
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
} else {
// 不能复用旧的直接创建dom
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
}
}
// newStartIdx 指针前移
newStartVnode = newCh[++newStartIdx];
}
}
// 如果oldStartIdx 大于oldEndIdx 说明旧节点遍历处理完毕,那么还未遍历到的新节点就是需要新增的节点
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
} else if (newStartIdx > newEndIdx) {
// 如果newStartIdx 比newEndIdx 大,说明新节点已经遍历结束,那么老节点里还没有遍历到的旧是需要删除的节点
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
虚拟dom的diff算法核心逻辑就是上面updateChildren函数的内容,其中主要有四种diff判断,在此画图描述:假设有4个旧的节点(虚拟dom节点),新的节点也是4个,diff算法会对这四个虚拟节点进行比对。
oldStartIdx,oldEndIdx,newStartIdx,newEndIdx分别是四个指针,初始的时候分别指向老新节点列表的首部和尾部,表格dom行对应的是虚拟dom对应的实际dom。示例如下表格:
1.第一种情况,新的start指针对应节点和旧的start指针对应节点是可复用节点,通过新的n1到老的n1, 将dom旧d1更改为新d1,两个首部指针向后移动。见表格1:
2.第二种情况,新的newEndIdx指针对应节点和旧的oldEndIdx指针对应的节点是可复用节点,通过新的n4 和旧的n4,将旧的dom 旧d4更新为新的dom 新d4,两个尾部指针向前移动,见表格2:
3.第三种情况,旧的oldStartIdx指针对应的节点和新的newEndIdx指针对应的节点是可复用节点,将旧的oldStartIdx对应节点的dom移动到旧的oldEndIdx对应节点dom的后面,oldStartIdx向后移动一位,newEndIdx向前移动一位(为了演示清晰,移除前两种情况的改动),见表格3:
4.第四种情况,旧节点的oldEndIdx指针对应的节点和新节点newStartIdx指针对应的节点是可复用节点,需要将旧oldEndIdx指针对应节点dom移动到旧节点oldStartIdx指针对应节点的dom。oldEndIdx 向前移动,newStartIdx向后移动,见表格4:
还有需要注意的是,虚拟dom的diff比较永远是同层级比较。从root向下逐层进行处理。
如何判断新旧节点是否是可以直接复用更新的节点?
1.key相同 (都是undefined 也算相同)
2.tag名相同
3.isComment属性值相同
4.同时都有或者没有data属性
5.是否动态占位符,且两个动态函数是一样的,且没有error处理函数。
如果是input类型节点,还需要判断
1.是否同时都有或者没有attrs属性
2.是否都是表单的常见的几种type类型(text,number,password,search,email,tel,url)
//
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
function sameInputType (a, b) {
if (a.tag !== 'input') { return true }
var i;
var typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type;
var typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type;
return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)
}
三:是否设置key对dom更新有什么区别?
1.首先了解下key的用法, 我们可以像绑定一个普通的属性一样绑定key,我们经常会在v-for循环中来使用。从而提高性能。
<div v-for="x in list" :key="x.a">{{x.a}}</div>
官方文档是这么说key的作用的:
key 的特殊属性主要用在 Vue 的虚拟 DOM 算法,在新旧 nodes 对比时辨识 VNodes。如果不使用 key,Vue 会使用一种最大限度减少动态元素并且尽可能的尝试修复/再利用相同类型元素的算法。使用 key,它会基于 key 的变化重新排列元素顺序,并且会移除 key 不存在的元素。
有相同父元素的子元素必须有独特的 key。重复的 key 会造成渲染错误。
它也可以用于强制替换元素/组件而不是重复使用它。当你遇到如下场景时它可能会很有用:
完整地触发组件的生命周期钩子
触发过渡
我们先分析第一种 如果不加key,vue的处理方式,以v-for的场景为例
1.vue会对子节点标准化
function getNormalizationType (
children,
maybeComponent
) {
var res = 0;
for (var i = 0; i < children.length; i++) {
var el = children[i];
if (el.type !== 1) {
continue
}
if (needsNormalization(el) ||
(el.ifConditions && el.ifConditions.some(function (c) { return needsNormalization(c.block); }))) {
res = 2;
break
}
if (maybeComponent(el) ||
(el.ifConditions && el.ifConditions.some(function (c) { return maybeComponent(c.block); }))) {
res = 1;
}
}
return res
}
通过这个函数判断标准化类型
var SIMPLE_NORMALIZE = 1;
var ALWAYS_NORMALIZE = 2;
简单的标准化只是对children进行了数组化
function simpleNormalizeChildren (children) {
for (var i = 0; i < children.length; i++) {
if (Array.isArray(children[i])) {
return Array.prototype.concat.apply([], children)
}
}
return children
}
always标准化方法,在这个方法里为v-for里没有设置key的元素设置了默认规则的key
function normalizeChildren (children) {
return isPrimitive(children)
? [createTextVNode(children)]
: Array.isArray(children)
? normalizeArrayChildren(children)
: undefined
}
function isTextNode (node) {
return isDef(node) && isDef(node.text) && isFalse(node.isComment)
}
function normalizeArrayChildren (children, nestedIndex) {
var res = [];
var i, c, lastIndex, last;
for (i = 0; i < children.length; i++) {
c = children[i];
if (isUndef(c) || typeof c === 'boolean') { continue }
lastIndex = res.length - 1;
last = res[lastIndex];
// nested
if (Array.isArray(c)) {
if (c.length > 0) {
c = normalizeArrayChildren(c, ((nestedIndex || '') + "_" + i));
// 忽略N行代码
// default key for nested array children (likely generated by v-for)
// 在这里判断是否是v-for list
// 如果是按照循环顺序定义一个自定义的key的值
if (isTrue(children._isVList) &&
isDef(c.tag) &&
isUndef(c.key) &&
isDef(nestedIndex)) {
c.key = "__vlist" + nestedIndex + "_" + i + "__"; // 拼接的key
}
res.push(c);
}
}
}
return res
}
所以在用户未设置key的时候,vue会按照dom子元素的情况,判断是否需要为其添加默认生成的key。虽然有了这个默认的key,但是因为只是按照默认在数组的索引值来生成的,所以没有办法达到精准的新节点替换对应之前的旧节点,而是总是死板的按照索引位置来复用节点,造成很多不必要的复用。
如果最终没有添加默认规则的key,key是undefined,下面代码中的a.key === b.key 就默认成立。所以只要后面的条件成立,就会被复用。
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
观察下面的场景
<input type="text" v-if="b"/>
<input type="text" v-else />
b的值初始为true,所以默认只会显示第一个input,当我们在第一个input里输入一些值,然后切换显示第二个input,上一个input里显示的值还会被保留。这个就是因为没有设置key,导致符合sameVnode的条件,vue就直接复用dom,只修改dom上显式定义的属性。value就被保留下来了。
需要注意:直接复用更新的dom是不会触发完整的钩子函数。如果你想默认触发钩子函数,一定要设置一个不同的key,保证更新的时候调用createElm创建新dom
function createElm (
vnode,
insertedVnodeQueue,
parentElm,
refElm,
nested,
ownerArray,
index
) {
if (isDef(vnode.elm) && isDef(ownerArray)) {
// This vnode was used in a previous render!
// now it's used as a new node, overwriting its elm would cause
// potential patch errors down the road when it's used as an insertion
// reference node. Instead, we clone the node on-demand before creating
// associated DOM element for it.
vnode = ownerArray[index] = cloneVNode(vnode);
}
vnode.isRootInsert = !nested; // for transition enter check
// 创建新的组件子节点dom
if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
return
}
var data = vnode.data;
var children = vnode.children;
var tag = vnode.tag;
if (isDef(tag)) {
{
if (data && data.pre) {
creatingElmInVPre++;
}
if (isUnknownElement$$1(vnode, creatingElmInVPre)) {
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 */
{
createChildren(vnode, children, insertedVnodeQueue);
if (isDef(data)) {
invokeCreateHooks(vnode, insertedVnodeQueue);
}
insert(parentElm, vnode.elm, refElm);
}
if (data && data.pre) {
creatingElmInVPre--;
}
} 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);
}
}