网上实现虚拟dom的文章也很多了,本项目代码结构、函数等完全按照vue@2.5.17源码思路实现,主要也是为了总结一下自己的学习。github地址在这里。
从图中可以看到,这个dom树改变了许多地方,但是只新建了一个div元素,这说明其余的元素只是做了移动和文本内容的修改,这比重新渲染整棵dom树要节省很多资源。
不多解释了,下面直接看代码吧。因为vue是通过模版解析之后生成的虚拟dom,我主要为了学习虚拟dom,没有做模版解析,所以手动建立了两棵虚拟dom树(这不重要),然后通过patch函数对比,改变真实的dom树结构。
<body>
<script src="./vdom/vnode.js"></script>
<script src="./vdom/patch.js"></script>
<script>
var ul = new VNode('ul',{class: 'ul'},[
new VNode('p', {class: 'li'},[],'virtual dom'),
new VNode('li',{class: 'li'},[],'mvvm'),
new VNode('li', {class: 'li'},[],'virtual dom'),
new VNode('input',{type: 'text'}),
new VNode('li', {class: 'li'},[],'virtual dom'),
new VNode('li',{},[],'mvvm'),
new VNode('li',{class: 'li'},[],'buppt')
])
var ul2 = new VNode('ul',{class: 'ul'},[
new VNode('li', {class: 'li'},[],'buppt'),
new VNode('li',{class: 'li'},[],'mvvm'),
new VNode('p',{},[],'h1 dom'),
new VNode('li',{class: 'li'},[],'h1 dom'),
new VNode('div',{},[],'h1 dom'),
new VNode('input',{type:'text'},[]),
])
document.body.appendChild(ul.render())
setTimeout(()=>{
console.log('vnode change')
patch(ul,ul2)
},2000)
</script>
</body>
VNode类的代码如下,主要记录一个虚拟元素节点的标签名称、属性、子节点、文本内容、对应的真实dom中的element元素。render函数就是将这个虚拟的元素节点渲染成一个真实的dom节点的函数。
class VNode{
constructor(tagName,props={},children=[],text=''){
this.tagName=tagName;
this.props=props ;
this.children=children;
this.text=text
this.key = props && props.key
var count = 0;
children.forEach(child => {
if(child instanceof VNode){
count+=child.count;
}
count++;
});
this.count = count;
}
render(){
let element = document.createElement(this.tagName);
for(let key in this.props){
element.setAttribute(key,this.props[key])
}
for(let child of this.children){
if(child instanceof VNode){
element.appendChild(child.render())
}
}
if(this.text){
element.appendChild(document.createTextNode(this.text))
}
this.elm = element;
console.log(element)
return element;
}
}
这些比较简单,主要是下面对比两棵虚拟dom树的diff算法。
function patch (oldVnode, vnode) {
if(isUndef(vnode)){
return
}
if (oldVnode === vnode) {
return
}
if(sameVnode(oldVnode, vnode)){
patchVnode(oldVnode, vnode)
}else{
const parentElm = oldVnode.elm.parentNode;
createElm(vnode,parentElm,oldVnode.elm)
removeVnodes(parentElm,[oldVnode],0,0)
}
}
function sameVnode (a, b) {
return (
a.key === b.key &&
a.tagName=== b.tagName &&
sameInputType(a, b)
)
}
function sameInputType (a, b) {
if (a.tag !== 'input') return true
return a.props.type == b.props.type
}
可以看到,如果两棵树相同,即没有发生变化,直接返回。
因为虚拟dom只是判断两棵树的同一层的树结构有没有变化,所以这里判断两个根节点是否为sameVnode,如果是,就执行更关键的patchVnode函数,如果不是,直接新建这棵新树。
function patchVnode(oldVnode, vnode){
var ch = vnode.children
var oldCh = oldVnode.children
if(isUndef(vnode.text)){
if(isDef(ch) && isDef(oldCh)){
updateChildren(oldVnode.elm,oldCh,ch)
}else if(isDef(ch)){
if (isDef(oldVnode.text)) setTextContent(oldVnode.elm, '')
addVnodes(oldVnode, ch, 0, ch.length - 1)
}else if(isDef(oldCh)){
removeVnodes(oldVnode.elm, oldCh, 0, oldCh.length - 1)
}
}else{
setTextContent(oldVnode.elm,vnode.text);
}
}
已知patchVnode函数是两个根节点相同的树了,需要的是判断他们两个的子节点。
根据代码中的几个判断可以得知,如果元素是文本节点,直接替换其中的文本即可。
如果新树和旧树都有子节点,则执行更为关键的updateChildren函数,如果新树有子节点,老树没有,直接添加子节点,如果新树没有子节点,老树有,直接删除子节点。
function updateChildren(parentElm, oldCh, newCh,){
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
insertBefore(parentElm, oldStartVnode.elm, oldEndVnode.elm.nextSibling)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) {
createElm(newStartVnode, parentElm, oldStartVnode.elm)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode)
oldCh[idxInOld] = undefined
insertBefore(parentElm,vnodeToMove.elm, oldStartVnode.elm)
} else {
createElm(newStartVnode, parentElm, oldStartVnode.elm)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, newCh, newStartIdx, newEndIdx)
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
updateChildren函数用到了四个指针,就是判断比较多。oldStartIdx 、oldEndIdx 分别指向老树的头和尾,newStartIdx 、newEndIdx 分别指向新树的头和尾。
如果新树的头等于老树的头,两个startId都++,如果新树的尾等于老树的尾,两个endId都–。
如果新树的头等于老树的尾,则把老树的尾移动到老树的头前,然后newStartIdx ++,oldEndIdx --。
如果新树的尾等于老树的头,则把老树的头移动到老树的尾后面,然后oldStartIdx ++,newEndIdx --。
如果上面四个判断都不成立,如果新树的头有key的话,就直接找有key的老树节点,没有key则将新树的头与现在老树头和尾直接的元素一一比较。如果有相同的,就把老树的这个节点移动到老树的头前,newStartIdx ++;如果没有相同的,就新建这个节点,插到老树的头前,newStartIdx ++。
操作真实dom的代码如下
function setTextContent(elm, content){
elm.textContent = content;
}
function addVnodes (parentElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
createElm(vnodes[startIdx], parentElm, null)
}
}
function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
for (let i=startIdx; i <= endIdx; i++) {
var ch = vnodes[i]
if(ch){
parentElm.removeChild(vnodes[i].elm)
}
}
}
function createElm (vnode, parentElm, afterElm) {
let element = vnode.render()
vnode.elm = element;
if(isDef(afterElm)){
insertBefore(parentElm,element,afterElm)
}else{
parentElm.appendChild(element)
}
return element;
}
function insertBefore(parentElm,element,afterElm){
parentElm.insertBefore(element,afterElm)
}