通过从头构建 UI 框架来学习 JavaScript

2025-06-04

通过从头构建 UI 框架来学习 JavaScript

在我之前的文章中,我解释了如何将你最喜欢的库和框架的 API 转化为编程课程。今天,我将更进一步地阐述这个想法。我们不会阅读别人的代码。相反,我们将编写自己的代码,并构建一个非常基础的 UI 框架。

构建框架是深化 JavaScript 和编程知识的好方法,因为它迫使你探索语言特性和软件工程原理。例如,所有 Web 框架都试图解决保持应用程序数据与 UI 同步的问题。所有这些问题的解决方案都可能涉及不同的领域,例如路由、DOM 操作、状态管理和异步编程。

解决 UI 状态同步问题的一种更流行的方法是使用虚拟 DOM(简称 vdom)。我们可以使用 JavaScript 对象来响应数据更改,而不是直接操作 DOM,因为它们的操作计算成本更低。vdom 方法可以分解如下:

  1. 首次加载应用程序时,创建一个描述 UI 的 JavaScript 对象树
  2. 使用 DOM API 将这些对象转换为 DOM 元素,例如document.createElement
  3. 当你需要更改 DOM(响应用户交互、动画或网络请求)时,创建另一个 JavaScript 对象树来描述你的新 UI
  4. 比较新旧 JavaScript 对象树,查看哪些 DOM 元素发生了变化以及如何
  5. 只对已更改的地方进行 DOM 更改

任何 vdom 实现的基本部分之一是创建对象的函数。本质上,该函数必须返回一个包含创建 DOM 元素所需信息的对象。例如,为了创建以下 DOM 结构:

<ul class="list">
    <li class="list-item" style="color: red;">Item 1</li>
    <li class="list-item" style="color: blue;">Item 2</li>
</ul>

您需要了解每个 DOM 元素的以下信息:

  • 元素类型
  • 属性列表
  • 如果它有任何孩子(对于每个孩子,我们还需要知道上面列出的相同信息)

这引出了我们的第一课:数据结构。正如 Linus Torvalds 所说:“糟糕的程序员担心代码。优秀的程序员担心数据结构及其关系。” 那么,我们如何在代码中表示上面的 DOM 结构呢?

{
  type: 'ul',
  attrs: {
      'class': 'list'
  },
  children: [
    {
      type: 'li',
      attrs: {
        class: 'list-item',
        style: {
          color: 'red'
        }
      },
    },
    {
      type: 'li',
      attrs: {
        class: 'list-item',
        style: {
          color: 'blue'
        }
      },
    } 
  ]
}

我们有一个具有三个属性的对象,每个属性要么是stringobject要么是array。我们如何选择这些数据类型?

  • 所有 HTML 元素都可以用字符串表示
  • HTML 属性key: value与对象之间存在良好的关系
  • HTML 子节点可以采用列表格式,创建它们需要对列表中的每个项目执行相同的操作。数组非常适合这种情况

现在我们知道了数据结构是什么样的,我们可以继续讨论创建这个对象的函数了。根据我们的输出判断,最简单的做法是创建一个接受三个参数的函数。

createElement (type, attrs, children) {
  return {
    type: type,
    attrs: attrs,
    children: children
  }
}

我们有函数,但如果调用时没有收到所有参数,会发生什么?此外,创建对象是否需要所有参数都存在?

这将引出我们的下一课:错误处理默认参数解构属性简写

首先,如果不指定类型,就无法创建 HTML 元素,因此我们需要防范这种情况。对于错误,我们可以借鉴 Mithril抛出错误的方法。或者,我们可以按照此处所述定义自定义错误。

createElement (type, attrs, children) {
  if (type == null || typeof type !== 'string') {
    throw Error('The element type must be a string');
  }

  return {
    type: type,
    attrs: attrs,
    children: children
  }
}

我们稍后会重新讨论这个检查type == null || typeof type !== 'string',但现在先专注于创建对象。虽然我们不能在不指定类型的情况下创建 HTML 元素,但我们可以创建没有子元素或属性的 HTML 元素。

在 JavaScript 中,如果调用函数时未提供任何必需的参数,则这些参数会被undefined默认赋值。因此,如果用户未指定attrs, 和children也会被undefined赋值。我们不希望出现这种情况,因为正如我们稍后会看到的,其余代码期望这些参数包含一个值。为了解决这个问题,我们将attrschildren赋值为默认值:

createElement (type, attrs = {}, children = []) {
  if (type == null || typeof type !== 'string') {
    throw Error('The element type must be a string');
  }

  return {
    type: type
    attrs: attr,
    children: children
  }
}

如前所述,HTML 元素可以在没有任何子元素或属性的情况下创建,因此我们的函数不需要三个参数,而是需要两个:

createElement (type, opts) {
  if (type == null || typeof type !== 'string') {
    throw Error('The element type must be a string');
  }

  return {
    type: type
    attrs: opts.attr,
    children: opts.children
  }
}

我们失去了之前引入的默认参数,但可以通过解构将它们恢复。解构允许我们解包对象属性(或数组值),并将它们用作不同的变量。我们可以将其与简写属性结合使用,以简化代码。

createElement (type, { attrs = {}, children = [] }) {
  if (type == null || typeof type !== 'string') {
    throw Error('The element type must be a string');
  }

  return {
    type,
    attrs,
    children
  }
}

我们的函数可以创建虚拟 DOM 对象,但还没完。之前我们跳过了这段代码type == null || typeof type !== 'string'。现在我们可以重新回顾一下,并学习一些其他的东西:强制转换

这里有四件事需要注意:

  • ==松散相等运算符的行为
  • ||操作员的行为
  • typeof操作员的行为
  • !==操作员的行为

我第一次学习 JavaScript 时,看到很多文章建议不要使用松散相等运算符。因为它会产生一些令人惊讶的结果,例如:

1 == '1' // true
null == undefined // true

这很令人惊讶,因为在上面的例子中,我们比较了四种不同原始类型的值:number上述检查结果为,因为在比较不同类型的值时会执行强制转换。控制这种情况如何发生的规则可以在这里找到。对于我们的具体情况,我们需要知道始终返回 true 的规范状态。此外,通过执行与执行相同的检查,然后对结果取反来工作。您可以在这里阅读有关规则stringnullundefinedtrue==null == undefined!=====

回到我们的函数,它首先type == null || typeof type !== 'string'检查的是是否传递了nullundefined值。如果是true||运算符将返回 的结果typeof type !== 'string'。这个过程的顺序很重要。||运算符不返回任何boolean值。它返回两个表达式之一的值。它首先对 进行boolean测试type == null,结果要么是 ,true要么是false。如果测试返回true,则会抛出我们的错误。

但是,如果false返回的是 ,||则返回第二个表达式的值,在我们的例子中,该值要么是,要么truefalse。如果我们的检查结果为 ,type == null || type并且第一个表达式的结果为false,则第二个表达式将返回变量 中的任何值typetypeof运算符返回一个字符串,指示给定值的类型。我们没有使用它进行type == null检查,因为typeof null返回,这是JavaScript 中object一个臭名昭著的bug。

有了这些新知识,我们可以更深入地审视createElement并问自己以下问题:

  • 我们如何检查第二个参数是否可以被破坏?
  • 我们如何检查第二个参数是一个对象?

让我们首先使用不同的参数类型调用我们的函数:

createElement (type, { attrs = {}, children = [] }) {
  if (type == null || typeof type !== 'string') {
    throw Error('The element type must be a string');
  }

  return {
    type,
    attrs,
    children
  }
}

createElement('div', []); // { type: "div", attrs: {…}, children: Array(0) }
createElement('div', function(){}); // { type: "div", attrs: {…}, children: Array(0) }
createElement('div', false); // { type: "div", attrs: {…}, children: Array(0) }
createElement('div', new Date()); // { type: "div", attrs: {…}, children: Array(0) }
createElement('div', 4); // { type: "div", attrs: {…}, children: Array(0) }

createElement('div', null); // Uncaught TypeError: Cannot destructure property `attrs` of 'undefined' or 'null'
createElement('div', undefined); // Uncaught TypeError: Cannot destructure property `attrs` of 'undefined' or 'null'

现在我们修改一下函数:

createElement (type, opts) {
  if (type == null || typeof type !== 'string') {
    throw Error('The element type must be a string');
  }

  if (arguments[1] !== undefined && Object.prototype.toString.call(opts) !== '[object Object]') { 
    throw Error('The options argument must be an object'); 
  }

  const { attrs = {}, children = [] } = opts || {};

  return {
    type,
    attrs,
    children
  }
}

createElement('div', []); // Uncaught Error: The options argument must be an object
createElement('div', function(){}); // Uncaught Error: The options argument must be an object
createElement('div', false); // Uncaught Error: The options argument must be an object
createElement('div', new Date()); // Uncaught Error: The options argument must be an object
createElement('div', 4); // Uncaught Error: The options argument must be an object

createElement('div', null); // Uncaught Error: The options argument must be an object
createElement('div', undefined); // Uncaught Error: The options argument must be an object

TypeError我们的第一个函数不符合预期,因为它接受了错误类型的值。当使用null或 调用时,它还报错undefined。我们在第二个函数中解决了这个问题,引入了新的检查和新的经验教训:错误类型剩余参数this

null当我们使用或undefined作为第二个参数调用函数时,我们看到了这样的消息: Uncaught TypeError: Cannot destructure property 'attrs' of 'undefined' or 'null'。ATypeError是一个对象,它表示由于值不是预期类型而导致的错误。它是与ReferenceError和 一起最常见的错误类型之一SyntaxError。这就是为什么我们恢复使用对象作为参数的原因,因为在解构函数参数时没有办法防止null和值。undefined

让我们仔细看看第二次迭代中的检查:

if (arguments[1] !== undefined && Object.prototype.toString.call(opts) !== '[object Object]') { 
  throw Error('The options argument must be an object'); 
}

首先要问的问题是:既然有剩余参数,为什么还要使用arguments对象?剩余参数是在ES6中引入的,它允许开发人员以数组的形式表示不定数量的参数,从而更简洁。如果我们使用了它们,我们就可以写出如下代码:

createElement (type, ...args) {
  if (type == null || typeof type !== 'string') {
    throw Error('The element type must be a string');
  }

  if (args[0] !== undefined && Object.prototype.toString.call(args[0]) !== '[object Object]') { 
    throw Error('The options argument must be an object'); 
  }
}

如果我们的函数有很多参数,这段代码就很有用,但因为我们只需要两个参数,所以前一种方法效果更好。第二个函数最令人兴奋的地方在于表达式Object.prototype.toString.call(opts) !== '[object Object]'。这个表达式是这个问题的答案之一:在 JavaScript 中,如何检查某个东西是否是对象?显而易见的解决方案是首先尝试 ,但正如我们之前讨论过的,由于 JavaScript 中存在使用typeof opts === "object"返回的 bug,因此它并不可靠truetypeofnull

我们选择的解决方案在 ES5 中有效,因为它利用了[[Class]]内置对象中的 internal 属性。根据 ES5规范,这是一个字符串值,表示规范定义的对象分类。它可以通过方法访问toString规范toString深入解释了的行为,但本质上,它返回一个格式为 的字符串[object [[Class]]],其中[[Class]]是内置对象的名称。

大多数内置函数都会覆盖,toString因此我们还必须使用call方法。此方法使用特定this绑定调用函数。这很重要,因为每当调用函数时,它都会在特定上下文中调用。JavaScript 大师 Kyle Simpson概述了确定 优先顺序的四条规则this。第二条规则是,当使用callapply或 调用函数时bind,绑定指向this的第一个参数中指定的对象。因此,使用指向 中任何值的绑定执行callapplybindObject.prototype.toString.call(opts)toStringthisopts

在 ES6 中,该[[Class]]属性已被移除,因此虽然该解决方案仍然有效,但其行为略有不同。规范建议不要使用此解决方案,因此我们可以从Lodash的处理方式中汲取灵感。但是,我们仍会保留它,因为它产生错误结果的风险非常低。

表面上看,我们创建了一个看似简单实用的功能,但正如我们所经历的,整个过程绝非如此。我们可以进入下一个阶段,但这引出了一个问题:这个阶段应该是什么?我们的功能可以进行一些测试,但这需要创建一个开发工作流。现在进行测试是否为时过早?如果我们添加测试,应该使用哪个测试库?在执行任何其他操作之前,先创建一个可行的解决方案不是更好吗?这些都是开发人员每天都要面对的难题,我们将在下一个教程中探讨这些难题(以及答案)。

文章来源:https://dev.to/carlmungazi/learning-javascript-by-building-a-ui-framework-from-scratch-1767
PREV
使用 NestJS 和 MongoDB(Mongoose)构建 RESTful API 简介 安装 安装 Nest CLI 创建新的 Nest 项目 在 IDE 中打开项目 创建“Todo”功能 创建 Todo 模型/模式 在 TodoService 中注入模型 定义 CRUD 功能 在 TodoController 中定义方法和路由端点 使用 REST 客户端进行测试 结论
NEXT
通过构建 UI 框架学习 JavaScript:第 6 部分 - 虚拟 DOM 算法简介