前端译趣

虚拟DOM精简实现详解

2018-05-24  本文已影响5人  linc2046
虚拟DOM精简实现详解

引言

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

只要50行!

以下是这两个概念:

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

更新:关于虚拟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’] }
] }

这里有两点要注意:

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

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

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创建真实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。

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

这里我们写个函数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函数。

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

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

上一篇下一篇

猜你喜欢

热点阅读