前言
通过手写Vue2源码,更深入了解Vue;
在项目开发过程,一步一步实现Vue核心功能,我会将不同功能放到不同分支,方便查阅;
另外我会编写一些开发文档,阐述编码细节及实现思路;
源码地址:手写Vue2源码
为何需要diff
如果没有diff,每次修改数据,更新视图,都重新进行一遍页面渲染,耗费性能。
我们知道,vue中template会编译成render函数,执行render函数会生成VNode;我们更新数据时,只需要比较oldVnode和newVnode,比较过程中应尽可能去复用老的dom,只更新我们修改的那一小块dom即可。
patch改写
当数据变化时,会执行相关watcher的watcher.run()
方法,如果是渲染watcher,则进一步会执行mountComponent
——>updateComponent
——>vm._update(vm._render())
;即重新执行render函数,然后将新生成的Vnode与老的vnode作为参数,执行patch(oldVnode, vnode)
方法。
注:
$mount
时才创建AST树,即一个组件只创建一次AST树- 后续组件的更新只是重新执行
vm._update(vm._render())
,即重新执行render函数,以及生成真实dom,不重复创建AST树 - vue只会管理自己的template,手动直接创建dom,vue不会管理,后续这些dom中的数据更新,也与vue无关
首先,我们改写一下_update()
方法,对老的vnode进行缓存:
// src/lifecycle.js
Vue.prototype._update = function (vnode) {
const vm = this;
const prevVnode = vm._vnode; // 获取上一次的vnode
vm._vnode = vnode; // 保存本次的vnode
// 【核心】patch是渲染vnode为真实dom
if (!prevVnode) {
// 初次渲染
vm.$el = patch(vm.$el, vnode); // 初次渲染 vm._vnode肯定不存在 要通过虚拟节点 渲染出真实的dom 赋值给$el属性
} else {
// 视图更新
vm.$el = patch(prevVnode, vnode); // 更新时把上次的vnode和这次更新的vnode穿进去 进行diff算法
}
};
可见,第一次渲染和之后的更新,都是执行的 patch()
方法:
// src/vdom/patch.js
export function patch(oldVnode, vnode) {
// 1. 第一次渲染【组件元素】时;没有$el,也没有oldVnode
if (!oldVnode) {
// 组件的创建过程是没有el属性的
return createElm(vnode);
} else {
// Vnode没有设置nodeType,值为undefined;真实节点可以获取到nodeType
const isRealElement = oldVnode.nodeType;
// 2. 如果是初次渲染元素节点
if (isRealElement) {
const oldElm = oldVnode;
const parentElm = oldElm.parentNode;
// 将虚拟dom转化成真实dom节点
const el = createElm(vnode);
// 插入到 老的el节点 的下一个节点的前面,就相当于插入到老的el节点的后面
// 这里不直接使用父元素appendChild是为了不破坏替换的位置
parentElm.insertBefore(el, oldElm.nextSibling);
// 删除老的el节点
parentElm.removeChild(oldVnode);
return el;
} else {
/**
* 3. 如果是更新视图
*/
// 1. 如果标签名不一样,直接删掉老的,换成新的
// debugger;
if (oldVnode.tag !== vnode.tag) {
// vnode.el就是在 createElm(vnode)创建真实dom时添加到vnode上的,vnode.el是真实dom
oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el); // oldVnode.el代表的是真实dom节点
}
// 2. 如果新旧节点是一个文本节点(新节点是一个文本节点,则旧节点一定是文本节点,否则两者tag不同,会走上面的判断)
if (!vnode.tag) {
if (oldVnode.text !== vnode.text) {
oldVnode.el.textContent = vnode.text;
}
}
// 3. 不符合上面两种,代表标签名一致,并且不是文本节点
const el = (vnode.el = oldVnode.el); // 为了节点复用 所以直接把旧的虚拟dom对应的真实dom赋值给新的虚拟dom的el属性
updateProperties(vnode, oldVnode.data); // 更新属性
const oldCh = oldVnode.children || []; // 老的儿子
const newCh = vnode.children || []; // 新的儿子
if (oldCh.length > 0 && newCh.length > 0) {
// 3.1. 新老都存在子节点
updateChildren(el, oldCh, newCh); // 【核心算法】
} else if (oldCh.length) {
// 3.2 老的有儿子,新的没有
el.innerHTML = "";
} else if (newCh.length) {
// 3.3 新的有儿子,老的没儿子
for (let i = 0; i < newCh.length; i++) {
const child = newCh[i];
el.appendChild(createElm(child));
}
}
}
}
}
patch逻辑梳理:
- 第一次渲染组件元素时,组件的vnode中没有el元素,所以
vm.$el
为undefined,有第一个判断 - 第一次渲染元素节点,oldVnode为真实元素
$el
,走第二个判断 - 组件更新时,oldVnode和vnode都可以取到,且不是真实dom,走第三个判断
- 如果新旧vnode的标签名不一样,直接删掉老的,换成新的
- 如果新旧节点是文本节点,且文本内容不相等,直接采用新的
- 使用
updateProperties(vnode, oldVnode.data)
方法更新属性 - 如果新旧VNode都有子节点,使用
updateChildren(el, oldCh, newCh)
方法进行子节点的对比 - 如果老的有儿子,新的没有儿子,直接将老的innerHTML设为空
- 如果新的有儿子,老的没儿子,直接将新的子节点生成真实dom,插入老的节点中
updateProperties
先看第一个核心方法 —— 属性更新:
// src/vdom/patch.js
// 初次调用时oldProps为空,更新时oldProps可能有值,都可以调用此方法来解析vnode的属性
function updateProperties(vnode, oldProps = {}) {
let newProps = vnode.data || {};
let el = vnode.el; // 真实节点
// 如果新的节点没有该属性,需要把老的节点属性移除
for (let k in oldProps) {
if (!newProps[k]) {
el.removeAttribute(k);
}
}
// 对style样式做特殊处理,如果新的没有,需要把老的style值置为空
let newStyle = newProps.style || {};
let oldStyle = oldProps.style || {};
for (let key in oldStyle) {
if (!newStyle[key]) {
el.style[key] = "";
}
}
// 遍历新的属性,进行增加操作
for (const key in newProps) {
if (key === "style") {
for (const styleName in newProps.style) {
el.style[styleName] = newProps.style[styleName];
}
} else if (key === "class") {
el.className = newProps.class;
} else {
// 给这个元素添加属性 值就是对应的值
el.setAttribute(key, newProps[key]);
}
}
}
updateChildren
第二个核心方法 —— 子节点比对:
// src/vdom/patch.js
// 判断两个vnode的标签和key是否相同,如果相同,就可以认为是同一节点就地复用
function isSameVnode(oldVnode, newVnode) {
return oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key;
}
// diff算法核心,采用双指针的方式,对比新老vnode的儿子节点
function updateChildren(parent, oldCh, newCh) {
let oldStartIndex = 0; //老儿子的起始下标
let oldStartVnode = oldCh[0]; //老儿子的第一个节点
let oldEndIndex = oldCh.length - 1; //老儿子的结束下标
let oldEndVnode = oldCh[oldEndIndex]; //老儿子的结束节点
let newStartIndex = 0; // 新儿子的,同上
let newStartVnode = newCh[0];
let newEndIndex = newCh.length - 1;
let newEndVnode = newCh[newEndIndex];
// 根据key来创建老的儿子的index映射表;类似 {'a':0,'b':1}:表示key为'a'的节点在第一个位置,key为'b'的节点在第二个位置
function makeIndexByKey(children) {
let map = {};
children.forEach((item, index) => {
item.key && (map[item.key] = index);
});
return map;
}
// 生成oldCh的映射表(key:index)
let keysMap = makeIndexByKey(oldCh);
// 只有当新老儿子的双指标的起始位置不大于结束位置的时候,才能循环;
// 一方的开始位置大于结束位置,说明该方循环完毕,需要结束循环
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
// 如果节点已经被移走了,直接跳过
if (!oldStartVnode) {
oldStartVnode = oldCh[++oldStartIndex];
} else if (!oldEndVnode) {
oldEndVnode = oldCh[--oldEndIndex];
} else if (isSameVnode(oldStartVnode, newStartVnode)) {
// 头头比较
patch(oldStartVnode, newStartVnode); // 递归比较儿子以及他们的子节点
// 指针往后移一位,startVnode也相应改变
oldStartVnode = oldCh[++oldStartIndex];
newStartVnode = newCh[++newStartIndex];
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
// 尾尾比较
patch(oldEndVnode, newEndVnode); // 递归比较儿子以及他们的子节点
// 指针往前移一位,endVnode也相应改变
oldEndVnode = oldCh[--oldEndIndex];
newEndVnode = newCh[--newEndIndex];
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
// 头尾比较
patch(oldStartVnode, newEndVnode);
// 比较完,就需要将递归的结果,放到oldEndVnode后面(因为新的是在尾部,所以当头尾比较满足samavnode时,需要将老的vnode移到尾部,与newCh顺序保持一致)
parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling); // 比较完,就需要将结果移动到末尾
// 指针改变,oldStartVnode、newEndVnode相应改变
oldStartVnode = oldCh[++oldStartIndex];
newEndVnode = newCh[--newEndIndex];
} else if (isSameVnode(oldEndVnode, newStartVnode)) {
// 尾头比较
patch(oldEndVnode, newStartVnode);
// 比较完,就需要将递归的结果,放到oldStartVnode前面(因为新的是在头部,所以当尾头比较满足samavnode时,需要将老的vnode移到头部,与newCh顺序保持一致)
parent.insertBefore(oldEndVnode.el, oldStartVnode.el);
// 指针改变,oldEndVnode、newStartVnode相应改变
oldEndVnode = oldCh[--oldEndIndex];
newStartVnode = newCh[++newStartIndex];
} else {
// 如果以上四种情况都不满足,需要进行暴力对比
// 在oldCh中寻找newStartVnode对应key相同的节点(keysMap是表示oldCh中key-index对应关系的对象)
let moveIndex = keysMap[newStartVnode.key];
if (!moveIndex) {
// 如果老的节点找不到与newStartVnode相同key的节点,则直接将newStartVnode插入
parent.insertBefore(createElm(newStartVnode), oldStartVnode.el);
} else {
// 如果在oldCh中找到与newStartVnode相同key的节点
let moveVnode = oldCh[moveIndex]; // 找得到就拿到老的节点
oldCh[moveIndex] = null; // 这个是占位操作,避免数组塌陷,防止老节点移动走了之后破坏了初始的映射表位置,即后续如果再次采用乱序比对会出现索引位置错乱(因为moveVnode是根据索引获取的)
parent.insertBefore(moveVnode.el, oldStartVnode.el); //把找到的节点移动到最前面
patch(moveVnode, newStartVnode); // 递归patch
}
// 指针和newStartVnode相应做出改变
newStartVnode = newCh[++newStartIndex];
}
}
// 如果老节点循环完毕了,但是新节点还有;证明新节点需要被添加到头部或者尾部
if (newStartIndex <= newEndIndex) {
// 此时newStartIndex并非为0,而是等于oldCh比对完时,newCh所处的位置
// 遍历newCh剩余的节点,生成真实dom,插入到parent中
for (let i = newStartIndex; i <= newEndIndex; i++) {
// 看下一个指针是否为null,不是的话,取它的el属性
// 这是一个优化写法 insertBefore的第二个参数是null等同于appendChild作用
const anchor =
newCh[newEndIndex + 1] == null ? null : newCh[newEndIndex + 1].el;
parent.insertBefore(createElm(newCh[i]), anchor);
}
}
// 如果新节点循环完毕,老节点还有;证明老的节点需要直接被删除
if (oldStartIndex <= oldEndIndex) {
// 遍历oldCh剩余的节点,将他们从parent中删除
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
let child = oldCh[i];
parent.removeChild(child.el);
}
}
}
updateChildren流程分析:
- 采用双指针的方式来对比新旧vnode的子节点
- 子节点对比流程:
- 初始化指针及指针对应的oldEndVnode、oldStartVnode、newEndVnode、newStartVnode
- 当
oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex
时,循环进行下列比对,直到某一方所有节点比较完毕- 根据是否是sameVnode(tag和key都相同则是sameVnode),判断采用 首首对比、尾尾对比、首尾对比、尾首对比中的哪一种,并且递归patch处理子孙节点;
- 如果上述四种都没匹配上,则采用暴力对比:在oldChildren中查找与newStartVnode匹配的节点。如果匹配上了,就将该节点移到oldStartVnode前面;如果没匹配上,直接在oldStartVnode前面插入newStartVnode
- 在以上五种比对的过程中,比对完需要移动oldCh中节点的位置,移动指针,以及重新设置oldEndVnode、oldStartVnode、newEndVnode、newStartVnode的值
- 如果newCh或oldCh其中一方比对完成:
- 当newCh比对完了(即依然存在
oldStartIndex <= oldEndIndex
),则将oldCh中剩余的节点全部删除 - 当oldCh比对完了(即依然存在
newStartIndex <= newEndIndex
),将newCh中剩余的节点添加到oldCh中
- 当newCh比对完了(即依然存在
小结
- diff采用同层比较,不跨层比较
- 采用双指针比较同层子节点
- 后代节点使用patch递归比对
- 设置key可以最大化的利用节点
系列文章
- 手写Vue2源码(一)—— 环境搭建
- 手写Vue2源码(二)—— 数据劫持
- 手写Vue2源码(三)—— 模板编译
- 手写Vue2源码(四)—— 初次渲染
- 手写Vue2源码(五)—— 观察者模式
- 手写Vue2源码(六)—— 异步更新及nextTick
- 手写Vue2源码(七)—— 侦听属性
- 手写Vue2源码(八)—— 计算属性
- 手写Vue2源码(九)—— 混入原理与生命周期
- 手写Vue2源码(十)—— 组件原理
- 手写Vue2源码(十一)—— diff算法
- 手写Vue2源码(十二)—— keep-alive
- 手写Vue2源码(十三)—— 全局API
- vue-router原理解析
- vuex原理解析
- vue3原理解析