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

尽人皆知,对前端而言,直接操纵 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)

《从零开始,手写一个浅易的Virtual DOM》

用 JavaScript 对象示意 DOM 树

Virtual DOM 是用 JavaScript 对象示意,并存储在内存中的。主流的框架均支撑运用 JSX 的写法, JSX 终究会被 babel 编译为JavaScript 对象,用于来示意Virtual DOM,思索以下的 JSX:

<div>
    <span className="item">item</span>
    <input disabled={true} />
</div>

终究会被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》

将 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 构造:

<div>
    <span className="item" data-node="item" onClick={() => console.log('item')}>item</span>
    <input disabled={true} />
</div>

从上面的 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来完成:

《从零开始,手写一个浅易的Virtual DOM》

转化为代码表述为:

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

同理,关于删除一个旧节点的状况,我们经由历程removeChild来完成,在这里,我们应该从实在 DOM 中将旧的节点删掉,但题目是在这个函数中是直接取不到这一个节点的,我们须要晓得这个节点在父节点中的位置,事实上,能够经由历程$parent.childNodes[index]来取到,这便是上面提到的为什么须要传入index,它示意当前更新的节点在父节点中的索引:

《从零开始,手写一个浅易的Virtual DOM》

转化为代码表述为:

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的:

《从零开始,手写一个浅易的Virtual 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);
}

但这远远还没有完毕,斟酌下面这类状况:

<!-- old -->
<div class="item" data-item="old-item"></div>
<!-- new -->
<div id="item" data-item="new-item"></div>

对照上面的新旧两个节点,发明节点范例并没有发作转变,即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 算法是有天地之别的,笔者在这里举个最简朴的例子:

<!-- old -->
<ul>
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
</ul>
<!-- new -->
<ul>
    <li>5</li>
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
</ul>

关于上述代码中完成的updateDom函数而言,更新前后的 DOM 构造如上所示,则会触发五个li节点悉数从新衬着,这显然是一种机能的糟蹋。而snabbdom则经由历程挪动节点的体式格局较好地处置惩罚了上述题目,由于本文篇幅有限,而且社区也有很多对该 Virtual DOM 算法的剖析文章,笔者就不在本文做过量论述了,有兴致的读者能够到自行研讨。笔者也基于本文实例,参考snabbdom算法完成了终究的版本,有兴致的读者能够检察本文示例终究版

    原文作者:counterxing
    原文地址: https://segmentfault.com/a/1190000019425098
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞