文章大致目录:
- 虚拟dom是什么
- 如何创建虚拟dom
- 虚拟dom如何渲染成真实的dom
- 虚拟dom如何patch
- 虚拟dom的优势
- vue中key的到底有什么用,为什么最好不是index
- vue中diff算法的实现
1.virtual DOM是什么
- 在virtual DOM没有提出之前,我们操作的都是真实的dom,当我们每次进行dom查询的时,几乎需要遍历整颗dom树,以及dom操作引起浏览器的重绘和重排,这都十分影响性能。
- 因此用js模拟一颗dom树(在我看来,虚拟dom就是一个描述真实dom的对象),放在浏览器内存中.当你要变更时,虚拟dom使用diff算法进行新旧虚拟dom的比较,将变更放到变更队列中,反应到实际的dom树,减少了dom操作.
- 虚拟DOM将DOM树转换成一个JS对象树,diff算法逐层比较,删除,添加操作,但是,如果有多个相同的元素,可能会浪费性能,所以,react和vue-for引入key值进行区分,便于复用。
2. 如何创建虚拟dom
解析传入的参数,将参数分类,输出统一的格式
// h.js
import { vnode } from './vnode'
export default function creatElement(type, props, ...children) {
let key;
if (props.key) {
key = props.key // 一般属性中的key不会传给子,因此单独设为虚拟dom的一个属性
delete props.key
}
children = children.map(child => {
if (typeof child === 'string') {
return vnode(null,null,null,null, child)
} else {
return child
}
})
return vnode(type, key, props, children)
}
// vnode.js
/*
@type:节点类型
@props: 节点属性
@children 子节点集合
@key: 节点的索引
@text: 文本
*/
export function vnode(type, key, props, children, text) {
return {
type,
key,
props,
children,
text
}
}
// index.js 测试
import { h, render } from './vdom/index'
const vnode = h('div', {id: 'diff', a: 1, style: {color: '#ab4d63'}}, h('span', { style: {color: '#969696'}}, 'vue-'), 'diff')

3.虚拟dom渲染成真实的dom
// patch.js
/*
将生成的真实dom挂在到容器中
**/
export function render(vnode, container) {
const ele = createDomByVnode(vnode)
container.appendChild(ele)
}
/*
生成真实dom
**/
function createDomByVnode(vnode) {
const { type, key, props, children = [], text} = vnode
if (type) {
// 标签
vnode.domElement = document.createElement(type)
updateProperties(vnode)
} else {
// 文本
vnode.domElement = document.createTextNode(text)
}
// children的处理
children && children.forEach(child => {
return render(child, vnode.domElement)
});
return vnode.domElement // 在vnode中创建一个属性,映射真正的dom
}
/**
* 更新属性
* **/
function updateProperties(vnode, oldProps = {}) {
// 属性暂时只分析到style
const domElement = vnode.domElement
const props = vnode.props
// 老有, 新没有,删除属性
for(let oldPropsName in oldProps) {
if (!props[oldPropsName]) {
domElement.removeAttribute(oldPropsName)
}
}
// 针对styles, 老有,新没有,应该将style的对应属性置为空
const newStyle = vnode.props.style
const oldStyle = props.style
for(let k in oldStyle) {
if(!newStyle[k]) {
domElement.style[k] = ''
}
}
// 老没有,新有,添加属性
for(let newPropsName in props) {
if (newPropsName === 'style') {
const styleObj = props.style
for(let i in styleObj) {
domElement.style[i] = styleObj[i]
}
} else {
domElement.setAttribute(newPropsName, props[newPropsName])
}
}
}
// index.js 测试
import { h, render } from './vdom/index'
const vnode = h('div', {id: 'diff', a: 1, style: {color: '#ab4d63'}}, h('span', { style: {color: '#969696'}}, 'vue-'), 'diff')
render(vnode, app)
启动项目,打开localhost:8080,dom已然渲染出来~~


4. 虚拟dom如何patch(更新)
/*比对更新属性
*/
export function patch(newVnode, oldVnode) { //geng
// 标签不相同相同复用
if (newVnode.type !== oldVnode.type) {
return oldVnode.domElement.parentNode.replaceChild(createDomByVnode(newVnode), oldVnode.domElement)
}
// 文本
if (newVnode.text !== oldVnode.text) {
return oldVnode.domElement.textContent = newVnode.text
}
// 是标签,并且类型相同,根据新节点的属性更新旧节点
const domElement = newVnode.domElement = oldVnode.domElement //节点复用
// 更新属性
updateProperties(newVnode, oldVnode.props)
// 外层更新后,继续更新children
const newChildren = newVnode.children
const oldChildren = oldVnode.children
/** 有三种情况
* newChildren: 有, oldChildren:有 // 最复杂,比较,diff算法的核心
* newChildren: 有, oldChildren:无 // 直接添加
* newChildren: 无, oldChildren:有 //直接删除
*/
if(newChildren.length > 0 && oldChildren.length > 0) {
updeteChild(domElement, newChildren, oldChildren)
} else if (newChildren.length > 0){
// 新的有,直接循环添加
for(let i = 0; i < newChildren.length; i++) {
domElement.appendChild(createDomByVnode(newChildren[i]))
}
} else if (oldChildren.length > 0) {
// 老的有,直接删除
oldVnode.domElement.innerHTML = ''
}
}
function oldmap(arr) {
return arr.reduce((acc, [item, index]) => {
const {key} = item
key ? acc[key] = index : null
return acc
}, {})
}
/**
* parent, 外层节点,方便操作内部的节点
* newChildren新虚拟dom的儿子
* oldChildren老虚拟dom的儿子
*/
function updeteChild(parent, newChildren, oldChildren) {
// 采用列表比对, 会对常见的dom操作做优化:前后追加,正序和倒序
// 定义头指针和尾指针
let newStartIndex = 0
let newStart = newChildren[0] // 新的开始虚拟节点
let newEndIndex = newChildren.length - 1
let newEnd = newChildren[newEndIndex]
let oldStartIndex = 0
let oldStart = oldChildren[0] // 新的开始虚拟节点
let oldEndIndex = oldChildren.length - 1
let oldEnd = oldChildren[newEndIndex]
let oldChildrenMap = oldmap(oldChildren)
/* 情况:
* 新的开始 == 老的开始
* 新的开始 == 老的尾
* 老的开始 == 新的尾
* 老的尾 == 新的开始
* 以上四种情况都不是
*/
// 谁先满足就结束循环
while(newStartIndex <= newEndIndex && oldStartIndex <= oldEndIndex) {
if (!oldStart) {
// 排除undefined的情况
oldStart = oldChildren[++oldStartIndex]
} else if(!oldEnd) {
oldEnd = oldChildren[--oldEndIndex]
} else if(isSameNode(newStart, oldStart)) {
// 新的开始 == 老的开始,头指针后移,更新属性
patch(newStart, oldStart)
newStart = newChildren[++newStartIndex]
oldStart = oldChildren[++oldStartIndex]
} else if (isSameNode(newEnd, oldEnd)) {
// 新的尾 == 老的尾,尾指针前移,更新属性
patch(newEnd, oldEnd)
newEnd = newChildren[--newEndIndex]
oldEnd = oldChildren[--oldEndIndex]
} else if(isSameNode(newStart, oldEnd)) {
// 新的开始 == 老的尾, 新的头指针后移, 老的尾指针前移
patch(newStart, oldEnd)
parent.insertBefore(oldEnd.domElement, oldStart.domElement)
newStart = newChildren[++newStartIndex]
oldEnd = oldChildren[--oldEndIndex]
} else if(isSameNode(newEnd, oldStart)) {
// 新的尾 == 老的开始, 新的尾指针前移, 老的头指针后移
patch(newEnd, oldStart)
parent.insertBefore(oldStart.domElement, oldEnd.domElement.nextSiblings)
newEnd = newChildren[--newEndIndex]
oldStart = oldChildren[++oldStartIndex]
} else {
// 都不一样,则要建立一个老children中key和元素的映射,遍历新的每一个,如果在老的存在就复用,不存在就创建
const index = oldChildrenMap[newStart.key]
if (index) {
// 复用
patch(newStart, oldChildren(index))
parent.insertBefore(oldChildren[index].domElement, oldStartIndex.domElement)
oldChildren[index] = null
} else {
// 创建
parent.insertBefore(createDomByVnode(newStart), oldStart.domElement)
}
newStart = newChildren[++newStartIndex]
}
}
// 循环之后,新的如果有剩余
if (newStartIndex <= newEndIndex) {
for (let i = newStartIndex; i <= newEndIndex; i++) {
// 如果遍历之后, i+1个元素有值则是向前追加元素, 否则是向后插入元素
const beforeElement = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].domElement
beforeElement.domElement.insertBefore(createDomByVnode(creanewChildren[i]), beforeElement)
}
}
// 如果老的有剩余,则删除
if (oldIndex <= oldEndIndex) {
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
// 删除
if (oldChildred[i].domElement) {
parent.removeChild(oldChildred[i].domElement)
}
}
}
}
总结整个流程:

5. 虚拟dom的优劣
很多时候手工优化dom确实会比virtual dom效率高,对于比较简单的dom结构用手工优化没有问题,但当页面结构很庞大,结构很复杂时,手工优化会花去大量时间,而且可维护性也不高,不能保证每个人都有手工优化的能力。至此,virtual dom的解决方案应运而生,virtual dom很多时候都不是最优的操作,但它具有普适性,在效率、可维护性之间达平衡。
- 虚拟DOM具有批处理和高效的Diff算法,最终表现在DOM上的修改只是变更的部分,可以保证非常高效的渲染,优化性能.
- 但首次渲染大量DOM时,由于多了一层虚拟DOM的计算,会比直接操作dom要慢。
6. vue中key的到底有什么用,为什么最好不是index
diff算法的思想
-
如果节点类型不同,直接干掉前面的节点,再创建并插入新的节点,不会再比较这个节点以后的子节点了。
-
如果节点类型相同,则会重新设置该节点的属性,从而实现节点的更新
为什么加key
情景1:
没加key之前,Diff算法默认把C更新成F,D更新成C,E更新成D,最后再插入E:


key的作用主要是为了高效的更新虚拟DOM,因为如果只是位置发生了变化,就可以通过移动操作调整位置,而不是去做创建和删除的操作了
为什么不用index作为key
- 1)index作为key,其实就等于不加key
- 2)index作为key,只适用于不依赖子组件状态或临时 DOM 状态 (例如:表单输入值) 的列表渲染输出(这是vue官网的说明)
在网上找了个例子:
- 假设v-for渲染[k1, k2, k3],将key设为index,目的是删除第一个
- 如下图所示,复用1和2,其实删除的是第三个

7.vue中diff算法的实现
找了一张图

参考文献
- 过渡效果 - vue.js列表渲染 - vue.jsReact’s diff algorithm
- blog.csdn.net/m6i37jk/art…
- cn.vuejs.org/v2/api/#key
- www.cnblogs.com/youhong/p/1…
- www.cnblogs.com/wind-lanyan…
