在这篇帖子中,我将详细介绍 在200多行JavaScript代码中实现虚拟DOM的完整过程。

结果是 功能完备且性能足够的虚拟DOM库(演示)。它作为 smvc包 在NPM上可用。

主要目标 是阐释像React这样的工具背后的基本技术。

React、Vue 和 Elm 语言都通过允许你描述你希望页面看起来如何,而不必担心添加/移除元素来简化 交互式网页的创建。它们通过 虚拟 DOM 来实现这一点。

虚拟 DOM 的目标

这不仅仅是 关于性能。

虚拟 DOM 是一个抽象,用于简化 UI 的修改行为。

你描述 你希望页面看起来如何,库就负责将DOM从当前状态,转变为 你想要的状态。

关键思想

库将接管 一个单一的DOM元素并在其中操作。

这个元素最初应该是空的,我们假设 除了我们的库之外,没有任何东西会修改它。这将是 用户应用程序的根。

如果我们只能修改它,那么我们就可以确切地知道 这个元素里面有什么,而不需要检查它。怎么做?通过 跟踪到目前为止我们对它所做的所有修改。

我们将通过 保持一个结构来跟踪我们的根节点内部是什么,这个结构包含 每个HTML元素的简化表示。或者更准确地说,每个DOM节点。

因为 这种表示是DOM节点的反映,但它不在真实的DOM中,让我们称它为 虚拟节点,它将组成 我们的虚拟DOM。

用户永远不会创建真实的DOM节点,只有 那些虚拟的。他们将通过使用虚拟节点告诉我们 整个页面应该看起来如何。然后 我们的库将负责修改真实的DOM,使其符合 我们的表示。

为了知道要修改什么,我们的库将获取 用户创建的虚拟DOM,并将其与 代表页面当前看起来如何的虚拟DOM进行比较。这个过程称为 diffing。它将记录差异,例如 应该添加或删除哪些元素,以及 应该添加或删除哪些属性。diffing的输出是一个 虚拟DOM diff。

然后我们将 apply 那个diff中的更改到 真实的DOM。一旦我们完成修改,用户创建的虚拟DOM现在已经成为 真实DOM的真实忠实表示。

所以,对于UI部分,我们需要:

  1. 创建 一个虚拟表示的DOM
  2. diff 虚拟DOM节点
  3. apply 虚拟DOM diff到 一个HTML元素

构建这些之后,我们将看到 如何通过在几行代码中添加状态处理,将这样的虚拟DOM作为 强大的库来使用。

表示 DOM

我们希望 这个结构包含尽可能少的信息,以忠实地表示 页面上的内容。

一个 DOM 节点有一个标签(div, p, span等),属性和子元素。所以让我们使用 具有这些属性的对象来表示它们。

  1. const exampleButton = {
  2. tag : "button",
  3. properties: { class: "primary", disabled: true, onClick: doSomething },
  4. children : [] // an array of virtual nodes
  5. };

我们还需要一种方式来表示文本节点。文本节点没有标签、属性或子元素。我们可以使用一个具有单个属性的对象来存储文本内容。

  1. const exampleText = {
  2. text: "Hello World"
  3. };

我们可以检查是否存在标签或文本属性来区分文本虚拟节点和元素节点。

就是这样!这就是我们完全指定的虚拟 DOM。

我们可以为用户创建一些便利函数来创建这些类型的节点。

  1. function h(tag, properties, children) {
  2. return { tag, properties, children);
  3. }
  4. function text(content) {
  5. return { text : content };
  6. };

现在,创建复杂的嵌套结构变得很容易。

  1. const pausedScreen = h("div", {}, [
  2. h("h2", {}, text("Game Paused")),
  3. h("button", { onClick: resumeGame }, [ text("Resume") ]),
  4. h("button", { onClick: quitGame }, [ text("Quit") ])
  5. ])

差异比较

在开始差异比较之前,让我们思考一下我们希望差异比较操作的输出是什么样的。

差异比较应该描述如何修改一个元素。我能想到几种类型的修改:

  • 创建(Create) - 在DOM中添加一个新节点。应该包含要添加的虚拟DOM节点。
  • 移除(Remove) - 不需要包含任何信息。
  • 替换(Replace) - 移除一个节点,但在其位置放置一个新的节点。应该包含要添加的节点。
  • 修改现有节点(Modify an existing node) - 应该包含要添加的属性,要移除的属性,以及对子元素的修改数组。
  • 不修改(Don’t modify) - 元素保持不变,无需进行任何操作。

你可能会想知道,为什么我们除了创建(create)和移除(remove)之外还有一个替换(replace)修改。这是因为除非用户为每个虚拟DOM节点提供了一个唯一标识符,否则我们没有办法知道元素子元素的顺序是否发生了变化。

考虑这种情况,最初的 DOM 描述看起来像这样:

  1. { tag: "div",
  2. properties: {},
  3. chidlren: [
  4. { text: "One" },
  5. { text: "Two" },
  6. { text: "Three" }
  7. ]
  8. }

以及随后的描述是这样的:

  1. { tag: "div",
  2. properties: {},
  3. chidlren: [
  4. { text: "Three" }
  5. { text: "Two" },
  6. { text: "One" },
  7. ]
  8. }

要注意到一和三交换了位置,我们必须将第一个对象的每个子元素与第二个对象的每个子元素进行比较。这不能有效地完成。因此,我们通过它们在 children 数组中的索引来标识元素。这意味着我们将 替换 数组中的第一个和最后一个文本节点。

这也意味着我们只能在作为最后一个子元素插入元素时使用 创建(create) 。所以除非我们正在添加子元素,否则我们将使用 替换(replace)

现在让我们直接深入实现这个 diff 函数。

  1. // It takes two nodes to be compared, an old and a new one.
  2. function diffOne(l, r) {
  3. // First we deal with text nodes. If their text content is not
  4. // identical, then let's replace the old one for the new one.
  5. // Otherwise it's a `noop`, which means we do nothing.
  6. const isText = l.text !== undefined;
  7. if (isText) {
  8. return l.text !== r.text
  9. ? { replace: r }
  10. : { noop : true };
  11. }
  12. // Next we start dealing with element nodes.
  13. // If the tag changed we should just replace the whole thing.
  14. if (l.tag !== r.tag) {
  15. return { replace: r };
  16. }
  17. // Now that replacement is out of the way we could only possibly
  18. // modify the element. So let's start by taking note of properties
  19. // that should be removed.
  20. // Any property that is not present in the new node should be removed.
  21. const remove = [];
  22. for (const prop in l.properties) {
  23. if (r.properties[prop] === undefined) {
  24. remove.push(prop);
  25. }
  26. }
  27. // And now let's check which ones should be set.
  28. // This includes new and modified properties.
  29. // So unless the property's value is the same in the old and
  30. // new nodes we will take note of it.
  31. const set = {};
  32. for (const prop in r.properties) {
  33. if (r.properties[prop] !== l.properties[prop]) {
  34. set[prop] = r.properties[prop];
  35. }
  36. }
  37. // Lastly we diff the list of children.
  38. const children = diffList(l.children, r.children);
  39. return { modify: { remove, set, children } };
  40. }

作为优化,我们可以注意到当没有属性变化,并且所有子元素的修改都是无操作 (no-ops) 时,我们可以让元素的差异比较结果也是一个无操作 (no-op)。(像这样

差异比较子元素列表是相当直接的。我们创建一个差异列表,其大小为比较的两个列表中较长的那个。如果旧的列表更长,多余的元素应该被移除。如果新的列表更长,多余的元素应该被创建。所有共有的元素应该进行差异比较。

  1. function diffList(ls, rs) {
  2. const length = Math.max(ls.length, rs.length);
  3. return Array.from({ length })
  4. .map((_,i) =>
  5. (ls[i] === undefined)
  6. ? { create: rs[i] }
  7. : (rs[i] == undefined)
  8. ? { remove: true }
  9. : diffOne(ls[i], rs[i])
  10. );
  11. }

差异比较完成了!

应用差异

我们已经可以创建虚拟DOM并对其进行差异比较。现在该将差异应用到真实的DOM了。

apply 函数将接收一个真实的DOM节点及其应该受到影响的子节点,以及上一步创建的差异数组。这些差异是这个节点的子节点的差异。

apply 将没有实际的返回值,因为它的主要目的是执行修改DOM的副作用。

它的实现相当简单,只是分派每个子节点要执行的相应操作。createmodify DOM节点的过程被移到了它们自己的函数中。

  1. function apply(el, childrenDiff) {
  2. const children = Array.from(el.childNodes);
  3. childrenDiff.forEach((diff, i) => {
  4. const action = Object.keys(diff)[0];
  5. switch (action) {
  6. case "remove":
  7. children[i].remove();
  8. break;
  9. case "modify":
  10. modify(children[i], diff.modify);
  11. break;
  12. case "create": {
  13. const child = create(diff.create);
  14. el.appendChild(child);
  15. break;
  16. }
  17. case "replace": {
  18. const child = create(diff.replace);
  19. children[i].replaceWith(child);
  20. break;
  21. }
  22. case "noop":
  23. break;
  24. }
  25. });
  26. }

事件监听器

在处理创建和修改之前,让我们思考一下我们希望如何处理事件监听器。

我们希望添加和移除事件监听器非常便宜且容易,我们希望确保我们不会留下任何悬空的监听器。

我们还将强制执行一个不变性,即对于任何给定的节点,每个事件应该只有一个监听器。由于我们的 API 使用属性对象中的键来指定事件监听器,并且 JavaScript 对象不能有重复的键,这将已经是我们的情况。

这里有一个想法。我们向 DOM 对象节点添加一个由我们的库创建的特殊属性,其中包含一个对象,所有用户定义的该DOM 节点的事件监听器都可以在这个对象中找到。

  1. // Create a property `_ui` where we can store data relevant to
  2. // our library directly in the DOM node itself.
  3. // We store the event listeners for that node in this space.
  4. element["_ui"] = { listeners : { click: doSomething } };

现在,我们可以使用一个单一的函数 listener,作为所有节点中所有事件的事件监听器。

一旦触发事件,我们的 listener 函数就会捕获它,并使用监听器对象,将其分派给适当的用户定义的函数来处理事件。

  1. function listener(event) {
  2. const el = event.currentTarget;
  3. const handler = el._ui.listeners[event.type];
  4. handler(event);
  5. }

到目前为止,这为我们提供了一个好处,即每次用户监听器函数更改时,我们不需要调用 addEventListenerremoveEventListener。更改事件监听器只需要在 listeners 对象中更改值。稍后我们将看到这种方法的更有说服力的好处。

有了这些知识,我们可以创建一个专门的函数来向 DOM 节点添加事件监听器。

  1. function setListener(el, event, handle) {
  2. if (el._ui.listeners[event] === undefined) {
  3. el.addEventListener(event, listener);
  4. }
  5. el._ui.listeners[event] = handle;
  6. }

我们尚未完成的一件事是确定 properties 对象中的任何给定条目是否是事件监听器。

让我们编写一个函数,如果属性不是事件监听器,则返回 null,否则返回要监听的事件名称。这个函数可能会检查属性名是否以 on 开头,这是常见的JavaScript事件处理模式,如 onClickonSubmit 等。

  1. function eventName(str) {
  2. if (str.indexOf("on") == 0) { // starts with `on`
  3. return str.slice(2).toLowerCase(); // lowercase name without the `on`
  4. }
  5. return null;
  6. }

属性

很好,我们知道如何添加事件监听器。对于属性,我们可以直接调用 setAttribute,对吗?嗯,不是的。

对于某些属性,我们应该使用 setAttribute 函数,而对于其他一些,我们应该直接在DOM对象中设置属性。

例如,如果你有一个 <input type="checkbox"> 并且调用 element.setAttribute("checked", true),它不会变为选中状态 🙃。你应该改为 element["checked"] = true。这样才会有效。

我们怎么知道应该使用哪一个呢?嗯,这有点复杂。我只是根据 Elm的Html库是如何做的 编制了一个列表。这是结果:

  • 对于布尔值属性,如 checkeddisabledreadonly 等,如果它们的值是 true,则直接设置为 element[property] = true
  • 对于其他属性,如 classidstyle 等,使用 setAttribute

这个列表可能不是详尽无遗的,但它提供了一个起点。在实际应用中,你可能需要根据特定情况调整或扩展这个列表。

  1. const props = new Set([ "autoplay", "checked", "checked", "contentEditable", "controls",
  2. "default", "hidden", "loop", "selected", "spellcheck", "value", "id", "title",
  3. "accessKey", "dir", "dropzone", "lang", "src", "alt", "preload", "poster",
  4. "kind", "label", "srclang", "sandbox", "srcdoc", "type", "value", "accept",
  5. "placeholder", "acceptCharset", "action", "autocomplete", "enctype", "method",
  6. "name", "pattern", "htmlFor", "max", "min", "step", "wrap", "useMap", "shape",
  7. "coords", "align", "cite", "href", "target", "download", "download",
  8. "hreflang", "ping", "start", "headers", "scope", "span" ]);
  9. function setProperty(prop, value, el) {
  10. if (props.has(prop)) {
  11. el[prop] = value;
  12. } else {
  13. el.setAttribute(prop, value);
  14. }
  15. }

创建和修改

有了这些知识,我们现在可以尝试从虚拟 DOM 节点创建一个真实的 DOM 节点了。

  1. function create(vnode) {
  2. // Create a text node
  3. if (vnode.text !== undefined) {
  4. const el = document.createTextNode(vnode.text);
  5. return el;
  6. }
  7. // Create the DOM element with the correct tag and
  8. // already add our object of listeners to it.
  9. const el = document.createElement(vnode.tag);
  10. el._ui = { listeners : {} };
  11. for (const prop in vnode.properties) {
  12. const event = eventName(prop);
  13. const value = vnode.properties[prop];
  14. // If it's an event set it otherwise set the value as a property.
  15. (event !== null)
  16. ? setListener(el, event, value)
  17. : setProperty(prop, value, el);
  18. }
  19. // Recursively create all the children and append one by one.
  20. for (const childVNode of vnode.children) {
  21. const child = create(childVNode);
  22. el.appendChild(child);
  23. }
  24. return el;
  25. }

modify 函数的实现同样直接。它设置和移除节点的适当属性,并将控制权交给 apply 函数,以便它能够更改子节点。注意 modifyapply 之间的递归调用。

  1. function modify(el, diff) {
  2. // Remove props
  3. for (const prop of diff.remove) {
  4. const event = eventName(prop);
  5. if (event === null) {
  6. el.removeAttribute(prop);
  7. } else {
  8. el._ui.listeners[event] = undefined;
  9. el.removeEventListener(event, listener);
  10. }
  11. }
  12. // Set props
  13. for (const prop in diff.set) {
  14. const value = diff.set[prop];
  15. const event = eventName(prop);
  16. (event !== null)
  17. ? setListener(el, event, value)
  18. : setProperty(prop, value, el);
  19. }
  20. // Deal with the children
  21. apply(el, diff.children);
  22. }

处理状态

现在我们有了一个完整的虚拟DOM渲染实现。使用 htext 我们可以创建一个VDOM,使用 applydiffList 我们可以将它实现到真实的DOM中并进行更新。

我们可以在这里停止,但我认为如果没有一种结构化的方式来处理状态变化,实现是不完整的。毕竟,虚拟DOM的整个意义在于当状态改变时,你重复地重新创建它。

API

我们将实现一种非常直接的方式来处理它。将有两种用户定义的值:

  • 应用的状态:包含渲染VDOM所需的所有信息的值。
  • 应用消息:包含有关如何更改状态的信息的值。

我们将要求用户实现两个函数:

  • view 函数接收应用状态,并返回一个VDOM。
  • update 函数接收应用状态和一条应用消息,并返回一个新的应用状态。

这足以构建任何复杂的应用程序。

用户在程序开始时提供这两个函数,VDOM库将控制何时调用它们。用户从不直接调用它们。

我们还需要为用户提供一种方式,通过 update 函数处理消息。我们将通过提供一个 enqueue 函数来实现这一点,它将消息添加到待处理的消息队列中。

我们从用户那里需要的最后几件东西是一个初始状态来开始,以及一个HTML节点,VDOM应该在其中渲染。

有了这些最后的部件,我们就有我们完整的API。我们定义了一个叫做 init 的函数,它将从用户那里获取所有所需的输入,并启动应用程序。它将返回该应用程序的 enqueue 函数。这种设计允许我们在同一个页面上运行多个VDOM应用程序,每个应用程序都有自己的 enqueue 函数。

这里是一个使用这种设计实现的计数器示例:

Counter: 1040

  1. function view(state) {
  2. return [
  3. h("p", {}, [ text(`Counter: ${state.counter}`) ])
  4. ];
  5. }
  6. function update(state, msg) {
  7. return { counter : state.counter + msg }
  8. }
  9. const initialState = { counter: 0 };
  10. const root = document.querySelector(".my-application");
  11. // Start application
  12. const { enqueue } = init(root, initialState, update, view);
  13. // Increase the counter by one every second.
  14. setInterval(() => enqueue(1), 1000);

初始化函数

API已经明确了,让我们思考一下这个 init 函数应该如何工作。

我们肯定会对每条消息调用一次 update。但我们不需要每次状态改变时都调用 view,因为那可能会导致我们比浏览器能够显示DOM更新更频繁地更新DOM。我们希望每个动画帧最多调用一次 view

此外,我们希望用户能够根据需要多次调用 enqueue,并且可以在任何地方调用它,而不会导致我们的应用程序崩溃。这意味着我们应该接受即使在 update 函数内部调用 enqueue

我们将通过解耦消息排队、更新状态和更新DOM来实现这一点。

enqueue 的调用只会将消息添加到一个数组中。然后,在每个动画帧上,我们将取出所有排队的消息,并通过每次调用 update 来处理它们。一旦所有消息都被处理,我们将使用 view 函数渲染结果状态。

现在运行应用程序只包括在每个动画帧上重复这个过程。

  1. // Start managing the contents of an HTML element.
  2. function init(root, initialState, update, view) {
  3. let state = initialState; // client application state
  4. let nodes = []; // virtual DOM nodes
  5. let queue = []; // msg queue
  6. function enqueue(msg) {
  7. queue.push(msg);
  8. }
  9. // draws the current state
  10. function draw() {
  11. let newNodes = view(state);
  12. apply(root, diffList(nodes, newNodes));
  13. nodes = newNodes;
  14. }
  15. function updateState() {
  16. if (queue.length > 0) {
  17. let msgs = queue;
  18. // replace queue with an empty array so that we don't process
  19. // newly queued messages on this round.
  20. queue = [];
  21. for (msg of msgs) {
  22. state = update(state, msg);
  23. }
  24. draw();
  25. }
  26. // schedule next round of state updates
  27. window.requestAnimationFrame(updateState);
  28. }
  29. draw(); // draw initial state
  30. updateState(); // kick-off state update cycle
  31. return { enqueue };
  32. }

便利性

用户可以从他们想要的任何地方调用 enqueue,但目前从 updateview 函数内部调用它有点麻烦。这是因为 enqueue 是由 init 返回的,而 init 期望 updateview 在定义时就已经存在。

让我们首先通过将 enqueue 作为第三个参数传递给 update 来改进这一点。现在我们的状态更新看起来像这样:

  1. state = update(state, msg, enqueue)

这已经很容易了。现在让我们思考一下如何在 view 函数中改进这种情况。

用户在渲染期间不会调用 enqueue。他们会在响应某些事件时调用它,比如 onClickonInput。因此,让事件处理函数接收 enqueue 作为参数,与事件对象一起,这是有意义的。

有了这个,事件处理可以像这样:

  1. const button = h(
  2. "button",
  3. { onClick : (_event, enqueue) => { enqueue(1) } },
  4. [text("Increase counter")]
  5. );

我们甚至可以通过将事件处理程序返回的任何与 undefined 不同的值视为消息来使它更简单。这将允许上面的按钮被写成:

  1. const button = h(
  2. "button",
  3. { onClick : () => 1 },
  4. [text("Increase counter")]
  5. );

这种设计模式简化了事件处理和状态更新的过程,使得代码更加简洁和易于理解。通过允许事件处理程序直接返回一个消息,我们减少了样板代码,并允许用户专注于定义应用程序逻辑。

Increase counterCounter: 0

好的,我们如何实现这一点呢?我们的单一 listener 函数需要分派事件,并且它需要访问 enqueue。最简单的方法是通过 _ui 对象传递它,该对象已经保存了用户定义的监听器。

有了这个,我们的 listener 实现变为:

  1. function listener(event) {
  2. const el = event.currentTarget;
  3. const handler = el._ui.listeners[event.type];
  4. const enqueue = el._ui.enqueue;
  5. const msg = handler(event);
  6. if (msg !== undefined) {
  7. enqueue(msg);
  8. }
  9. }

要在节点创建时向 _ui 添加 enqueue,我们需要将其传递通过 applymodifycreate

  1. function apply(el, enqueue, childrenDiff) { ... }
  2. function modify(el, enqueue, diff) { ... }
  3. function create(enqueue, vnode) { ... }

有了这些,我们的完整库现在就完成了!你可以在 这里 查看完整代码。

演示

Todo MVC

以下是使用我们刚刚编写的库运行的著名 TodoMVC 应用程序的实现。完整源代码

开始 - 图1

1 Million Nodes

对于一项稍微推动极限的测试,这里有一个链接到一个页面,它渲染了一个虚拟列表,包含100万个HTML元素,并希望能够以每秒60帧的速度更新。完整源代码

这个测试展示了虚拟 DOM 库处理大量 DOM 节点的能力,这对于评估库的性能和效率非常有用。通过这样的测试,可以观察到在极端情况下库的行为,例如滚动性能、内存使用情况以及更新频率。