从头构建一个简单的虚拟 DOM

2025-05-28

从头构建一个简单的虚拟 DOM

上周,我在曼彻斯特 Web Meetup #4上做了一个现场编程演讲。演讲期间,我在不到一个小时的时间里从零开始构建了一个虚拟 DOM。这是迄今为止我做过的技术难度最高的演讲。

我的演讲视频已上传至此处。这篇文章基本上是我演讲的打字稿,旨在澄清一些我在演讲中没来得及提及的额外内容。建议在阅读本文之前先观看视频,这样更容易理解。

这是我在演讲中编写的代码的github repocodesandbox 。

附注

  • 本文将在所有变量前添加
    • $- 当提及真实领域时,例如$div,,$el$app
    • v- 当提到虚拟 DOM 时,例如vDiv,,vElvApp
  • 本文将以实际演讲的形式呈现,逐步添加代码。每个部分都会有一个 codesandbox 链接来展示进度。
  • 这篇文章很长,可能需要半个多小时才能读完。请确保你有足够的时间阅读。或者考虑先看视频。
  • 如果您发现任何错误,请随时指出!

概述

背景:什么是虚拟 DOM?

虚拟 DOM 通常指的是代表实际DOM的普通对象

文档对象模型 (DOM) 是 HTML 文档的编程接口。

例如,当您执行以下操作时:

const $app = document.getElementById('app');
Enter fullscreen mode Exit fullscreen mode

您将获得页面上的 DOM <div id="app"></div>。此 DOM 将提供一些编程接口供您控制。例如:

$app.innerHTML = 'Hello world';
Enter fullscreen mode Exit fullscreen mode

为了制作一个简单的对象来表示$app,我们可以写如下内容:

const vApp = {
  tagName: 'div',
  attrs: {
    id: 'app',
  },
};
Enter fullscreen mode Exit fullscreen mode

谈话中没有提到

虚拟 DOM 的外观没有严格的规定。你可以用tagLabel代替tagName,或者props用 代替attrs。只要它代表了 DOM,它就是“虚拟 DOM”。

虚拟 DOM 不需要任何编程接口。这使得它们比实际的 DOM更轻量。

不过,请记住,由于 DOM 是浏览器的基本元素,大多数浏览器肯定都对其进行了认真的优化。因此,实际的 DOM 可能并不像许多人声称的那样慢。

设置

https://codesandbox.io/s/7wqm7pv476?expanddevtools=1

我们首先创建并进入我们的项目目录。

$ mkdir /tmp/vdommm
$ cd /tmp/vdommm
Enter fullscreen mode Exit fullscreen mode

然后我们将启动 git repo,.gitignore使用gitignorer创建文件并启动 npm。

$ git init
$ gitignore init node
$ npm init -y
Enter fullscreen mode Exit fullscreen mode

让我们进行初始提交。

$ git add -A
$ git commit -am ':tada: initial commit'
Enter fullscreen mode Exit fullscreen mode

接下来,安装Parcel Bundler,一款真正的零配置打包工具。它开箱即用,支持各种文件格式。它一直是我在现场编程演讲中首选的打包工具。

$ npm install parcel-bundler
Enter fullscreen mode Exit fullscreen mode

(有趣的事实:您不再需要通过--save了。)

在安装的同时,让我们在项目中创建一些文件。

src/index.html

<html>
  <head>
    <title>hello world</title>
  </head>
  <body>
    Hello world
    <script src="./main.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

src/main.js

const vApp = {
  tagName: 'div',
  attrs: {
    id: 'app',
  },
};

console.log(vApp);
Enter fullscreen mode Exit fullscreen mode

包.json

{
  ...
  "scripts": {
    "dev": "parcel src/index.html", // add this script
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

现在您可以通过执行以下操作来生成开发服务器:

$ npm run dev

> vdommm@0.0.1 dev /private/tmp/vdommm

> parcel src/index.html



Server running at http://localhost:1234

Built in 959ms.
Enter fullscreen mode Exit fullscreen mode

访问http://localhost:1234,你应该会在页面上看到 hello world,并在控制台中看到我们定义的虚拟 DOM。如果看到了,就说明设置正确!

createElement(标签名称,选项)

https://codesandbox.io/s/n9641jyo04?expanddevtools=1

大多数虚拟 DOM 实现都会有一个叫做createElementfunction 的函数,通常简称为h。这些函数只会返回一个“虚拟元素”。所以,让我们来实现它。

src/vdom/createElement.js

export default (tagName, opts) => {
  return {
    tagName,
    attrs: opts.attrs,
    children: opts.children,
  };
};
Enter fullscreen mode Exit fullscreen mode

利用对象解构,我们可以像这样写上述内容:

src/vdom/createElement.js

export default (tagName, { attrs, children }) => {
  return {
    tagName,
    attrs,
    children,
  };
};
Enter fullscreen mode Exit fullscreen mode

我们还应该允许创建没有任何选项的元素,因此让我们为选项设置一些默认值。

src/vdom/createElement.js

export default (tagName, { attrs = {}, children = [] } = {}) => {
  return {
    tagName,
    attrs,
    children,
  };
};
Enter fullscreen mode Exit fullscreen mode

回想一下我们之前创建的虚拟 DOM:

src/main.js

const vApp = {
  tagName: 'div',
  attrs: {
    id: 'app',
  },
};

console.log(vApp);
Enter fullscreen mode Exit fullscreen mode

现在可以写成:

src/main.js

import createElement from './vdom/createElement';

const vApp = createElement('div', {
  attrs: {
    id: 'app',
  },
});

console.log(vApp);
Enter fullscreen mode Exit fullscreen mode

回到浏览器,你应该会看到和我们之前定义的相同的虚拟 DOM。让我们在divgiphy 的 sourcing 下添加一张图片:

src/main.js

import createElement from './vdom/createElement';

const vApp = createElement('div', {
  attrs: {
    id: 'app',
  },
  children: [
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),
  ],
});

console.log(vApp);
Enter fullscreen mode Exit fullscreen mode

返回浏览器,您应该会看到更新的虚拟 DOM。

谈话中没有提到

对象字面量(例如{ a: 3 })自动继承自Object。这意味着通过对象字面量创建的对象将具有在 、 等中定义Object.prototypehasOwnProperty方法toString

我们可以使用 来让我们的虚拟 DOM 更“纯粹”一些Object.create(null)。这将创建一个真正的普通对象,它不是继承自 ,Object而是null继承自 。

src/vdom/createElement.js

export default (tagName, { attrs, children }) => {
  const vElem = Object.create(null);

  Object.assign(vElem, {
    tagName,
    attrs,
    children,
  });

  return vElem;
};
Enter fullscreen mode Exit fullscreen mode

渲染(vNode)

https://codesandbox.io/s/pp9wnl5nj0?expanddevtools=1

渲染虚拟元素

现在我们有了一个可以生成虚拟 DOM 的函数。接下来,我们需要一个方法将虚拟 DOM 转换为真实 DOM。让我们定义一个函数,render (vNode)该函数接受一个虚拟节点并返回相应的 DOM。

src/vdom/render.js

const render = (vNode) => {
  // create the element
  //   e.g. <div></div>
  const $el = document.createElement(vNode.tagName);

  // add all attributs as specified in vNode.attrs
  //   e.g. <div id="app"></div>
  for (const [k, v] of Object.entries(vNode.attrs)) {
    $el.setAttribute(k, v);
  }

  // append all children as specified in vNode.children
  //   e.g. <div id="app"><img></div>
  for (const child of vNode.children) {
    $el.appendChild(render(child));
  }

  return $el;
};

export default render;
Enter fullscreen mode Exit fullscreen mode

上面的代码应该很容易理解。如果您有任何需要,我很乐意提供更多解释。


ElementNode 和 TextNode

在真实的 DOM 中,有8 种类型的节点。在本文中,我们只讨论两种类型:

  1. ElementNode,例如<div><img>
  2. TextNode、纯文本

我们的虚拟元素结构{ tagName, attrs, children }仅代表ElementNodeDOM 中的 。因此,我们还需要一些 的表示TextNode。我们只需使用String来表示TextNode

为了演示这一点,让我们向当前虚拟 DOM 添加一些文本。

src/main.js

import createElement from './vdom/createElement';

const vApp = createElement('div', {
  attrs: {
    id: 'app',
  },
  children: [
    'Hello world', // represents TextNode
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),  // represents ElementNode
  ],
}); // represents ElementNode

console.log(vApp);
Enter fullscreen mode Exit fullscreen mode

扩展渲染以支持 TextNode

正如我提到的,我们正在考虑两种类型的节点。当前的节点render (vNode)仅渲染ElementNode。因此,让我们扩展render它,使其TextNode也支持 的渲染。

我们首先要根据现有函数的功能重命名renderElem它。我还将添加对象解构,使代码看起来更美观。

src/vdom/render.js

const renderElem = ({ tagName, attrs, children}) => {
  // create the element
  //   e.g. <div></div>
  const $el = document.createElement(tagName);

  // add all attributs as specified in vNode.attrs
  //   e.g. <div id="app"></div>
  for (const [k, v] of Object.entries(attrs)) {
    $el.setAttribute(k, v);
  }

  // append all children as specified in vNode.children
  //   e.g. <div id="app"><img></div>
  for (const child of children) {
    $el.appendChild(render(child));
  }

  return $el;
};

export default render;
Enter fullscreen mode Exit fullscreen mode

让我们重新定义render (vNode)。我们只需要检查 是否vNodeString。如果是,那么我们可以使用document.createTextNode(string)来渲染textNode。否则,只需调用renderElem(vNode)

src/vdom/render.js

const renderElem = ({ tagName, attrs, children}) => {
  // create the element
  //   e.g. <div></div>
  const $el = document.createElement(tagName);

  // add all attributs as specified in vNode.attrs
  //   e.g. <div id="app"></div>
  for (const [k, v] of Object.entries(attrs)) {
    $el.setAttribute(k, v);
  }

  // append all children as specified in vNode.children
  //   e.g. <div id="app"><img></div>
  for (const child of children) {
    $el.appendChild(render(child));
  }

  return $el;
};

const render = (vNode) => {
  if (typeof vNode === 'string') {
    return document.createTextNode(vNode);
  }

  // we assume everything else to be a virtual element
  return renderElem(vNode);
};

export default render;
Enter fullscreen mode Exit fullscreen mode

现在我们的render (vNode)函数能够渲染两种类型的虚拟节点:

  1. createElement虚拟元素 - 使用我们的函数创建
  2. 虚拟文本 - 用字符串表示

渲染我们的vApp

现在让我们尝试渲染我们的vAppconsole.log它!

src/main.js

import createElement from './vdom/createElement';
import render from './vdom/render';

const vApp = createElement('div', {
  attrs: {
    id: 'app',
  },
  children: [
    'Hello world',
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),
  ],
});

const $app = render(vApp);
console.log($app);
Enter fullscreen mode Exit fullscreen mode

转到浏览器,您将看到控制台显示以下内容的 DOM:

<div id="app">
  Hello world
  <img src="https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif">
</div>
Enter fullscreen mode Exit fullscreen mode

安装 ($node, $target)

https://codesandbox.io/s/vjpk91op47

现在,我们可以创建虚拟 DOM 并将其渲染为真实 DOM。接下来,我们需要将真实 DOM 放到页面上。

让我们首先为我们的应用程序创建一个挂载点。我将用替换Hello world上的src/index.html<div id="app"></div>

src/index.html

<html>
  <head>
    <title>hello world</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="./main.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

我们现在要做的是div用我们渲染的替换掉这个空的$app。如果我们忽略 IE 和 Safari,这真的超级简单。我们直接使用 就可以了ChildNode.replaceWith

让我们定义mount ($node, $target)。此函数将简单地替换$target$node并返回$node

src/vdom/mount.js

export default ($node, $target) => {
  $target.replaceWith($node);
  return $node;
};
Enter fullscreen mode Exit fullscreen mode

现在在我们的main.js中只需将我们的安装$app到空的 div 上。

src/main.js

import createElement from './vdom/createElement';
import render from './vdom/render';
import mount from './vdom/mount';

const vApp = createElement('div', {
  attrs: {
    id: 'app',
  },
  children: [
    'Hello world',
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),
  ],
});

const $app = render(vApp);
mount($app, document.getElementById('app'));
Enter fullscreen mode Exit fullscreen mode

我们的应用程序现在将显示在页面上,我们应该在页面上看到一只猫。

让我们的应用程序更有趣

https://codesandbox.io/s/ox02294zo5

现在让我们让我们的应用程序更有趣一些。我们将我们的代码包装vApp在一个名为的函数中createVApp。它将接收一个count参数,然后它vApp会使用它。

src/main.js

import createElement from './vdom/createElement';
import render from './vdom/render';
import mount from './vdom/mount';

const createVApp = count => createElement('div', {
  attrs: {
    id: 'app',
    dataCount: count, // we use the count here
  },
  children: [
    'The current count is: ',
    String(count), // and here
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),
  ],
});

let count = 0;
const vApp = createVApp(count);
const $app = render(vApp);
mount($app, document.getElementById('app'));
Enter fullscreen mode Exit fullscreen mode

然后,我们将setInterval每秒增加计数并在页面上再次创建、渲染和安装我们的应用程序。

src/main.js

import createElement from './vdom/createElement';
import render from './vdom/render';
import mount from './vdom/mount';

const createVApp = count => createElement('div', {
  attrs: {
    id: 'app',
    dataCount: count, // we use the count here
  },
  children: [
    'The current count is: ',
    String(count), // and here
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),
  ],
});

let count = 0;
const vApp = createVApp(count);
const $app = render(vApp);
let $rootEl = mount($app, document.getElementById('app'));

setInterval(() => {
  count++;
  $rootEl = mount(render(createVApp(count)), $rootEl);
}, 1000);
Enter fullscreen mode Exit fullscreen mode

注意,我曾经$rootEl跟踪过根元素。这样它就mount知道在哪里挂载我们的新应用了。

如果我们现在回到浏览器,我们应该看到计数每秒增加 1,并且运行正常!

现在,我们能够以声明式的方式创建应用程序了。该应用程序的渲染方式可预测,并且非常易于理解。如果您了解 JQuery 的实现方式,您就会明白这种方法是多么简洁明了。

但是,每秒重新渲染整个应用程序存在一些问题:

  1. 真实 DOM 比虚拟 DOM 重得多。将整个应用程序渲染到真实 DOM 上可能会非常昂贵。
  2. 元素将丢失其状态。例如,<input>每当应用程序重新挂载到页面时,它们将失去焦点。点击此处查看现场演示。

我们将在下一节解决这些问题。

差异(旧VTree,新VTree)

https://codesandbox.io/s/0xv007yqnv

假设我们有一个函数diff (oldVTree, newVTree),它计算两个虚拟树之间的差异;返回一个patch函数,该函数接受的真实 DOMoldVTree并对真实 DOM 执行适当的操作,使真实 DOM 看起来像newVTree

如果我们有这个diff函数,那么我们可以将间隔重写为:

src/main.js

import createElement from './vdom/createElement';
import render from './vdom/render';
import mount from './vdom/mount';
import diff from './vdom/diff';

const createVApp = count => createElement('div', {
  attrs: {
    id: 'app',
    dataCount: count, // we use the count here
  },
  children: [
    'The current count is: ',
    String(count), // and here
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),
  ],
});

let count = 0;
let vApp = createVApp(count);
const $app = render(vApp);
let $rootEl = mount($app, document.getElementById('app'));

setInterval(() => {
  count++;
  const vNewApp = createVApp(count)
  const patch = diff(vApp, vNewApp);

  // we might replace the whole $rootEl,
  // so we want the patch will return the new $rootEl
  $rootEl = patch($rootEl);

  vApp = vNewApp;
}, 1000);
Enter fullscreen mode Exit fullscreen mode

那么让我们尝试实现它diff (oldVTree, newVTree)。让我们从一些简单的案例开始:

  1. newVTreeundefined
    • 我们可以简单地删除$node传递到 then 的部分patch
  2. 它们都是 TextNode(字符串)
    • 如果它们是相同的字符串,则不执行任何操作。
    • 如果不是,请替换$noderender(newVTree)
  3. 其中一个树是 TextNode,另一个是 ElementNode
    • 那么,它们显然不是同一个东西,那么我们就$node用 来代替render(newVTree)
  4. oldVTree.tagName !== newVTree.tagName
    • 我们假设在这种情况下,新树和旧树是完全不同的。
    • 我们不会尝试找出两棵树之间的差异,而是将 替换$noderender(newVTree)
    • 这个假设在 React 中也存在。(来源
    • > 两种不同类型的元素将产生不同的树。

src/vdom/diff.js

import render from './render';

const diff = (oldVTree, newVTree) => {
  // let's assume oldVTree is not undefined!
  if (newVTree === undefined) {
    return $node => {
      $node.remove();
      // the patch should return the new root node.
      // since there is none in this case,
      // we will just return undefined.
      return undefined;
    }
  }

  if (typeof oldVTree === 'string' ||
    typeof newVTree === 'string') {
    if (oldVTree !== newVTree) {
      // could be 2 cases:
      // 1. both trees are string and they have different values
      // 2. one of the trees is text node and
      //    the other one is elem node
      // Either case, we will just render(newVTree)!
      return $node => {
         const $newNode = render(newVTree);
         $node.replaceWith($newNode);
         return $newNode;
       };
    } else {
      // this means that both trees are string
      // and they have the same values
      return $node => $node;
    }
  }

  if (oldVTree.tagName !== newVTree.tagName) {
    // we assume that they are totally different and 
    // will not attempt to find the differences.
    // simply render the newVTree and mount it.
    return $node => {
      const $newNode = render(newVTree);
      $node.replaceWith($newNode);
      return $newNode;
    };
  }

  // (A)
};

export default diff;
Enter fullscreen mode Exit fullscreen mode

如果代码达到(A),则意味着以下内容:

  1. oldVTreenewVTree都是虚拟元素。
  2. 他们有相同的tagName
  3. 他们可能有不同的attrschildren

我们将实现两个函数来分别处理属性和子项,即diffAttrs (oldAttrs, newAttrs)diffChildren (oldVChildren, newVChildren),它们将分别返回一个补丁。由于我们目前知道不会替换,因此在应用两个补丁后,$node我们可以安全地返回。$node

src/vdom/diff.js

import render from './render';

const diffAttrs = (oldAttrs, newAttrs) => {
  return $node => {
    return $node;
  };
};

const diffChildren = (oldVChildren, newVChildren) => {
  return $node => {
    return $node;
  };
};

const diff = (oldVTree, newVTree) => {
  // let's assume oldVTree is not undefined!
  if (newVTree === undefined) {
    return $node => {
      $node.remove();
      // the patch should return the new root node.
      // since there is none in this case,
      // we will just return undefined.
      return undefined;
    }
  }

  if (typeof oldVTree === 'string' ||
    typeof newVTree === 'string') {
    if (oldVTree !== newVTree) {
      // could be 2 cases:
      // 1. both trees are string and they have different values
      // 2. one of the trees is text node and
      //    the other one is elem node
      // Either case, we will just render(newVTree)!
      return $node => {
         const $newNode = render(newVTree);
         $node.replaceWith($newNode);
         return $newNode;
       };
    } else {
      // this means that both trees are string
      // and they have the same values
      return $node => $node;
    }
  }

  if (oldVTree.tagName !== newVTree.tagName) {
    // we assume that they are totally different and 
    // will not attempt to find the differences.
    // simply render the newVTree and mount it.
    return $node => {
      const $newNode = render(newVTree);
      $node.replaceWith($newNode);
      return $newNode;
    };
  }

  const patchAttrs = diffAttrs(oldVTree.attrs, newVTree.attrs);
  const patchChildren = diffChildren(oldVTree.children, newVTree.children);

  return $node => {
    patchAttrs($node);
    patchChildren($node);
    return $node;
  };
};

export default diff;
Enter fullscreen mode Exit fullscreen mode

diffAttrs(旧Attrs,newAttrs)

我们先来看一下diffAttrs。其实很简单。我们知道要把所有东西都设置到 中newAttrs。设置完成后,我们只需要检查 中的所有键oldAttrs,确保它们也都存在于 中newAttrs。如果不存在,就删除它们。

const diffAttrs = (oldAttrs, newAttrs) => {
  const patches = [];

  // setting newAttrs
  for (const [k, v] of Object.entries(newAttrs)) {
    patches.push($node => {
      $node.setAttribute(k, v);
      return $node;
    });
  }

  // removing attrs
  for (const k in oldAttrs) {
    if (!(k in newAttrs)) {
      patches.push($node => {
        $node.removeAttribute(k);
        return $node;
      });
    }
  }

  return $node => {
    for (const patch of patches) {
      patch($node);
    }
    return $node;
  };
};
Enter fullscreen mode Exit fullscreen mode

注意我们如何创建包装器补丁并循环应用patches它们。

diffChildren(旧VChildren,新VChildren)

儿童的情况会稍微复杂一些。我们可以考虑三种情况:

  1. oldVChildren.length === newVChildren.length
    • 我们可以做diff(oldVChildren[i], newVChildren[i])哪里到哪里i0oldVChildren.length - 1
  2. oldVChildren.length > newVChildren.length
    • 我们还可以执行到 的diff(oldVChildren[i], newVChildren[i])操作i0oldVChildren.length - 1
    • newVChildren[j]undefined用于j >= newVChildren.length
    • 但这没关系,因为我们diff可以处理diff(vNode, undefined)
  3. oldVChildren.length < newVChildren.length
    • 我们还可以执行到 的diff(oldVChildren[i], newVChildren[i])操作i0oldVChildren.length - 1
    • 此循环将为每个已经存在的子项创建补丁
    • 我们只需要创建剩余的额外子项,即newVChildren.slice(oldVChildren.length)

总而言之,oldVChildren无论如何,我们都会循环并调用diff(oldVChildren[i], newVChildren[i])

然后我们将渲染额外的子项(如果有),并将它们附加到$node

const diffChildren = (oldVChildren, newVChildren) => {
  const childPatches = [];
  oldVChildren.forEach((oldVChild, i) => {
    childPatches.push(diff(oldVChild, newVChildren[i]));
  });

  const additionalPatches = [];
  for (const additionalVChild of newVChildren.slice(oldVChildren.length)) {
    additionalPatches.push($node => {
      $node.appendChild(render(newVChildren));
      return $node;
    });
  }

  return $parent => {
    // since childPatches are expecting the $child, not $parent,
    // we cannot just loop through them and call patch($parent)
    $parent.childNodes.forEach(($child, i) => {
      childPatches[i]($child);
    });

    for (const patch of additionalPatches) {
      patch($parent);
    }
    return $parent;
  };
};
Enter fullscreen mode Exit fullscreen mode

我认为如果我们使用该函数的话会更优雅一些zip

import render from './render';

const zip = (xs, ys) => {
  const zipped = [];
  for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
    zipped.push([xs[i], ys[i]]);
  }
  return zipped;
};

const diffChildren = (oldVChildren, newVChildren) => {
  const childPatches = [];
  oldVChildren.forEach((oldVChild, i) => {
    childPatches.push(diff(oldVChild, newVChildren[i]));
  });

  const additionalPatches = [];
  for (const additionalVChild of newVChildren.slice(oldVChildren.length)) {
    additionalPatches.push($node => {
      $node.appendChild(render(additionalVChild));
      return $node;
    });
  }

  return $parent => {
    // since childPatches are expecting the $child, not $parent,
    // we cannot just loop through them and call patch($parent)
    for (const [patch, $child] of zip(childPatches, $parent.childNodes)) {
      patch($child);
    }

    for (const patch of additionalPatches) {
      patch($parent);
    }
    return $parent;
  };
};
Enter fullscreen mode Exit fullscreen mode

完成 diff.js

src/vdom/diff.js

import render from './render';

const zip = (xs, ys) => {
  const zipped = [];
  for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
    zipped.push([xs[i], ys[i]]);
  }
  return zipped;
};

const diffAttrs = (oldAttrs, newAttrs) => {
  const patches = [];

  // setting newAttrs
  for (const [k, v] of Object.entries(newAttrs)) {
    patches.push($node => {
      $node.setAttribute(k, v);
      return $node;
    });
  }

  // removing attrs
  for (const k in oldAttrs) {
    if (!(k in newAttrs)) {
      patches.push($node => {
        $node.removeAttribute(k);
        return $node;
      });
    }
  }

  return $node => {
    for (const patch of patches) {
      patch($node);
    }
    return $node;
  };
};

const diffChildren = (oldVChildren, newVChildren) => {
  const childPatches = [];
  oldVChildren.forEach((oldVChild, i) => {
    childPatches.push(diff(oldVChild, newVChildren[i]));
  });

  const additionalPatches = [];
  for (const additionalVChild of newVChildren.slice(oldVChildren.length)) {
    additionalPatches.push($node => {
      $node.appendChild(render(additionalVChild));
      return $node;
    });
  }

  return $parent => {
    // since childPatches are expecting the $child, not $parent,
    // we cannot just loop through them and call patch($parent)
    for (const [patch, $child] of zip(childPatches, $parent.childNodes)) {
      patch($child);
    }

    for (const patch of additionalPatches) {
      patch($parent);
    }
    return $parent;
  };
};

const diff = (oldVTree, newVTree) => {
  // let's assume oldVTree is not undefined!
  if (newVTree === undefined) {
    return $node => {
      $node.remove();
      // the patch should return the new root node.
      // since there is none in this case,
      // we will just return undefined.
      return undefined;
    }
  }

  if (typeof oldVTree === 'string' ||
    typeof newVTree === 'string') {
    if (oldVTree !== newVTree) {
      // could be 2 cases:
      // 1. both trees are string and they have different values
      // 2. one of the trees is text node and
      //    the other one is elem node
      // Either case, we will just render(newVTree)!
      return $node => {
         const $newNode = render(newVTree);
         $node.replaceWith($newNode);
         return $newNode;
       };
    } else {
      // this means that both trees are string
      // and they have the same values
      return $node => $node;
    }
  }

  if (oldVTree.tagName !== newVTree.tagName) {
    // we assume that they are totally different and 
    // will not attempt to find the differences.
    // simply render the newVTree and mount it.
    return $node => {
      const $newNode = render(newVTree);
      $node.replaceWith($newNode);
      return $newNode;
    };
  }

  const patchAttrs = diffAttrs(oldVTree.attrs, newVTree.attrs);
  const patchChildren = diffChildren(oldVTree.children, newVTree.children);

  return $node => {
    patchAttrs($node);
    patchChildren($node);
    return $node;
  };
};

export default diff;
Enter fullscreen mode Exit fullscreen mode

使我们的应用程序更加复杂

https://codesandbox.io/s/mpmo2yy69

我们当前的应用并没有充分利用虚拟 DOM 的强大功能。为了展示虚拟 DOM 的强大,让我们将应用变得更复杂一些:

src/main.js

import createElement from './vdom/createElement';
import render from './vdom/render';
import mount from './vdom/mount';
import diff from './vdom/diff';

const createVApp = count => createElement('div', {
  attrs: {
    id: 'app',
    dataCount: count, // we use the count here
  },
  children: [
    'The current count is: ',
    String(count), // and here
    ...Array.from({ length: count }, () => createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    })),
  ],
});

let vApp = createVApp(0);
const $app = render(vApp);
let $rootEl = mount($app, document.getElementById('app'));

setInterval(() => {
  const n = Math.floor(Math.random() * 10);
  const vNewApp = createVApp(n);
  const patch = diff(vApp, vNewApp);

  // we might replace the whole $rootEl,
  // so we want the patch will return the new $rootEl
  $rootEl = patch($rootEl);

  vApp = vNewApp;
}, 1000);
Enter fullscreen mode Exit fullscreen mode

我们的应用现在会生成一个n0 到 9 之间的随机数,并n在页面上显示猫咪照片。如果你打开开发者工具,你会看到我们是如何<img>根据 智能地插入和移除的n

谢谢

如果你读到这里,非常感谢你花时间读完这篇文章。这篇文章真的很长!如果你真的读完了,请留言。爱你!

文章来源:https://dev.to/ycmjason/building-a-simple-virtual-dom-from-scratch-3d05
PREV
我最喜欢的 Bash 技巧、窍门和快捷键
NEXT
4 个免费的 Python Web 应用托管平台,包含分步说明