真实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 的前端组件库