美文网首页前端译趣
虚拟DOM精简实现详解

虚拟DOM精简实现详解

作者: linc2046 | 来源:发表于2018-05-24 10:05 被阅读5次
    虚拟DOM精简实现详解

    引言

    实现一套虚拟DOM你无需深入理解React的源码或者其他实现版本,只需要知道两件事,大部分实现都很庞大复杂,但实际上虚拟DOM的主体部分可以通过约50行代码实现,

    只要50行!

    以下是这两个概念:

    • 虚拟DOM是真实DOM的某种展现

    • 当虚拟DOM树发生变化时,我们会获得新的虚拟树。比较算法会比较这两棵树,找出差异,然后将最小的变化应用到真实DOM。

    就只有这么多,让我们深入这些概念。

    更新:关于虚拟DOM中实现设置属性和事件的文章在这里

    如何表示真实DOM树

    首先我们需要在内存存储DOM树。可以使用纯JS对象展示,假设有下面的树:

    <ul class=”list”>
      <li>item 1</li>
      <li>item 2</li>
    </ul>
    

    看起来很简单?我们如何用js对象展示呢?

    { type: ‘ul’, props: { ‘class’: ‘list’ }, children: [
      { type: ‘li’, props: {}, children: [‘item 1’] },
      { type: ‘li’, props: {}, children: [‘item 2’] }
    ] }
    

    这里有两点要注意:

    • 我们用js对象展现DOM元素
    { type: '...', props: {...}, children: [...] }
    
    • 我们使用js字符串展示DOM文本节点

    但像这样编写大型树会十分困难。我们实现工具函数,简化理解上面的结构

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

    我们可以这样写dom树

    h(‘ul’, { ‘class’: ‘list’ },
      h(‘li’, {}, ‘item 1’),
      h(‘li’, {}, ‘item 2’),
    );
    

    这样看起来很简洁,我们可以进一步,这里使用jsx。

    如果你看过Babel JSX的官方文档, 你会知道,Babel会这样转译代码:

    <ul className=”list”>
      <li>item 1</li>
      <li>item 2</li>
    </ul>
    

    最终转换成这样:

    React.createElement(‘ul’, { className: ‘list’ },
      React.createElement(‘li’, {}, ‘item 1’),
      React.createElement(‘li’, {}, ‘item 2’),
    );
    

    注意到有什么一样的吗? 如果我们把React.createElement替换成我们的h()函数调用。

    最后我们可以使用jsx语法,只需要在源文件头部添加类似注释的声明:

    /* @jsx h */
    <ul className=”list”>
      <li>item 1</li>
      <li>item 2</li>
    </ul>
    

    这里会告诉Babel,使用h函数替换React.createElement转译jsx文件,

    实际上你可以使用任意函数替换这里的h函数

    总结一下,我们会这样写DOM:

    /* @jsx h */
    const a = (
        <ul className="list">
            <li>item 1</li>
            <li>item 2</li>
        </ul>
    )
    

    Babel转译成下面的代码:

    const a = (
        h('ul', {className: 'list'},
            h(‘li’, {}, ‘item 1’),
            h(‘li’, {}, ‘item 1’),
        )
    )
    

    h函数执行后,会返回纯js对象,即虚拟DOM

    const a = (
      { type: ‘ul’, props: { className: ‘list’ }, children: [
        { type: ‘li’, props: {}, children: [‘item 1’] },
        { type: ‘li’, props: {}, children: [‘item 2’] }
      ] }
    );
    

    应用虚拟DOM

    我们已经使用自定义js对象数据结构展现DOM树。

    因为我们不能直接把虚拟DOM直接添加到DOM, 需要实现从js对象创建真实DOM。

    首先我们做下面的假设,建立方法:

    • 我会定义所有DOM节点(元素、文本节点)变量以$开头, $parent表示真实DOM元素

    • 虚拟DOM表示会以node命名

    • 类似React, 只有一个根节点,所有节点都在根节点内部

    定义了这些,我们开始编写从虚拟DOM创建真实DOM的createElement函数,暂时忽略propschildren, 后面再实现。

    function createElement(node) {
        if(typeof node === 'string') {
            return document.createTextNode(node);
        }
        return document.createElement(node.type);
    }
    

    因为我们同时有文本节点(表示为js字符串)和元素节点(表示为js对象)

    { type: '...', props: {...}, childrenL [...] }
    

    我们可以同时传递虚拟文本节点和元素节点。

    考虑到children,要么是文本节点或元素节点,也可以用createElement函数创建。 这里使用递归实现。 对于元素的每个children,调用createElement函数,

    最终调用原生appendChild函数添加至根元素:

    function createElement(node) {
        if( typeof node === 'string' ){
                return document.createTextNode(node);
        }
        const $el = document.createElement(node.type);
        node.children
          .map(createElement)
          .forEach($el.appendChild.bind($el));
        return $el;
    }
    

    喔,很棒!我们暂时把节点props忽略,暂时不需要添加props实现,引入复杂度。

    处理虚拟DOM变化

    我们已经实现把虚拟DOM转换成真实DOM,可以想想如何比较虚拟树。

    需要实现一套基础逻辑,用来比较新树和旧树,最后只讲必要的变化应用到真实DOM。

    如何比较树? 我们需要考虑下面的场景:

    • 没有旧节点,节点新增,只需要调用appendChild函数

    • 节点在某个位置被删除,只用调用removeChild函数

    • 相同位置的节点不同,节点被替换,调用replaceChild函数

    • 相同位置节点一样,需要继续比较子节点

    这里我们写个函数updateElement, 接收三个参数, $parent 作为虚拟DOM节点真实父元素,newNode, oldNode

    下面详细介绍如何处理上面的场景.

    纯粹新节点情况

    function updateElement($parent, newNode, oldNode) {
        if( !oldeNode) {
            $parent.appendChild(
                createElement(newNode)
            );
        }
    }
    

    节点被删除情况

    如果新虚拟树不存在节点,这意味着需要在真实节点汇中删除旧节点,但如何实现? 我们知道父元素,传入函数的参数,可以调用$parent.removeChild()方法,

    传递真实DOM引用。但我们没有dom引用。

    或者如果我们知道节点在父元素中的位置,可以通过*$parent.childNodes[index]获取引用,index就是节点在父元素中的位置。

    假设index传入函数中,看看代码实现:

    function updateElement($parent, newNode, oldNode, index = 0) {
      if( !oldNode) {
        $parent.appendChild(
          createElement(newNode)
        );
      } else if (!newNode){
        $parent.removeChild(
          $parent.childNode[index]
        );
      }
    }
    

    节点发生变化

    首先我们需要写个函数用来比较新旧节点,确认节点发生变化,

    我们应该考虑节点可能同时是元素和文本节点。

    function changed(node1, node2) {
      return typeof node1 !== typeof node2 || 
             typeof node1 === 'string' && node1 !== node2 ||
             node1.type !== node2.type
    }
    

    有了当前节点在父元素的位置,我们可以轻易替换成新创建的节点:

    function updateElement($parent, newNode, oldNode, index = 0) {
      if(!oldNode){
        $parent.appendChild(
          createElement(newNode)
        );
      } else if( !newNode ){
        $parent.removeChild(
          $parent.childNodes[index]
        );
      } else if(changed(newNode, oldNode)) {
        $parent.replaceChild(
          createElement(newNode),
          $parent.childNodes[index]
        );
      }
    }
    

    比较子代

    比较子代,需要查找新旧节点的每个子代进行比较,然后递归调用updateElement函数。

    编写这段代码之前需要考虑几点:

    • 只能比较元素节点的子代元素

    • 我们将当前节点作为父元素

    • 每次比较子代只能逐个比较,即使有时我们碰到undefined,没关系,我们的函数可以处理这种场景

    • 最后就是index, 就是子节点在子代数组的索引

    function updateElement($parent, newNode, oldNode, index = 0) {
      if(!oldNode){
        $parent.appendChild(
          createElement(newNode)
        );
      } else if( !newNode ){
        $parent.removeChild(
          $parent.childNodes[index]
        );
      } else if(changed(newNode, oldNode)) {
        $parent.replaceChild(
          createElement(newNode),
          $parent.childNodes[index]
        );
      } else if (newNode.type) {
        const newLength = newNode.children.length;
        const oldLength = oldNode.children.length;
        for(let i = 0; i < newLength || i < oldLength; i++){
          updateElement(
            $parent.childNodes[index],
            newNode.children[i],
            oldNode.children[i],
            i
          );
        }
      }
    }
    

    组合实现

    我已经所有代码放在jsfiddle中,总体实现真的只有我前面说的50行代码,你可以尝试玩下。

    当你点击刷新按钮,可以打开开发者工具,查看元素变化。

    处理Babel

    开始实现props之前,我们需要修复之前实现的小问题,我们开始写节点时没有设置属性:

    <div></div>
    

    由于没有属性,Babel编译时会将元素props属性设置为null

    { type: '', props: null, children: [] }
    

    最好默认设置属性为空对象,这样后面迭代属性时不会发生错误。

    为了修正这项,我们把h函数改造成这样:

    function h(type, props, ...children) {
      return { type, props: props || {}, children };
    }
    

    设置属性

    设置属性很简单,还记得DOM实现不,我们会像纯js对象一样存储props:

    <ul className=”list” style=”list-style: none;”></ul>
    

    虚拟DOM在内存中的展示:

    { 
      type: ‘ul’, 
      props: { className: ‘list’, style: ’list-style: none;’ } 
      children: []
    }
    

    props对象的键名就是属性名称,键值就是属性值。

    我们只需要把props对象设置到真实DOM节点。

    我们写个函数包装setAttribute方法:

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

    完成设置单个属性后,我们迭代整个props对象,实现全部设置:

    function setProps($target, props) {
      Object.keys(props).forEach(name => {
        setProp($target, name, props[name]);
      })
    }
    

    还记得createElement函数不? 我们只需要在完成真实DOM节点创建后设置所有props属性即可:

    function createElement(node) {
      if (typeof node === ‘string’) {
        return document.createTextNode(node);
      }
      const $el = document.createElement(node.type);
      setProps($el, node.props);
      node.children
        .map(createElement)
        .forEach($el.appendChild.bind($el));
      return $el;
    }
    

    到这里还没有结束,我们忽略一些细节,首先class是js保留字,不能在属性名称中使用,这里用className替代:

    <nav className=”navbar light”>
      <ul></ul>
    </nav>
    

    但是在真实DOM中没有className属性,setProp函数中需要处理这种情况。

    另外一点是设置DOM节点的布尔属性(checked、disabled)十分方便:

    <input type=”checkbox” checked={false} />
    

    这个例子里面我希望checked属性不会设置到真实DOM中,但实际上会。

    因为你知道这个属性的存在会设置真实节点。我们需要修复下。

    注意这里不仅仅是设置属性,也会设置元素引用对应的布尔属性。

    function setBooleanProp($target, name, value) {
      if (value) {
        $target.setAttribute(name, value);
        $target[name] = true;
      } else {
        $target[name] = false;
      }
    }
    

    最后一点要提的是自定义属性, 对于我们的实现,未来或许需要设置属性,但不会在DOM中显示。

    这里我们写个函数检测属性是否是自定义。

    现在还是空的,因为暂时没有任何自定义属性。

    function isCustomProp(name) {
      return false;
    }
    

    下面是修复所有问题的setProp完整实现:

    function setProp($target, name, value) {
      if (isCustomProp(name)) {
        return;
      } else if (name === ‘className’) {
        $target.setAttribute(‘class’, value);
      } else if (typeof value === ‘boolean’) {
        setBooleanProp($target, name, value);
      } else {
        $target.setAttribute(name, value);
      }
    }
    

    比较props

    现在我们完成创建带props的元素,需要考虑如何比较props变化。

    其实,最终都是设置或删除属性。设置属性函数已经有了,需要写个删除属性函数:

    function removeBoolenProp($target, name) {
      $target.removeAttribute(name);
      $target[name] = false;
    }
    
    function removeProp($target, name, value) {
      if (isCustomProp(name)) {
        return;
      } else if (name === ‘className’) {
        $target.removeAttribute(‘class’);
      } else if (typeof value === ‘boolean’) {
        removeBooleanProp($target, name);
      } else {
        $target.removeAttribute(name);
      }
    }
    

    下面编写updateProp函数用来比较属性变化,根据比较结果修改真实DOM节点。这里需要处理几种情况:

    • 新节点属性不存在,需要删除

    • 旧节点属性不存在,需要新增

    • 新旧节点中同时存在相同属性,需要比较属性值,如果不等,重新设置新节点属性值

    • 其他场景属性没有任何变化,无需处理

    下面是更新单个属性的实现:

    function updateProp($target, name, newVal, oldVal) {
      if (!newVal) {
        removeProp($target, name, oldVal);
      } else if (!oldVal || newVal !== oldVal) {
        setProp($target, name, newVal);
      }
    }
    

    实现还很简答,但是节点有多个属性,我们再写个可以遍历所有属性,然后每个键值对调用updateProp函数:

    function updateProps($target, newProps, oldProps = {}) {
      const props = Object.assign({}, newProps, oldProps);
      Object.keys(props).forEach(name => {
        updateProp($target, name, newProps[name], oldProps[name]);
      });
    }
    

    注意这里我们创建的是复合对象,包括新旧节点的属性。因此当遍历属性时会遇到undefined值,函数内部已经处理了,

    最后一点需要考虑的是函数放在updateElement的哪个位置,可能节点没有变化,我们要比对子代,会首先检查属性变化。我们把属性比较放在最后一个if语句中在比对子代节点之前:

    function updateElement($parent, newNode, oldNode, index = 0) {
      ...
      } else if (newNode.type) {
        updateProps(
          $parent.childNodes[index],
          newNode.props,
          oldNode.props
        );
        ...
      }
    }
    

    事件

    当然一般的交互应用中我们需要知道如何处理事件,之前我们通过class 调用querySelector查找节点,然后调用addEventListener绑定事件。
    这不是很友好,我更想像React绑定事件的方式:

    <button onClick={ () => alert('hi') }></button>
    

    这看起来很棒!这里使用props定义事件监听器,属性名称以on开头:

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

    为了取出事件名称,这里需要写个函数用来删除on前缀:

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

    似乎我们在Props对象中声明事件,那么需要在setProps/updateProps函数中处理事件。

    这里需要考虑下如何比较函数?这里不能根据相同标志比较,可以使用toString()来比较函数代码。有一些含有原生代码的函数让我们无法进行比较。

    当然我们可以使用事件冒泡进行处理,可以实现自己的事件管理器,绑定body或根元素用来内部元素的所有事件。这样每次更新时可以重新添加事件监听器,也不会那么费劲。

    这里不会这样实现,实际上只会带来更多问题,事件监听器一般不会频繁变化。

    所有元素创建时事件监听器只会设置一次。

    我们不想setProps函数处理事件属性到真实DOM节点,需要单独添加事件处理器。还记得自定义属性处理函数不?这里改造下:

    function isCustomProp(name) {
      return isEventProp(name);
    }
    

    向已知带有属性的真实DOM节点添加事件监听器还很简单:

    function addEventListers($target, props) {
      Object.keys(props).forEach(name => {
        if(isEventProp(name)) {
          $target.addEventListener(
            extractEventName(name),
            props[name]
          );
        }
      });
    }
    

    把上面的实现放到创建元素函数中:

    function createElement(node) {
      if (typeof node === ‘string’) {
        return document.createTextNode(node);
      }
      const $el = document.createElement(node.type);
      setProps($el, node.props);
      addEventListeners($el, node.props);
      node.children
        .map(createElement)
        .forEach($el.appendChild.bind($el));
      return $el;
    }
    

    重新添加事件

    有一个简单方案可以实现重新添加事件,缺点是会损害性能。

    我们引入一个自定义属性叫做forceUpdate
    这里要调整changed函数:

    function changed(node1, node2) {
      return typeof node1 !== typeof node2 ||
             typeof node1 === ‘string’ && node1 !== node2 ||
             node1.type !== node2.type ||
             node.props.forceUpdate;
    }
    

    如果forceUpdate为真,那么整个节点会重新创建,这样新的事件监听器可以再次添加,这里我们并不想设置到真实DOM节点:

    function isCustomProp(name) {
      return isEventProp(name) || name === 'forceUpdate';
    }
    

    基本上就是这些,这个方案性能方面不好,但很简单。

    译者注

    原文链接1
    原文链接2

    • 因译者水平有限,如有错误,欢迎指正交流

    相关文章

      网友评论

        本文标题:虚拟DOM精简实现详解

        本文链接:https://www.haomeiwen.com/subject/xzcdjftx.html