虚拟DOM学习与总结
虚拟DOM
虚拟DOM简而言之就是,用JS去按照DOM结构来实现的树形结构对象,一般称之为虚拟节点(VNode)
优点:解决浏览器性能问题 ,真实DOM频繁排版与重绘的效率是相当低的,虚拟DOM进行频繁修改,然后一次性比较并修改真实DOM中需要改的部分(注意!),最后并在真实DOM中进行排版与重绘,减少过多DOM节点排版与重绘损耗。
例子1:
<div>我是文本</div>
let VNode = { tag:'div', children:'我是文本' }
例子2:
<div class="container" style="color:yellow"></div>
let VNode = { tag:'div', data:{ class:'container', style:{ color:'yellow' } }, children:'' }
例子3:
<div class="container"> <h1 style="color:red">标题</h1> <span style="color:grey">内容</span> <span></span> <div>
let VNode = { tag: 'div', data:{ class:'container' }, children:[ { tag:'h1', data:null, children:{ data: { style:{ color:'red' } }, children: '标题' } }, { tag:'span', data:null, children:{ data: { style:{ color:'grey' } }, children: '内容' } }, { tag:'span', data:null, children:'' } ] }
看完了例子,聪明的你一定知道了什么是虚拟dom。
snabbdom
先看一眼github上的例子
snabbdom有几个核心函数,h函数,render函数和patch函数。
h函数
用于创建VNode(virtual node虚拟节点),追踪dom变化的。
React中通过babel将JSX转换为h函数的形式,Vue中通过vue-loader将模板转换为h函数。
假设在vue中我们有如下模板
<template> <div> <h1></h1> </div> </template>
用h函数来创建与之相符的VNode:
const VNode = h('div',null,h('h1'))
得到的VNode对象如下:
const VNode = { tag: 'div', data: null, children: { tag: 'span', data: null, children: null } }
什么是虚拟DOM的挂载
虚拟DOM挂载:将虚拟DOM转化为真实DOM的过程
主要用到如下原生属性或原生方法:
-
创建标签:document.createElement(tag)
-
创建文本:document.createTextNode(text);
-
追加节点:parentElement.appendChild(element)
什么是虚拟DOM的更新
虚拟DOM更新:当节点对应得vnode发生改变时,比较新旧vnode的异同,从而更新真实的DOM节点。
let prevVNode = { //... } let nextVNode = { //... } //挂载 render(prevVNode,container) //更新 setTimeout(function(){ render(nextVNode,container) },2000)
我们在更新的时候,又分为两种情况:
-
prevVNode和nextVNode都有,执行比较操作
-
有prevVNode没有nextVNode,删除prevVNode对应的DOM即可
function render(vNode,container){ const prevVNode = container.vNode; //之前没有-挂载 if(prevVNode === null || prevVNode === undefined){ if(vNode){ mount(vNode,container); container.vNode = vNode; } } //之前有-更新 else{ //之前有,现在也有 if(vNode){ //比较 } //以前有,现在没有,删除 else{ //删除原有节点 } } }
render函数
将VNode转化为真实DOM
接收两个参数:
- 虚拟节点
- 挂载的容器
function render(VNode,container){ //... }
最终render代码
function render(vNode,container){ const prevVNode = container.vNode; //之前没有-挂载 if(prevVNode === null || prevVNode === undefined){ if(vNode){ mount(vNode,container); container.vNode = vNode; } } //之前有-更新 else{ //之前有,现在也有 if(vNode){ patch(prevVNode,vNode,container); container.vNode = vNode; } //以前有,现在没有,删除 else{ removeChild(container,prevVNode.el); container.vNode = null; } } }
patch函数
想了半天没想到怎么描述,我个人的理解就是,挂载更新,就是prevVNode 和 nextVNode 是如何进行对比的
我们现在将VNode只分为了两类:
-
元素节点
-
文本节点
那么 prevVNode 和 nextVNode 可能出现的情况只会有以下三种:
-
二者类型不同
-
二者都是文本节点
-
二者都是元素节点,且标签相同
当二者类型不同时,只需删除原节点,挂载新节点即可:
function patch (prevVNode, nextVNode, container) { removeChild(container, prevVNode.el); mount(nextVNode, container); }
当二者都是文本节点时,只需修改文本即可
function patch (prevVNode, nextVNode, container) { const el = (nextVNode.el = prevVNode.el) if(nextVNode.children !== prevVNode.children){ el.nodeValue = nextVNode.children; } }
当二者都是元素节点且标签相同时,此时比较麻烦,考虑是一个patchElement函数用于处理此种情况
function patch (prevVNode, nextVNode, container) { patchElement(prevVNode, nextVNode, container) }
最终 patch 函数的代码如下:
function patch (prevVNode, nextVNode, container) { // 类型不同,直接替换 if ((prevVNode.tag || nextVNode.tag) && prevVNode.tag !== nextVNode.tag) { removeChild(container, prevVNode.el); mount(nextVNode, container); } // 都是文本 else if(!prevVNode.tag && !nextVNode.tag){ const el = (nextVNode.el = prevVNode.el) if(nextVNode.children !== prevVNode.children){ el.nodeValue = nextVNode.children; } } // 都是相同类型的元素 else { patchElement(prevVNode, nextVNode, container) } }
比较相同tag的VNode(patchElement)
因为tag相同,所以patchElement函数的功能主要有两个:
-
检查prevVNode和nextVNode对应的元素属性是否一致(style、class、event等),不一致更新
-
比较prevVNode和nextVNode对应的子节点(children)
关于元素属性的比较与挂载阶段的逻辑基本一致,就不在此继续展开,我们主要考虑如何对子节点进行比较
子节点可能出现的情况有三种:
-
没有子节点
-
一个子节点
-
多个子节点
所以关于prevVNode和nextVNode子节点的比较,共有9种情况:
-
旧:单个子节点 && 新:单个子节点
-
旧:单个子节点 && 新:没有子节点
-
旧:单个子节点 && 新:多个子节点
-
旧:没有子节点 && 新:单个子节点
-
旧:没有子节点 && 新:没有子节点
-
旧:没有子节点 && 新:多个子节点
-
旧:多个子节点 && 新:单个子节点
-
旧:多个子节点 && 新:没有子节点
-
旧:多个子节点 && 新:多个子节点
前8中情况都比较简单,这里简单概括一下:
1.旧:单个子节点 && 新:单个子节点
都为单个子节点,递归调用patch函数
2.旧:单个子节点 && 新:没有子节点
删除旧子节点对应的DOM
3.旧:单个子节点 && 新:多个子节点
删除旧子节点对应的DOM,并将多个新子节点依次递归调用mount函数进行挂载即可
4.旧:没有子节点 && 新:单个子节点
直接调用mount函数疆新单个子节点进行挂载即可
5.旧:没有子节点 && 新:没有子节点
什么也不做
6.旧:没有子节点 && 新:多个子节点
将多个新子节点依次递归调用mount函数进行挂载即可
7.旧:多个子节点 && 新:单个子节点
删除多个旧子节点对应的DOM,递归调用mount函数对单个新子节点进行挂载即可
8.旧:多个子节点 && 新:没有子节点
删除多个旧子节点对应的DOM即可
9.旧:多个子节点 && 新:多个子节点
对于新旧子节点均为多个子节点的情况,是VNode更新阶段最复杂的情况,无论是React还是Vue都有不同的实现方案,这些实现方案也就是我们常说的Diff算法。
今天先不涉及比较复杂的Diff算法,关于Diff算法的内容,留到日后进行讲解,我们先通过最简单的方式来实现多个新旧子节点的更新(性能最差的做法)。
遍历旧的子节点,将其全部移除:
for (let i = 0; i < prevChildren.length; i++) { removeChild(container,prevChildren[i].el) }
遍历新的子节点,将其全部挂载
for (let i = 0; i < nextChildren.length; i++) { mount(nextChildren[i], container) }
最终的代码如下:
export const patchElement = function (prevVNode, nextVNode, container) { const el = (nextVNode.el = prevVNode.el); const prevData = prevVNode.data; const nextData = nextVNode.data; if (nextData) { for (let key in nextData) { let prevValue = prevData[key]; let nextValue = nextData[key]; patchData(el, key, prevValue, nextValue); } } if (prevData) { for (let key in prevData) { let prevValue = prevData[key]; if (prevValue && !nextData.hasOwnProperty(key)) { patchData(el, key, prevValue, null); } } } //比较子节点 patchChildren( prevVNode.children, nextVNode.children, el ) } function patchChildren(prevChildren, nextChildren, container) { //旧:单个子节点 if(prevChildren && !Array.isArray(prevChildren)){ //新:单个子节点 if(nextChildren && !Array.isArray(nextChildren)){ patch(prevChildren,nextChildren,container) } //新:没有子节点 else if(!nextChildren){ removeChild(container,prevChildren.el) } //新:多个子节点 else{ removeChild(container,prevChildren.el) for(let i = 0; i<nextChildren.length; i++){ mount(nextChildren[i], container) } } } //旧:没有子节点 else if(!prevChildren){ //新:单个子节点 if(nextChildren && !Array.isArray(nextChildren)){ mount(nextChildren, container) } //新:没有子节点 else if(!nextChildren){ //什么都不做 } //新:多个子节点 else{ for (let i = 0; i < nextChildren.length; i++) { mount(nextChildren[i], container) } } } //旧:多个子节点 else { //新:单个子节点 if(nextChildren && !Array.isArray(nextChildren)){ for(let i = 0; i<prevChildren.length; i++){ removeChild(container,prevChildren[i].el) } mount(nextChildren,container) } //新:没有子节点 else if(!nextChildren){ for(let i = 0; i<prevChildren.length; i++){ removeChild(container,prevChildren[i].el) } } //新:多个子节点 else{ // 遍历旧的子节点,将其全部移除 for (let i = 0; i < prevChildren.length; i++) { removeChild(container,prevChildren[i].el) } // 遍历新的子节点,将其全部添加 for (let i = 0; i < nextChildren.length; i++) { mount(nextChildren[i], container) } } } }
此文参考:
冰山工作室 http://www.bingshangroup.com/blog2/action2/jspool%EF%BC%9A%E9%99%88%E5%85%B6%E4%B8%B0/VNode2.html