前言
在vue和react中都有用到使用vdom来进行渲染界面,在页面刚创建的时候使用vdom来渲染界面性能并没有直接使用dom操作来渲染页面好,但是在更新界面的时候使用虚拟dom比直接使用dom操作要性能好,当然你如果原生dom操作非常熟练、经验也非常丰富,可能也有例外,不过那么多原生dom操作也会有比较大的心智压力。
准备工作
vite 官网:https://cn.vitejs.dev/guide/
使用vite是因为它比较快,也很成熟,支持多种模板,实现虚拟dom的话只需要空的模板即可,然后自己加个jest,然后再加个esm 转 cjs的babel插件即可,因为jset默认不支持import这样的语法。
jset 官网:https://jestjs.io/zh-Hans/docs/getting-started
babel插件:@babel/plugin-transform-modules-commonjs
yarn create vite vdom
cd vdom
yarn install
yarn add jset
yarn add @babel/plugin-transform-modules-commonjs
其它的看仓库代码,能正常运行,也都有注释,代码是基于下面的设计思路来实现的,没那么复杂。在浏览器直接看代码,点击 github1s。
设计思路
四个操作:正常的虚拟dom操作,diff算法,h函数返回虚拟dom,还有renderer渲染器将dom插入进视图。
dom操作
用过jquery就知道,dom操作也无非就是增删改查。
// 新增
createElement(typeName: string): HTMLDomElement;
insert(el:HTMLDomElement, parent:HTMLDomElement): void;
createText(text: string): Text;
// 删除
remove(el:HTMLDomElement, parent:HTMLDomElement): void;
// 修改
replaceElement(oldEle:HTMLDomElement, newEle:HTMLDomElement): void;
setText(el:HTMLDomElement, text: string): void;
setInnerHTML(el:HTMLDomElement, text: string): void;
// 事件的名称纠正和绑定,然后属性的替换、设置和移除
patchProp(el: HTMLDomElement, key: string, prevVal: any, nextVal: any): void;
diff 算法
这里只做大概的diff设计,简单理解的diff,diff开发中比较常见的,比如git diff、code diff。
先对比tag,不一样就直接替换。
tag一样就检测props。props存在的话,props中新节点不是老节点,直接替换。props中老节点有但是新节点没有,那么全删掉。
然后检测children,children有两种,一种是字符串,一种是数组。
新的children是字符串,而之前的children也是字符串,那就对比字符串,看看是否要替换。
新的children是字符串,之前的children是数组,那就整个替换。
新的children是数组,而之前的children是字符串,那就把之前的节点清空,再遍历挂载新children中的的元素。
新的children是数组,而之前的children也是数组,这里就复杂一点了,需要递归。简单的话就是按照新旧节点中最少的节点数来进行循环递归,然后看看有没有必要移除掉旧节点中多余的节点,比如旧节点比新节点多。最后看看有没有必要添加新的节点,比如新的节点比旧节点多。
递归是一个深度优先遍历的过程,并不复杂,我有一篇文章有写通过链表来思考递归,递归和回溯的设计是接触算法比较友好的思想的噢,不会很难。
h函数
h用于返回一个虚拟dom,类似一个集装箱吧,把虚拟dom组装好,返回给你。这里只做简单的实现,理解思想就行。
// 这里偷了点懒,不过理解它的入参类型即可,不用纠结具体的参数中每一个类型具体的细节
h(tag: string, props: object, children: any[]): object;
renderer 渲染器
这里实现一个mountElement就行了,将vnode渲染成真实的dom,处理props,处理children,插入到视图中,返回传入的vonde。
mountElement(vnode: object, container: string|HTMLDomElement): object;
代码实现
dom.js
import { log } from './_log.js'
/* 新增相关 */
export function createElement(typeName) {
log('创建元素',['createElement'])
return document.createElement(typeName)
}
export function insert (el, parent) {
log('插入元素',['insert'])
parent.append(el)
}
export function createText(text) {
log('创建文本',['createText'])
return new Text(text)
}
/* 删除相关 */
export function remove (el, parent) {
log('移除元素',['insert'])
parent.remove(el)
}
/* 修改相关 */
export function replaceElement (oldEle, newEle) {
log('替换元素',['repalceElement'])
oldEle.repaceWith(newEle)
}
export function setText (el, text) {
log('设置文本', ['setText'])
el.textContent = text // 支持空格换行,innerText会把空白符都清除
// el.innerText = text
}
export function setInnerHTML (el, text) {
log('设置InnerHTML', ['setInnerHTML'])
el.innerHTML = text
}
/* 事件的名称纠正和绑定,然后属性的替换、设置和移除 相关*/
export function patchProp (el, key, prevVal, nextVal) {
log('patchProp')
if (key.starsWith('on')) {
// el.addEventListener(key.slice(2).toLowerCase(), nextVal)
el.addEventListener(key.slice(2).toLocaleLowerCase(), nextVal) // 支持不同的语言环境时,采用本地地区的转换方法
log('事件名称纠正和绑定')
return
}
log('属性替换、设置或者移除')
nextVal === null ? el.removeAttribute(key) : el.setAttribute(key, nextVal)
}
h.js
import { log } from './_log'
export function h(tag, props, children = []){
log('返回vdom',['h'])
return {
tag, props, children
}
}
renderer.js
import {
createElement,
patchProp,
insert,
createText
} from './dom'
export function createMount () {
return mountElement
function mountElement(vnode, container) {
vnode.el = createElement(vnode.tag)
const el = vnode.el
// 处理props
vnode.props && Object.keys(vnode.props).forEach(keyName => {
const val = vnode.props[keyName]
patchProp(vnode.el, keyName, null, val)
})
// 处理children
Array.isArray(vnode.children) ? vnode.children.forEach(v => mountElement(v, el)) : insert(createText(vnode.children), el)
// 插入到视图中
insert(el, container)
// 返回vnode
return vnode
}
}
diff.js
import {
patchProp,
setText,
createElement,
replaceElement,
remove
} from './dom'
export function createDiff(mountElement) {
return diff
function diff (oldNode, newNode) {
const { props: oldProps, el: oldEl, tag: oldTag, children: oldChildren = [] } = oldNode
const { props: newProps, tag: newTag, children: newChildren = [] } = newNode
// 标签不同 便可替换
if (oldTag !== newTag) {
replaceElement(oldEl, createElement(newTag))
return
}
const el = newNode.el = oldEl
if (newProps) {
// 新旧不同 便可替换
Object.keys(newProps).forEach(keyName => {
newProps[keyName] !== oldProps[keyName] && patchProp(el, keyName, oldProps[keyName], newProps[keyName])
})
// 旧有新无 便可移除
Object.keys(oldProps).forEach(keyName => {
(!newProps[keyName]) && patchProp(el, keyName, oldProps[keyName], null)
})
}
if (typeof newChildren === 'string') {
// 都是字符串,但值不同,那么直接替换
typeof oldChildren === 'string' && newChildren !== oldChildren && setText(el, newChildren)
// 新children为字符串,旧children为数组,那么直接整个替换
Array.isArray(oldChildren) && setText(oldEl, newChildren)
return
}
// 非字符串 非数组,那就是错误的数据类型
if (!Array.isArray(newChildren)) {
throw 'children is not string or array.'
}
// 旧children为字符串,新children为数组,那就先清空dom内容,再生成新children的dom内容挂载到之前清空的这个dom中
typeof oldChildren === 'string' && (setText(el, ''), newChildren.forEach(vnode => { mountElement(vnode, el) }))
if (Array.isArray(oldChildren)) {
const [oldLen, newLen] = [newChildren.length, oldChildren.length]
const minLen = Math.min(oldLen, newLen)
// 最小长度的对比,从左到右,按照顺序对比vnode
for (let i = 0; i < minLen; i++) {
const [oldVnode, newVnode] = [oldChildren[i], newChildren[i]]
diff(oldVnode, newVnode)
}
// 移除多的节点
oldLen > minLen && oldChildren.filter((_, i) => i >= minLen).forEach(vnode => remove(vnode.el, el))
// 添加少的节点
newLen > minLen && oldChildren.filter((_, i) => i >= minLen).forEach(vnode => mountElement(vnode, el))
}
}
}
总结
从图中看更新虚拟dom的节点在更新的时候是针对性的更新数据变更的那部分dom,而真实dom操作一般都是数据发生变化导致整个部分的dom都会重新渲染,从而让页面重新排版重新布局,性能损耗大同时体验也不好。
虚拟DOM的存在是为了提高渲染的性能,减少多余的重绘和回流,原生DOM操作的话,当你数据发生变化,会整块替换。而虚拟dom通过diff对比找到需要更新的dom,从而针对性的替换那一小部分,所以性能更优。
使用虚拟DOM算法的性能损耗 = 虚拟DOM增删改 + diff 真实DOM差异 + 针对性的一小部分渲染时的回流和重绘。
操作真实DOM的的性能损耗 = 真实DOM的增删改 + 大部分或者小部分渲染时的回流和重绘。
综上分析,如果你使用真实DOM的操作,在遇到很大的业务场景时,如果你的经验丰富并且写的非常好,那么肯定比虚拟DOM的性能好,但是这和个人水平及心智上的抗压压力有关系,没有谁愿意写那种非常冗余并且重复、还难以维护的代码、开发效率还低的代码。
所以这就是虚拟dom存在的意义,让你的开发更加的便捷,同时降低了心智上的压力,能够更好的关注具体的业务从而做出更好的应用,性能在绝大多数情况下都是很不错的。