Virtual DOM到底有什么迷人之处?如何搭建一款迷你版Virtual DOM库?

简介: Virtual DOM到底有什么迷人之处?如何搭建一款迷你版Virtual DOM库?

微信截图_20220506154831.png

为什么使用Virtual DOM


  • 手动操作DOM比较麻烦。还需要考虑浏览器兼容性问题,虽然有JQuery等库简化DOM操作,但是随着项目的复杂DOM操作复杂提升。


  • 为了简化DOM的复杂操作于是出现了各种MVVM框架,MVVM框架解决了视图和状态的同步问题


  • 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是Virtual DOM出现了


  • Virtual DOM的好处是当状态改变时不需要立即更新DOM,只需要创建一个虚拟树来描述DOM,Virtual DOM内部将弄清楚如何有效的更新DOM(利用Diff算法实现)。


Virtual DOM的特性


  1. Virtual DOM可以维护程序的状态,跟踪上一次的状态。


  1. 通过比较前后两次的状态差异更新真实DOM。


实现一个基础的Virtual DOM库


我们可以仿照snabbdom库https://github.com/snabbdom/snabbdom.git自己动手实现一款迷你版Virtual DOM库。


首先,我们创建一个index.html文件,写一下我们需要展示的内容,内容如下:


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>vdom</title>
    <style>
        .main {
            color: #00008b;
        }
        .main1{
            font-weight: bold;
        }
    </style>
</head>
<body>
    <div id="app"></div>
    <script src="./vdom.js"></script>
    <script>
        function render() {
            return h('div', {
                style: useObjStr({
                    'color': '#ccc',
                    'font-size': '20px'
                })
            }, [
                h('div', {}, [h('span', {
                    onClick: () => {
                        alert('1');
                    }
                }, '文本'), h('a', {
                    href: 'https://www.baidu.com',
                    class: 'main main1'
                }, '点击')
                ]),
            ])
        }
        // 页面改变
        function render1() {
            return h('div', {
                style: useStyleStr({
                    'color': '#ccc',
                    'font-size': '20px'
                })
            }, [
                h('div', {}, [h('span', {
                    onClick: () => {
                        alert('1');
                    }
                }, '文本改变了')
                ]),
            ])
        }
        // 首次加载
        mountNode(render, '#app');
        // 状态改变
        setTimeout(()=>{
            mountNode(render1, '#app');
        },3000)
    </script>
</body>
</html>


我们在body标签内创建了一个id是app的DOM元素,用于被挂载节点。接着我们引入了一个vdom.js文件,这个文件就是我们将要实现的迷你版Virtual DOM库。最后,我们在script标签内定义了一个render方法,返回为一个h方法。调用mountNode方法挂载到id是app的DOM元素上。h方法中数据结构我们是借鉴snabbdom库,第一个参数是标签名,第二个参数是属性,最后一个参数是子节点。还有,你可能会注意到在h方法中我们使用了useStyleStr方法,这个方法主要作用是将style样式转化成页面能识别的结构,实现代码我会在最后给出。


思路理清楚了,展示页面的代码也写完了。下面我们将重点看下vdom.js,如何一步一步地实现它。


第一步


我们看到index.html文件中首先需要调用mountNode方法,所以,我们先在vdom.js文件中定义一个mountNode方法。


// Mount node
function mountNode(render, selector) {
}


接着,我们会看到mountNode方法第一个参数是render方法,render方法返回了h方法,并且看到第一个参数是标签,第二个参数是属性,第三个参数是子节点。


那么,我们接着在vdom.js文件中再定义一个h方法。


function h(tag, props, children) {
    return { tag, props, children };
}


还没有结束,我们需要根据传入的三个参数tagpropschildren来挂载到页面上。


我们需要这样操作。我们在mountNode方法内封装一个mount方法,将传给mountNode方法的参数经过处理传给mount方法。


// Mount node
function mountNode(render, selector) {
  mount(render(), document.querySelector(selector))
}


接着,我们定义一个mount方法。


function mount(vnode, container) {
    const el = document.createElement(vnode.tag);
    vnode.el = el;
    // props
    if (vnode.props) {
        for (const key in vnode.props) {
            if (key.startsWith('on')) {
                el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key],{
                    passive:true
                })
            } else {
                el.setAttribute(key, vnode.props[key]);
            }
        }
    }
    if (vnode.children) {
        if (typeof vnode.children === "string") {
            el.textContent = vnode.children;
        } else {
            vnode.children.forEach(child => {
                mount(child, el);
            });
        }
    }
    container.appendChild(el);
}


第一个参数是调用传进来的render方法,它返回的是h方法,而h方返回一个同名参数的对象{ tag, props, children },那么我们就可以通过vnode.tagvnode.propsvnode.children取到它们。


我们看到先是判断属性,如果属性字段开头含有,on标识就是代表事件,那么就从属性字段第三位截取,利用addEventListenerAPI创建一个监听事件。否则,直接利用setAttributeAPI设置属性。


接着,再判断子节点,如果是字符串,我们直接将字符串赋给文本节点。否则就是节点,我们就递归调用mount方法。


最后,我们将使用appendChildAPI把节点内容挂载到真实DOM中。


页面正常显示。


微信截图_20220506154735.png


第二步


我们知道Virtual DOM有以下两个特性:


  1. Virtual DOM可以维护程序的状态,跟踪上一次的状态。
  2. 通过比较前后两次的状态差异更新真实DOM。


这就利用到了我们之前提到的diff算法。


我们首先定义一个patch方法。因为要对比前后状态的差异,所以第一个参数是旧节点,第二个参数是新节点。


function patch(n1, n2) {
}


下面,我们还需要做一件事,那就是完善mountNode方法,为什么这样操作呢?是因为当状态改变时,只更新状态改变的DOM,也就是我们所说的差异更新。这时就需要配合patch方法做diff算法。


相比之前,我们加上了对是否挂载节点进行了判断。如果没有挂载的话,就直接调用mount方法挂载节点。否则,调用patch方法进行差异更新。


let isMounted = false;
let oldTree;
// Mount node
function mountNode(render, selector) {
    if (!isMounted) {
        mount(oldTree = render(), document.querySelector(selector));
        isMounted = true;
    } else {
        const newTree = render();
        patch(oldTree, newTree);
        oldTree = newTree;
    }
}


那么下面我们将主动看下patch方法,这也是在这个库中最复杂的方法。


function patch(n1, n2) {
    // Implement this
    // 1. check if n1 and n2 are of the same type
    if (n1.tag !== n2.tag) {
        // 2. if not, replace
        const parent = n1.el.parentNode;
        const anchor = n1.el.nextSibling;
        parent.removeChild(n1.el);
        mount(n2, parent, anchor);
        return
    }
    const el = n2.el = n1.el;
    // 3. if yes
    // 3.1 diff props
    const oldProps = n1.props || {};
    const newProps = n2.props || {};
    for (const key in newProps) {
        const newValue = newProps[key];
        const oldValue = oldProps[key];
        if (newValue !== oldValue) {
            if (newValue != null) {
                el.setAttribute(key, newValue);
            } else {
                el.removeAttribute(key);
            }
        }
    }
    for (const key in oldProps) {
        if (!(key in newProps)) {
            el.removeAttribute(key);
        }
    }
    // 3.2 diff children
    const oc = n1.children;
    const nc = n2.children;
    if (typeof nc === 'string') {
        if (nc !== oc) {
            el.textContent = nc;
        }
    } else if (Array.isArray(nc)) {
        if (Array.isArray(oc)) {
            // array diff
            const commonLength = Math.min(oc.length, nc.length);
            for (let i = 0; i < commonLength; i++) {
                patch(oc[i], nc[i]);
            }
            if (nc.length > oc.length) {
                nc.slice(oc.length).forEach(c => mount(c, el));
            } else if (oc.length > nc.length) {
                oc.slice(nc.length).forEach(c => {
                    el.removeChild(c.el);
                })
            }
        } else {
            el.innerHTML = '';
            nc.forEach(c => mount(c, el));
        }
    }
}


我们从patch方法入参开始,两个参数分别是在mountNode方法中传进来的旧节点oldTree和新节点newTree,首先我们进行对新旧节点的标签进行对比。


如果新旧节点的标签不相等,就移除旧节点。另外,利用nextSiblingAPI取指定节点之后紧跟的节点(在相同的树层级中)。然后,传给mount方法第三个参数。这时你可能会有疑问,mount方法不是有两个参数吗?对,但是这里我们需要传进去第三个参数,主要是为了对同级节点进行处理。


if (n1.tag !== n2.tag) {
        // 2. if not, replace
        const parent = n1.el.parentNode;
        const anchor = n1.el.nextSibling;
        parent.removeChild(n1.el);
        mount(n2, parent, anchor);
        return
    }


所以,我们重新修改下mount方法。我们看到我们只是加上了对anchor参数是否为空的判断。


如果anchor参数不为空,我们使用insertBeforeAPI,在参考节点之前插入一个拥有指定父节点的子节点。insertBeforeAPI第一个参数是用于插入的节点,第二个参数将要插在这个节点之前,如果这个参数为 null 则用于插入的节点将被插入到子节点的末尾。


如果anchor参数为空,直接在父节点下的子节点列表末尾添加子节点。


function mount(vnode, container, anchor) {
    const el = document.createElement(vnode.tag);
    vnode.el = el;
    // props
    if (vnode.props) {
        for (const key in vnode.props) {
            if (key.startsWith('on')) {
                el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key],{
                    passive:true
                })
            } else {
                el.setAttribute(key, vnode.props[key]);
            }
        }
    }
    if (vnode.children) {
        if (typeof vnode.children === "string") {
            el.textContent = vnode.children;
        } else {
            vnode.children.forEach(child => {
                mount(child, el);
            });
        }
    }
    if (anchor) {
        container.insertBefore(el, anchor);
    } else {
        container.appendChild(el);
    }
}


下面,我们再回到patch方法。如果新旧节点的标签相等,我们首先要遍历新旧节点的属性。我们先遍历新节点的属性,判断新旧节点的属性值是否相同,如果不相同,再进行进一步处理。判断新节点的属性值是否为null,否则直接移除属性。然后,遍历旧节点的属性,如果属性名不在新节点属性表中,则直接移除属性。


分析完了对新旧节点属性的对比,接下来,我们来分析第三个参数子节点。


首先,我们分别定义两个变量ocnc,分别赋予旧节点的children属性和新节点的children属性。如果新节点的children属性是字符串,并且新旧节点的内容不相同,那么就直接将新节点的文本内容赋予即可。


接下来,我们看到利用Array.isArray()方法判断新节点的children属性是否是数组,如果是数组的话,就执行下面这些代码。


else if (Array.isArray(nc)) {
        if (Array.isArray(oc)) {
            // array diff
            const commonLength = Math.min(oc.length, nc.length);
            for (let i = 0; i < commonLength; i++) {
                patch(oc[i], nc[i]);
            }
            if (nc.length > oc.length) {
                nc.slice(oc.length).forEach(c => mount(c, el));
            } else if (oc.length > nc.length) {
                oc.slice(nc.length).forEach(c => {
                    el.removeChild(c.el);
                })
            }
        } else {
            el.innerHTML = '';
            nc.forEach(c => mount(c, el));
        }
    }


我们看到里面又判断旧节点的children属性是否是数组。


如果是,我们取新旧子节点数组的长度两者的最小值。然后,我们将其循环递归patch方法。为什么取最小值呢?是因为如果取的是他们共有的长度。然后,每次遍历递归时,判断nc.lengthoc.length的大小,循环执行对应的方法。


如果不是,直接将节点内容清空,重新循环执行mount方法。


这样,我们搭建的迷你版Virtual DOM库就这样完成了。


页面如下所示。


微信截图_20220506154751.png


源码


index.html


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>vdom</title>
    <style>
        .main {
            color: #00008b;
        }
        .main1{
            font-weight: bold;
        }
    </style>
</head>
<body>
    <div id="app"></div>
    <script src="./vdom.js"></script>
    <script>
        function render() {
            return h('div', {
                style: useObjStr({
                    'color': '#ccc',
                    'font-size': '20px'
                })
            }, [
                h('div', {}, [h('span', {
                    onClick: () => {
                        alert('1');
                    }
                }, '文本'), h('a', {
                    href: 'https://www.baidu.com',
                    class: 'main main1'
                }, '点击')
                ]),
            ])
        }
        // 页面改变
        function render1() {
            return h('div', {
                style: useStyleStr({
                    'color': '#ccc',
                    'font-size': '20px'
                })
            }, [
                h('div', {}, [h('span', {
                    onClick: () => {
                        alert('1');
                    }
                }, '文本改变了')
                ]),
            ])
        }
        // 首次加载
        mountNode(render, '#app');
        // 状态改变
        setTimeout(()=>{
            mountNode(render1, '#app');
        },3000)
    </script>
</body>
</html>


vdom.js


// vdom ---
 function h(tag, props, children) {
    return { tag, props, children };
}
function mount(vnode, container, anchor) {
    const el = document.createElement(vnode.tag);
    vnode.el = el;
    // props
    if (vnode.props) {
        for (const key in vnode.props) {
            if (key.startsWith('on')) {
                el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key],{
                    passive:true
                })
            } else {
                el.setAttribute(key, vnode.props[key]);
            }
        }
    }
    if (vnode.children) {
        if (typeof vnode.children === "string") {
            el.textContent = vnode.children;
        } else {
            vnode.children.forEach(child => {
                mount(child, el);
            });
        }
    }
    if (anchor) {
        container.insertBefore(el, anchor);
    } else {
        container.appendChild(el);
    }
}
// processing strings
function useStyleStr(obj) {
    const reg = /^{|}/g;
    const reg1 = new RegExp('"',"g");
    const str = JSON.stringify(obj);
    const ustr = str.replace(reg, '').replace(',', ';').replace(reg1,'');
    return ustr;
}
function patch(n1, n2) {
    // Implement this
    // 1. check if n1 and n2 are of the same type
    if (n1.tag !== n2.tag) {
        // 2. if not, replace
        const parent = n1.el.parentNode;
        const anchor = n1.el.nextSibling;
        parent.removeChild(n1.el);
        mount(n2, parent, anchor);
        return
    }
    const el = n2.el = n1.el;
    // 3. if yes
    // 3.1 diff props
    const oldProps = n1.props || {};
    const newProps = n2.props || {};
    for (const key in newProps) {
        const newValue = newProps[key];
        const oldValue = oldProps[key];
        if (newValue !== oldValue) {
            if (newValue != null) {
                el.setAttribute(key, newValue);
            } else {
                el.removeAttribute(key);
            }
        }
    }
    for (const key in oldProps) {
        if (!(key in newProps)) {
            el.removeAttribute(key);
        }
    }
    // 3.2 diff children
    const oc = n1.children;
    const nc = n2.children;
    if (typeof nc === 'string') {
        if (nc !== oc) {
            el.textContent = nc;
        }
    } else if (Array.isArray(nc)) {
        if (Array.isArray(oc)) {
            // array diff
            const commonLength = Math.min(oc.length, nc.length);
            for (let i = 0; i < commonLength; i++) {
                patch(oc[i], nc[i]);
            }
            if (nc.length > oc.length) {
                nc.slice(oc.length).forEach(c => mount(c, el));
            } else if (oc.length > nc.length) {
                oc.slice(nc.length).forEach(c => {
                    el.removeChild(c.el);
                })
            }
        } else {
            el.innerHTML = '';
            nc.forEach(c => mount(c, el));
        }
    }
}
let isMounted = false;
let oldTree;
// Mount node
function mountNode(render, selector) {
    if (!isMounted) {
        mount(oldTree = render(), document.querySelector(selector));
        isMounted = true;
    } else {
        const newTree = render();
        patch(oldTree, newTree);
        oldTree = newTree;
    }
}



相关文章
|
6月前
|
JavaScript 前端开发 算法
js开发:请解释什么是虚拟DOM(virtual DOM),以及它在React中的应用。
虚拟DOM是React等前端框架的关键技术,它以轻量级JavaScript对象树形式抽象表示实际DOM。当状态改变,React不直接操作DOM,而是先构建新虚拟DOM树。通过高效diff算法比较新旧树,找到最小变更集,仅更新必要部分,提高DOM操作效率,降低性能损耗。虚拟DOM的抽象特性还支持跨平台应用,如React Native。总之,虚拟DOM优化了状态变化时的DOM更新,提升性能和用户体验。
80 0
|
11月前
|
数据采集 JavaScript 小程序
laravel8(四)使用simple_html_dom库解析html
首先:simple_html_dom下载地址: github.com/samacs/simp… 这是一个PHP解析html的一个库。 这玩意还是很有用的,比如我们在使用PHP做爬虫的时候,需要解析html中的内容等。 他的强大之处不仅仅只是帮助我们验证html文档;更能解析不符合W3C标准的html文档。 关于如何引入第三方类库,请移步《laravel5.8(十)引入第三方类库》laravel8中的设置方法也大同小异。 一:加载 html 有三种方式调用这个类: 1. 从url中加载html文档 2. 从字符串中加载html文档 3. 从文件中加载html文档
107 1
|
4月前
|
JavaScript 前端开发 API
前端框架与库 - jQuery基础与DOM操作
【7月更文挑战第18天】jQuery 是一个简化JavaScript任务的库,以其“write less, do more”理念著称。核心功能包括DOM操作、事件处理和Ajax。DOM操作如选择元素(`$(&quot;p&quot;)`、`$(&quot;#myDiv&quot;)`、`$(&quot;.myClass&quot;)`)、创建及添加元素、修改属性和内容。事件处理如绑定(`click`)和触发(`trigger`)。常见问题涉及`$`符号冲突(使用`jQuery`代替)、异步加载管理和选择器性能优化。了解并规避这些问题能提升jQuery使用效率。
32 0
|
4月前
|
XML JavaScript 数据格式
【Python】已解决:(Python xml库 import xml.dom.minidom导包报错)‘No module named dom’
【Python】已解决:(Python xml库 import xml.dom.minidom导包报错)‘No module named dom’
76 0
|
6月前
|
XML JavaScript 数据格式
Beautiful Soup 库的工作原理基于解析器和 DOM(文档对象模型)树的概念
【5月更文挑战第10天】Beautiful Soup 使用解析器(如 html.parser, lxml, html5lib)解析HTML/XML文档,构建DOM树。它提供方法查询和操作DOM,如find(), find_all()查找元素,get_text(), get()提取信息。还能修改DOM,添加、修改或删除元素,并通过prettify()输出格式化字符串。它是处理网页数据的利器,尤其在处理不规则结构时。
77 2
|
6月前
|
数据采集 存储 JavaScript
PHP爬虫技术:利用simple_html_dom库分析汽车之家电动车参数
本文旨在介绍如何利用PHP中的simple_html_dom库结合爬虫代理IP技术来高效采集和分析汽车之家网站的电动车参数。通过实际示例和详细说明,读者将了解如何实现数据分析和爬虫技术的结合应用,从而更好地理解和应用相关技术。
PHP爬虫技术:利用simple_html_dom库分析汽车之家电动车参数
|
11月前
|
数据采集 监控 JavaScript
巧用简单工具:PHP使用simple_html_dom库助你轻松爬取JD.com
本文将介绍如何使用PHP语言和一个简单的第三方库simple_html_dom来爬取JD.com的商品信息。simple_html_dom是一个轻量级的HTML解析器,它可以方便地从HTML文档中提取元素和属性,而无需使用正则表达式或DOM操作。本文将通过一个实例来展示如何使用simple_html_dom库来爬取JD.com的商品名称、价格、评分和评论数,并将结果保存到CSV文件中。本文还将介绍如何使用代理IP技术来避免被目标网站封禁或限制。
巧用简单工具:PHP使用simple_html_dom库助你轻松爬取JD.com
|
6月前
|
XML JavaScript 前端开发
React中 Real DOM 和 Virtual DOM 的区别?优缺点?
React中 Real DOM 和 Virtual DOM 的区别?优缺点?
81 0
|
6月前
|
JavaScript 前端开发 算法
React中的Virtual DOM(看这一篇就够了)
React中的Virtual DOM(看这一篇就够了)
950 0
|
6月前
|
JavaScript 前端开发 算法
Virtual DOM
Virtual DOM
30 0