乐趣区

关于javascript:通过编写简易虚拟DOM来学习虚拟DOM-的原理

作者:deathmood
译者:前端小智
起源:medium

1024 程序员节,160 就能买到 400 的书,红宝书 5 折

为了保障的可读性,本文采纳意译而非直译。

要构建本人的虚构 DOM,须要晓得两件事。你甚至不须要深刻 React 的源代码或者深刻任何其余虚构 DOM 实现的源代码,因为它们是如此宏大和简单——但实际上,虚构 DOM 的次要局部只需不到 50 行代码。

有两个概念:

  • Virtual DOM 是实在 DOM 的映射
  • 当虚构 DOM 树中的某些节点扭转时,会失去一个新的虚构树。算法对这两棵树 (新树和旧树) 进行比拟,找出差别,而后只须要在实在的 DOM 上做出相应的扭转。

用 JS 对象模仿 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’] }
] }

这里有两件事须要留神:

  • 用如下对象示意 DOM 元素
{type:‘…’, props: { …}, children: […] }

  • 用一般 JS 字符串示意 DOM 文本节点

然而用这种形式示意内容很多的 Dom 树是相当艰难的。这里来写一个辅助函数,这样更容易了解:

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

用这个办法重新整理一开始代码:

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

这样看起来简洁多了,还能够更进一步。这里应用 JSX, 如下:

<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’),
);

是不是看起来有点相熟?如果可能用咱们刚定义的 h(...) 函数代替 React.createElement(…),那么咱们也能应用 JSX 语法。其实,只须要在源文件头部加上这么一句正文:

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

它实际上通知 Babel ‘ 嘿,小老弟帮我编译 JSX 语法,用 h(...) 函数代替 React.createElement(…),而后 Babel 就开始编译。’

综上所述,咱们将 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 2’),
  );
);

当函数 “h” 执行时,它将返回一般 JS 对象 - 即咱们的虚构 DOM:

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

从 Virtual DOM 映射到实在 DOM

好了,当初咱们有了 DOM 树,用一般的 JS 对象示意,还有咱们本人的构造。这很酷,但咱们须要从它创立一个真正的 DOM。

首先让咱们做一些假如并申明一些术语:

  • 应用以 ’ $ ‘ 结尾的变量示意真正的 DOM 节点(元素,文本节点),因而 $parent 将会是一个实在的 DOM 元素
  • 虚构 DOM 应用名为 node 的变量示意

* 就像在 React 中一样,只能有一个根节点——所有其余节点都在其中

那么,来编写一个函数 createElement(…),它将获取一个虚构 DOM 节点并返回一个实在的 DOM 节点。这里先不思考 propschildren 属性:

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

上述办法我也能够创立有两种节点别离是文本节点和 Dom 元素节点,它们是类型为的 JS 对象:

{type:‘…’, props: { …}, children: […] }

因而,能够在函数 createElement 传入虚构文本节点和虚构元素节点——这是可行的。

当初让咱们思考子节点——它们中的每一个都是文本节点或元素。所以它们也能够用 createElement(…) 函数创立。是的,这就像递归一样,所以咱们能够为每个元素的子元素调用 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 属性放到一边。待会再谈。咱们不须要它们来了解虚构 DOM 的基本概念,因为它们会减少复杂性。

残缺代码如下:

/** @jsx h */

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

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;
}

const a = (
  <ul class="list">
    <li>item 1</li>
    <li>item 2</li>
  </ul>
);

const $root = document.getElementById('root');
$root.appendChild(createElement(a));

比拟两棵虚构 DOM 树的差别

当初咱们能够将虚构 DOM 转换为实在的 DOM,这就须要思考比拟两棵 DOM 树的差别。根本的,咱们须要一个算法来比拟新的树和旧的树,它可能让咱们晓得什么中央扭转了,而后相应的去扭转实在的 DOM。

怎么比拟 DOM 树?须要解决上面的状况:

  • 增加新节点,应用 appendChild(…) 办法增加节点

  • 移除老节点,应用 removeChild(…) 办法移除老的节点

  • 节点的替换,应用 replaceChild(…) 办法

如果节点雷同的——就须要须要深度比拟子节点

编写一个名为 updateElement(…) 的函数,它承受三个参数—— $parentnewNodeoldNode,其中 $parent 是虚构节点的一个理论 DOM 元素的父元素。当初来看看如何解决下面形容的所有状况。

增加新节点

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

移除老节点

这里遇到了一个问题——如果在新虚构树的以后地位没有节点——咱们应该从理论的 DOM 中删除它—— 这要如何做呢?

如果咱们已知父元素(通过参数传递),咱们就能调用 $parent.removeChild(…) 办法把变动映射到实在的 DOM 上。但前提是咱们得晓得咱们的节点在父元素上的索引,咱们能力通过 $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]
    );
  }
}

节点的替换

首先,须要编写一个函数来比拟两个节点(旧节点和新节点),并通知节点是否真的产生了变动。还有须要思考这个节点能够是元素或是文本节点:

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

当初,以后的节点有了 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]
    );
  }
}

比拟子节点

最初,但并非最不重要的是——咱们应该遍历这两个节点的每一个子节点并比拟它们——实际上为每个节点调用 updateElement(…) 办法,同样须要用到递归。

  • 当节点是 DOM 元素时咱们才须要比拟(文本节点没有子节点)
  • 咱们须要传递以后的节点的援用作为父节点
  • 咱们应该一个一个的比拟所有的子节点,即便它是 undefined 也没有关系,咱们的函数也会正确处理它。
  • 最初是 index,它是子数组中子节点的 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
      );
    }
  }
}

残缺的代码

Babel+JSX
/* @jsx h /

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

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;
}

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]
    );
  } 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
      );
    }
  }
}

// ---------------------------------------------------------------------

const a = (
  <ul>
    <li>item 1</li>
    <li>item 2</li>
  </ul>
);

const b = (
  <ul>
    <li>item 1</li>
    <li>hello!</li>
  </ul>
);

const $root = document.getElementById('root');
const $reload = document.getElementById('reload');

updateElement($root, a);
$reload.addEventListener('click', () => {updateElement($root, b, a);
});

HTML

<button id="reload">RELOAD</button>
<div id="root"></div>

CSS

#root {
  border: 1px solid black;
  padding: 10px;
  margin: 30px 0 0 0;
}

关上开发者工具,并察看当按下“Reload”按钮时利用的更改。

总结

当初咱们曾经编写了虚构 DOM 实现及理解它的工作原理。作者心愿,在浏览了本文之后,对了解虚构 DOM 如何工作的基本概念以及在幕后如何进行响应有肯定的理解。

然而,这里有一些货色没有突出显示(将在当前的文章中介绍它们):

  • 设置元素属性 (props) 并进行 diffing/updating
  • 处理事件——向元素中增加事件监听
  • 让虚构 DOM 与组件一起工作,比方 React
  • 获取对理论 DOM 节点的援用
  • 应用带有库的虚构 DOM,这些库能够间接扭转实在的 DOM,比方 jQuery 及其插件

原文:
https://medium.com/@deathmood…

代码部署后可能存在的 BUG 没法实时晓得,预先为了解决这些 BUG,花了大量的工夫进行 log 调试,这边顺便给大家举荐一个好用的 BUG 监控工具 Fundebug。


交换

文章每周继续更新,能够微信搜寻「大迁世界」第一工夫浏览和催更(比博客早一到两篇哟),本文 GitHub https://github.com/qq449245884/xiaozhi 曾经收录,整顿了很多我的文档,欢送 Star 和欠缺,大家面试能够参照考点温习,另外关注公众号,后盾回复 福利,即可看到福利,你懂的。

退出移动版