以下代码和分析过程需要结合vue.js源码查看,通过打断点逐一比对。
模板代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="./../../oldVue.js"></script>
</head>
<body>
<div id="app">
<div>
<div>
<div @click='updateNode'>更新</div>
<span v-if='oldVal'>更换这里</Span>
</div>
<span v-if='newVal'>更换这里</span>
</div>
</div>
<script>
var app = new Vue({
el: '#app',
beforeCreate() { },
created() { },
beforeMount() { },
mounted: () => { },
beforeUpdate() { },
updated() { },
beforeDestroy() { },
destroyed() { },
data: function () {
return {
oldVal: true,
newVal: false,
}
},
methods: {
updateNode() {
this.oldVal = false
this.newVal = true
}
}
})
</script>
</body>
</html>
前言
本文的结构依据点,线,面来展开。
- 点即函数的作用
- 线即函数的执行流程
- 面即源码的详细解读
十分不建议直接看源码,很多函数非常长,并且链路很长,在没有对函数有大概的了解情况,大概率下,你读了一遍源码后会发现,wc 我刚看了源码了吗?可是咋记不清它们做了啥操作。因此,先看作用,再看流程,再展开看源码。
1. withMacroTask
作用:
当我们点击更新去更新节点时,就会走到这里。
源码:
function withMacroTask(fn) {
//宏任务
return fn._withTask || (fn._withTask = function () {
useMacroTask = true;
var res = fn.apply(null, arguments);
useMacroTask = false;
return res
})
}
function flushCallbacks() {
pending = false;
//.slice(0) 浅拷贝
var copies = callbacks.slice(0);
callbacks.length = 0;
for (var i = 0; i < copies.length; i++) {
//执行回调函数
copies[i]();
}
}
2. mountComponent
作用:
接着就会走到这里。
源码:
function mountComponent(
vm, //Vue 实例
el, //真实dom
hydrating //新的虚拟dom vonde
) {
vm.$el = el;
if (!vm.$options.render) {
vm.$options.render = createEmptyVNode;
...
//如果参数中的template模板第一个不为# 号则会 警告,
// 因为Runtime only版本的代码是这种格式:el: '#app'
//无法装载组件:未定义template或render函数
}
callHook(vm, 'beforeMount');
var updateComponent;
if ("development" !== 'production' && config.performance && mark) {
updateComponent = function () {
var name = vm._name;
var id = vm._uid;
var startTag = "vue-perf-start:" + id;
var endTag = "vue-perf-end:" + id;
mark(startTag); //插入一个名称 并且记录插入名称的时间
var vnode = vm._render();
mark(endTag);
measure(("vue " + name + " render"), startTag, endTag);
mark(startTag); //浏览器 性能时间戳监听
//更新组件
vm._update(vnode, hydrating);
mark(endTag);
measure(("vue " + name + " patch"), startTag, endTag);
};
} else {
// 会走到这里----------
updateComponent = function () {
//直接更新view试图
vm._update(
vm._render(),
hydrating
);
};
}
}
3. lifecycleMixin
作用:
接着就会走到这里,最终走到vm.$el = vm.__patch__(prevVnode, vnode);
源码:
function lifecycleMixin(Vue) {
// 更新数据函数,负责更新页面,页面首次渲染和后续更新的入口位置,也是 patch 的入口位置
Vue.prototype._update = function (vnode, hydrating) {
debugger
var vm = this;
// 是否 触发过 钩子Mounted
// Todo vm._isMounte表示什么
if (vm._isMounted) {
//触发更新数据 触发生命周期函数
callHook(vm, 'beforeUpdate');
}
// 标志上一个 el 节点
var prevEl = vm.$el;
// 标志上一个 vonde
var prevVnode = vm._vnode;
// 活动实例
var prevActiveInstance = activeInstance;
activeInstance = vm;
//标志上一个 vonde
vm._vnode = vnode;
// 【逻辑 1】 执行初始化
//如果不存在表示上一次没有创建过vnode,即当前是初始化,第一次进来
if (!prevVnode) {
// 这里通过patch函数,是创建真实dom
// 注意,patch在vue中非常核心,我们将在后面用单独文章来解析
debugger
vm.$el = vm.__patch__(
vm.$el, //真正的dom
vnode, //vnode
hydrating, // 空
false /* removeOnly */,
vm.$options._parentElm, //父节点 空
vm.$options._refElm //当前节点 空
);
// 初始补丁之后不需要ref节点,这可以防止在内存中保留分离的DOM树
vm.$options._parentElm = vm.$options._refElm = null;
}
// 【逻辑 2】 执行数据更新
else {
// 如果这个prevVnode存在,表示vno的已经创建过,只是更新数据而已
// 比较新旧节点,生成新的dom
//-------重点就是这里了
debugger
vm.$el = vm.__patch__(prevVnode, vnode);
}
// 更新全局的activeInstance原来的旧值
activeInstance = prevActiveInstance;
// 更新vue参考
if (prevEl) {
prevEl.__vue__ = null;
}
if (vm.$el) { // 更新真实dom上对虚拟dom的指向
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.
};
... // 省略其它代码
}
4. createPatchFunction
作用:
接着就会走到这里,最终走到vm.$el = vm.__patch__(prevVnode, vnode);
执行流程:
- vnode和oldVnod有一个不存在,则销毁老节点或者创建新节点
- 剩下的逻辑为vnode和oldVnode都存在
- 如果oldVnode不是真实dom,且2个节点的基本属性相同,那么就进入了2个节点的【diff过程】,用patchVnode函数进行后续比对工作
- 如果oldVnode是真实dom,并且服务端渲染元素或者合并到真实DOM失败,则创建一个空的Vnode节点去替换它
- 如果老节点是真实
DOM,创建对应的vnode节点 - 为新的
Vnode创建元素/组件实例,若parentElm存在,则插入到父元素上 - 如果组件根节点被替换,遍历更新父节点
elm - 然后移除老节点
- 如果老节点是真实
- 返回vnode.elm 源码:
function createPatchFunction(backend) {
...//省略多行代码
// 最终走到这里
return function patch(
oldVnode, //旧的vonde或者是真实的dom. 或者是没有
vnode, //新的vode
hydrating,
removeOnly, //是否要全部删除标志
parentElm, //父节点 真实的dom
refElm//当前节点 真实的dom
) {
debugger
;
// vnode和oldVnod有一个不存在--------------------
/**
* 如果vnode不存在,但是oldVnode存在,说明意图是要【销毁老节点】,
* 那么就调用invokeDestroyHook(oldVnode)来进行销毁
*/
if (isUndef(vnode)) {
if (isDef(oldVnode)) {
invokeDestroyHook(oldVnode);
}
return
}
var isInitialPatch = false;
/**
vonde队列 如果vnode上有insert钩子,那么就将这个vnode放入
insertedVnodeQueue中作记录,到时再在全局批量调用insert钩子回调
*/
var insertedVnodeQueue = [];
//如果没有定义旧的vonde, 则当前操作为【创建新的节点】
if (isUndef(oldVnode)) {
isInitialPatch = true;
createElm( //创建节点
vnode, //虚拟dom
insertedVnodeQueue, //vonde队列空数组
parentElm, //真实的 父节点
refElm //当前节点
);
}
// 剩余情况为vnode和oldVnode都存在--------------------
else {
var isRealElement = isDef(oldVnode.nodeType); //判断是否为真是dom
/**
如果oldVnode不是真实dom,且2个节点的基本属性相同,
那么就进入了2个节点的【diff过程】,用patchVnode函数进行后续比对工作
*/
if (!isRealElement &&
sameVnode(oldVnode, vnode)
) {
patchVnode(
oldVnode,
vnode,
insertedVnodeQueue, //vonde队列
removeOnly //是否要全部删除标志
);
} else {
//oldVnode是真实dom
if (isRealElement) {
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
// 当旧的Vnode是服务端渲染元素,hydrating记为true
oldVnode.removeAttribute(SSR_ATTR)
hydrating = true
}
// 需要用hydrate函数将虚拟DOM和真实DOM进行映射
if (isTrue(hydrating)) {
// 需要合并到真实DOM上
if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
// 调用insert钩子
invokeInsertHook(vnode, insertedVnodeQueue, true)
return oldVnode
} else if (process.env.NODE_ENV !== 'production') {
...
//客户端呈现的虚拟DOM树不匹配服务器呈现的内容,可能由于标签缺失
}
}
// 如果不是服务端渲染元素或者合并到真实DOM失败,则创建一个空的Vnode节点去替换它
oldVnode = emptyNodeAt(oldVnode)
}
// 获取oldVnode父节点
var oldElm = oldVnode.elm;
var parentElm$1 = nodeOps.parentNode(oldElm);
// 根据vnode创建一个真实DOM节点并挂载至oldVnode的父节点下
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm$1,
nodeOps.nextSibling(oldElm)
);
// 如果组件根节点被替换,遍历更新父节点Element
if (isDef(vnode.parent)) {
var ancestor = vnode.parent;
var patchable = isPatchable(vnode);
while (ancestor) {
for (var i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor);
}
ancestor.elm = vnode.elm;
if (patchable) {
for (var i$1 = 0; i$1 < cbs.create.length; ++i$1) {
cbs.create[i$1](emptyNode, ancestor);
}
var insert = ancestor.data.hook.insert;
if (insert.merged) {
for (var i$2 = 1; i$2 < insert.fns.length; i$2++) {
insert.fns[i$2]();
}
}
} else {
registerRef(ancestor);
}
ancestor = ancestor.parent;
}
}
// 销毁旧节点
if (isDef(parentElm)) {
// 移除老节点
removeVnodes(parentElm, [oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
// 调用destroy钩子
invokeDestroyHook(oldVnode)
}
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
return vnode.elm
}
}
5.sameNode
源码:
function sameVnode (a, b) { // 是否是相同的VNode节点
return (
a.key === b.key && ( // 如平时v-for内写的key
(
a.tag === b.tag && // tag相同
a.isComment === b.isComment && // 注释节点
isDef(a.data) === isDef(b.data) && // 都有data属性
sameInputType(a, b) // 相同的input类型
) || (
isTrue(a.isAsyncPlaceholder) && // 是异步占位符节点
a.asyncFactory === b.asyncFactory && // 异步工厂方法
isUndef(b.asyncFactory.error)
)
)
)
}
6. updateChildren
执行流程:
如何 循环遍历?
1、使用 while
2、新旧节点数组都配置首尾两个索引
新节点的两个索引:newStartIdx , newEndIdx
旧节点的两个索引:oldStartIdx,oldEndIdx
以两边向中间包围的形式 来进行遍历
头部的子节点比较完毕,startIdx 就加1
尾部的子节点比较完毕,endIdex 就减1
只要其中一个数组遍历完(startIdx<endIdx),则结束遍历
源码处理的流程分为两个
1、比较新旧子节点
2、比较完毕,处理剩下的节点
源码:
function updateChildren(parentElm, oldCh, newCh) {
var oldStartIdx = 0;
var oldEndIdx = oldCh.length - 1;
var oldStartVnode = oldCh[0];
var oldEndVnode = oldCh[oldEndIdx];
var newStartIdx = 0;
var newEndIdx = newCh.length - 1;
var newStartVnode = newCh[0];
var newEndVnode = newCh[newEndIdx];
var oldKeyToIdx, idxInOld, vnodeToMove, refElm;
// 不断地更新 OldIndex 和 OldVnode ,newIndex 和 newVnode
while (
oldStartIdx <= oldEndIdx &&
newStartIdx <= newEndIdx
) {
if (!oldStartVnode) {
oldStartVnode = oldCh[++oldStartIdx];
}
else if (!oldEndVnode) {
oldEndVnode = oldCh[--oldEndIdx];
}
// 旧头 和新头 比较
else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
}
// 旧尾 和新尾 比较
else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
}
// 旧头 和 新尾 比较
else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode);
// oldStartVnode 放到 oldEndVnode 后面,还要找到 oldEndValue 后面的节点
parentElm.insertBefore(
oldStartVnode.elm,
oldEndVnode.elm.nextSibling
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
}
// 旧尾 和新头 比较
else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode);
// oldEndVnode 放到 oldStartVnode 前面
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
// 单个新子节点 在 旧子节点数组中 查找位置
else {
// oldKeyToIdx 是一个 把 Vnode 的 key 和 index 转换的 map
if (!oldKeyToIdx) {
oldKeyToIdx = createKeyToOldIdx(
oldCh, oldStartIdx, oldEndIdx
);
}
// 使用 newStartVnode 去 OldMap 中寻找 相同节点,默认key存在
idxInOld = oldKeyToIdx[newStartVnode.key]
// 新孩子中,存在一个新节点,老节点中没有,需要新建
if (!idxInOld) {
// 把 newStartVnode 插入 oldStartVnode 的前面
createElm(
newStartVnode,
parentElm,
oldStartVnode.elm
);
}
else {
// 找到 oldCh 中 和 newStartVnode 一样的节点
vnodeToMove = oldCh[idxInOld];
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode);
// 删除这个 index
oldCh[idxInOld] = undefined;
// 把 vnodeToMove 移动到 oldStartVnode 前面
parentElm.insertBefore(
vnodeToMove.elm,
oldStartVnode.elm
);
}
// 只能创建一个新节点插入到 parentElm 的子节点中
else {
// same key but different element. treat as new element
createElm(
newStartVnode,
parentElm,
oldStartVnode.elm
);
}
}
// 这个新子节点更新完毕,更新 newStartIdx,开始比较下一个
newStartVnode = newCh[++newStartIdx];
}
}
// 处理剩下的节点
if (oldStartIdx > oldEndIdx) {
var newEnd = newCh[newEndIdx + 1]
refElm = newEnd ? newEnd.elm :null;
for (; newStartIdx <= newEndIdx; ++newStartIdx) {
createElm(
newCh[newStartIdx], parentElm, refElm
);
}
}
// 说明新节点比对完了,老节点可能还有,需要删除剩余的老节点
else if (newStartIdx > newEndIdx) {
for (; oldStartIdx<=oldEndIdx; ++oldStartIdx) {
oldCh[oldStartIdx].parentNode.removeChild(el);
}
}
}
源码提问
1. diff算法的时间复杂度
两个树完全diff算法的时间复杂度为O(n3),Vue进行了优化,只考虑同级不考虑跨级,将时间复杂度降为O(n)
前端当中,很少会跨层级的移动Dom元素,所以Virtual Dom只会对同一个层级的元素进行对比
2. 简述vue中diff算法原理
- 先同级比较,再比较儿子节点
- 先判断一方有儿子一方没儿子的情况
- 比较都有儿子的情况
- 递归比较子节点
vue3中做了优化,只比较动态节点,略过静态节点,极大的提高了效率
双指针去确定位置
diff算法做的事情是比较VNode和oldVNode,再以VNode为标准的情况下在oldVNode上做小的改动,完成VNode对应的Dom渲染。
回到之前_update方法的实现,这个时候就会走到else的逻辑了:
Vue.prototype._update = function(vnode) {
const vm = this
const prevVnode = vm._vnode // 缓存为之前vnode
vm._vnode = vnode //挂载新的vnode
if(!prevVnode) { // 首次渲染(没有旧节点),只要传入新的vnode
vm.$el = vm.__patch__(vm.$el, vnode)
} else { // 重新渲染,需要传入旧的vnode和新vnode,因为它需要进行对比
vm.$el = vm.__patch__(prevVnode, vnode)
}
}
既然是在现有的VNode上修修补补来达到重新渲染的目的,所以无非是做三件事情:
创建新增节点,删除废弃节点,更新已有节点
2-1. 创建新增节点
新增节点两种情况下会遇到:
-
VNode
中有的节点而oldVNode`没有VNode中有的节点而oldVNode中没有,最明显的场景就是首次渲染了,这个时候是没有oldVNode的,所以将整个VNode渲染为真实Dom插入到根节点之内即可。
-
VNode和oldVNode完全不同- 当
VNode和oldVNode不是同一个节点时,直接会将VNode创建为真实Dom,插入到旧节点的后面,这个时候旧节点就变成了废弃节点,移除以完成替换过程。
- 当
判断两个节点是否为同一个节点,内部是这样定义的:
function sameVnode (a, b) { // 是否是相同的VNode节点
return (
a.key === b.key && ( // 如平时v-for内写的key
(
a.tag === b.tag && // tag相同
a.isComment === b.isComment && // 注释节点
isDef(a.data) === isDef(b.data) && // 都有data属性
sameInputType(a, b) // 相同的input类型
) || (
isTrue(a.isAsyncPlaceholder) && // 是异步占位符节点
a.asyncFactory === b.asyncFactory && // 异步工厂方法
isUndef(b.asyncFactory.error)
)
)
)
}
2-2. 删除废弃节点
上面创建新增节点的第二种情况以略有提及,比较vnode和oldVnode,如果根节点不相同就将Vnode整颗渲染为真实Dom,插入到旧节点的后面,最后删除掉已经废弃的旧节点即可:
在patch方法内将创建好的Dom插入到废弃节点后面之后:
if (isDef(parentElm)) { // 在它们的父节点内删除旧节点
removeVnodes(parentElm, [oldVnode], 0, 0)
}
-------------------------------------------------------------
function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
removeNode(ch.elm)
}
}
} // 移除从startIdx到endIdx之间的内容
------------------------------------------------------------
function removeNode(el) { // 单个节点移除
const parent = nodeOps.parentNode(el)
if(isDef(parent)) {
nodeOps.removeChild(parent, el)
}
}
2-3. 更新已有节点
这个才是diff算法的重点,当两个节点是相同的节点时,这个时候就需要找出它们的不同之处,比较它们主要是使用patchVnode方法,这个方法里面主要也是处理几种分支情况:
2-3-1. 都是静态节点
function patchVnode(oldVnode, vnode) {
if (oldVnode === vnode) { // 完全一样
return
}
const elm = vnode.elm = oldVnode.elm
if(isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic)) {
vnode.componentInstance = oldVnode.componentInstance
return // 都是静态节点,跳过
}
...
}
什么是静态节点了?这是编译阶段做的事情,它会找出模板中的静态节点并做上标记(isStatic为true),例如:
<template>
<div>
<h2>{{title}}</h2>
<p>新鲜食材</p>
</div>
</template>
这里的h2标签就不是静态节点,因为是根据插值变化的,而p标签就是静态节点,因为不会改变。如果都是静态节点就跳过这次比较,这也是编译阶段为diff比对做的优化。
2-3-2. vnode节点没文本属性
function patchVnode(oldVnode, vnode) {
const elm = vnode.elm = oldVnode.elm
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) { // vnode没有text属性
if (isDef(oldCh) && isDef(ch)) { // // 都有children
if (oldCh !== ch) { // 且children不同
updateChildren(elm, oldCh, ch) // 更新子节点
}
}
else if (isDef(ch)) { // 只有vnode有children
if (isDef(oldVnode.text)) { // oldVnode有文本节点
nodeOps.setTextContent(elm, '') // 设置oldVnode文本为空
}
addVnodes(elm, null, ch, 0, ch.length - 1)
// 往oldVnode空的标签内插入vnode的children的真实dom
}
else if (isDef(oldCh)) { // 只有oldVnode有children
removeVnodes(elm, oldCh, 0, oldCh.length - 1) // 全部移除
}
else if (isDef(oldVnode.text)) { // oldVnode有文本节点
nodeOps.setTextContent(elm, '') // 设置为空
}
}
else { vnode有text属性
...
}
...
如果vnode没有文本节点,又会有接下来的四个分支:
1. 都有children且不相同
- 使用
updateChildren方法更详细的比对它们的children,如果说更新已有节点是patch的核心,那这里的更新children就是核心中的核心,这个之后使用流程图的方式仔仔细细说明。
2. 只有vnode有children
- 那这里的
oldVnode要么是一个空标签或者是文本节点,如果是文本节点就清空文本节点,然后将vnode的children创建为真实Dom后插入到空标签内。
3. 只有oldVnode有children
- 因为是以
vnode为标准的,所以vnode没有的东西,oldVnode内就是废弃节点,需要删除掉。
4. 只有oldVnode有文本
- 只要是
oldVnode有而vnode没有的,清空或移除即可。
2-3-3. vnode节点有文本属性
function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
const elm = vnode.elm = oldVnode.elm
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) { // vnode没有text属性
...
} else if(oldVnode.text !== vnode.text) { // vnode有text属性且不同
nodeOps.setTextContent(elm, vnode.text) // 设置文本
}
...
还是那句话,以vnode为标准,所以vnode有文本节点的话,无论oldVnode是什么类型节点,直接设置为vnode内的文本即可。至此,整个diff比对的大致过程就算是说明完毕了,我们还是以一张流程图来理清思路:
2-3-4. 更新已有节点之更新子节点 (重点中的重点)
更新子节点示例:
<template>
<ul>
<li v-for='item in list' :key='item.id'>{{item.name}}</li>
</ul>
</template>
export default {
data() {
return {
list: [{
id: 'a1',name: 'A'}, {
id: 'b2',name: 'B'}, {
id: 'c3',name: 'C'}, {
id: 'd4',name: 'D'}
]
}
},
mounted() {
setTimeout(() => {
this.list.sort(() => Math.random() - .5)
.unshift({id: 'e5', name: 'E'})
}, 1000)
}
}
上述代码中首先渲染一个列表,然后将其随机打乱顺序后并添加一项到列表最前面,这个时候就会触发该组件更新子节点的逻辑,之前也会有一些其他的逻辑,这里只用关注更新子节点相关,来看下它怎么更新Dom的:
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0 // 旧第一个下标
let oldStartVnode = oldCh[0] // 旧第一个节点
let oldEndIdx = oldCh.length - 1 // 旧最后下标
let oldEndVnode = oldCh[oldEndIdx] // 旧最后节点
let newStartIdx = 0 // 新第一个下标
let newStartVnode = newCh[0] // 新第一个节点
let newEndIdx = newCh.length - 1 // 新最后下标
let newEndVnode = newCh[newEndIdx] // 新最后节点
let oldKeyToIdx // 旧节点key和下标的对象集合
let idxInOld // 新节点key在旧节点key集合里的下标
let vnodeToMove // idxInOld对应的旧节点
let refElm // 参考节点
checkDuplicateKeys(newCh) // 检测newVnode的key是否有重复
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 开始遍历children
if (isUndef(oldStartVnode)) { // 跳过因位移留下的undefined
oldStartVnode = oldCh[++oldStartIdx]
} else if (isUndef(oldEndVnode)) { // 跳过因位移留下的undefine
oldEndVnode = oldCh[--oldEndIdx]
}
else if(sameVnode(oldStartVnode, newStartVnode)) { // 比对新第一和旧第一节点
patchVnode(oldStartVnode, newStartVnode) // 递归调用
oldStartVnode = oldCh[++oldStartIdx] // 旧第一节点和下表重新标记后移
newStartVnode = newCh[++newStartIdx] // 新第一节点和下表重新标记后移
}
else if (sameVnode(oldEndVnode, newEndVnode)) { // 比对旧最后和新最后节点
patchVnode(oldEndVnode, newEndVnode) // 递归调用
oldEndVnode = oldCh[--oldEndIdx] // 旧最后节点和下表重新标记前移
newEndVnode = newCh[--newEndIdx] // 新最后节点和下表重新标记前移
}
else if (sameVnode(oldStartVnode, newEndVnode)) { // 比对旧第一和新最后节点
patchVnode(oldStartVnode, newEndVnode) // 递归调用
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
// 将旧第一节点右移到最后,视图立刻呈现
oldStartVnode = oldCh[++oldStartIdx] // 旧开始节点被处理,旧开始节点为第二个
newEndVnode = newCh[--newEndIdx] // 新最后节点被处理,新最后节点为倒数第二个
}
else if (sameVnode(oldEndVnode, newStartVnode)) { // 比对旧最后和新第一节点
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) // 递归调用
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
// 将旧最后节点左移到最前面,视图立刻呈现
oldEndVnode = oldCh[--oldEndIdx] // 旧最后节点被处理,旧最后节点为倒数第二个
newStartVnode = newCh[++newStartIdx] // 新第一节点被处理,新第一节点为第二个
}
else { // 不包括以上四种快捷比对方式
if (isUndef(oldKeyToIdx)) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 获取旧开始到结束节点的key和下表集合
}
idxInOld = isDef(newStartVnode.key) // 获取新节点key在旧节点key集合里的下标
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // 找不到对应的下标,表示新节点是新增的,需要创建新dom
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
)
}
else { // 能找到对应的下标,表示是已有的节点,移动位置即可
vnodeToMove = oldCh[idxInOld] // 获取对应已有的旧节点
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
}
newStartVnode = newCh[++newStartIdx] // 新开始下标和节点更新为第二个节点
}
}
...
}
函数内首先会定义一堆let定义的变量,这些变量是随着while循环体而改变当前值的,循环的退出条件为只要新旧节点列表有一个处理完就退出,看着循环体代码挺复杂,其实它只是做了三件事,明白了哪三件事再看循环体,会发现其实并不复杂:
2-3-4-1. 跳过undefined
为什么会有undefined,之后的流程图会说明清楚。这里只要记住,如果旧开始节点为undefined,就后移一位;如果旧结束节点为undefined,就前移一位。
2-3-4-2. 快捷查找
首先会尝试四种快速查找的方式,如果不匹配,再做进一步处理:
- 1 新开始和旧开始节点比对
如果匹配,表示它们位置都是对的,Dom不用改,就将新旧节点开始的下标往后移一位即可。
- 2 旧结束和新结束节点比对
如果匹配,也表示它们位置是对的,Dom不用改,就将新旧节点结束的下标前移一位即可。
- 3 旧开始和新结束节点比对
如果匹配,位置不对需要更新Dom视图,将旧开始节点对应的真实Dom插入到最后一位,旧开始节点下标后移一位,新结束节点下标前移一位。
- 4 旧结束和新开始节点比对
如果匹配,位置不对需要更新Dom视图,将旧结束节点对应的真实Dom插入到旧开始节点对应真实Dom的前面,旧结束节点下标前移一位,新开始节点下标后移一位。
2-3-4-3. key值查找
-
- 如果和已有key值匹配
那就说明是已有的节点,只是位置不对,那就移动节点位置即可。
-
- 如果和已有key值不匹配
在已有的key值集合内找不到,那就说明是新的节点,那就创建一个对应的真实Dom节点,插入到旧开始节点对应的真实Dom前面即可。
这么说并不太好理解,结合之前的示例,根据以下的流程图将会明白很多:
↑ 示例的初始状态就是这样了,之前定义的下标以及对应的节点就是start和end标记。
↑ 首先进行之前说明两两四次的快捷比对,找不到后通过旧节点的key值列表查找,并没有找到说明E是新增的节点,创建对应的真实Dom,插入到旧节点里start对应真实Dom的前面,也就是A的前面,已经处理完了一个,新start位置后移一位。
↑ 接着开始处理第二个,还是首先进行快捷查找,没有后进行key值列表查找。发现是已有的节点,只是位置不对,那么进行插入操作,参考节点还是A节点,将原来旧节点C设置为undefined,这里之后会跳过它。又处理完了一个节点,新start后移一位。
↑ 再处理第三个节点,通过快捷查找找到了,是新开始节点对应旧开始节点,Dom位置是对的,新start和旧start都后移一位。
↑ 接着处理的第四个节点,通过快捷查找,这个时候先满足了旧开始节点和新结束节点的匹配,Dom位置是不对的,插入节点到最后位置,最后将新end前移一位,旧start后移一位。
↑ 处理最后一个节点,首先会执行跳过undefined的逻辑,然后再开始快捷比对,匹配到的是新开始节点和旧开始节点,它们各自start后移一位,这个时候就会跳出循环了。接着看下最后的收尾代码:
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0
...
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
...
}
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) // 删除废弃节点
}
}
我们之前的示例刚好是新旧节点列表同时处理完退出的循环,这里是退出循环后为还有没有处理完的节点,做不同的处理:
以新节点列表为标准,如果是新节点列表处理完,旧列表还有没被处理的废弃节点,删除即可;如果是旧节点先处理完,新列表里还有没被使用的节点,创建真实Dom并插入到视图即可。
3. v-for中为什么要用key
在diff比对内部做更新子节点时,会根据oldVnode内没有处理的节点得到一个key值和下标对应的对象集合,为的就是当处理vnode每一个节点时,能快速查找该节点是否是已有的节点,从而提高整个diff比对的性能。如果是一个动态列表,key值最好能保持唯一性,但像轮播图那种不会变更的列表,使用index也是没问题的。
4. Vue中常见性能优化
1、编码优化
- 不要将所有的数据都放到data中,data中的数据都会增加getter、setter,会收集对应的watcher
- vue在v-for时给每项元素绑定事件需要用事件代理
- SPA页面采用keep-alive缓存组件
- 拆分组件(提高复用性、增加代码的可维护性,减少不必要的渲染)
- v-if当值为false时,内部指令不会执行,具有阻断功能。很多情况下使用v-if替换v-show
- key保证唯一性(默认vue会采用就地复用策略)
- Object.freeze冻结数据
- 合理使用路由懒加载、异步组件
- 数据持久化的问题,防抖、节流
2、Vue加载性能优化
- 第三方模块按需导入(babel-plugin-component)
- 滚动到可视区域动态加载(tangbc.github.io/vue-virtual…
- 图片懒加载(github.com/hilongjw/vu…
3、用户体验
- app-skeleton骨架屏
- app shell app壳
- pwa
4、SEO优化
- 预渲染插件prerender-spa-plugin
- 服务端渲染ssr
5、打包优化
- 使用cdn的方式加载第三方模块
- 多线程打包happypack
- splitChunks抽离公共文件
- sourceMap生成
6、缓存压缩
- 客户端缓存、服务端缓存
- 服务端gzip压缩