我学会了,实现一个vdom

简介: 在vue和react中都有用到使用vdom来进行渲染界面,在页面刚创建的时候使用vdom来渲染界面性能并没有直接使用dom操作来渲染页面好,但是在更新界面的时候使用虚拟dom比直接使用dom操作要性能好,当然你如果原生dom操作非常熟练、经验也非常丰富,可能也有例外,不过那么多原生dom操作也会有比较大的心智压

前言

在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存在的意义,让你的开发更加的便捷,同时降低了心智上的压力,能够更好的关注具体的业务从而做出更好的应用,性能在绝大多数情况下都是很不错的。

目录
相关文章
|
4月前
|
前端开发 JavaScript API
React 之 Suspense
React 之 Suspense
192 0
|
18天前
|
存储 前端开发 JavaScript
react的useRef用什么作用
react的useRef用什么作用
12 1
|
1月前
|
存储 自然语言处理 JavaScript
vue中template到VDOM发生了什么
vue中template到VDOM发生了什么
vue中template到VDOM发生了什么
|
4月前
|
JavaScript 算法
vue如何通过VNode渲染节点
vue如何通过VNode渲染节点
94 0
|
前端开发
react总结之react-router-dom
react总结之react-router-dom
|
4月前
|
前端开发 JavaScript
React useRef 详细使用
React useRef 详细使用
61 0
|
11月前
|
前端开发 JavaScript
react createElement 和 cloneElement 有什么区别?
react createElement 和 cloneElement 有什么区别?
70 0
|
前端开发
react children初步理解
react children初步理解
39 0
|
JavaScript 前端开发
从认识 VNode & VDOM 到实现 mini-vue(下)
从认识 VNode & VDOM 到实现 mini-vue
64 0
|
JavaScript 前端开发 Android开发
从认识 VNode & VDOM 到实现 mini-vue
从认识 VNode & VDOM 到实现 mini-vue
162 0