接着上一版
如果你不知道上一版在哪?电梯直达
mini-vue 仓库源码 点击关注不迷路,哈哈。
v0.0.3 虚拟DOM与diff算法
要求:
- 使用vnode进行更新比较,最后再操作真实DOM渲染到页面上去
- 最小更新,只修改有变化的DOM节点,没有变化的不更新
- 探究diff算法,更高效的更新(适合大部分小范围更新场景,不适用于数据量大的更新)
- 明白v-for中key属性的意义
每个版本的要求我都会写在最前面,这里推荐大家先不要着急往下看,在上一个版本的基础上,试着先自己写写看。
在上一个版本中我们以及实现了一个低配版的响应式更新功能,这个版本我们再完善下虚拟DOM的实现。
- 首先我们先新增一个patch.js模块文件,用来编写vnode的处理逻辑,向外暴露一个patch方法
/**
* 更新DOM
* @param {vnode} n1 旧vnode
* @param {vnode} n2 新vnode
* @param {Element} container 父节点
* @param {Object} vm 当前组件实例对象
*/
export function patch (n1, n2, container, vm) {
n2.vm = vm
if (n1 == null) {
initPatch(n2, container)
} else {
updatePatch(n1, n2, container)
}
// vm是全局不变的对象,因此用_vnode属性来存储本次的虚拟DOM,用于下次更新时比较
vm._vnode = n2
}
- 接下来我们应该在什么地方调用这个patch方法呢? 看看vue.js文件中,之前操作DOM的地方,改成如下写法
function updateComponent () {
const container = vm.container
// 调用render函数,获得虚拟dom节点
const vnode = app.render ? app.render.call(vm) : ''
patch(vm._vnode, vnode, container, vm)
}
- 同时注意到,之前render函数返回的是真实DOM,现在变成vnode了,因此我们还得再修改下render.js,执行后返回一个vnode对象
export function createElement (vm, tag, prop, children) {
const vnode = { vm, tag }
if (Array.isArray(prop)) {
children = prop
} else if (prop) {
let events;
for (let attr of Object.getOwnPropertyNames(prop)) {
if (attr === 'on') {
// 如果存在on属性,则进行事件绑定操作
events = {}
for (let ev of Object.getOwnPropertyNames(prop.on)) {
events[ev] = prop.on[ev].bind(vm)
}
} else {
vm[attr] = prop[attr]
}
}
}
events && (vnode.events = events)
if (children && children.length > 0) {
const arr = []
children.forEach(child => {
if (typeof child === 'string') {
child = { vm, tag: 'text', text: child }
}
arr.push(child)
})
vnode.children = arr
}
return vnode
}
- 接下来,回到我们刚开始创建的patch.js文件中,起先我们只写了一个patch函数,里面分为第一次渲染和更新渲染两种情况,为啥要区分呢?因为这两种操作差别很大,而且第一次渲染页面时,初始化了好些配置,以及依赖收集等等,也为我们后续更新操作减少了复杂性。不然共用一个函数,要注意很大边界条件的判定。
先看initPatch,很简单,清空,再重新渲染,很快,注意这里我们用到了vnodeToDom函数,将虚拟DOM转为真实DOM
/**
* 初始化DOM,不用多余的判断,直接渲染
*/
function initPatch (vnode, container) {
let el = vnodeToDom(vnode)
container.innerHTML = ''
container.append(el)
}
vnodeToDom函数
/**
* 将虚拟DOM转换为真实DOM
* @param {*} vnode
* @returns el
*/
function vnodeToDom (vnode) {
const { tag, events, attrs, children, text } = vnode
// 注意下这里, 为了方便处理,我们把单纯的文本字符串,也视为一个DOM节点
if (tag === 'text') {
vnode.el = document.createTextNode(text)
return vnode.el
}
const el = vnode.el || (vnode.el = document.createElement(tag))
if (events) {
Object.getOwnPropertyNames(events).forEach(ev => {
el.addEventListener(ev, events[ev])
})
}
if (attrs) {
Object.getOwnPropertyNames(attrs).forEach(name => {
el.setAttribute(name, attrs[name])
})
}
if (children) {
children.forEach(child => {
el.append(vnodeToDom(child))
})
}
return el
}
接下来就是重头戏了,diff算法更新DOM
/**
* 更新节点
* @param {vnode} n1 旧vnode
* @param {vnode} n2 新vnode
* @param {Element} container 父节点
* @param {boolean} isSame 判断n1,n2是否已经比较过了, 在updateChildren函数中有使用
*/
function updatePatch (n1, n2, container, isSame) {
let el = n1.el
if (!isSame && !sameVnode(n1, n2)) {
el = vnodeToDom(n2)
}
n2.el = el
if (n2.children && n1.children) {
updateChildren(n1.children, n2.children, el)
} else if (isUndef(n2.children)) {
el.innerHTML = ''
} else if (isUndef(n1.children)) {
n2.children.forEach(child => {
el.appendChild(vnodeToDom(child))
})
}
if (el !== n1.el) { // 当新的节点不是原来旧的节点时,执行替换操作
container.replaceChild(el, n1.el)
}
}
/**
* diff算法,更新DOM子节点们
* @param {Array} oldCh 旧vnode子节点集合
* @param {Array} newCh 新vnode子节点集合
* @param {Element} container 父节点
*/
function updateChildren (oldCh, newCh, container) {
let oldStartIdx = 0
let oldStart = oldCh[oldStartIdx]
let oldEndIdx = oldCh.length - 1
let oldEnd = oldCh[oldEndIdx]
let newStartIdx = 0
let newStart = newCh[newStartIdx]
let newEndIdx = newCh.length - 1
let newEnd = newCh[newEndIdx]
let oldKeyMap, idxInOld // 定义旧节点的KeyMap, 要新插入的DOM, 要移除的DOM
// 新旧数组,分别使用双指针进行遍历
// 旧前-新前, 旧后-新后, 旧前-新后, 旧后-新前
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStart)) { // 此处使用isUndef, 避免把0,''等一些合法值过滤了
oldStart = oldCh[++oldStartIdx]
} else if (isUndef(oldEnd)) {
oldEnd = oldCh[--oldEndIdx]
} else if (sameVnode(oldStart, newStart)) { // 旧前-新前
// 当前节点相同时,进一步比较他们的子节点是否也相同
updatePatch(oldStart, newStart, oldStart.el, true)
oldStart = oldCh[++oldStartIdx]
newStart = newCh[++newStartIdx]
} else if (sameVnode(oldEnd, newEnd)) { // 旧后-新后
updatePatch(oldEnd, newEnd, oldEnd.el, true)
oldEnd = oldCh[--oldEndIdx]
newEnd = newCh[--newEndIdx]
} else if (sameVnode(oldStart, newEnd)) { // 旧前-新后
updatePatch(oldStart, newEnd, oldStart.el, true)
oldStart = oldCh[++oldStartIdx]
newEnd = newCh[--newEndIdx]
} else if (sameVnode(oldEnd, newStart)) { // 旧后-新前
updatePatch(oldEnd, newStart, oldEnd.el, true)
oldEnd = oldCh[--oldEndIdx]
newStart = newCh[++newStartIdx]
} else { // 当前后两端都没匹配到时,通过key来查找,顺序匹配newCh, 这也就是newStart = [++newStartIdx]的原因
// 初始化old的keyMap
oldKeyMap = oldKeyMap || getKeyMap(oldCh, oldStartIdx, oldEndIdx)
// 判断newStart是否在oldCh中, 优先使用key获取,其次遍历oldCh全部进行查找
// 因此在使用v-for时,最好定义唯一的key值
idxInOld = isDef(newStart.key) ? oldKeyMap[newStart.key] : findNewInOld(newStart, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // 如果不存在,则newStart为新增的节点
insertBefore(container, vnodeToDom(newStart), oldStart.el)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStart)) { // 说明该节点在此次更新中移动了位置
updatePatch(vnodeToMove, newStart, vnodeToMove.el, true)
insertBefore(container, vnodeToMove.el, oldStart.el)
oldCh[idxInOld] = undefined
} else {
// key值相同,但却不是同一个节点,因此还是按照新增节点处理
insertBefore(container, vnodeToDom(newStart), oldStart.el)
}
}
newStart = [++newStartIdx]
}
}
// 循环匹配结束后,只存在两种情况
if (oldStartIdx > oldEndIdx) { // 旧的队列提前遍历完了,说明新的比旧的多,还需要进行插入操作
for (let i = newStartIdx; i <= newEndIdx; i++) {
container.appendChild(vnodeToDom(newCh[i]))
}
} else if (newStartIdx > newEndIdx) { // 新的队列提前遍历完了,说明新的比旧的少,还需要把旧的里面剩余节点移除
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
container.removeChild(oldCh[i].el)
}
}
}
慢慢的代码就比较多了,完整代码看源码仓库
代码中难理解的地方都写了注释,如果看不明白,可以debugger看执行过程。我还是推荐自己先动手试着写写,然后在对比,再改,再调试。在不断摸索中才能成长的更快。
突然间,你就悟了,哈哈,真的有可能。
下一版v0.0.4生命周期 施工中...