资讯专栏INFORMATION COLUMN

从零开始,手写一个简易的Virtual DOM

forrest23 / 1231人阅读

摘要:本文为笔者通过实际操作,实现了一个非常简单的,加深对现今主流前端框架中的理解。用对象表示树是用对象表示,并存储在内存中的。如果类型不一致,那么属性一定是被更新的。如果有不相等的属性,则认为发生改变,需要处理的变化。

众所周知,对前端而言,直接操作 DOM 是一件及其耗费性能的事情,以 React 和 Vue 为代表的众多框架普遍采用 Virtual DOM 来解决如今愈发复杂 Web 应用中状态频繁发生变化导致的频繁更新 DOM 的性能问题。本文为笔者通过实际操作,实现了一个非常简单的 Virtual DOM ,加深对现今主流前端框架中 Virtual DOM 的理解。

关于 Virtual DOM ,社区已经有许多优秀的文章,而本文是笔者采用自己的方式,并有所借鉴前辈们的实现,以浅显易懂的方式,对 Virtual DOM 进行简单实现,但不包含snabbdom的源码分析,在笔者的最终实现里,参考了snabbdom的原理,将本文的Virtual DOM实现进行了改进,感兴趣的读者可以阅读上面几篇文章,并参考笔者本文的最终代码进行阅读。

本文阅读时间约15~20分钟。

概述

本文分为以下几个方面来讲述极简版本的 Virtual DOM 核心实现:

Virtual DOM 主要思想

用 JavaScript 对象表示 DOM 树

将 Virtual DOM 转换为真实 DOM

设置节点的类型

设置节点的属性

对子节点的处理

处理变化

新增与删除节点

更新节点

更新子节点

Virtual DOM 主要思想

要理解 Virtual DOM 的含义,首先需要理解 DOM ,DOM 是针对 HTML 文档和 XML 文档的一个 API , DOM 描绘了一个层次化的节点树,通过调用 DOM API,开发人员可以任意添加,移除和修改页面的某一部分。而 Virtual DOM 则是用 JavaScript 对象来对 Virtual DOM 进行抽象化的描述。Virtual DOM 的本质是JavaScript对象,通过 Render函数,可以将 Virtual DOM 树 映射为 真实 DOM 树。

一旦 Virtual DOM 发生改变,会生成新的 Virtual DOM ,相关算法会对比新旧两颗 Virtual DOM 树,并找到他们之间的不同,尽可能地通过最少的 DOM 操作来更新真实 DOM 树。

我们可以这么表示 Virtual DOM 与 DOM 的关系:DOM = Render(Virtual DOM)

用 JavaScript 对象表示 DOM 树

Virtual DOM 是用 JavaScript 对象表示,并存储在内存中的。主流的框架均支持使用 JSX 的写法, JSX 最终会被 babel 编译为JavaScript 对象,用于来表示Virtual DOM,思考下列的 JSX:

item

最终会被babel编译为如下的 JavaScript对象:

{
    type: "div",
    props: null,
    children: [{
        type: "span",
        props: {
            class: "item",
        },
        children: ["item"],
    }, {
        type: "input",
        props: {
            disabled: true,
        },
        children: [],
    }],
}

我们可以注意到以下两点:

所有的 DOM 节点都是一个类似于这样的对象:

{ type: "...", props: { ... }, children: { ... }, on: { ... } }

本文节点是用 JavaScript 字符串来表示

那么 JSX 又是如何转化为 JavaScript 对象的呢。幸运的是,社区有许许多多优秀的工具帮助我们完成了这件事,由于篇幅有限,本文对这个问题暂时不做探讨。为了方便大家更快速地理解 Virtual DOM ,对于这一个步骤,笔者使用了开源工具来完成。著名的 babel 插件babel-plugin-transform-react-jsx帮助我们完成这项工作。

为了更好地使用babel-plugin-transform-react-jsx,我们需要搭建一下webpack开发环境。具体过程这里不做阐述,有兴趣自己实现的同学可以到simple-virtual-dom查看代码。

对于不使用 JSX 语法的同学,可以不配置babel-plugin-transform-react-jsx,通过我们的vdom函数创建 Virtual DOM:

function vdom(type, props, ...children) {
    return {
        type,
        props,
        children,
    };
}

然后我们可以通过如下代码创建我们的 Virtual DOM 树:

const vNode = vdom("div", null,
    vdom("span", { class: "item" }, "item"),
    vdom("input", { disabled: true })
);

在控制台输入上述代码,可以看到,已经创建好了用 JavaScript对象表示的 Virtual DOM 树:

将 Virtual DOM 转换为真实 DOM

现在我们知道了如何用 JavaScript对象 来代表我们的真实 DOM 树,那么, Virtual DOM 又是怎么转换为真实 DOM 给我们呈现的呢?

在这之前,我们要先知道几项注意事项:

在代码中,笔者将以$开头的变量来表示真实 DOM 对象;

toRealDom函数接受一个 Virtual DOM 对象为参数,将返回一个真实 DOM 对象;

mount函数接受两个参数:将挂载 Virtual DOM 对象的父节点,这是一个真实 DOM 对象,命名为$parent;以及被挂载的 Virtual DOM 对象vNode

下面是toRealDom的函数原型:

function toRealDom(vNode) {
    let $dom;
    // do something with vNode
    return $dom;
}

通过toRealDom方法,我们可以将一个vNode对象转化为一个真实 DOM 对象,而mount函数通过appendChild,将真实 DOM 挂载:

function mount($parent, vNode) {
    return $parent.appendChild(toRealDom(vNode));
}

下面,让我们来分别处理vNodetypepropschildren

设置节点的类型

首先,因为我们同时具有字符类型的文本节点和对象类型的element节点,需要对type做多带带的处理:

if (typeof vNode === "string") {
    $dom = document.createTextNode(vNode);
} else {
    $dom = document.createElement(vNode.type);
}

在这样一个简单的toRealDom函数中,对type的处理就完成了,接下来让我们看看对props的处理。

设置节点的属性

我们知道,如果节点有props,那么props是一个对象。通过遍历props,调用setProp方法,对每一类props多带带处理。

if (vNode.props) {
    Object.keys(vNode.props).forEach(key => {
        setProp($dom, key, vNode.props[key]);
    });
}

setProp接受三个参数:

$target,这是一个真实 DOM 对象,setProp将对这个节点进行 DOM 操作;

name,表示属性名;

value,表示属性的值;

读到这里,相信你已经大概清楚setProp需要做什么了,一般情况下,对于普通的props,我们会通过setAttribute给 DOM 对象附加属性。

function setProp($target, name, value) {
    return $target.setAttribute(name, value);
}

但这远远不够,思考下列的 JSX 结构:

console.log("item")}>item

从上面的 JSX 结构中,我们发现以下几点:

由于class是 JavaScript 的保留字, JSX 一般使用className来表示 DOM 节点所属的class

一般以on开头的属性来表示事件;

除字符类型外,属性还可能是布尔值,如disabled,当该值为true时,则添加这一属性;

所以,setProp也同样需要考虑上述情况:

function isEventProp(name) {
    return /^on/.test(name);
}

function extractEventName(name) {
    return name.slice(2).toLowerCase();
}

function setProp($target, name, value) {
    if (name === "className") { // 因为class是保留字,JSX使用className来表示节点的class
        return $target.setAttribute("class", value);
    } else if (isEventProp(name)) { // 针对 on 开头的属性,为事件
        return $target.addEventListener(extractEventName(name), value);
    } else if (typeof value === "boolean") { // 兼容属性为布尔值的情况
        if (value) {
            $target.setAttribute(name, value);
        }
        return $target[name] = value;
    } else {
        return $target.setAttribute(name, value);
    }
}

最后,还有一类属性是我们的自定义属性,例如主流框架中的组件间的状态传递,即通过props来进行传递的,我们并不希望这一类属性显示在 DOM 中,因此需要编写一个函数isCustomProp来检查这个属性是否是自定义属性,因为本文只是为了实现 Virtual DOM 的核心思想,为了方便,在本文中,这个函数直接返回false

function isCustomProp(name) {
    return false;
}

最终的setProp函数:

function setProp($target, name, value) {
    if (isCustomProp(name)) {
        return;
    } else if (name === "className") { // fix react className
        return $target.setAttribute("class", value);
    } else if (isEventProp(name)) {
        return $target.addEventListener(extractEventName(name), value);
    } else if (typeof value === "boolean") {
        if (value) {
            $target.setAttribute(name, value);
        }
        return $target[name] = value;
    } else {
        return $target.setAttribute(name, value);
    }
}
对子节点的处理

对于children里的每一项,都是一个vNode对象,在进行 Virtual DOM 转化为真实 DOM 时,子节点也需要被递归转化,可以想到,针对有子节点的情况,需要对子节点以此递归调用toRealDom,如下代码所示:

if (vNode.children && vNode.children.length) {
    vNode.children.forEach(childVdom => {
        const realChildDom = toRealDom(childVdom);
        $dom.appendChild(realChildDom);
    });
}

最终完成的toRealDom如下:

function toRealDom(vNode) {
    let $dom;
    if (typeof vNode === "string") {
        $dom = document.createTextNode(vNode);
    } else {
        $dom = document.createElement(vNode.type);
    }

    if (vNode.props) {
        Object.keys(vNode.props).forEach(key => {
            setProp($dom, key, vNode.props[key]);
        });
    }

    if (vNode.children && vNode.children.length) {
        vNode.children.forEach(childVdom => {
            const realChildDom = toRealDom(childVdom);
            $dom.appendChild(realChildDom);
        });
    }

    return $dom;
}
处理变化

Virtual DOM 之所以被创造出来,最根本的原因是性能提升,通过 Virtual DOM ,开发者可以减少许多不必要的 DOM 操作,以达到最优性能,那么下面我们来看看 Virtual DOM 算法 是如何通过对比更新前的 Virtual DOM 树和更新后的 Virtual DOM 树来实现性能优化的。

注:本文是笔者的最简单实现,目前社区普遍通用的算法是snabbdom,如 Vue 则是借鉴该算法实现的 Virtual DOM ,有兴趣的读者可以查看这个库的源代码,基于本文的 Virtual DOM 的小示例,笔者最终也参考了该算法实现,本文demo传送门,由于篇幅有限,感兴趣的读者可以自行研究。

为了处理变化,首先声明一个updateDom函数,这个函数接受以下四个参数:

$parent,表示将被挂载的父节点;

oldVNode,旧的VNode对象;

newVNode,新的VNode对象;

index,在更新子节点时使用,表示当前更新第几个子节点,默认为0;

函数原型如下:

function updateDom($parent, oldVNode, newVNode, index = 0) {

}
新增与删除节点

首先我们来看新增一个节点的情况,对于原本没有该节点,需要添加新的一个节点到 DOM 树中,我们需要通过appendChild来实现:

转化为代码表述为:

// 没有旧的节点,添加新的节点
if (!oldVNode) {
    return $parent.appendChild(toRealDom(newVNode));
}

同理,对于删除一个旧节点的情况,我们通过removeChild来实现,在这里,我们应该从真实 DOM 中将旧的节点删掉,但问题是在这个函数中是直接取不到这一个节点的,我们需要知道这个节点在父节点中的位置,事实上,可以通过$parent.childNodes[index]来取到,这便是上面提到的为何需要传入index,它表示当前更新的节点在父节点中的索引:

转化为代码表述为:

const $currentDom = $parent.childNodes[index];

// 没有新的节点,删除旧的节点
if (!newVNode) {
    return $parent.removeChild($currentDom);
}
更新节点

Virtual DOM 的核心在于如何高效更新节点,下面我们来看看更新节点的情况。

首先,针对文本节点,我们可以简单处理,对于文本节点是否发生改变,只需要通过比较其新旧字符串是否相等即可,如果是相同的文本节点,是不需要我们更新 DOM 的,在updateDom函数中,直接return即可:

// 都是文本节点,都没有发生变化
if (typeof oldVNode === "string" && typeof newVNode === "string" && oldVNode === newVNode) {
    return;
}

接下来,考虑节点是否真的需要更新,如图所示,一个节点的类型从span换成了div,显而易见,这是一定需要我们去更新DOM的:

我们需要编写一个函数isNodeChanged来帮助我们判断旧节点和新节点是否真的一致,如果不一致,需要我们把节点进行替换:

function isNodeChanged(oldVNode, newVNode) {
    // 一个是textNode,一个是element,一定改变
    if (typeof oldVNode !== typeof newVNode) {
        return true;
    }

    // 都是textNode,比较文本是否改变
    if (typeof oldVNode === "string" && typeof newVNode === "string") {
        return oldVNode !== newVNode;
    }

    // 都是element节点,比较节点类型是否改变
    if (typeof oldVNode === "object" && typeof newVNode === "object") {
        return oldVNode.type !== newVNode.type;
    }
}

updateDom中,发现节点类型发生变化,则将该节点直接替换,如下代码所示,通过调用replaceChild,将旧的 DOM 节点移除,并将新的 DOM 节点加入:

if (isNodeChanged(oldVNode, newVNode)) {
    return $parent.replaceChild(toRealDom(newVNode), $currentDom);
}

但这远远还没有结束,考虑下面这种情况:



对比上面的新旧两个节点,发现节点类型并没有发生改变,即VNode.type都是"div",但是节点的属性却发生了改变,除了针对节点类型的变化更新 DOM 外,针对节点的属性的改变,也需要对应把 DOM 更新。

与上述方法类似,我们编写一个isPropsChanged函数,来判断新旧两个节点的属性是否有发生变化:

function isPropsChanged(oldProps, newProps) {
    // 类型都不一致,props肯定发生变化了
    if (typeof oldProps !== typeof newProps) {
        return true;
    }

    // props为对象
    if (typeof oldProps === "object" && typeof newProps === "object") {
        const oldKeys = Object.keys(oldProps);
        const newkeys = Object.keys(newProps);
        // props的个数都不一样,一定发生了变化
        if (oldKeys.length !== newkeys.length) {
            return true;
        }
        // props的个数相同的情况,遍历props,看是否有不一致的props
        for (let i = 0; i < oldKeys.length; i++) {
            const key = oldKeys[i]
            if (oldProps[key] !== newProps[key]) {
                return true;
            }
        }
        // 默认未改变
        return false;
    }

    return false;
}

因为当节点没有任何属性时,propsnullisPropsChanged首先判断新旧两个节点的props是否是同一类型,即是否存在旧节点的propsnull,新节点有新的属性,或者反之:新节点的propsnull,旧节点的属性被删除了。如果类型不一致,那么属性一定是被更新的。

接下来,考虑到节点在更新前后都有props的情况,我们需要判断更新前后的props是否一致,即两个对象是否全等,遍历即可。如果有不相等的属性,则认为props发生改变,需要处理props的变化。

现在,让我们回到我们的updateDom函数,看看是把Virtual DOM 节点props的更新应用到真实 DOM 上的。

// 虚拟DOM的type未改变,对比节点的props是否改变
const oldProps = oldVNode.props || {};
const newProps = newVNode.props || {};
if (isPropsChanged(oldProps, newProps)) {
    const oldPropsKeys = Object.keys(oldProps);
    const newPropsKeys = Object.keys(newProps);

    // 如果新节点没有属性,把旧的节点的属性清除掉
    if (newPropsKeys.length === 0) {
        oldPropsKeys.forEach(propKey => {
            removeProp($currentDom, propKey, oldProps[propKey]);
        });
    } else {
        // 拿到所有的props,以此遍历,增加/删除/修改对应属性
        const allPropsKeys = new Set([...oldPropsKeys, ... newPropsKeys]);
        allPropsKeys.forEach(propKey => {
            // 属性被去除了
            if (!newProps[propKey]) {
                return removeProp($currentDom, propKey, oldProps[propKey]);
            }
            // 属性改变了/增加了
            if (newProps[propKey] !== oldProps[propKey]) {
                return setProp($currentDom, propKey, newProps[propKey]);
            }
        });
    }
}

上面的代码也非常好理解,如果发现props改变了,那么对旧的props的每项去做遍历。把不存在的属性清除,再把新增加的属性加入到更新后的 DOM 树中:

首先,如果新的节点没有属性,遍历删除所有旧的节点的属性,在这里,我们通过调用removeProp删除。removePropsetProp相对应,由于本文篇幅有限,笔者在这里就不做过多阐述;

function removeProp($target, name, value) {
    if (isCustomProp(name)) {
        return;
    } else if (name === "className") { // fix react className
        return $target.removeAttribute("class");
    } else if (isEventProp(name)) {
        return $target.removeEventListener(extractEventName(name), value);
    } else if (typeof value === "boolean") {
        $target.removeAttribute(name);
        $target[name] = false;
    } else {
        $target.removeAttribute(name);
    }
}

如果新节点有属性,那么拿到旧节点和新节点所有属性,遍历新旧节点的所有属性,如果属性在新节点中没有,那么说明该属性被删除了。如果新的节点与旧的节点属性不一致/或者是新增的属性,则调用setProp给真实 DOM 节点添加新的属性。

更新子节点

在最后,与toRealDom类似的是,在updateDom中,我们也应当处理所有子节点,对子节点进行递归调用updateDom,一个一个对比所有子节点的VNode是否有更新,一旦VNode有更新,则真实 DOM 也需要重新渲染:

// 根节点相同,但子节点不同,要递归对比子节点
if (
    (oldNode.children && oldNode.children.length) ||
    (newNode.children && newNode.children.length)
) {
    for (let i = 0; i < oldNode.children.length || i < newNode.children.length; i++) {
        updateDom($currentDom, oldNode.children[i], newNode.children[i], i);
    }
}
远远没有结束

以上是笔者实现的最简单的 Virtual DOM 代码,但这与社区我们所用到 Virtual DOM 算法是有天壤之别的,笔者在这里举个最简单的例子:


  • 1
  • 2
  • 3
  • 4
  • 5

  • 5
  • 1
  • 2
  • 3
  • 4

对于上述代码中实现的updateDom函数而言,更新前后的 DOM 结构如上所示,则会触发五个li节点全部重新渲染,这显然是一种性能的浪费。而snabbdom则通过移动节点的方式较好地解决了上述问题,由于本文篇幅有限,并且社区也有许多对该 Virtual DOM 算法的分析文章,笔者就不在本文做过多阐述了,有兴趣的读者可以到自行研究。笔者也基于本文实例,参考snabbdom算法实现了最终的版本,有兴趣的读者可以查看本文示例最终版

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/104537.html

相关文章

  • 【React进阶系列】从零开始手把手教你实现一个Virtual DOM(一)

    摘要:可实际上并不是创造的,将这个概念拿过来以后融会贯通慢慢地成为目前前端最炙手可热的框架之一。则是将再抽象一层生成的简化版对象,这个对象也拥有上的一些属性,比如等,但它是完全脱离于浏览器而存在的。所以今天我要手把手教大家怎么从零开始实现。 假如你的项目使用了React,你知道怎么做性能优化吗?你知道为什么React让你写shouldComponentUpdate或者React.PureCo...

    PumpkinDylan 评论0 收藏0
  • 【React进阶系列】从零开始手把手教你实现一个Virtual DOM(二)

    摘要:上集回顾从零开始手把手教你实现一个一上一集我们介绍了什么是,为什么要用,以及我们要怎样来实现一个。完成后,在命令行中输入安装下依赖。最后返回这个目标节点。明天,我们迎接挑战,开始处理数据变动引起的重新渲染,我们要如何新旧,生成补丁,修改。 上集回顾 从零开始手把手教你实现一个Virtual DOM(一)上一集我们介绍了什么是VDOM,为什么要用VDOM,以及我们要怎样来实现一个VDOM...

    dendoink 评论0 收藏0
  • javascript知识点

    摘要:模块化是随着前端技术的发展,前端代码爆炸式增长后,工程化所采取的必然措施。目前模块化的思想分为和。特别指出,事件不等同于异步,回调也不等同于异步。将会讨论安全的类型检测惰性载入函数冻结对象定时器等话题。 Vue.js 前后端同构方案之准备篇——代码优化 目前 Vue.js 的火爆不亚于当初的 React,本人对写代码有洁癖,代码也是艺术。此篇是准备篇,工欲善其事,必先利其器。我们先在代...

    Karrdy 评论0 收藏0
  • 【React进阶系列】从零开始手把手教你实现一个Virtual DOM(三)

    摘要:函数依次做了这几件事调用函数,对比新旧两个,根据两者的不同得到需要修改的补丁将补丁到真实上当计数器小于等于的时候,将加,再继续下一次当计数器大于的时候,结束下面我们来实现函数和函数。 上集回顾 【React进阶系列】从零开始手把手教你实现一个Virtual DOM(二) 上集我们实现了首次渲染从JSX=>Hyperscript=>VDOM=>DOM的过程,今天我们来看一下当数据变动的时...

    qqlcbb 评论0 收藏0
  • vue源码阅读之数据渲染过程

    摘要:图在中应用三数据渲染过程数据绑定实现逻辑本节正式分析从到数据渲染到页面的过程,在中定义了一个的构造函数。一、概述 vue已是目前国内前端web端三分天下之一,也是工作中主要技术栈之一。在日常使用中知其然也好奇着所以然,因此尝试阅读vue源码并进行总结。本文旨在梳理初始化页面时data中的数据是如何渲染到页面上的。本文将带着这个疑问一点点追究vue的思路。总体来说vue模版渲染大致流程如图1所...

    AlphaGooo 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<