50行代码实现Virtual DOM

50行代码实现Virtual DOM

在你创造出自己的Virtual DOM之前,你只需要知道两件事情。你甚至不需要深入了解React的源代码,或者其他Virtual DOM的实现。它们都太庞大和复杂了,但实际上Virtual DOM的部分只需要不超过50行的代码!(当然,你千万不要把它放在生产环境)

这里有2个概念:

  • Virtual DOM是真实DOM的映射。
  • 当我们在Virtual DOM树改变一些东西的时候,我们得到了一个新的Virtual DOM树,通过算法比较新树和旧树,找到不同的地方,然后只需要在真实的DOM上做出相应的改变。

仅此而已,让我们来深入这两个概念。

构建我们的Virtual 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'),
)

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

/** @jsx h */

它实际上是告诉Babel:'哥们, 帮我编译JSX语法,用h(...)函数代替React.createElement(…),然后Babel就开始编译。
因此,总结我之前说的,我们将用这样的方式去写我们的DOM树:

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

Babel会帮我们编译成这样的代码:

const a = h( 'ul',{ 'class': 'list' },
  h( 'li', null, 'item 1' ),
  h( 'li', null, 'item 2' )
);

h(...)执行的之后,它将会返回纯的JS对象,即我们的虚拟DOM。

运用Virtual DOM构建真实的DOM

现在我们使用JS对象来表示DOM的结构,这非常酷,但是我们需要用它创建一个真实的DOM。

首先,让我们做一些假设并设置一些术语。

  • 我会用带$的变量名来表示真实的DOM树, — 因此$parent将会是一个真实的DOM节点。
  • Virtual DOM在变量中使用node命名。
  • 就像在React中,你仅仅只有一个root节点,其他所有的节点都将会在它里面。

如上所述,让我们来写一个createElement(…)函数把Virtual DOM转换成真实的DOM。

因为我们有两种节点,text和element。因此我们的createElement函数需要处理这两种情况。

让我们想一下,其实子节点要么是一个element,要么是一个text节点,是text节点的话,我们直接渲染:

document.createTextNode(node)

是element节点的话 需要递归地把它的子节点也构建起来:

const $el = document.createElement(node.type)
node
  .children
  .map(createElement)
  .forEach($el.appendChild.bind($el))

createElement代码如下:

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
}

现在的完整代码如下:

<div id="root"></div>
/** @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))

WOW,是不是看起来很不错,让我们暂时先抛开props,我们稍后会谈到它。

比较两棵虚拟DOM树的差异

现在我们已经把virtual DOM转换成一棵真实的DOM树,是时候考虑下怎么比较两棵虚拟DOM树的差异了。最基本的,我们需要一个算法来比较新的树和旧的树,它能够让我们知道什么地方改变了,然后相应的去改变真实的DOM。

怎么比较DOM树呢?我们需要处理下面的情况:

  • 添加新节点,我们需要用appendChild方法添加节点
c1
  • 移除老节点,我们需要用removeChild方法移除老的节点
c2
  • 节点的替换,我们需要用replaceChild方法
c3
  • 节点相同,因此我们需要深度比较子节点
c4

让我们开始写updateElement方法,它需要传递3个参数:$parent, newNodeoldNode$parent是我们虚拟节点的真实的父级DOM元素。现在我们来看看怎么处理上面描述的所有的情况。

添加新节点

非常直接,我甚至都不需要写注释。

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

移除老节点

这里我们遇到一个问题 — 如果在新的Virtual DOM树里面没有某个节点,那我们应该在真实的DOM树移除它。但我们应该怎么做呢?

如果我们已知父元素(通过参数传递),我们就能调用$parent.removeChild(…)方法把变化映射到真实的DOM上。但前提是我们得知道我们的节点在父元素上的索引,我们才能通过$parent.childNodes[index]得到该节点的引用。

OK,让我们假设index将会通过参数传递(确实如此,稍后会看到),我们的代码如下:

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

节点变化

首先我们需要写一个函数比较旧树和新树的不同,告诉我们node真的改变了。我们需要考虑文本和元素这两种情况:

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方法,同样需要用到递归。

但是在写代码之前我们需要先考虑几点:

  • 只有当节点是元素的时候,我们才需要比较子节点(文本节点没有子元素)
  • 我们需要传递当前的节点的引用作为父节点
  • 我们应该一个一个的比较所有的子节点,即使它是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
      )
    }
  }
}

到此就基本完成了,当你点击Reload按钮的时候,你可以打开开发者工具观察元素的变化。

你可以在这里找到所有的代码,github

原文地址 How to write your own Virtual DOM

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,386评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,939评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,851评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,953评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,971评论 5 369
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,784评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,126评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,765评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,148评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,744评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,858评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,479评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,080评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,053评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,278评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,245评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,590评论 2 343

推荐阅读更多精彩内容