前言
在上一篇中我们整理了Vue2中页面渲染的主流程代码,并完成了页面渲染、依赖收集、页面更新的逻辑整理。但在其中我们对patch
只做了简略整理,但实际上它时Vue2的又一个核心。
在上一篇中我们的页面更新逻辑其实是粗暴地添加新节点,去除子节点。对于一个Vue2这样的优秀项目来说显然是不会如此简单的,在实际的实现中,这里应该经过diff算法优化:复用节点,以提升渲染性能。
这部分功能在patch
中实现,它的功能应该包含两块:
- 首次渲染,根据VNode生成真实元素
- 更新渲染,新老VNode对比再渲染
ini
function patch(oldVnode, vnode) {
const isRealElement = oldVnode.nodeType;
if (isRealElement) {
const elm = createElm(vnode);
const parentNode = oldVnode.parentNode;;
parentNode.insertBefore(elm, oldVnode.nextSibling);
parentNode.removeChild(oldVnode);
return elm;
} else {
if(!isSameVnode(oldVnode, vnode)){ // 不是相同节点,直接替换
return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
}
// 子节点比较
let oldChildren = oldVnode.children || {};
let newChildren = vnode.children || {};
}
}
diff的核心就在于子节点的对比,所以对于相同节点的比较、仅文本内容更新、属性更新的部分在这里先省略了。先专注于子节点比较这个核心需求:
老有新无
即老节点有子节点,新节点没有子节点的情况
ini
function patch(oldVnode, vnode) {
const isRealElement = oldVnode.nodeType;
if (isRealElement) {
const elm = createElm(vnode);
const parentNode = oldVnode.parentNode;;
parentNode.insertBefore(elm, oldVnode.nextSibling);
parentNode.removeChild(oldVnode);
return elm;
} else {
if(!isSameVnode(oldVnode, vnode)){
return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
}
// 子节点比较
let oldChildren = oldVnode.children || {};
let newChildren = vnode.children || {};
if (oldChildren.length > 0 && newChildren.length == 0) {
el.innerHTML = '';
}
}
}
老无新有
即老节点没有子节点,新节点有子节点的情况
ini
function patch(oldVnode, vnode) {
const isRealElement = oldVnode.nodeType;
if (isRealElement) {
const elm = createElm(vnode);
const parentNode = oldVnode.parentNode;;
parentNode.insertBefore(elm, oldVnode.nextSibling);
parentNode.removeChild(oldVnode);
return elm;
} else {
if(!isSameVnode(oldVnode, vnode)){
return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
}
// 子节点比较
let oldChildren = oldVnode.children || {};
let newChildren = vnode.children || {};
if (oldChildren.length > 0 && newChildren.length == 0) {
el.innerHTML = '';
} else if (oldChildren.length == 0 && newChildren.length > 0) {
newChildren.forEach((child)=>{
let childElm = createElm(child);
el.appendChild(childElm);
})
}
}
}
老有新有
以上两个情况都相对简单,所以难点其实是落在了新老节点都有子节点的情况中:
ini
function patch(oldVnode, vnode) {
const isRealElement = oldVnode.nodeType;
if (isRealElement) {
const elm = createElm(vnode);
const parentNode = oldVnode.parentNode;;
parentNode.insertBefore(elm, oldVnode.nextSibling);
parentNode.removeChild(oldVnode);
return elm;
} else {
if(!isSameVnode(oldVnode, vnode)){
return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
}
// 子节点比较
let oldChildren = oldVnode.children || {};
let newChildren = vnode.children || {};
if (oldChildren.length > 0 && newChildren.length == 0) {
el.innerHTML = '';
} else if (oldChildren.length == 0 && newChildren.length > 0) {
newChildren.forEach((child)=>{
let childElm = createElm(child);
el.appendChild(childElm);
})
} else {
updateChildren(el, oldChildren, newChildren);
}
}
}
updateChildren
的逻辑可以分成两大块:有序、无序,有序是指将新老子节点按照头对头、尾对尾、头对尾、尾对头几种方式来进行比较,无序则使用乱序比较。在做比较之前我们需要做一些铺垫工作:为新老子节点建立头尾指针。
ini
function updateChildren(el, oldChildren, newChildren) {
let oldStartIndex = 0
let oldStartVnode = oldChildren[oldStartIndex]
let oldEndIndex = oldChildren.length - 1
let oldEndVnode = oldChildren[oldEndIndex]
let newStartIndex = 0
let newStartVnode = newChildren[0]
let newEndIndex = newChildren.length - 1
let newEndVnode = newChildren[newEndIndex]
}
有序
头对头
scss
function updateChildren(el, oldChildren, newChildren) {
// ...
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
if (isSameVnode(oldStartVnode, newStartVnode)) { // 先进行头对头比较
patch(oldStartVnode, newStartVnode); // 调用patch递归处理节点,会判断进行当前文本内容更新、属性更新、子节点diff
oldStartVnode = oldChildren[++oldStartIndex]; // 更新 老子节点的头节点、头指针
newStartVnode = newChildren[++newStartIndex]; // 更新 新子节点的头节点、头指针
}
}
}
在这次比较中命中了"头对头"这一情况,所以移动的是新老子节点的头指针。
尾对尾
scss
function updateChildren(el, oldChildren, newChildren) {
// ...
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
if (isSameVnode(oldStartVnode, newStartVnode)) {
// ...
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
patch(oldEndVnode, newEndVnode);
oldEndVnode = oldChildren[--oldEndIndex]; // 更新 老子节点的尾节点、尾指针
newEndVnode = newChildren[--newEndIndex]; // 更新 新子节点的尾节点、尾指针
}
}
}
在这次比较中命中了"尾对尾"这一情况,所以移动的是新老子节点的尾指针。
头对尾
scss
function updateChildren(el, oldChildren, newChildren) {
// ...
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
if (isSameVnode(oldStartVnode, newStartVnode)) {
// ...
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
// ...
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
patch(oldStartVnode, newEndVnode);
oldStartVnode = oldChildren[++oldStartIndex]; // 更新 老子节点的头节点、头指针
newEndVnode = newChildren[--newEndIndex]; // 更新 新子节点的尾节点、尾指针
}
}
}
在这次比较中命中了"头对尾"这一情况,所以移动的是老子节点的头指针、新子节点的尾指针。
尾对头
scss
function updateChildren(el, oldChildren, newChildren) {
// ...
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
if (isSameVnode(oldStartVnode, newStartVnode)) {
// ...
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
// ...
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
// ...
} else if (isSameVnode(oldEndVnode, newStartVnode)) {
patch(oldEndVnode, newStartVnode);
oldEndVnode = oldChildren[--oldEndIndex]; // 更新 老子节点的尾节点、尾指针
newStartVnode = newChildren[++newStartIndex]; // 更新 新子节点的头节点、头指针
}
}
}
在这次比较中命中了"头对尾"这一情况,所以移动的是老子节点的尾指针、新子节点的头指针。
无序
但并不是每次比较都能命中有序情形的,所以Vue中在有序比较之外使用了乱序比较。
对于无序情况,则需要使用一些额外的铺垫:
为老节点建立映射关系
javascript
function updateChildren (el, oldChildren, newChildren) {
// ...
function makeKeyByIndex(children) {
let map = {};
children.forEach((item, index) => {
map[item.key] = index;
})
return map;
}
let mapping = makeKeyByIndex(oldChildren); // 为老节点建立一个映射
}
有了这个映射后,我们就可以在乱序比较时,查询新子节点是否在老子节点中存在,这个判断通过比较key
实现
新有老无
scss
function updateChildren(el, oldChildren, newChildren) {
// ...
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
if (isSameVnode(oldStartVnode, newStartVnode)) {
// ...
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
// ...
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
// ...
} else if (isSameVnode(oldEndVnode, newStartVnode)) {
// ...
} else {
let moveIndex = mapping[newStartVnode.key];
if (moveIndex == undefined) { // 新有老无
el.insertBefore(createElm(newStartVnode), oldStartVnode.el);
}
newStartVnode = newChildren[++newStartIndex]; // 乱序比较完后移动新节点指针
}
}
}
如果新子节点在老子节点的映射中查询不到,那么说明这个节点就是新增的。因为我们比较的是新子节点的头节点,所以此时需要把它添加到老子节点的头节点前。
新有老有
scss
function updateChildren(el, oldChildren, newChildren) {
// ...
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
if (isSameVnode(oldStartVnode, newStartVnode)) {
// ...
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
// ...
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
// ...
} else if (isSameVnode(oldEndVnode, newStartVnode)) {
// ...
} else {
let moveIndex = mapping[newStartVnode.key];
if (moveIndex == undefined) {
el.insertBefore(createElm(newStartVnode), oldStartVnode.el);
} else { // 新有老有
let moveVnode = oldChildren[moveIndex];
el.insertBefore(moveVnode.el, oldStartVnode.el); // 复用节点但是移动到到相应位置,即头节点前
patch(moveVnode, oldStartVnode); // 还需要调用patch递归处理节点,会判断进行当前文本内容更新、属性更新、子节点diff
oldChildren[moveIndex] = undefined; // 移动后需要将原子节点位置上的内容清除
}
newStartVnode = newChildren[++newStartIndex]; // 乱序比较完后移动新节点指针
}
}
}
边界情况
此时会出现一些边界情况需要处理,即乱序比较会置空节点,匹配到时需要跳过它:
scss
function updateChildren(el, oldChildren, newChildren) {
// ...
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
if (!oldStartVnode) { // 乱序比较置空了头节点 的情况
oldStartVnode = oldChildren[++oldStartIndex];
} else if (!oldEndVnode) { // 乱序比较置空了尾节点 的情况
oldEndVnode = oldChildren[--oldEndIndex];
} else if (isSameVnode(oldStartVnode, newStartVnode)) {
// ...
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
// ...
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
// ...
} else if (isSameVnode(oldEndVnode, newStartVnode)) {
// ...
} else {
// ...
}
}
}
对比完成
遍历完后,我们需要作出实际的操作来更新页面,这也是传入参数el
的原因:
scss
function updateChildren(el, oldChildren, newChildren) {
// ...
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
if (!oldStartVnode) { // 乱序比较置空了头节点 的情况
oldStartVnode = oldChildren[++oldStartIndex];
} else if (!oldEndVnode) { // 乱序比较置空了尾节点 的情况
oldEndVnode = oldChildren[--oldEndIndex];
} else if (isSameVnode(oldStartVnode, newStartVnode)) {
// ...
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
// ...
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
// ...
} else if (isSameVnode(oldEndVnode, newStartVnode)) {
// ...
} else {
// ...
}
}
// 遍历结束后处理
if (newStartIndex <= newEndIndex) {
// newChildren有余会被添加
for (let i = 0; i <= newEndIndex; i++) {
// 通过对anchor进行判断取值,可以确定是往前插入元素还是往后插入
let anchor = newChildren[newEndIndex + 1] === null ? null : newChildren[newEndIndex + 1].el
// 当anchor为null时,语句相当于el.appendChild(createElm(newChildren[i]))
el.insertBefore(createElm(newChildren[i]), anchor)
}
}
if (oldStartIndex <= oldEndIndex) {
// oldChildren有余会被删除
for (let i = 0; i <= oldEndIndex; i++) {
let child = oldChildren[i]
el.removeChild(child.el)
}
}
}
小结
一言以蔽之:子节点比较的过程就是一个根据不同情形使用指针进行比较,然后相应地更新子节点、指针直到完成比较的过程。
总结
在这篇中我们整理了Vue的diff实现,其中最主要的就是子节点对比的逻辑。