通过构建 UI 框架学习 JavaScript:第 6 部分 - 虚拟 DOM 算法简介

2025-06-04

通过构建 UI 框架学习 JavaScript:第 6 部分 - 虚拟 DOM 算法简介

本文是深入探讨 JavaScript 系列的第六篇。您可以访问与本项目相关的Github 仓库来查看之前的文章。

本系列并非全面涵盖所有 JavaScript 功能,而是根据各种问题的解决方案中出现的功能进行讲解。此外,每篇文章都基于其他开发者提供的教程和开源库,因此和您一样,我也在每篇文章中学习新知识。


在上一篇文章,我们通过向 DOM 元素添加事件来扩展 Aprender 的功能。在本文中,我们将迈出第一步,探索 UI 框架中最核心的部分——动态更改 DOM 元素。

和往常一样,我们将从一些示例代码开始。给定下面两个代表 UI 的对象,我们如何从旧 UI 转换为新 UI?

{ // old dom
  type: "div",
  attrs: {},
  children: [
    "Search",
    {
      type: "p",
      attrs: {},
      children: []
    }
  ]
}

{ // new dom
  type: "div",
  attrs: {},
  children: [
    "No Search",
    {
      type: "span",
      attrs: {},
      children: []
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

任何 UI 框架的核心(或者说是秘诀,如果你愿意的话)都在于如何检测和执行变更。在一个典型的应用程序中,底层框架会花费大量时间来弄清楚发生了哪些变更、如何变更以及如何应用这些变更。例如,React 代码库中的这个issue就对此进行了详细的技术讨论。市面上有很多 虚拟 DOM 实现,为了构建我们自己的虚拟 DOM,我们将从simple-virtual-dom中汲取灵感。

那么...发生了什么变化?

实现的“什么改变了?”部分从以下函数开始:

function diff(oldTree, newTree) {
  const patches = {};
  const index = 0;

  performDiff(oldTree, newTree, patches, index)

  return patches;
}
Enter fullscreen mode Exit fullscreen mode

参数oldTreenewTree是 UI 的对象表示——旧状态和新状态。在我们的例子中,我们将文本从 更改为"Search""No Search"并将段落元素更改为 span 元素。patches对象存储两个状态之间的差异,当我们使用 发现更改时,它将被填充performDiff。我们将在 diff 过程中进行递归调用,因此index它充当计数器来跟踪当前的 diff 迭代。最后,我们返回patches对象。它稍后将用于进行更改。让我们看一下performDiff

function performDiff(oldTree, newTree, patches, index) {
  const currentPatch = [];

  if (newTree === undefined) {
    // we do nothing here because the final else statement will deal with it
  } else if (typeof oldTree === 'string' && typeof newTree === 'string') {  
    if (oldTree !== newTree) {
      currentPatch.push({
        type: 'TEXT',
        content: newTree
      })
    }
  } else if (oldTree.type === newTree.type) {
    diffChildren(oldTree.children, newTree.children, patches, index)
  } else {
    currentPatch.push({
      type: 'REPLACE',
      node: newTree
    })
  }

  if (currentPatch.length) {
    patches[index] = currentPatch
  }
}
Enter fullscreen mode Exit fullscreen mode

performDiff当我们对任何子代进行 diff 操作时,都会递归调用这个函数,因此它currentPatch会保存属于当前迭代的更改。该函数的大部分工作是由一系列 if 语句完成的,这些语句源于以下问题:

我们是否有新的 DOM 树/元素可以进行比较?

如果没有,我们什么也不做,因为 if 语句的 else 子句会处理这个问题。

我们正在比较文本节点吗?

如果我们处理的是文本节点,则只有当文本不同时才会进行更改。此更改通过一个对象记录,该对象包含有关更改类型及其相关内容的信息。

我们有必要比较孩子吗?

这就是递归乐趣的开始。diffChildren函数如下:

function diffChildren(oldChildren, newChildren, patches, index) {
  oldChildren.forEach((oldChild, idx) => {
    index++
    performDiff(oldChild, newChildren[idx], patches, index)
  })
}
Enter fullscreen mode Exit fullscreen mode

这很简单。其他框架之所以能在这方面脱颖而出,是因为它们必须考虑一系列问题。例如,如果所有子元素都没有改变,有没有办法跳过这个调用?比较子元素的最有效方法是什么?

我们是在比较不同的元素吗?

如果我们要比较两个完全不同的 DOM 元素,最简单的做法就是用新的替换旧的。

大多数 UI 框架的算法都可以归结为类似的问题列表。


在我们的例子中,示例代码将沿着子比较路线进行,因此让我们逐步介绍一下:

首先,diffChildren接收一个由我们要比较的元素组成的子元素数组。对于每个子元素,它都会递归调用performDiff。然而,在执行此操作之前,它会先递增index计数器。在我们的示例中,它从 递增01

我们调用performDiff函数,传入参数“Search”和“No Search”作为比较元素。由于比较的是文本节点,因此我们创建了以下对象:

{
  type: "TEXT",
  content: "No Search"
}
Enter fullscreen mode Exit fullscreen mode

并将其存储在currentPatch每次调用时都会初始化的数组中performDiff。该数组会跟踪所有需要进行的更改,如果有更改,则在函数结束时将它们赋值给用作键的patches对象。index

我们对第二个孩子重复这个过程,一旦performDiff它完成工作,它就给我们留下了以下对象:

{
  1: [
    {
      type: "TEXT",
      content: "No Search"
    }
  ],
  2: [
    {
      type: "REPLACE",
      node: {
        type: "span",
        attrs: {},
        children: []
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

函数返回的对象diff代表了我们想要对 UI 进行的更改。您可以将其视为更新过程的第一阶段。在第二阶段,我们将这些更改应用到 DOM。这两个步骤与 React 的操作方式类似

我们将开始使用这两个函数应用我们的更改:

function patch(rootDomNode, patches) {
  const index = 0;
  performPatches(rootDomNode, patches, index)
}

function performPatches(node, patches, index) {
  const currentPatches = patches[index];

  if (node.childNodes) {
    node.childNodes.forEach(node => {
      index++
      performPatches(node, patches, index)
    });
  }

  if (currentPatches) {
    applyPatches(node, currentPatches)
  }
}
Enter fullscreen mode Exit fullscreen mode

这里的工作流程应该很熟悉。patch获取正在更新的 DOM 元素,然后performPatches使用更改和计数器进行调用。performPatches我们首先对子元素执行任何更改,然后再对目标元素进行更改。

function applyPatches(node, currentPatches) {
  currentPatches.forEach(patch => {
    switch (patch.type) {
      case 'TEXT': {
        if (node.textContent) {
          node.textContent = patch.content
        }
        break;
      }
      case 'REPLACE': {
        const newNode = render(patch.node);
        node.parentNode.replaceChild(newNode, node);
        break;
      }
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

简单的 switch case 语句是我们更新过程第二阶段的核心。替换元素时,我们调用 Aprenderrender函数来创建 DOM 元素。

就是这样!现在,通过diffpatch函数,我们可以更新 DOM 元素了。如果我们要把它写成一个合适的应用程序,它应该是这样的:

const aprender = require('aprender');

const oldTree = aprender.createElement('div', { 
    children: ['Search', aprender.createElement('p')] 
  }
);

const newTree = aprender.createElement('div', { 
  children: ['No Search', aprender.createElement('span')] 
  }
);

const root = aprender.render(oldTree)
aprender.mount(root, document.getElementById('app'))

const diff = aprender.diff(oldTree, newTree);

setTimeout(() => {
  aprender.patch(root, diff);
}, 5000)
Enter fullscreen mode Exit fullscreen mode

 概括

我们的算法只不过是一系列条件检查。如果你深入研究 UI 框架更新机制的核心,你会发现它们也是一样的。然而,我们的实现仍然缺少一个基本要素——自动执行更新并响应数据变化的能力。我们必须使用它setTimeout来触发变更检测过程。我们将在下一篇文章中解决这个问题。

文章来源:https://dev.to/carlmungazi/learn-javascript-by-building-a-ui-framework-part-6-intro-to-virtual-dom-algorithms-jcm
PREV
通过从头构建 UI 框架来学习 JavaScript
NEXT
了解 ITCSS:GhostCMS 博客中使用 ITCSS 的真实案例