2020年春节,一场突如其来的新型冠状病毒席卷全球,中国首当其冲,确诊病人人数急剧上升,一场没有硝烟的战争就此打响。全国进入一级响应,全国人民众志成城共同抗击疫情,医务人员"国有难,召必回,战必胜",奔赴一线;而我们自行在家隔离定不给祖国添麻烦。直至今日,疫情仍没停止,开学遥遥无期,也祈祷疫情早日结束,让我们回归正常的生活。以上的感慨发自肺腑,就当作2020年的一个纪念吧。
笔至于此,名归正传,近日在家对前端框架的虚拟dom、diff算法,做了深入的理解,有了全面的认识和启发,故作此文来梳理记录下。下面逐层讲解。
一、节点树以及虚拟 DOM
vue官网中这样描述,当浏览器读到HTML代码时,它会建立一个“DOM 节点”树来保持追踪所有内容,如同你会画一张家谱树来追踪家庭成员的发展一样。
每个元素都是一个节点。当我们更新节点时不得不操作dom,频繁的手动去操作dom而带来性能问题, 而用JS来模拟DOM结构,把DOM的变化操作放在JS层来做,这样大大降低了内存消耗,所以就引入了Virtual DOM(js对象树)。在vue中,渲染函数如下,完成视图更新,其前端框架虚拟dom借鉴于snabbdom.js,如要深挖,可前往了解。
render: function (createElement) {
return createElement('h1',{style: background: 'black'},children) //返回一个vnode
}
二、渲染过程
- Vue.js通过首先编译(通过vue-loader)将template 模板(react则是通过babel编译jsx语法)转换成渲染函数(render ) ,执行渲染函数中的createElement()就可以得到一个虚拟节点树
- 在模板编译过程中,模板当中变量的依赖和watcher相绑定(这个过程可参考上一篇文章),一旦model中的响应式的数据发生了变化,setter便会监听到,数据所维护的dep数组便会调用dep.notify()方法通知对应的watcher,从而调用render函数vm._render(),会返回一个新的VNode,进而调用patch(oldVnode, newVnode)方法,将变更的地方以打补丁的方式到oldVnode上,并完成真实dom的更新工作。
整个流程最核心的时patch()函数,通过Diff算法比较新老vnode差异,以至于达到最小的程度的更新节点。下文代码会粗略的展现实现流程。
三、Vue和react 虚拟dom更新策略不同之处
- vue ,采用了Object.defineProperty这个属性,将数据维护成了可观察的数据,数据的每一项都通过getter来收集依赖,然后将依赖转化成watcher保存在闭包中,数据修改后,触发数据的setter方法,然后通知所对应的watcher,调用render函数,会返回一个newVnode,进而调用patch(oldVnode,newVnode)函数来更新dom
- react将数据和jsx模版结合通过createElement方法生成js对象树,也就是虚拟dom。react采用setState来控制视图的更新,setState会自动调用render函数,触发patch,完成视图更新。但react给开发者暴露一个生命周期函数:shouldcomponentupdate,这个函数可以根据开发者的需求决定是否重新渲染。
- vue为每个数据设置setter、getter、watcher,当数据足够多时,运行效率反倒不如react。而如果项目中型,并且想快速开发,vue更高效。
四、流程及附上核心代码
index.js
// 创建虚拟dom,vnode为一个对象树(虚拟dom)
// let oldVnode = createElement('div', {
// id: 1, a: 1, key: 'xxx'
// }, createElement('span', {
// style: {
// color: 'red'
// }
// }, 'text2'), 'text1')
patch(oldVnode, newVnode)
export default function createElement(type, props = {}, ...children) {
//获取属性的key, 然后删除
let key
if (props.key) {
key = props.key
delete props.key
}
//子节点是标签还是文本
children = children.map(child => {
if (typeof child === 'string') {
return vnode(undefined, undefined, undefined, undefined, child)
} else {
return child
}
})
return vnode(type, props, key, children)
}
- 调用patch(oldVnode, newVnode),若oldVnode不存在,那么就用newVnode创建一个真实dom节点,若 存在,进行diff,完成更新操作,渲染视图
- 对于vnode采用深搜的方式层层递进调用patch();对于同层的vnode,调用updateChildren()进行比较更新。
export function patch(oldVnode, newVnode) {
// 判断类型不同
if (oldVnode.type !== newVnode.type) {
return oldVnode.domElement.parentNode.replaceChild(createDomElementVnode(newVnode), oldVnode.domElement)
}
// 类型相同,换文本
if (oldVnode.text) {
if (oldVnode.text == newVnode.text) return
return oldVnode.domElement.textContent = newVnode.text
}
//顶级更新
let domElement = newVnode.domElement = oldVnode.domElement
// console.log(domElement);
// 更新顶级属性
updateProps(newVnode, oldVnode.props)
//对比儿子-三种情况
// 1.老的有儿子,新的有儿子
// 2.老的有儿子,新的无儿子
// 3.新增儿子
let oldChildren = oldVnode.children
let newChildren = newVnode.children
if (oldChildren.length > 0 && newChildren.length > 0) {
//新老都有儿子执行updateChildren
updateChildren(domElement, oldChildren, newChildren)
} else if (oldChildren.length > 0) {//新的无儿子
domElement.innerHTML = ''
} else if (newChildren.length > 0) {//新增儿子,转成dom添加到domElement
for (let i = 0; i < newChildren.length; i++) {
domElement.appendChild(createDomElementVnode(newChildren[i]))
}
}
}
//根据我们虚拟节点的属性 去更新真实的dom属性
function updateProps(newVnode, oldProps = {}) {
let domElement = newVnode.domElement //节点
let newProps = newVnode.props
// 和老的做对比
//1.老的里面有,新的有,则直接干掉这个属性
for (const oldPropname in oldProps) {
if (!newProps[oldPropname]) {
delete domElement[oldPropname]
}
}
// 2.老的里面没有,新的里面有
for (const newPropsName in newProps) {
domElement[newPropsName] = newProps[newPropsName]
}
// 3.style
let newStyleObj = newProps.style || {}
let oldStyleObj = oldProps.style || {}
for (const propName in oldStyleObj) {
if (!newStyleObj[propName]) {
domElement.style[propName] = ''
}
}
// 循环将style给dom
for (const newPropsName in newProps) {
// 有style
if (newPropsName == 'style') {
for (const s in newProps.style) {
domElement.style[s] = newProps.style[s]
}
} else {
domElement[newPropsName] = newProps[newPropsName]
}
}
}
// 返回真实dom
function createDomElementVnode(vnode) {
let { type, props, key, children, text } = vnode
if (type) {//标签节点
vnode.domElement = document.createElement(type)
//根据我们虚拟节点的属性 去更新真实的dom属性
updateProps(vnode)
//递归调用渲染函数
children.forEach(childNode => render(childNode, vnode.domElement));
} else { //文本节点
vnode.domElement = document.createTextNode(text)
}
return vnode.domElement
}
//渲染view
export function render(vnode, container) {
let ele = createDomElementVnode(vnode)
container.appendChild(ele)
}
- updateChildren()方法时diff中最重要的环节,对于同层节点进行比较,找出最优的更新方案,以至于最小程度去修改dom
- 节点属性中key来作为唯一标识,可以提高diff效率。下文会举例说明
// 同层的儿子节点
function updateChildren(parent, oldChildren, newChildren) {
let oldStartIndex = 0
let oldStartVnode = oldChildren[0]
let oldEndIndex = oldChildren.length - 1
let oldEndVnode = oldChildren[oldChildren.length - 1]
let newStartIndex = 0
let newStartVnode = newChildren[0]
let newEndIndex = newChildren.length - 1
let newEndVnode = newChildren[newChildren.length - 1]
//判断老的儿子和新的儿子,在循环体中 谁先结束就停止
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
if (!oldStartVnode) {
oldStartVnode = oldChildren[++oldEndIndex]
} else if (!oldEndVnode) {
oldEndVnode = oldChildren[--oldEndIndex]
} else
//如果虚拟节点type和key相同
if (isSameVnode(oldStartVnode, newStartVnode)) {//首部
//深搜--去更新打补丁
patch(oldStartVnode, newStartVnode)
//同层节点下一个比较
oldStartVnode = oldChildren[++oldStartIndex]
newStartVnode = newChildren[++newStartIndex]
} else if (isSameVnode(oldEndVnode, newEndVnode)) {//尾部
patch(oldEndVnode, newEndVnode)
oldEndVnode = oldChildren[--oldEndIndex]
newEndVnode = newChildren[--newEndIndex]
} else if (isSameVnode(oldStartVnode, newEndVnode)) {//首尾
// oldS和newE相同,进行patch,oldS插入到oldE之后 oldS++,newE--
patch(oldStartVnode, newEndVnode)
parent.insertBefore(oldStartVnode.domElement, oldEndVnode.domElement.nextSiblings)
oldStartVnode = oldChildren[++oldStartIndex]
newEndVnode = newChildren[--newEndIndex]
} else if (isSameVnode(oldEndVnode, newStartVnode)) {//尾首
// oldE和newS相同,进行patch,oldE插入到oldS之前,oldE--,newS++
patch(oldEndVnode, newStartVnode)
parent.insertBefore(oldEndVnode.domElement, oldStartVnode.domElement)
oldEndVnode = oldChildren[--oldEndIndex]
newStartVnode = newChildren[++newStartIndex]
} else {
// 如果以上条件都不满足,那么这个时候开始比较key值,首先建立key和index索引的对应关系
// 暴力对比
let index = keyMapByINdex(oldChildren)[newStartVnode.key]
// let index = map[newStartIndex.key]
if (index = null) {
parent.insertBefore(createDomElementVnode(newStartVnode), oldStartVnode.domElement)
} else {
let toMoveNode = oldChildren[index]
patch(toMoveNode, newStartVnode)
parent.insertBefore(toMoveNode.domElement, oldStartVnode.domElement)
oldChildren[index] = undefined
}
// 移动位置
newStartVnode = newChildren[++newStartIndex]
}
}
//若新节点有多余,塞进去
if (newStartIndex <= newEndIndex) {
for (let i = newStartIndex; i <= newEndIndex; i++) {
//获取要塞入位置的后一节点,
let beforeElement = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].domElement
// console.log(beforeElement);
parent.insertBefore(createDomElementVnode(newChildren[i]), beforeElement)
}
}
// 判断中间的undefined
if (oldStartIndex <= oldEndIndex) {
for (let i = oldStartIndex; i < oldEndIndex; i++) {
if (oldChildren[i]) {
parent.removeChild(oldChildren[i].domElement)
}
}
}
}
function isSameVnode(oldVnode, newVnode) {
return oldVnode.key === newVnode.key && oldVnode.type === newVnode.type
}
五、属性key
下方图newVnode相对比oldVnode新增了F节点,加入都为
- 节点,各个字母为属性k
-
- 无属性k: 在updateChildren()函数中,每一项节点type都相同,isSameVnode(oldStartVnode, newStartVnode)一直为true,故从左一直比到右,做了三次节点更新和一次节点创建插入。
- 有k : 只做了一次创建插入操作。因此key的存在可以高效的更新虚拟Dom,在diff算法中更准确的判断是否为同一节点,减少不必要的更新。
- k为index下标时,在列表更新时会引发bug。如在v-for中,增删数据时,单选框的选择项会出现错乱(如在首部新增一个单选框,由于首部新老节点key和type 、属性都相同,只是e.target.checked不同而已但patch函数没做比较,所以不会更新,最后仅仅在oldNode尾部追加了一个节点,所以导致之前的选中项还是在索引下标的位置即错乱);在给key赋值时,不推荐index(会失去key值的意义),应使用唯一的id值。还有input文本框同理,只是value变化。
例子1:
例子2:
index做key
原始数据:A-0 B-1 C-2
删除A : B-0 C-1将会失去diff更新效率