虚拟DOM(Virtual DOM) 是对DOM的JS抽象表示,它们是JS对象,能够描述DOM的结构和关系。应用的各种状态变化会体现虚拟DOM上,最终映射到真实DOM。
虚拟DOM轻量、快速。当虚拟DOM发生变化时通过新旧虚拟DOM的对比,得到最小的DOM操作量,从而提升性能和用户体验。
跨平台:虚拟DOM是可以实现跨平台的,将虚拟DOM转换为不同平台运行时的操作来实现跨平台。
兼容性:还可以加入兼容性的代码增强兼容性
在react和Vue中,虚拟dom的创建都是由模板或者JSX完成,模版到compile和render函数的转译和JSX到新建虚拟dom的转译是由工程化(webpack、loader)完成。
既然是手写,那么我们就用最原始的方式来完成。
在渲染虚拟DOM之前,我们要做一些准备工作,通过观察真实DOM和组件我们可以知道:
虚拟DOM需要有自己的类型,例如:HTML标签、纯本文、组件(组件又分为class组件和function组件)等,因为我们是实现一个简单的虚拟DOM,所以我们只实现纯文本和HTML标签
我们知道真实DOM类似一个树形结构,所以我们需要知道DOM子元素的类型,是单个子元素、多个子元素还是空(纯文本或者为空)
同时,虚拟DOM是对真实DOM的描述,那么对于单个元素我们可以简单的概括为:标签、属性(属性包括样式、id、class、点击事件等)
所以我们首先要定义一些常量对上述进行区分
//虚拟DOM的类型
const vnodeType={
HTML:'HTML',//html标签
TEXT:'TEXT',//纯文本
COMPONENT:'COMPONENT',//function组件
CLASS_COMPONENT:'CLASS_COMPONENT',//class组件
}
// 子元素的类型
const childType = {
EMPTY:'EMPTY',//子元素为空(这里单纯指纯文本的情况)
SINGLE:'SINGLE',//单个子元素
MULTIPLE:'MULTIPLE'//存在多个子元素
}
注意!这里可能存在一个概念上的误区。vnodeType是指当前元素虚拟DOM的类型,主要是用来描述当前元素。
chileType我们可以简单的概括为描述子元素的个数,这个childType主要是在处理子元素更新的时候使用。
/**
* 新建虚拟DOM
* @param {string} tag 因为是实现简单的虚拟DOM,所以这个tag是简单的元素标签名
* @param {obj} data 属性
* @param {obj} children 子元素
*/
function createElement(tag, data, children = null) {
//新建虚拟DOM
let flag //用来标记当前vnode类型
if (typeof tag === 'string') {
//这是一个普通的html标签
flag = vnodeType.HTML
} else if (typeof tag === 'function') {
//如果是一个function,我们只写简版的,这里不做区分
flag = vnodeType.COMPONENT
} else {
flag = vnodeType.TEXT
}
let childrenFlag //标记children的类型,(因为我们在更新的时候会涉及到子元素的更新,所以这里也要标记一些children的类型)
if (children == null) {
//如果为空
childrenFlag = childType.EMPTY
}else if (Array.isArray(children)) {
//如果为数组
let length = children.length
if(length==0){
childrenFlag = childType.EMPTY
}else{
childrenFlag = childType.MULTIPLE
}
}else {
//其他情况认为是文本,文本按单个元素处理
childrenFlag = childType.SINGLE
//将children处理一下,返回文本类型的vnode
children = createTextVnode(children+'')
}
//返回虚拟DOM:vnode
return {
flag,//用来标记当前vnode的类型
tag,//标签:div, 文本(为空),组件(一个函数,暂时不涉及)
data,//数据
children,//子元素
childrenFlag,//children的类型
el:null//存储真实dom,开始默认为null
}
}
/**
* 处理children变为文本类型的vnode
* @param {*} text children元素
*/
function createTextVnode(text) {
return {
flag: vnodeType.TEXT,
tag: null,
key:data&&data.key,
children: text,
childrenFlag: childType.EMPTY,
el: null
}
}
渲染操作是虚拟DOM的核心操作。我们这里只实现简版的渲染方便理清思路。
function render(){
//首先我们需要区分首次渲染和再次渲染
mount(vnode,container)
}
/**
* 首次渲染虚拟DOM,挂载
* @param {obj} vnode 虚拟DOM
* @param {*} container 渲染容器
* @param {} flagNode 用来判断时是否进行插入操作
*/
function mount(vnode,container,flagNode){
//首先,根据vnode的flag进行渲染
let {flag} = vnode
//这里根据vnode的类型判断执行方式的挂载
if(flag === vnodeType.HTML){
//如果为html标签
mountElement(vnode,container,flagNode)
}else if(flag ===vnodeType.TEXT){
//如果为text纯文本
mountText(vnode,container)
}
}
/**
* 挂载html类型的虚拟DOM
* @param {obj} vnode 虚拟DOM
* @param {*} container 容器
* @param {} flagNode 用来判断时是否进行插入操作
*/
function mountElement(vnode,container,flagNode){
//html类型的vnode要根据标签名称创建dom
let dom = document.createElement(vnode.tag)
//在初次挂载的时候就将当前vnode对应的真实dom挂载到当前vnode上,以便后面挂载子元素的时候使用
vnode.el =dom
let {data,children,childrenFlag} = vnode
//挂载属性
if (data) {
for (let key in data) {
//挂载data
patchData(vnode.el, key, null, data[key])
}
}
//开始挂载子元素
if(childrenFlag !==childType.EMPTY){
//如果子元素不为空
if(childrenFlag===childType.SINGLE){
//挂载子元素
mount(children,vnode.el)
}else if(childrenFlag===childType.MULTIPLE){
for(let i=0;i<children.length;i++){
mount(children[i],vnode.el)
}
}
}
//挂载dom
flagNode?container.insertBefore(el, flagNode):container.appendChild(dom)
}
/**
* 挂载纯文本类型的虚拟DOM
* @param {odj} vnode 虚拟DOM
* @param {*} container 容器
*/
function mountText(vnode, container) {
//纯文本类型的vnode,子元素就是文本,所以直接执行
let dom = document.createTextNode(vnode.children)
vnode.el = dom
container.appendChild(vnode.el)
}
渲染虚拟DOM的时候我们还需要一个方法来渲染其中的data属性,也就是属性的挂载。挂载属性的时候我们需要对其进行区分,不同的属性进行不同的处理。
/**
* 挂载属性
* @param {*} dom 节点真实dom
* @param {string} key data对应的key
* @param {obj} preData data老值
* @param {obj} newData data新值
*/
function patchData(dom, key, preData, newData) {
//根据不同类型的属性实现不同方式的渲染
switch (key) {
case 'style':
for (let k in newData) {
//挂载style相应的属性
dom.style[k] = newData[k]
}
//patch的时候需要删除某些属性
break;
case 'class':
dom.className = newData
break;
default:
if (key[0] === '@') {
//存在@符号我们认为是点击事件
if (newData) {
dom.addEventListener(key.split(1), newData)
}
} else {
//否则,这里我们用粗暴的方式处理一下
dom.setAttribute(key, newData)
}
break;
}
}
<style>
.m-item{
font-size: 18px;
color: purple;
border: 1px solid orchid;
}
style>
<div id="app">
div>
<script>
let vnode = createElement('div', {
id: 'test'
}, [
createElement('p', {key:'a',style:{color:'red'}}, '纯文本1'),
createElement('p', {key:'b','@click':()=>alert('文本2')}, '纯文本2'),
createElement('p', {key:'c','class':'m-item'}, '纯文本3'),
createElement('p', {key:'d'}, '纯文本4')
]);
render(vnode, document.getElementById('app'))
script>
运行上述代码,我们手写的js文件就会将上面createElement中的内容渲染到界面中,说明代码功能正常。那么我们接着进行下一步的操作。
之前的代码实现了虚拟DOM的初次挂载。但是当我们对虚拟DOM进行更改时需要的是更新操作。同时更新操作也是render过程中比较复杂的部分。
function render(vnode, container) {
//首先我们需要区分首次渲染和再次渲染
if(container.vnode){
patch(container.vnode,vnode,container)
}else{
mount(vnode, container)
}
//挂载完毕后将vnode挂载到container中,以此判断,是第一次渲染还是后续更新渲染
container.vnode = vnode
}
该函数主要的作用是根据新老vnode中的flag属性区分实现何种更新操作,其中替换操作和更新text操作比较简单,这里不进行赘述
function patch(prev,next,container){
let nextFlag = next.flag
let prevFlag = prev.flag
//根据新老虚拟DOM的类型进行不同的处理
if(nextFlag!==prevFlag){
//如果flag类型不同,我们直接执行替换操作
replaceVnode(prev,next,container);
}else if(nextFlag==vnodeType.HTML){
//更新element
patchElement(prev,next,container)
}else if(nextFlag==vnodeType.TEXT){
//更新text
patchText(prev,next)
}
}
/**
* 更新Text
* @param {ovj} prev 旧的vnode
* @param {*} next 新的vnode
*/
function patchText(prev,next){
let el = (next.el = prev.el)
if(next.children!==prev.children){
//直接更改dom中的text
el.nodeValue = next.children
}
}
/**
* 更新虚拟DOM的替换操作
* @param {obj} prev 旧的vnode
* @param {obj} next 新的vnode
* @param {*} container 容器
*/
function replaceVnode(prev,next,container){
//直接进行替换操作
container.removeChild(prev.el)
mount(next,container)
}
function patchElement(prev, next, container) {
if (prev.tag !== next.tag) {
//如果两者标签类型不同,直接记性替换操作
replaceVnode(prev, next, container)
return
}
let el = (next.el = prev.el)
let prevData = prev.data
let nextData = next.data
if (nextData) {
//实现更新和新增属性
for (let key in nextData) {
let prevVal = prevData[key]
let nextVal = nextData[key]
patchData(el, key, prevVal, nextVal)
}
}
if (prevData) {
//删除newData中不存在的属性
for (let key in prevData) {
let prevVal = prevData[key]
if (prevVal && !nextData.hasOwnProperty(key)) {
patchData(el, key, prevVal, null)
}
}
}
//data更新完毕
//开始更新子元素
patchChildren(
prev.childrenFlag,//子元素的类型
next.childrenFlag,
prev.children,//子元素
next.children,
el,//当前元素
)
}
这里的patchElement简单来说实现了两个功能,就是更新了dom的属性和子元素,其中最重要的部分也是所有使用虚拟DOM框架的核心部分,就是patchChildren.一般情况下,不同的框架会在这个方法的diff算法上进行区分.
patchChildren顾名思义就是对children进行更新,考虑到新旧vnode中children的类型(即single,empty,multiple三种),这里新老vnode两两结合会出现九种情况.其中新旧vnode的children都为数组(mutiple)的情况是最为复杂的.其他的八种情况我们在这里只是做一些简单的替换和删除操作.
function patchChildren(
prevChildrenFlag,//子元素的类型
nextChildrenFlag,
prevChildren,//子元素
nextChildren,
container,//当前元素
) {
//更新子元素
//先根据prevChildrenFlag进行区分
switch (prevChildrenFlag) {
case childType.SINGLE:
switch (nextChildrenFlag) {
case childType.SINGLE:
//两个都为single,则直接调用patch更新
patch(prevChildren,nextChildren,container)
break;
case childType.EMPTY:
container.removeChild(prevChildren.el)
break;
case childType.MULTIPLE:
//如果新的为多个,老得是单个,我们直接进行简化处理
container.removeChild(prevChildren.el)
for(let i=0; i<nextChildren.length;i++){
mount(nextChildren[i],container)
}
break;
}
break
case childType.EMPTY:
//老得是空的,新的直接添加就好
switch (nextChildrenFlag) {
case childType.SINGLE:
mount(nextChildren,container)
break;
case childType.EMPTY:
break;
case childType.MULTIPLE:
for(let i = 0;i<nextChildren;i++){
mount(nextChildren[i],container)
}
break;
}
break;
case childType.MULTIPLE:
//如果老得是数组的情况
switch (nextChildrenFlag) {
case childType.SINGLE:
//我们这里直接简化处理,删掉之前的,添加新的
for(let i=0;i<prevChildren.length;i++){
container.removeChild(prevChildren[i].el)
}
mount(nextChildren,container)
break;
case childType.EMPTY:
for(let i=0;i<prevChildren.length;i++){
container.removeChild(prevChildren[i].el)
}
break;
case childType.MULTIPLE:
//众多虚拟DOM在这里进行区分,每家优化算法不同
let lastIndex=0;
for (let i=0;i<nextChildren.length;i++){
let nextVnode = nextChildren[i]
let j ;
//用来标记某个新老children是否同时存在,如果同时存在则对其进行更新操作,如果老children不存在,则进行新增操作
find =false;
for (j;j<prevChildren.length;j++){
let prevVnode = prevChildren[j]
if( prevVnode.key===nextVnode.key){
find = true;
//key相同,我们认为是同一个元素
//patch更新自己的内容
patch(prevVnode,nextVnode,container)
if(j<lastIndex){
//需要移动
//insertBefore移动元素
//找到要修改元素的下一个兄弟元素
let flagNode = nextChildren[i-1].el.nextSibling
container.insertBefore(prevVnode.el,flagNode)
break;
}else{
lastIndex=j
}
}
if(!find){
//需要新增的
let flagNode =i==0?prevChildren[0].el:nextChildren[i-1].el;
mount(nextVnode,container,flagNode)
}
}
}
//移除不需要的元素
for(let i=0;i<prevChildren.length;i++){
const prevVnode = prevChildren[i]
const has = nextChildren.find(next=>next.key===prevVnode.key)
if(!has){
container.removeChild(prevVnode.el)
}
}
break;
}
break;
}
}
上述代码是对children的更新操作,其中最核心的部分就是当新老dom的子元素都有多个的时候,在这里我们采用了React 15中的处理算法,这里介绍一下简化版.
当然,React中真正的diff操作,要比这个复杂的多。因为我们是自己手写一个简版的,所以只是又一个简单概念即可。
以上便是完整的更新操作了,我们可以通过代码测试一下:
<style>
.m-item{
font-size: 18px;
color: purple;
border: 1px solid orchid;
}
</style>
<div id="app">
</div>
<script>
let vnode = createElement('div', {
id: 'test'
}, [
createElement('p', {key:'a',style:{color:'red'}}, '纯文本1'),
createElement('p', {key:'b','@click':()=>alert('文本2')}, '纯文本2'),
createElement('p', {key:'c','class':'m-item'}, '纯文本3'),
createElement('p', {key:'d'}, '纯文本4')
]);
let vnode1 = createElement('div', {
id: 'test'
}, [
createElement('p', {key:'c'}, '纯文本3'),
createElement('p', {key:'d','@click':()=>alert('文本1'),'class':'m-item'}, '纯文本4'),
createElement('p', {key:'a',style:{color:'blue'}}, '纯文本1'),
createElement('p', {key:'e',style:{color:'#ccc'}}, '纯文本5')
]);
render(vnode, document.getElementById('app'))
setTimeout(()=>{
render(vnode1,document.getElementById('app'))
},1000)
</script>
虚拟DOM就是一个可对真实dom进行描述的JS对象。因为真实dom中存在海量的属性,没次更新时都会造成大量的开销。
使用虚拟DOM进行diff算法可以在最小操作量的情况下进行dom更新。从而提升用户体验和性能。
♣以上,是个人对虚拟DOM一点浅显的了解,在此做一下记录。