虚假的你就如同汉堡不健康却好吃,虚拟的dom不好写却真香!

644 阅读8分钟

真实dom与虚拟dom

真实dom

  • 我们知道在正常情况下我们对于dom的curd操作都是基于真实dom结构来进行,同样js也给我们提供和很多便利的api。但是随着业务量的逐渐庞杂,对于dom操作的需求就会变得复杂且频繁。不停的操作真实的dom会引起dom频繁的排版与重绘,这样会影响前端页面的效率。另一方面我们会发现真实dom上挂载有诸多属性,这些属性多数在日常使用中是用不到的,所以从另外层面上反应出真实dom比较臃肿。我们可以循环一个普通的dom来看下对应的属性。如下:

虚拟dom

为了解决真实dom带来的性能问题,现在主流的做法是引入虚拟dom。虚拟dom其实就是通过js对象来描述真实的dom结构。我们在内存中操js对象会比直接操作真实dom结构快。每次有dom变化的时候,通过算法来对比前后虚拟dom(js对象)间的差异,最后只需要更新有差异的dom结构就可以了。这样就尽可能的减少对真实dom的操作,提高性能。

模拟生成虚拟dom

需要通过对象来描述dom结构,需要将对应的dom标签、属性、样式等都能够描述清楚就可以了。下面定义Element类来描述真实的dom映射对象,当然这里为了便于理解,不去描述过多特性。代码如下:

   class Element {
        constructor(type, props, children) {
            this.type = type;
            this.props = typeof props.props ===  "undefined"? props:props.props;
            this.children = children || [];
        }
    }
    function h(type, props, children) {
        return new Element(type, props, children);
    }

这里定义h函数来生成虚拟dom,代码如下:

function h(type, props, children) {
        return new Element(type, props, children);
    }

生成dom结构如图:

渲染虚拟dom

虚拟dom虽然是通过对象来模拟真实dom但是最终还是需要将表述的结构渲染到真实的dom结构中,我们通过定义render函数来实现虚拟dom渲染到真实dom中的过程,代码如下:

function render(vdom) {
        let el = document.createElement(vdom.type);
        if (typeof vdom.props === "string") {
            el.innerHTML = vdom.props;
        }

        if (typeof vdom.children === "string") {
            el.innerHTML = vdom.children;
        }

        if (typeof vdom.props === "object") {
            console.log(vdom.props)
            for (let key in vdom.props) {
                setAttrs(el, key, vdom.props[key]);
            }
        }

        if (Array.isArray(vdom.children) && vdom.children.length > 0) {
            vdom.children.forEach(item => {
                el.appendChild(render(item));
            })
        }
        return el;
    }

    function setAttrs(node, key, value) {
        switch (key) {
            case 'value':
                if (node.tagName.toUpperCase() === 'INPUT' || node.tagName.toUpperCase() === "TEXTAREA") {
                    node.value = value;
                } else {
                    node.setAttribute(key, value);
                }
                break;
            case 'style':
                for (let styleName in value) {
                    node.style[styleName] = value[styleName];
                }
                break;
            default:
                node.setAttribute(key, value)
                break;
        }
    }

上述代码通过render函数及辅助函数setAttrs来将虚拟dom转换成真实dom。是不是我们有了虚拟dom,在可以转换成真实dom之后就能够提高性能了呢?答案当然是否定的。那如何才能将dom操作降到最低呢?答案是通过diff来在js层面比较虚拟dom之间的差异,将差异的位置渲染到真实的dom中去,这样就最小化操作dom节约性能。

diff操作

diff操作就是查找更新前后虚拟dom操作差异,然后最小化更新到dom结构上。在diff时候注意为了减少运算,都会采取同层级比较。不会垮层级比较不同。这里我们将不同的改变标记成不同类型,比如,属性改变标记为 ATTRS,文本改变标记为 TEXT ,删除标记为 REMOVE 等等 。将虚拟dom的每一层标记对应的索引。代码如下:

const ATTRS = 'ATTRS';
const TEXT = 'TEXT';
const REMOVE = 'REMOVE';
const REPLACE = 'REPLACE';
const ADD = 'ADD';
let Index = 0;

export default function diff(oldTree, newTree) {
	let patches = {}; //补丁
	let index = 0; //开始比较的节点
	//递归树  比较后的结果放到补丁包中
	walk(oldTree, newTree, index, patches)
	return patches;
}
//属性比较
function diffAttr(oldAttrs, newAttrs) {
	let patch = {}
	//新旧属性对比
	for (let key in oldAttrs) {
		if(key==="style"){
			if(!isObjectValueEqual(oldAttrs[key],newAttrs[key])){
				patch[key] = newAttrs[key]
			}
		}else{
			if (oldAttrs[key] !== newAttrs[key]) {
				patch[key] = newAttrs[key]
			}
		}
	}
	for (let key in newAttrs) {
		//老节点没有新节点的属性
		if (!oldAttrs.hasOwnProperty(key)) {
			patch[key] = newAttrs[key]
		}
	}
	return patch;
}

function walk(oldNode, newNode, index, patches) {
	//自己的补丁包
	let currentPatch = []
	//节点删除
	if (!newNode) {
		console.log(oldNode,newNode);
		currentPatch.push({
			type: REMOVE,
			index,
			oldNode
		})
	} else if (!oldNode) {
		// 节点新增
		currentPatch.push({
			type: ADD,
			index,
			newNode
		})
	} else if (isString(oldNode) && isString(newNode)) {//字符串
		//文本不一致修改为最新的
		if (oldNode !== newNode) {
			currentPatch.push({
				type: TEXT,
				text: newNode
			})
		}
	} else if (oldNode.type === newNode.type) {//节点类型相同
		if (typeof oldNode.props === "object" && typeof newNode.props === "object") {
			let attrs = diffAttr(oldNode.props, newNode.props);
			//判断属性是否有修改
			if (Object.keys(attrs).length > 0) {
				currentPatch.push({
					type: ATTRS,
					attrs
				})
			}
		}
		//子节点  遍历
		diffChildren(oldNode.children, newNode.children, patches);
	} else { //节点被替换
		currentPatch.push({
			type: REPLACE,
			newNode
		})
	}
	//有补丁
	if (currentPatch.length > 0) {
		//将元素和补丁对应放到外面的大补丁中去
		patches[index] = currentPatch
	}
}

function diffChildren(oldChildren, newChildren, patches) {
	if (oldChildren.length >= newChildren.length) {
		oldChildren.forEach((child, idx) => {
			//索引全局 index
			walk(child, newChildren[idx], ++Index, patches)
		})
	} else {
		newChildren.forEach((newchild, idx) => {
			//索引全局 index
			walk(oldChildren[idx], newchild, ++Index, patches)
		})
	}
}

function isString(node) {
	return Object.prototype.toString.call(node) === "[object String]"
}

function isObjectValueEqual (a, b) {   
	//取对象a和b的属性名
	var aProps = Object.getOwnPropertyNames(a);
	var bProps = Object.getOwnPropertyNames(b);
	//判断属性名的length是否一致
	if (aProps.length != bProps.length) {
		return false;
	}
	//循环取出属性名,再判断属性值是否一致
	for (var i = 0; i < aProps.length; i++) {
	  var propName = aProps[i];
	  if (a[propName] !== b[propName]) {
		  return false;
	  }
	}
	return true;
  }
  

通过 diff来获取每次更新的差异patch,这样我们会得到定义差异内的各种patch。

  • 有节点修改得到patch如下: 对比之后得到的patch如下:

同理对于add、remove、text、attrs 都做了类似的处理得到对应的patch。接下来需要将patch更新到视图上。

更新视图

针对不同类型的patch来做最小化dom操作更新就可以了。代码如下:

import {Element,render,setAttrs} from './vdom.js';
let allPatches;
let index = 0;  //需要补丁的索引

export default function patch(node, patches){
    allPatches = patches
	patchWalk(node);
}

function patchWalk(node){
	let currentPatch = allPatches[index++]
	let childNodes = node.childNodes;
	childNodes.forEach(child=>{
		patchWalk(child)
	})
	//有补丁
	if(currentPatch){
		doPatch(node, currentPatch)
	}
}

//给对应节点对应补丁
function doPatch(node, patches){
	patches.forEach(item => {
		switch(item.type){
			// 处理属性
			case 'ATTRS':
				for(let key in item.attrs){
					let val = item.attrs[key]
					if(val){
						setAttrs(node, key, val)	
					}
				}
			break;
			// 处理文本改变 
			case 'TEXT':
				node.textContent = item.text
			break;
			// 处理节点更新
			case 'REPLACE':
                let newNode = (item.newNode instanceof Element) ? render(item.newNode) : document.createTextNode(item.newNode);
				node.parentNode.replaceChild(newNode, node)
			break;
			// 处理节点删除
			case 'REMOVE':
				node.parentNode.removeChild(item.oldNode.el);	
			break;
			// 添加节点
			case 'ADD':
				node.parentNode.appendChild(render(item.newNode));
			break;
			
		}
	})
}

恩 有了这些我们就可以完成一次dom结构的更新。例如如下新旧虚拟dom : diff之后得到 ATTRS及ADD对应的补丁,最后通过patch函数将差异填充到视图上。完成一次对应的操作。

snabbdom虚拟dom库

通过上述代码其实可以通过简单的代码了解虚拟dom、diff操作及真实dom和虚拟dom中相互转换过程,但是如果要把代码应用于生产还是需要考虑更多。好在目前很多框架已经集成了虚拟dom的生成,渲染及 diff操作。很典型的就是vue及reactjs 都引入了虚拟dom及diff操作。虽然实现方式上不尽相同但是思路上是共通的。很多情况下不需要我们自己去实现 虚拟dom及diff操作。vue中的虚拟dom及diff更多的参考了snabbdom 虚拟dom库。snabbdom提供了很多api来帮我们实现虚拟dom到真实dom相互转换也提供diff算法。github地址 相关使用可以看下文档。下面我们通过snabbdom来实现一个案例来看下snabbdom虚拟dom库的使用。案例是一个员工列表信息排序。如下:

这样一个年龄及性别筛选通过操作原生dom结构当然是很简单实现,但是这里我们通过这个排序用虚拟dom来实现。这里我使用原生的js环境,不在配置webpack环境。

  • 首先需要引入snabbdom相关js 如下:
   <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/snabbdom.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/h.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/snabbdom-patch.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/tovnode.js"></script>
  • 引入h及patch函数 针对这个案例 我需要用到生成虚拟dom的h及更新视图的patch函数 所以先要把需要的函数解构出来。当然也还有很多其他的函数这里不一一赘述可以查看文档。
let { h, patch } = snabbdom.default;
  • 根据数据渲染视图 这里的数据直接写的对象数组,如下:
let data = [
           {
               id: 1,
               name: '小明',
               age: 24,
               gender: '男'
           },
           {
               id: 2,
               name: '小芳',
               age: 30,
               gender: '女'
           },
           {
               id: 3,
               name: '小美',
               age: 31,
               gender: '女'
           },
           {
               id: 4,
               name: '小刚',
               age: 21,
               gender: '男'
           },
           {
               id: 5,
               name: '小琪',
               age: 18,
               gender: '女'
           }
       ];

定义renderData函数来渲染数据,这里根据数据先渲染成虚拟节点,事件也绑定在虚拟节点上进行操作。内容如下:

 	function renderData(data) {
            let trvnode = data.map(item => {
                return h("tr", [h("th", item.id),
                h("th", item.name),
                h("th", item.age),
                h("th", item.gender)])
            })
            return h("div.wrap", [
                h("div.ctrl", [
                    h("div.age_sort.nu", [
                        h(ageIndex === 0 ? "a.active" : "a", { props: { "href": "javascript:;" }, on: { "click": [ageSort, 0] } }, "年龄从小到大"),
                        h(ageIndex === 1 ? "a.active" : "a", { props: { "href": "javascript:;" }, on: { "click": [ageSort, 1] } }, "年龄从大到小"),
                        h(ageIndex === 2 ? "a.active" : "a", { props: { "href": "javascript:;" }, on: { "click": [ageSort, 2] } }, "默认")
                    ]),
                    h("div.gender_show", [
                        h(genderIndex === 0 ? "a.active" : "a", { props: { "href": "javascript:;" }, on: { "click": [genderFilter, 0] } }, "只显示男性"),
                        h(genderIndex === 1 ? "a.active" : "a", { props: { "href": "javascript:;" }, on: { "click": [genderFilter, 1] } }, "只显示女性"),
                        h(genderIndex === 2 ? "a.active" : "a", { props: { "href": "javascript:;" }, on: { "click": [genderFilter, 2] } }, "默认排序")
                    ])
                ]),
                h("table#table", [
                    h("thead", [
                        h("tr", [
                            h("th", "id"),
                            h("th", "姓名"),
                            h("th", "年龄"),
                            h("th", "性别")
                        ])
                    ]),
                    h("tbody", trvnode)
                ])
            ])
        }
  • 将虚拟dom更新到真实dom节点
let el = document.querySelector(".wrap");
function update(data) {
    el = patch(el, renderData(data));
}
update(data);
  • 定义年龄排序规则
let ageSortArr = [data => data.map(item => item).sort((r1, r2) => r1.age - r2.age), data => data.map(item => item).sort((r1, r2) => r2.age - r1.age), data => data];
  • 定义性别筛选规则
let genderFilterArr = [data => data.filter(item => item.gender === "男"), data => data.filter(item => item.gender === "女"), data => data];
  • 定义年龄排序事件函数
function ageSort(index) {
          let newData = ageSortArr[index](data);
          let res = genderFilterArr[genderIndex](newData);
          ageIndex = index;
          update(res);
      }
  • 定义性别筛选事件函数
 function genderFilter(index) {
         let newData = genderFilterArr[index](data);
         let res = ageSortArr[ageIndex](newData);
         genderIndex = index;
         update(res);
     }

每次点击事件后会更新数据,通过数据生成新的vdom 然后通过update里的patch方法在对比新旧vdom,最后更新到视图。这里的diff过程在patch的时候一并操作了。使用上更加简单了。同样也减少了dom的操作。

项目中引入虚拟节点及diff操作节约性能是一个相对概念。如果节点及项目复杂程度并不高,引入虚拟dom及diff操作会适得其反,只有在dom操作到达一定量级的时候性能才能就体现出来。但是作为现在前端开发,需要在多终端及多语言中使用dom,虚拟dom引入可以帮我们统一虚拟dom的描述,如同json一样更方便的跨端及跨平台使用。


  • 这是我们团队的开源项目 element3
  • 一个支持 vue3 的前端组件库