资讯专栏INFORMATION COLUMN

如何编写自己的虚拟DOM

mushang / 2050人阅读

摘要:要构建自己的虚拟,需要知道两件事。现在来看看如何处理上面描述的所有情况。代码如下节点的替换首先,需要编写一个函数来比较两个节点旧节点和新节点,并告诉节点是否真的发生了变化。总结现在我们已经编写了虚拟实现及了解它的工作原理。

要构建自己的虚拟DOM,需要知道两件事。你甚至不需要深入 React 的源代码或者深入任何其他虚拟DOM实现的源代码,因为它们是如此庞大和复杂——但实际上,虚拟DOM的主要部分只需不到50行代码。

想阅读更多优质文章请猛戳GitHub博客,一年百来篇优质文章等着你!

有两个概念:

Virtual DOM 是真实DOM的映射

当虚拟 DOM 树中的某些节点改变时,会得到一个新的虚拟树。算法对这两棵树(新树和旧树)进行比较,找出差异,然后只需要在真实的 DOM 上做出相应的改变。

用JS对象模拟DOM树

首先,我们需要以某种方式将 DOM 树存储在内存中。可以使用普通的 JS 对象来做。假设我们有这样一棵树:

  • item 1
  • item 2

看起来很简单,对吧? 如何用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,如下:

  • item 1
  • item 2

编译成:

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

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

/** @jsx h */
  • item 1
  • item 2

它实际上告诉 Babel " 嘿,小老弟帮我编译 JSX 语法,用 h(...) 函数代替 React.createElement(…),然后 Babel 就开始编译。"

综上所述,我们将DOM写成这样:

/** @jsx h */
const a = (
  
  • item 1
  • item 2
);

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 = (
  
  • item 1
  • item 2
); 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 = (
  
  • item 1
  • item 2
); const b = (
  • item 1
  • hello!
); const $root = document.getElementById("root"); const $reload = document.getElementById("reload"); updateElement($root, a); $reload.addEventListener("click", () => { updateElement($root, b, a); });

HTML


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...

你的点赞是我持续分享好东西的动力,欢迎点赞!

交流

干货系列文章汇总如下,觉得不错点个Star,欢迎 加群 互相学习。

https://github.com/qq44924588...

我是小智,公众号「大迁世界」作者,对前端技术保持学习爱好者。我会经常分享自己所学所看的干货,在进阶的路上,共勉!

关注公众号,后台回复福利,即可看到福利,你懂的。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/100839.html

相关文章

  • JavaScript 是如何工作编写自己 Web 开发框架 + React 及其虚拟 DOM

    摘要:与大多数全局对象不同,没有构造函数。为什么要设计更加有用的返回值早期写法写法函数式操作早期写法写法可变参数形式的构造函数一般写法写法当然还有很多,大家可以自行到上查看什么是代理设计模式代理模式,为其他对象提供一种代理以控制对这个对象的访问。 这是专门探索 JavaScript 及其所构建的组件的系列文章的第 19 篇。 如果你错过了前面的章节,可以在这里找到它们: 想阅读更多优质文章请...

    余学文 评论0 收藏0
  • 用JavaScript自己写MVVM前端框架-VM实现篇

    摘要:关于前端框架大家都有了解,或多或少的使用过,比如,,等等。那么你是否也想自己手写一个的前端框架呢,我们从入手,手把手教你写基于的前端框架,在整个编写的过程中,希望大家学习更多,理解更多。本节我们以打包工具结合转换插件实现数据的抽象。 关于MVVM前端框架大家都有了解,或多或少的使用过,比如Angular,React,VUE等等。那么你是否也想自己手写一个MVVM的前端框架呢,我们从Vi...

    VincentFF 评论0 收藏0
  • 基于虚拟DOM(Snabbdom)迷你React

    摘要:一个字符串或者虚拟的数组用于表示该节点的。传递给函数的参数有两个首先是当前状态,其次是事件处理的回调函数,对生成的视图中触发的事件进行处理。回调函数主要负责为应用程序构建一个新的状态,并使用新的状态重启循环。 原文链接原文写于 2015-07-31,虽然时间比较久远,但是对于我们理解虚拟 DOM 和 view 层之间的关系还是有很积极的作用的。 React 是 JavaScript...

    lieeps 评论0 收藏0
  • 【全栈React】第1天: 什么是 React?

    摘要:本文转载自众成翻译译者链接原文今天,我们从一开始就开始。让我们看看是什么,是什么让运转起来。什么是是一个用于构建用户界面的库。它是应用程序的视图层。所有应用程序的核心是组件。组件是可组合的。虚拟完全存在于内存中,并且是网络浏览器的的表示。 本文转载自:众成翻译译者:iOSDevLog链接:http://www.zcfy.cc/article/3765原文:https://www.ful...

    ralap 评论0 收藏0
  • Rxjs 响应式编程-第六章 使用Cycle.js响应式Web应用程序

    摘要:我们将使用,这是一个现代,简单,漂亮的框架,在内部使用并将响应式编程概念应用于前端编程。驱动程序采用从我们的应用程序发出数据的,它们返回另一个导致副作用的。我们将使用来呈现我们的应用程序。仅采用长度超过两个字符的文本。 Rxjs 响应式编程-第一章:响应式Rxjs 响应式编程-第二章:序列的深入研究Rxjs 响应式编程-第三章: 构建并发程序Rxjs 响应式编程-第四章 构建完整的We...

    EastWoodYang 评论0 收藏0

发表评论

0条评论

mushang

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<