Web 组件 101:原生 JavaScript

2025-06-04

Web 组件 101:原生 JavaScript

如今,许多现代 Web 应用都是使用组件构建的。虽然像 React 这样的框架可以添加实现,但 Web 组件旨在使这些实践标准化,并成为浏览器的一部分。

在本文中,我们将介绍什么是 Web 组件、如何在没有框架的情况下构建它们,以及开发过程中需要注意的一些限制。在后续文章中,我们将展示轻量级框架(例如 Lit)如何为那些希望构建更大规模应用程序的用户提供更优质的体验。

什么是 Web 组件?

关于 Web 组件究竟是什么,人们存在很多误解。有些人可能认为它只是一种在一个统一的地方创建具有专用 UI​​、样式和逻辑的自定义元素的能力(稍后会详细介绍),但它的意义远不止于此。

Web 组件融合了三种不同的 Web 标准,它们结合使用,可以成为 React 等提供类似功能的框架的可行替代方案。这些 Web 标准包括:

  1. 自定义元素- 能够创建新元素,当添加相关 HTML 标签时,这些新元素将提供独特的 UI 和应用逻辑
  2. Shadow DOM - 能够将特定元素与主文档 DOM 隔离开来,从而避免文档冲突问题
  3. HTML 模板- 允许您编写未绘制到页面的 HTML 的元素,但可以用作标记模板以便在其他地方重复使用

虽然 Shadow DOM 和 HTML 模板在应用程序中无疑很有用,但今天我们将重点关注自定义元素,因为我们认为它们是整体引入 Web 组件最容易的起点。

虽然这些是 Web 组件的唯一官方规范部分,但它们通常与其他 JavaScript 和浏览器功能一起使用,以创建有凝聚力的开发体验。

这些经常使用的功能之一是JavaScript 模块。虽然将应用程序拆分成多个文件的概念在 Webpack 等打包工具中已经很常见了,但将其内置到浏览器中已经改变了游戏规则。

什么是自定义元素?

自定义元素的核心功能是创建新的 HTML 标签。这些标签可用于实现自定义 UI 和逻辑,并可在整个应用程序中使用。

<!-- page.html -->

<!-- These are custom elements, combined to make a page -->
<page-header></page-header>
<page-contents></page-contents>
<page-footer></page-footer>
Enter fullscreen mode Exit fullscreen mode

这些组件可以像样式按钮一样简单,也可以像应用程序的整个页面一样复杂,并具有完整的业务逻辑。

虽然我们倾向于认为 HTML 标签直接映射到单个 DOM 元素,但自定义元素并非总是如此。例如,上例中的“page-header”标签可能包含“nav”和“a”元素作为其子元素的列表。

图片

因此,我们可以通过减少单个文件中可见的标签数量来改善应用程序的组织,以便以更好的流程进行阅读。

但是自定义元素不仅仅由 HTML 组成——您还可以将 JavaScript 逻辑与这些标签关联!这使您能够将逻辑与其关联的 UI 放在一起。假设您的标题是一个由 JavaScript 驱动的下拉菜单。现在,您可以将该 JavaScript 保留在“page-header”组件中,从而保持逻辑的统一。

最后,组件带来的一个显著改进是可组合性。您可以在不同的页面上使用这些组件,从而保持页面之间的标题代码同步。这减少了标准组件出现差异的可能性——例如,在一个页面中出现多个大小不同的按钮——这可能会让用户感到困惑。只要您谨慎使用现有组件,就能通过这种方式让您的应用更加一致。

历史

但 Web 组件并非凭空而来。虽然 Web 组件如今已得到广泛应用,但情况并非一直如此。让我们来回顾一下 Web 组件及其相关生态系统的简短历史。

虽然具有类似概念的 JavaScript 框架至少自 2010 年就已经出现,但 Web 组件已经找到了在浏览器中标准化这些概念的方法。

显然,Web Components 的核心理念自那时起就得到了广泛的应用。例如,React 就秉承了许多相同的理念,如今在 JavaScript 编写的网站和应用中占据了相当大的市场份额。

现在我们已经了解了 Web 组件的简短历史,让我们来看看如何在不使用框架的情况下构建自定义元素。

生命周期方法

虽然许多组件的实现存在差异,但有一个概念相当通用,那就是“生命周期方法”。生命周期方法的核心在于,它允许你在元素发生事件时运行代码。即使是像 React 这样已经不再使用类的框架,仍然保留着类似的概念,即在组件发生某种变化时执行操作。

让我们看一下浏览器实现中的一些生命周期方法。

自定义元素有 4 种可以附加到组件的生命周期方法。

回调名称 描述
connectedCallback 连接到 DOM 时运行
disconnectedCallback 在未连接到 DOM 时运行
attributeChangedCallback 当 Web 组件的某个属性发生更改时运行。必须明确跟踪
adoptedCallback 从一个 HTML 文档移动到另一个 HTML 文档时运行

虽然它们各有用途,但我们主要关注前 3 个。adoptedCallback它们主要在特定情况下有用,因此很难进行直接的演示。

现在我们知道了生命周期方法是什么,让我们看一个实际的例子。

连接生命周期

我们将要讨论的前两种生命周期方法通常成对使用connectedCallbackdisconnectedCallback

connectedCallback当组件挂载到 DOM 上时运行。这意味着,当你希望元素显示时,你可以更改innerHTML,为元素添加事件监听器,或执行任何其他用于设置组件的代码逻辑。

同时,disconnectedCallback在元素从 DOM 中移除时运行。这通常用于移除在 期间添加的事件监听器connectedCallback,或执行元素所需的其他形式的清理工作。

这是一个简单的 Web 组件,它使用文本“Hello world”呈现标题。

class MyComponent extends HTMLElement {
  connectedCallback() {
      console.log("I am connecting");
      this.innerHTML = `<h1>Hello world</h1>`;
  }

  disconnectedCallback() {
      console.log("I am leaving");
  }
}

customElements.define('my-component', MyComponent);
Enter fullscreen mode Exit fullscreen mode

在操场上运行此代码示例

属性已改变

虽然还有其他方法可以将数据传递给元素(我们稍后会介绍),但属性的简单易用性是毋庸置疑的。它们在 HTML 规范标签中被广泛使用,并且大多数显示自定义元素都应该能够利用属性轻松地从父元素传递数据。

虽然attributeChangedCallback是用于检测属性值何时发生变化的生命周期方法,但您必须告诉组件要跟踪哪些属性。

例如,在本例中,我们正在跟踪message属性。如果message属性值发生变化,它将运行this.render()。但是,任何其他属性值的更改都不会触发,attributeChangedCallback因为没有其他属性被标记为要跟踪。

class MyComponent extends HTMLElement {
  connectedCallback() {
      this.render();
  }

   // Could also be:
  // static observedAttributes = ['message'];
  static get observedAttributes() {
      return ['message'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
      this.render();
  }

  render() {
      const message = this.attributes.message.value || 'Hello world';
      this.innerHTML = `<h1>${message}</h1>`;
  }
}

customElements.define('my-component', MyComponent);
Enter fullscreen mode Exit fullscreen mode

在操场上运行此代码示例

您会注意到,“ attributeChangedCallback”接收了更改的属性的名称、其先前的值以及当前值。这对于精细的手动更改检测优化非常有用。

然而,利用属性向组件传递值有其局限性。为了解释这些局限性,我们必须首先讨论可序列化性。

可序列化

序列化是将数据结构或对象转换为可存储和后续重建的格式的过程。序列化的一个简单示例是使用 JSON 编码数据。

JSON.stringify([
    {hello: 1},
    {other: 2}
])

// "[{\"hello\": 1}, {\"other\":2}]"
Enter fullscreen mode Exit fullscreen mode

由于这个 JavaScript 对象很简单,并且只使用了原始数据类型,因此将其转换为字符串相对简单。然后,该字符串可以保存到文件中,通过 HTTP 发送到服务器(并返回),并在再次需要数据时重建。

这种 JSON 序列化的简单性是 JSON 成为通过 REST 端点传输数据的流行格式的原因之一。

序列化限制

虽然简单对象和数组可以相对轻松地序列化,但也存在一些限制。例如,以下代码:

const obj = {
    method() {
        console.log(window);
    }
}
Enter fullscreen mode Exit fullscreen mode

虽然对于我们开发人员来说,这段代码的行为可能看起来很简单,但请从机器的角度来思考。

如果我们想从客户端远程将这个对象发送到服务器,并且方法完好无损,我们应该怎么做呢?

window虽然在浏览器中可用,但在 NodeJS 中不可用,因为服务器很可能是用 NodeJS 编写的。我们应该尝试序列化window对象并将其与方法一起传递吗?那么window对象上的方法呢?我们应该对这些方法做同样的处理吗?

另一方面,虽然console.log 在 NodeJS 和浏览器中都实现了,但它在两个运行时都是使用原生代码实现的。即使我们想序列化原生方法,又该如何操作呢?或许我们可以传递机器码?即使忽略安全问题,我们该如何处理用户 ARM 设备和服务器 x86_64 架构之间机器码的差异呢?

在你考虑到你的服务器可能没有运行 NodeJS 之前,所有这些都会成为一个问题。你该如何this用 Java 这样的语言来表示“”这个概念?你该如何处理 JavaScript 和 C++ 这样的动态类型语言之间的差异?

让我们将一些函数字符串化

现在了解了序列化函数的问题,您可能想知道如果运行会发生JSON.stringify()什么obj

const obj = {
    method() {
        console.log(this, window);
    }
}

JSON.stringify(obj); // "{}"
Enter fullscreen mode Exit fullscreen mode

它只是从 JSON 字符串中省略了键。在我们接下来的操作中,牢记这一点很重要。

HTML 属性字符串

为什么我们在本文中讨论序列化?为了回答这个问题,我想提一下关于 HTML 元素的两个事实。

  • HTML 属性不区分大小写
  • HTML 属性必须是字符串

第一个事实是,对于任何属性,你可以更改其键的大小写,它都会做出相同的响应。根据 HTML 规范,以下两者之间没有区别:

<input type="checkbox"/>
Enter fullscreen mode Exit fullscreen mode

和:

<input tYpE="checkbox"/>
Enter fullscreen mode Exit fullscreen mode

第二个事实与我们本次讨论更相关。虽然看起来你可以将非字符串值赋给属性,但实际上它们总是会被解析为字符串。

您可能会考虑采取一些技巧并使用 JavaScript 将非字符串值分配给属性:

const el = document.querySelector('input');
el.setAttribute('data-arr', [1, 2, 3, 4]);
Enter fullscreen mode Exit fullscreen mode

但是,该属性的指定值可能不符合您的期望:

<input type="checkbox" data-arr="1,2,3,4">
Enter fullscreen mode Exit fullscreen mode

您会注意到属性中缺少括号。这是因为 JavaScript 会隐式地处理toString您的数组,并将其转换为字符串,然后再将其赋值给属性。

不管你如何旋转它 - 你的属性都将是一个字符串。

这也是为什么当你尝试使用非字符串值的属性时,可能会遇到意想不到的行为。即使是内置元素,例如 ,也是如此input

<input type="checkbox" checked="false"/>
Enter fullscreen mode Exit fullscreen mode

如果不了解此 HTML 属性的限制,您可能希望复选框处于未选中状态。然而,在渲染时,它显示为选中状态。

在操场上运行此代码示例

这是因为您没有传递布尔值false,而是传递了字符串"false",而该字符串(令人困惑地)是真实的。

console.log(Boolean("false")); // true
Enter fullscreen mode Exit fullscreen mode

某些属性足够智能,可以知道何时您打算通过属性为元素分配数字或其他原始值,但内部实现可能看起来像这样:

class NumValidator extends HTMLElement {
  connectedCallback() {
      this.render();
  }

  static get observedAttributes() {
      return ['max'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
      this.render();
  }

  render() {
      // Coerce "attribute.value" to a number. Again, attributes
      // can only be passed as a string
      const max = Number(this.attributes.max.value || Infinity);
      // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

虽然这往​​往是 HTML 元素属性反序列化的程度,但我们可以进一步扩展此功能。

传递字符串数组

正如我们之前提到的,如果我们尝试使用 JavaScript 的 将数组传递给属性setAttribute,它将不会包含括号。这是由于Array.toString()的输出造成的。

如果我们尝试将数组["test", "another", "hello"]从 JS 传递给属性,输出将如下所示:

<script>
  class MyComponent extends HTMLElement {
      connectedCallback() {
          this.render();
      }

      static get observedAttributes() {
          return ['todos'];
      }

      attributeChangedCallback(name, oldValue, newValue) {
          this.render();
      }

      render() {
          const todos = this.attributes.todos.value || '';
          this.innerHTML = `<p>${todos}</p>`;
      }
  }

  customElements.define('my-component', MyComponent);
</script>

<my-component id="mycomp" todos="test,another,hello"></my-component>
Enter fullscreen mode Exit fullscreen mode

在操场上运行此代码示例

由于 的输出toString,很难将属性值转换回字符串。因此,我们只在标签内显示数据<p>。但是列表不属于单个段落标签!它们应该放在 中,并且列表中的每个项目都对应一个ul单独的。毕竟,语义化的 HTML 对于一个可访问的网站至关重要li

让我们使用JSON.stringify来序列化该数据,将该字符串传递给属性值,然后使用在元素中对其进行反序列化JSON.parse

<script>
  class MyComponent extends HTMLElement {
      connectedCallback() {
          this.render();
      }

      static get observedAttributes() {
          return ['todos'];
      }

      attributeChangedCallback(name, oldValue, newValue) {
          this.render();
      }

      render() {
          const todosArr = JSON.parse(this.attributes.todos.value || '[]');
          console.log(todosArr);
          const todoEls = todosArr.map(todo => `<li>${todo}</li>`).join('\n');
          this.innerHTML = `<ul>${todoEls}</ul>`;
      }
  }

  customElements.define('my-component', MyComponent);
</script>

<my-component todos="[&quot;hello&quot;,&quot;this&quot;]">
</my-component>
Enter fullscreen mode Exit fullscreen mode

在操场上运行此代码示例

使用这个方法,我们可以在render方法中获取一个数组。然后,我们只需map覆盖该数组即可创建li元素,并将其传递给我们的innerHTML

传递对象数组

虽然字符串数组是序列化属性的直接演示,但它很难代表现实世界的数据结构。

让我们开始努力让数据更贴近现实。一个好的开始可能是将字符串数组转换为对象数组。毕竟,我们希望能够在待办事项应用中将项目标记为“已完成”。

现在我们先把它控制在小规模,以后再慢慢扩大。让我们跟踪待办事项的“名称”以及它是否已完成:

const data = [{name: "hello", completed: false}];
Enter fullscreen mode Exit fullscreen mode

让我们看看如何使用自定义元素以合理的方式显示它:

<script>
  class MyComponent extends HTMLElement {
      connectedCallback() {
          this.render();
      }

      static get observedAttributes() {
          return ['todos'];
      }

      attributeChangedCallback(name, oldValue, newValue) {
          this.render();
      }

      render() {
          const todosArr = JSON.parse(this.attributes.todos.value || '[]');
          const todoEls = todosArr
              .map(todo => `
              <li>                 
                <!-- checked=”false” doesn’t do what you might think -->
                <input type="checkbox" ${todo.completed ? 'checked' : ''}/>
                ${todo.name}
              </li>
          `)
              .join('\n');
          this.innerHTML = `<ul>${todoEls}</ul>`;
      }
  }

  customElements.define('my-component', MyComponent);
</script>

<my-component
  id="mycomp"
  todos="[{&quot;name&quot;:&quot;hello&quot;,&quot;completed&quot;:false}]">
</my-component>
Enter fullscreen mode Exit fullscreen mode

记住,checked=”false” 表示复选框处于选中状态。这是因为“false” 是真值字符串。更多内容请参阅“序列化限制”部分。

现在我们正在显示这些复选框,让我们添加一种切换它们的方法!

var todoList = [];

function toggleAll() {
  todoList = todoList.map(todo => ({...todo, completed: !todo.completed}));
  changeElement();
}

function changeElement() {
  const compEl = document.querySelector('#mycomp');
  compEl.attributes.todos.value = JSON.stringify(todoList);     
}
Enter fullscreen mode Exit fullscreen mode

现在,我们需要做的就是在按下按钮时运行函数“toggleAll”,它将更新我们自定义元素中的复选框。

在操场上运行此代码示例

现在我们有了切换所有复选框的方法,让我们看看如何切换单个待办事项。

使用函数传递对象

虽然有很多方法可以让自定义元素中的用户输入与父级的数据集进行交互,但让我们在每个 todo 对象中存储一个方法并将其传递到自定义元素中。

此模式遵循组件的最佳实践,保持数据单向传递。之前,我们讨论过如何让 React 和 Web 组件保持单向传递

让我们改变一个 todo 对象来反映类似的内容:

todoList.push({
  name: inputEl.value,
  completed: false,
  id: todoId,
  onChange: () => {
    toggleTodoItem(todoId)
  }
});
Enter fullscreen mode Exit fullscreen mode

然后,我们只需toggleTodoItem使用 ID 来实现我们的方法来修改相关的待办事项对象:

function toggleTodoItem(todoId) {
  thisTodo = todoList.find(todo => todo.id == todoId);
  thisTodo.completed = !thisTodo.completed;
  changeElement();
}

function changeElement() {
  const compEl = document.querySelector('#mycomp');
  compEl.attributes.todos.value = JSON.stringify(todoList);
}
Enter fullscreen mode Exit fullscreen mode

通过这些更改,我们从父元素中获得了处理复选框逻辑所需的所有逻辑。现在,我们需要更新自定义元素,以便onChange在复选框被选中时触发该方法。为了将事件监听器绑定到“input”元素,我们需要访问底层HTMLElement引用。为此,我们需要放弃innerHTML之前使用的逻辑,转而使用document.createElement

render() {
  this.clear();

  // Create list element
  const todosArr = JSON.parse(this.attributes.todos.value || '[]');
  const todoEls = todosArr
      .map(todo => {
          // Use `createElement` to get access to the element. We can then add event listeners
          const checkboxEl = document.createElement('input');
          checkboxEl.type = "checkbox";

          // This doesn't work, we'll explain why shortly
          checkboxEl.addEventListener('change', todo.onChange);

          checkboxEl.checked = todo.completed;

          const liEl = document.createElement('li');
          liEl.append(checkboxEl);
          liEl.append(todo.name);
          return liEl;
      });

  const ulEl = document.createElement('ul');
  for (const liEl of todoEls) {
      ulEl.append(liEl);
  }

  // Add header. This should update to tell us how many items are completed
  const header = document.createElement('h1');
  header.innerText = todosArr.filter(todo => todo.completed).length;

  // Reconstruct logic
  this.append(header);
  this.append(ulEl);
}
Enter fullscreen mode Exit fullscreen mode

太棒了!现在我们已经完成了所有必要的更改,让我们看看它们是否能正常工作!

在操场上运行此代码示例

哦……奇怪……我们的复选框好像在更新,但我们的h1却没有。而且,如果我们查看开发者控制台,console.log在重新渲染时并没有看到我们期望看到的 。

这是为什么?

正如我们在序列化限制部分提到的,函数是不可序列化的。因此,当将一个带有方法的对象传递给 时JSON.parse,这些键会被移除。当我们添加事件监听器时,该函数是undefined,因此不会执行任何操作。

checkboxEl.addEventListener('change', todo.onChange); // onChange is undefined
Enter fullscreen mode Exit fullscreen mode

复选框的状态在视觉上更新但没有反映在我们的数据中,这是 DOM 与我们用于构建 DOM 的数据之间不一致的一个例子。

但是,除了序列化问题之外,我们可以验证代码是否正确。如果我们修改该行代码,toggleTodoItem直接使用全局函数,它将按预期运行:

checkboxEl.addEventListener('change', () => toggleTodoItem(todo.id))
Enter fullscreen mode Exit fullscreen mode

更新上面沙箱中的这行代码以查看正确的行为!

虽然这适用于我们当前的设置,但构建自定义元素的优势之一是能够将应用程序拆分为多个文件,从而保持应用程序代码库的井然有序。一旦toggleTodoItem不再与自定义元素位于同一作用域,此代码就会中断。

如果这不是一个好的长期解决方案,我们可以做些什么来解决序列化问题?

通过 Props 传递,而不是 Attributes

属性提供了一种将原始数据传递给自定义元素的简单方法。然而,正如我们所演示的,由于需要序列化数据,它在更复杂的用法中会失效。

我们知道无法使用属性来绕过这个限制,因此我们可以利用 JavaScript 类来更直接地传递数据。

因为我们的组件是扩展的类HTMLElement,所以我们能够从自定义元素的父元素访问我们的属性和方法。假设我们想todos在属性更改后更新并渲染。

为此,我们只需在组件的类中添加一个名为“ setTodos”的方法。当我们使用 查询元素时,就可以访问此方法document.querySelector

class MyComponent extends HTMLElement {
  todos = [];

  connectedCallback() {
      this.render();
  }

  setTodos(todos) {
      this.todos = todos;
      this.clear();
      this.render();
  }

  render() {
      // ...
  }
}

// ...

function changeElement() {
  const compEl = document.querySelector('#mycomp');
  compEl.setTodos(todoList);
}
Enter fullscreen mode Exit fullscreen mode

在操场上运行此代码示例

现在,如果我们在待办事项列表中切换项目,我们的h1标签就会按预期更新:我们已经解决了 DOM 和数据层之间的不匹配问题!

因为我们正在更新自定义元素的属性,所以我们称之为“通过属性传递”,这解决了“通过属性传递”的序列化问题。

但这还不是全部!属性在数据传递方面也比属性有一个隐藏的优势:内存大小。

当我们将待办事项序列化为属性时,我们复制了数据。我们不仅将待办事项列表保存在 JavaScript 内存中,浏览器也会将加载的 DOM 元素保存在内存中。这意味着,对于我们添加的每个待办事项,我们不仅会在 JavaScript 中保留一份副本,还会在 DOM 中(通过属性字符串)保留一份副本。

但是,这肯定是迁移到属性时提高内存效率的唯一方法,对吗?错了!

因为请记住,除了在主标签的 JS 内存中加载script,以及通过 DOM 在浏览器中加载之外,我们还在自定义元素中对其进行了反序列化!这意味着我们同时在内存中初始化了数据的第三份副本!

虽然这些性能考虑在演示应用程序中可能并不重要,但它们会在生产规模的应用程序中增加显著的复杂性。

结论

今天我们讲了很多内容!我们介绍了一些 Web 组件的核心概念、如何最好地实现各种功能,以及 DOM 的局限性。

虽然我们今天讨论了很多关于通过属性 (Attribute) 和属性 (Property) 传递数据的问题,但两者各有利弊。理想情况下,我们希望兼顾两者:既可以通过属性传递数据以避免序列化,又可以通过将属性值与相关的 DOM 元素一起反射来保持属性的简单性。

自本文开始以来,我们还失去了元素创建过程中代码的可读性。最初,当我们使用 时innerHTML,我们能够看到输出 DOM 的可视化表示。然而,当我们需要添加事件监听器时,就需要切换到document.createElement。理想情况下,我们可以在不牺牲自定义元素渲染输出的代码 HTML 表示的情况下附加事件监听器。

虽然这些功能可能并未包含在 Web 组件规范中,但还有其他选择。在下一篇文章中,我们将介绍一个轻量级框架,可以用来构建更强大的 Web 组件,并使其能够与许多其他前端技术栈集成!

文章来源:https://dev.to/coderpad/web-components-101-vanilla-javascript-2pja
PREV
JavaScript、异步编程和 Promises
NEXT
如何使用 CodeSandbox - 初学者指南