前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >如何编写你自己的 Virtual DOM

如何编写你自己的 Virtual DOM

作者头像
疯狂的技术宅
发布2019-03-27 15:56:23
7110
发布2019-03-27 15:56:23
举报
文章被收录于专栏:京程一灯

为了构建你自己的 Virtual DOM,你只需要知道两件事,甚至你都不必深入 React 或者其它 Virtual DOM 实现的源码。因为它们都太庞大和复杂了 —— 但是实际上 Virtual DOM 的主要部分可以用少于 50 行代码实现。50 行!!!

两个概念:

  • Virtual DOM 是真实 DOM 的任意一种表达形式;
  • 在 Virtual DOM 树上的改动,会创建一个新的 Virtual DOM 树。比较新老 Virtual DOM 树的算法,会计算差异并对真实 DOM 进行最小的更改,所谓“虚拟”

就是这些,让我们深挖每个概念的含义。

更新:关于 Virtual DOM 中设置属性和事件的第二篇文章在这里。

描述 DOM 树

首先,我们需要以某种方式在内存中存储 DOM 树。可以利用纯 JavaScript 对象实现。假如我们有这样一棵树:

代码语言:javascript
复制
<ul class=”list”>
  <li>item 1</li>
  <li>item 2</li>
</ul>

看起来非常简单,是吧?我们如何用 JS 对象来表示它?

代码语言:javascript
复制
{ type: ‘ul’, props: { ‘class’: ‘list’ }, children: [
  { type: ‘li’, props: {}, children: [‘item 1’] },
  { type: ‘li’, props: {}, children: [‘item 2’] }
] }

这里我们强调两件事:

  • 我们用对象来表示 DOM 元素
代码语言:javascript
复制
{ type: ‘…’, props: { … }, children: [ … ] }
  • 我们用纯 JS 字符串表示 DOM 的文本节点

但是以这种方式写大型的树是非常困难的。所以我们来写一个帮助函数,使得理解这个结构更容易一些:

代码语言:javascript
复制
function h(type, props, …children) {
  return { type, props, children };
}

现在向树中写入数据是这样的:

代码语言:javascript
复制
h(‘ul’, { ‘class’: ‘list’ },
  h(‘li’, {}, ‘item 1’),
  h(‘li’, {}, ‘item 2’),
);

看起来清晰多了,是不是?我们更进一步。你听说过 JSX,对么?嗯,我也要实现它。那么它是如何工作的呢?

如果你阅读过 Babel 的官方 JSX 文档,你会知道,Babel 把下面的代码:

代码语言:javascript
复制
<ul className=”list”>
  <li>item 1</li>
  <li>item 2</li>
</ul>

转译成:

代码语言:javascript
复制
React.createElement(‘ul’, { className: ‘list’ },
  React.createElement(‘li’, {}, ‘item 1’),
  React.createElement(‘li’, {}, ‘item 2’),
);

注意到相似点了么?对对对,如果我们把 React.createElement(…) 替换成我们的 h(…) 就好了 —— 我们确实可以使用所谓的 jsx 编译指令 做到这一点。只要在源码的开头放一行像注释的东西:

代码语言:javascript
复制
/** @jsx h */
<ul className=”list”>
  <li>item 1</li>
  <li>item 2</li>
</ul>

这一行实际上告诉 Babel:嘿,用 h 而不是 React.createElement 来编译 jsx。你可以将 h 替换成任何东西,都会被编译。

因此,总结上面我所说的来看,我们会以下面的形式写 DOM:

代码语言:javascript
复制
/** @jsx h */
const a = (
  <ul className=”list”>
    <li>item 1</li>
    <li>item 2</li>
  </ul>
);

Babel 会把它转译成:

代码语言:javascript
复制
const a = (
  h(‘ul’, { className: ‘list’ },
    h(‘li’, {}, ‘item 1’),
    h(‘li’, {}, ‘item 2’),
  );
);

当函数 h 被执行时,它会返回纯 JS 对象 —— 我们的 Virtual DOM 表示形式:

代码语言:javascript
复制
const a = (
  { type: ‘ul’, props: { className: ‘list’ }, children: [
    { type: ‘li’, props: {}, children: [‘item 1’] },
    { type: ‘li’, props: {}, children: [‘item 2’] }
  ] }
);

JSFiddle

应用 DOM 表达形式

Ok,现在我们有了纯 JS 对象以及自己结构的 DOM 树表达形式。非常酷,但是我们得利用它创建一个真实的 DOM。毕竟我们不能直接把表达式写入 DOM。

首先我们先进行一系列假设并设定一些术语:

  • 我会用 $ 开头的变量代表真实 DOM 节点(元素以及文本),那么 $parent 就是一个真实 DOM 元素;
  • Virtual DOM 表达形式存储于变量 node 中;
  • 像 React 一样,你可以只有一个根节点 —— 其它都是其后代节点

Ok,如前所述,我们写一个函数 createElement(…) 把虚拟 DOM 节点转换成真实 DOM 节点。暂时忘记 propschildren —— 过后再说:

代码语言:javascript
复制
function createElement(node) {
  if (typeof node === ‘string’) {
    return document.createTextNode(node);
  }
  return document.createElement(node.type);
}

因为我们已经有了纯 JS 字符串表示的文本节点和像下面的以 JS 对象表示的元素

代码语言:javascript
复制
{ type: ‘…’, props: { … }, children: [ … ] }

因此,我们在这里既可以处理虚拟文本节点也可以处理虚拟元素节点。

现在我们来考虑 children —— 每一个要么是一个文本节点要么是一个元素。所以他们都可以用我们的 createElement(…) 函数来创建。啊...你感到了么?我感受到了递归 :)) 于是我们在 children 的每一个元素上调用 createElement(…),并用 appendChild() 加入我们的元素中,像这样:

代码语言:javascript
复制
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 放一放,过后再讨论它,因为理解基本的 Virtual DOM 概念不需要它们,只会徒增复杂性。

JSFiddle

处理更新

Ok,现在我们能够把虚拟 DOM 转换为真实 DOM,到了该比较虚拟树差异的时候了。基本上我们要写个算法,比较两棵新旧树的差异,并对真实 DOM 做最少必要的更新。

如何比较树的差异?我们需要处理下面几个问题:

  • 某个位置有新节点 —— 因此节点是被增加的,我们需要 appendChild(…) 它;
  • 某个位置有旧节点 —— 因此节点是被删除的,我们需要 removeChild(…) 它;
  • 某个位置有不同的节点 —— 节点被更新,我们需要 replaceChild(…) 它;
  • 节点是相同的,我需要到下一层比较子节点

Ok,我们写一个函数 updateElement(…),输入 3 个参数,$parent, newNode and oldNode,其中 $parent 是我们的虚拟节点的对应的真实节点的父节点。现在看看我们如何处理上面提到的问题。

有一个新节点

相当简单了,都不必注释:

代码语言:javascript
复制
function updateElement($parent, newNode, oldNode) {
  if (!oldNode) {
    $parent.appendChild(
      createElement(newNode)
    );
  }
}

有旧节点

这里有问题了 —— 如果在 Virtual DOM 树的当前位置没有节点 —— 我们应该从真实 DOM 树中移除它 —— 但是我们如果做到?是的,我们知道父元素(传给函数了),于是,我们该调用 $parent.removeChild(…) 并传入真实的 DOM 元素引用。但是我们并没有这个引用。如果知道在父元素中的位置的话,我们则可以用 $parent.childNodes[index] 获取引用,这里 index 是索引:

假设这个 index 被传入了我们的函数(后面会看到,确实被传入了)。所以我们的代码是:

代码语言:javascript
复制
function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(
      createElement(newNode)
    );
  } else if (!newNode) {
    $parent.removeChild(
      $parent.childNodes[index]
    );
  }
}

节点更新

首先我们需要写一个函数来比较两个节点(新和旧),并且告诉我们节点是否被真的更新了。我们应该考虑到元素和文本节点:

代码语言:javascript
复制
function changed(node1, node2) {
  return typeof node1 !== typeof node2 ||
         typeof node1 === ‘string’ && node1 !== node2 ||
         node1.type !== node2.type
}

现在,有了 index,我们可以轻易地用新的节点替换它:

代码语言:javascript
复制
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 —— 它只是子节点在 children 中的索引
代码语言:javascript
复制
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 行代码,亦如我承诺你的那样。去玩玩它吧。

打开开发者工具,在你按下 Reload 按钮后观察应用的更新。

总结

恭喜你!我们达到了目的,实现了自己的 Virtual DOM,并且能正常工作。我希望在阅读完这篇文章后,你已经对 Virtual DOM 是如何工作的、React 的内部机制有了基本的了解。

然而,这里我们有些事情没有强调(我会在未来的文章中涉及到):

  • 设置元素属性并且比较或更新它们;
  • 处理事件 —— 为元素增加事件;
  • 让 Virtual DOM 和组件一起工作,像 React 那样;
  • 获取到真实 DOM 节点的引用;
  • 让 Virtual DOM 与直接操作 DOM 的库一同工作,如 jQuery 极其插件;
  • 其它…

往期精选文章

使用虚拟dom和JavaScript构建完全响应式的UI框架

扩展 Vue 组件

使用Three.js制作酷炫无比的无穷隧道特效

一个治愈JavaScript疲劳的学习计划

全栈工程师技能大全

WEB前端性能优化常见方法

一小时内搭建一个全栈Web应用框架

干货:CSS 专业技巧

四步实现React页面过渡动画效果

让你分分钟理解 JavaScript 闭包



小手一抖,资料全有。长按二维码关注京程一灯,阅读更多技术文章和业界动态。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2017-09-27,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 京程一灯 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 描述 DOM 树
  • 应用 DOM 表达形式
  • 处理更新
  • 有一个新节点
  • 有旧节点
  • 节点更新
  • 比较子节点
  • 综合
  • 总结
相关产品与服务
云开发 CLI 工具
云开发 CLI 工具(Cloudbase CLI Devtools,CCLID)是云开发官方指定的 CLI 工具,可以帮助开发者快速构建 Serverless 应用。CLI 工具提供能力包括文件储存的管理、云函数的部署、模板项目的创建、HTTP Service、静态网站托管等,您可以专注于编码,无需在平台中切换各类配置。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档