原生 Web 组件开发

2025-06-07

原生 Web 组件开发

你有没有问过自己,你用不同的库或框架编写过多少次按钮组件?组件是任何 Web 项目的基础,但随着各种变化和新框架的出现,组件的复用或更新变得非常困难。这反而增加了开发时间。

为了解决这个问题,Web 组件可以简化这个过程,因为它们可以与浏览器原生协作,也可以集成到任何 JS 框架/库中。

在开始之前,您必须了解 HTML、CSS 和 Javascript。

最后,您将了解如何创建和集成 Web 组件。我将提供包含示例的链接,并分享我在开发原生 Web 组件时遇到的最常见问题的经验、注意事项和解决方案。

什么是 Web 组件?

Web 组件是一种创建封装的、单一职责的代码块的方法,可以在任何页面上重复使用。

Web Component 技术比大多数人所知的更古老,使用也更广泛。<audio><meter><video>以及许多其他 HTML 标签在每个浏览器中都使用类似 Web Component 的技术实现。但是,这项技术当时无法对外提供。因此,我们现在所说的“Web Component”(自定义元素 API、模板、Shadow DOM)正是我们所有人都可以使用的同一项技术。

Web 组件的构建块

开始创建自己的组件需要了解的主要功能是:

对于本教程,您将构建一个警报组件。

自定义警报元素

影子 DOM

Web 组件的一个关键方面是封装——将标记结构、样式和行为隐藏并与页面上的其他代码分离,这样不同部分就不会发生冲突,代码也能保持整洁美观。Shadow DOM API 至关重要,它提供了一种将隐藏的、独立的 DOM 附加到元素的方法。

Shadow DOM 允许将隐藏的 DOM 树附加到常规 DOM 树中的元素——此 Shadow DOM 树以 Shadow 根开始,其下可以附加到任何您想要的元素,方式与标准 DOM 相同。

ShadowDOM 抽象

简单来说,影子 DOM 是常规 DOM 内的自包含、封装的代码块,具有自己的范围。

HTML模板

HTML模板用于创建和添加 HTML 标记和 CSS。您只需在<template>标签内编写标记即可使用。

模板的不同之处在于它会被解析但不会被渲染,因此模板会出现在 DOM 中,但不会呈现在页面上。为了更好地理解,我们来看下面的例子。

<template>
  <div class="alert">
    <span class="alert__text">
      <slot></slot>
    </span>
    <button id="close-button" type="button" class="alert__button">x</button>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

由于您没有将 HTML 文件导入 JavaScript 代码的本机支持,因此实现此目的的更简单方法是通过 JavaScript 文件中的代码添加模板标签并将 HTML 内容分配给 innerHTML 属性。

const template = document.createElement('template');
template.innerHTML = /*html*/ `
<div class="alert">
  <span class="alert__text">
    <slot></slot>
  </span>
  <button id="close-button" type="button" class="alert__button">x</button>
</div>`;
Enter fullscreen mode Exit fullscreen mode

显示实际代码

这是您将要构建的组件的草稿,这是注册和导入后的结果:

ce-alert 预览

<ce-alert>Hello there!</ce-alert>
Enter fullscreen mode Exit fullscreen mode

稍后我将详细解释如何注册和导入它,以及如何添加 CSS 样式。此外,你可能注意到了一个名为 的新标签,<slot>它是 Web Component 技术的一个重要特性,让我们来了解一下。

元素<slot>

元素<slot>是 Web 组件内的占位符,您可以用自己的标记填充它,从而创建单独的 DOM 树并将它们组合在一起呈现,并且只能与 Shadow DOM 一起使用name属性可用于指定要放置内容的目标。

让我们看一下这个例子。您创建了一个名为 的新 Web 组件ce-article,它包含以下标记:

<article>
  <header>
    <slot name="header">
      <h1>title</h2>
    </slot>
    <slot name="subheader">
      <h2>subtitle</h2>
    </slot>
  </header>
  <p>
    <slot></slot>
  </p>
  <footer>
    <slot name="footer"></slot>
  </footer>
</article>
Enter fullscreen mode Exit fullscreen mode

显示实际代码

要使用此组件,您可以按如下方式声明它:

<ce-article>
  <h1 slot="header">My articles title</h1>
  Loren ipsum neros victus...
  <a href="#" slot="footer">Read more</a>
</ce-article>
Enter fullscreen mode Exit fullscreen mode

显示实际代码

然后,所有内容将被放置在您在 Web 组件内声明的位置,如下图所示。

ce-文章预览

自定义元素

要创建自定义元素,您需要定义其名称以及一个表示元素行为的类对象。通常,您应该为组件添加前缀,以避免与原生 HTML 标签冲突。此外,还要注意自定义元素名称必须包含连字符。因此,在本例中,您可以ce为组件添加 (自定义元素) 前缀,例如ce-alert

创建新的自定义元素

创建一个从 HTMLElement 继承的新类Alert,并在构造函数方法中使用 super 调用基类构造函数。

const template = document.createElement('template');
//...
export class Alert extends HTMLElement {
  constructor() {
    super();
  }
}
Enter fullscreen mode Exit fullscreen mode

显示实际代码

注册新的自定义元素

接下来,您使用该customElements.define方法来注册您的新组件。

const template = document.createElement('template');
//...
export class Alert extends HTMLElement {
  //...
}
customElements.define('ce-alert', Alert);
Enter fullscreen mode Exit fullscreen mode

显示实际代码

自定义元素生命周期

从您创建、更新或删除自定义元素的那一刻起,它就会触发特定的方法来定义每个阶段。

自定义元素生命周期

  • connectedCallback:每次将自定义元素附加到文档连接元素时调用。每次移动节点时,此调用都可能在元素内容完全解析之前发生。
  • disconnectedCallback:每次自定义元素与文档的 DOM 断开连接时调用。
  • adoptedCallback:每次将自定义元素移动到新文档时调用。
  • attributeChangedCallbackobservedAttributes:每次添加、删除或更改自定义元素的属性时都会调用。在静态 get方法中指定要通知哪些属性的更改

让我们看一个使用这些概念的例子。

// https://github.com/mdn/web-components-examples/tree/main/life-cycle-callbacks
//...
class Square extends HTMLElement {
  // Specify observed attributes so that attributeChangedCallback will work
  static get observedAttributes() {
    return ['c', 'l'];
  }
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    const div = document.createElement('div');
    const style = document.createElement('style');
    shadow.appendChild(style);
    shadow.appendChild(div);
  }
  connectedCallback() {
    console.log('Custom square element added to page.');
    updateStyle(this);
  }
  disconnectedCallback() {
    console.log('Custom square element removed from page.');
  }
  adoptedCallback() {
    console.log('Custom square element moved to new page.');
  }
  attributeChangedCallback(name, oldValue, newValue) {
    console.log('Custom square element attributes changed.');
    updateStyle(this);
  }
}
//...
Enter fullscreen mode Exit fullscreen mode

显示实际代码

类的构造函数很简单——只需将一个影子 DOM 附加到自定义元素,然后就可以在其中附加模板了。影子模式可以是openclosed。在open状态下,可以在影子根之外访问元素,反之亦然。

要访问自定义元素内的元素,您需要使用自定义元素名称进行查询选择,使用shadowRootprop,然后再次查询所需的元素。

document.querySelector('ce-alert').shadowRoot.querySelector('#close-button');
Enter fullscreen mode Exit fullscreen mode

注意:仅当将阴影根附加到自定义元素时将模式设置为打开时才有可能。

总结一下,所有更新都由生命周期回调函数处理,这些回调函数以方法的形式放置在类定义中。connectedCallback() 函数在每次元素添加到 DOM 时运行。disconnectedCallback 函数在元素被移除时运行。attributeChangedCallback 函数在属性(在静态 get observedAttributes() 方法中映射)发生更改时运行。

提示:要检查组件是否连接到 DOM,可以使用this.isConnected

定义属性和特性

属性 (Attribute) 和特性 (Property) 的工作方式与您过去在 JS 库/框架中理解的略有不同。属性 (Attribute) 是在 HTML 标签内声明的内容,而特性 (Property) 是您扩展的HTMLElement类的一部分,当您定义新组件时,它已经包含一组已定义的特性 (Property)。因此,可以通过将特性 (Property) 反射到属性 (Attribute) 来实现同步属性 (Attribute) 和特性 (Property)。让我们通过以下示例进行演示:

<ce-alert color="red"></ce-alert>
Enter fullscreen mode Exit fullscreen mode

需要注意的是,属性始终是字符串。因此,您无法定义方法、对象或数字。但是,如果您需要其他类型,则必须稍后进行强制类型转换或直接在元素对象内部声明。

现在将属性与类中的属性同步:

//...
export class Alert extends HTMLElement {
  //...
  set color(value) {
    this.setAttribute('color', value);
  }

  get color() {
    return this.getAttribute('color');
  }

  connectedCallback() {
    console.log(this.color); // outputs: "red"
  }
}
//...
Enter fullscreen mode Exit fullscreen mode

显示实际代码

虽然这种方法有效,但随着组件属性越来越多,它可能会变得冗长乏味。不过,还有一种无需手动声明所有属性的替代方法:HTMLElement.datasets接口提供对元素上自定义数据属性 ( ) 的读/写访问。它为每个属性条目data-*公开一个字符串映射 (DOMStringMap) ,您还可以将其与属性结合使用,以获得更大的灵活性。让我们使用数据集声明更新示例:data-*get/set

<ce-alert data-color="red"></ce-alert>
Enter fullscreen mode Exit fullscreen mode
//...
export class Alert extends HTMLElement {
  //...
  attributeChangedCallback() {
    console.log(this.dataset.color); // outputs: "red"
  }
}
//...
Enter fullscreen mode Exit fullscreen mode

显示实际代码

同步属性和特性(奖励)

这是可选的,但如果您想要在属性和属性之间进行同步,这里有一个可以简化这个过程的函数:

/**
 * @param target - the custom element class
 * @param props - properties that need to be synced with the attributes
 */
const defineProperties = (target, props) => {
  Object.defineProperties(
    target,
    Object.keys(props).reduce((acc, key) => {
      acc[key] = {
        enumerable: true,
        configurable: true,
        get: () => {
          const attr = target.getAttribute(getAttrName(key));
          return (attr === '' ? true : attr) ?? props[key];
        },
        set: val => {
          if (val === '' || val) {
            target.setAttribute(getAttrName(key), val === true ? '' : val);
          } else {
            target.removeAttribute(key);
          }
        }
      };
      return acc;
    }, {})
  );
};
Enter fullscreen mode Exit fullscreen mode

观察属性和特性

要检测属性或属性的变化,您需要使用静态方法返回一个包含所需所有值的数组observedAttributes。接下来,配置回调函数attributeChangedCallback来定义当定义的属性发生变化时会发生什么。

//...
export class Alert extends HTMLElement {
  //...
  static get observedAttributes() {
    return ['data-color'];
  }
  attributeChangedCallback(name, prev, curr) {
    if (prev !== curr) {
      this.shadowRoot.querySelector('.alert').classList.remove(prev);
      this.shadowRoot.querySelector('.alert').classList.add(curr);
    }
  }
}
//...
Enter fullscreen mode Exit fullscreen mode

显示实际代码

浏览器集成

现在您可以在 HTML 文件中使用自定义元素了。要集成,您必须将 js 文件作为模块导入。

<html>
  <head>
    <style>
      ...;
    </style>
    <script type="module" src="./index.js"></script>
  </head>
  <body>
    <ce-alert></ce-alert>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

显示实际代码

元素样式

在 Web 组件中,至少有四种使用 CSS 定义样式的方法:

除了常规的CSS 选择器之外,Web Components 还支持以下选择器:

选择器 描述
:主机/:主机(名称) 选择阴影宿主元素或者它是否具有某个类。
:主机上下文(名称) 仅当作为函数参数给出的选择器与影子宿主在 DOM 层次结构中的位置的祖先匹配时,才选择影子宿主元素。
::开槽() 如果与选择器匹配,则选择一个插槽元素。
::部分() 选择影子树中具有匹配部分属性的任何元素。

内联样式

初始 (也是最常见的)开始设置组件样式的方法是在模板内声明样式。

<template>
  <style>
    :host {
      --bg-color: #ffffff;
      --border-color: #d4d4d8;
      --text-color: #374151;
    }
    .alert {
      font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 0.5rem 1.25rem;
      color: var(--text-color);
      background-color: var(--background-color);
      border: 1px solid var(--border-color);
      border-radius: 0.75rem;
    }
    .alert__text {
      font-size: 0.875rem;
      line-height: 1.25rem;
    }
    .alert__button {
      -webkit-appearance: button;
      cursor: pointer;
      color: var(--text-color);
      background-color: transparent;
      background-image: none;
      border: none;
      height: 2rem;
      width: 2rem;
      margin-left: 0.25rem;
    }
  </style>
  <div class="alert">
    <span class="alert__text">
      <slot></slot>
    </span>
    <button id="close-button" type="button" class="alert__button">x</button>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

显示实际代码

这里的主要区别是使用:host选择器,而不是:root封装元素内部不可用且无法访问 Web 组件内部的全局 CSS 变量的选择器。

使用“ part ”属性

另一种解决方案是使用::part选择器从外部自定义组件,这样就可以使用:root选择器创建共享样式。您需要将part属性添加到要自定义的元素,然后外部的 CSS 选择器就可以访问组件。

让我们看一下这个例子,您可以更新模板并将类属性更改为part

<template>
  <style>
    //...
  </style>
  <div part="alert">
    <span part="text">
      <slot></slot>
    </span>
    <button id="close-button" type="button" part="button">x</button>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

显示实际代码

然后,创建一个新的 CSS 文件并将所有样式块移入其中并更新选择器以匹配ce-alert组件。

:root {
  --bg-color: #ffffff;
  --border-color: #d4d4d8;
  --text-color: #374151;
  font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
}

ce-alert::part(alert) {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.5rem 1.25rem;
  color: var(--text-color);
  border: 1px solid var(--border-color);
  background-color: var(--background-color);
  border-radius: 0.75rem;
}

ce-alert::part(text) {
  font-size: 0.875rem;
  line-height: 1.25rem;
}

ce-alert::part(button) {
  -webkit-appearance: button;
  color: var(--text-color);
  background-color: transparent;
  background-image: none;
  border: none;
  margin-left: 0.25rem;
  height: 2rem;
  width: 2rem;
  cursor: pointer;
}
Enter fullscreen mode Exit fullscreen mode

显示实际代码

注意:::part选择器仅接受一个参数。

最后,更新index.html文件以导入这个新的 CSS 文件,就这样。

CSS注入

自定义元素的另一种方法是将样式注入 Web 组件中。首先,您必须创建一个代表单个 CSS 样式表的CSSStyleSheet对象,然后替换样式,最后将其应用于影子根。唯一的缺点是,它需要特殊的 polyfill才能与 Safari 兼容。

const stylesheet = new CSSStyleSheet();
stylesheet.replace('body { font-size: 1rem };p { color: gray; };');
this.shadowRoot.adoptedStyleSheets = [stylesheet];
Enter fullscreen mode Exit fullscreen mode

显示实际代码

您可以将其与 JS Bundler 结合使用并启用 PostCSS 功能。您需要将其配置为将 CSS 文件加载为字符串

维特

如果您使用的是Vite,请附加raw 后缀以作为字符串导入

import styles from './ce-alert.css?raw';
Enter fullscreen mode Exit fullscreen mode

显示实际代码

Webpack

如果您正在使用Webpack,则必须安装postcsspostcss-loaderraw-loader

npm install --save-dev postcss postcss-loader raw-loader
Enter fullscreen mode Exit fullscreen mode

之后,更新webpack.config.js文件以将 CSS 文件作为字符串导入。

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['raw-loader', 'postcss-loader']
      }
    ]
  }
};
Enter fullscreen mode Exit fullscreen mode

显示实际代码

链接参考

链接引用是我首选的解决方案,因为您可以加载外部 CSS 文件而无需重复任何代码,甚至可以用于将您的 Web 组件与流行的 CSS 框架(如TailwindBulmaBootstrap )集成。

在此示例中,您将TailwindVite集成。按照设置说明tailwind.css操作后,在项目根目录下创建一个文件:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

同时安装并配置 package.json 以将 tailwind 编译器与开发服务器一起运行:

npm install --save-dev concurrently
Enter fullscreen mode Exit fullscreen mode
{
  "name": "vite-starter",
  "private": true,
  "version": "0.0.0",
  "scripts": {
    "start": "concurrently --kill-others-on-fail \"npm:dev\" \"npm:tailwind\"",
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "tailwind": "tailwindcss -i ./tailwind.css -o ./public/tailwind.css --watch"
  },
  "devDependencies": {
    "@tailwindcss/typography": "^0.5.2",
    "autoprefixer": "^10.4.5",
    "concurrently": "^7.1.0",
    "postcss": "^8.4.12",
    "postcss-import": "^14.1.0",
    "postcss-nesting": "^10.1.4",
    "tailwindcss": "^3.0.24",
    "vite": "^2.9.6"
  }
}
Enter fullscreen mode Exit fullscreen mode

之后,更新index.html以包含样式并将脚本作为模块加载:

...
<head>
 ...
 <link href="/tailwind.css" rel="stylesheet" />
 ...
</head>
<body>
 ...
 <script src="/src/main.ts" type="module"></script>
</body>
Enter fullscreen mode Exit fullscreen mode

显示实际代码

现在,您可以在 Web 组件中链接 CSS 库:

<template>
  <link rel="stylesheet" href="/tailwind.css" />
  <div
    class="flex items-center justify-between rounded-xl border border-contrast-300 bg-canvas py-2 pl-4 pr-3 text-sm text-content shadow-sm">
    <span class="text-sm">
      <slot></slot>
    </span>
    <button
      id="close-button"
      type="button"
      class="ml-1 -mr-1 inline-flex h-8 w-8 items-center justify-center p-0.5 text-current">
      x
    </button>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

显示实际代码

最终解决方案

以下是您迄今为止学到的所有知识的新 Web 组件的结果:

const template = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
  :host {
    --bg-color: #ffffff;
    --border-color: #d4d4d8;
    --text-color: #374151;
  }
  .alert {
    font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0.5rem 1.25rem;
    color: var(--text-color);
    background-color: var(--background-color);
    border: 1px solid var(--border-color);
    border-radius: 0.75rem;
  }
  .alert__text {
    font-size: 0.875rem;
    line-height: 1.25rem;
  }
  .alert__button {
    -webkit-appearance: button;
    cursor: pointer;
    color: var(--text-color);
    background-color: transparent;
    background-image: none;
    border: none;
    height: 2rem;
    width: 2rem;
    margin-left: 0.25rem;
  }
</style>
<div class="alert">
  <span class="alert__text">
    <slot></slot>
  </span>
  <button id="close-button" type="button" class="alert__button">x</button>
</div>`;

export class Alert extends HTMLElement {
  static get observedAttributes() {
    return ['data-color'];
  }
  constructor() {
   super()  // sets AND returns 'this' scope
      .attachShadow({ mode: 'open' })  // sets AND returns this.shadowRoot
      .append(template.content.cloneNode(true));
  }
  connectedCallback() {
    const button = this.shadowRoot.getElementById(`close-button`);
    button.addEventListener(
      'click',
      () => {
        this.dispatchEvent(new CustomEvent('close'));
        this.remove();
      },
      { once: true }
    );
  }
  attributeChangedCallback(name, prev, curr) {
    if (prev !== curr) {
      this.shadowRoot.querySelector('.alert').classList.remove(prev);
      this.shadowRoot.querySelector('.alert').classList.add(curr);
    }
  }
}

customElements.define('ce-alert', Alert);
Enter fullscreen mode Exit fullscreen mode

显示实际代码

问题与议题

使用 Web 组件有很多好处,比如它可以在任何地方运行,体积小巧,并且由于使用内置平台 API 而运行速度更快。但它并非只有鲜花,也有一些事情可能无法按预期工作。

属性与特性

在自定义元素中使用属性的一个缺点是它只接受字符串,并且将属性与属性同步需要手动声明。

组件更新

自定义元素可以检测属性是否发生变化,但接下来发生什么则由开发人员定义。

造型

由于组件是封装的,因此样式可能会有问题且棘手,而下拉菜单、弹出窗口或工具提示等需要在其他元素之上添加动态元素的组件可能会难以实现。

无障碍设施

由于 Shadow DOM 边界,诸如label/fortab-indexaria-pressed和 等常用属性role无法按预期工作。不过,您可以使用名为辅助功能对象模型的新浏览器 API 来提供替代方案。

表格

使用带有自定义元素的表单需要一些自定义表单关联才能使其工作。

SSR 支持

由于 Web 组件的性质,它不能在 SSR 页面中使用,因为 Web 组件依赖于特定于浏览器的 DOM API,并且 Shadow DOM 不能以声明方式表示,因此不能以字符串格式发送。

结论

在本文中,您了解了 Web 组件的世界,它由三个部分组成:HTML 模板影子 DOM自定义元素。将它们组合起来,可以创建可在许多其他应用程序中重复使用的自定义 HTML 元素。

要获取有关构建 Web 组件的更多信息,您可以查看webcomponents.dev网站,在那里您可以发现和尝试制作 Web 组件的不同方法。

尝试一下,使用它,并为您的应用程序创建您的第一个 Web 组件。

文章来源:https://dev.to/mercedesbenzio/you-dont-need-a-javascript-library-for-your-components-35bk
PREV
12 个理由告诉你为什么 Python 比 HTML+JS 更适合做前端 Web 开发
NEXT
完成比完美更重要