自定义元素 v1 - 可重复使用的网络组件

借助自定义元素,网站开发者可以定义新的 HTML 标记、扩展现有标记,以及创建可重复使用的 Web 组件。

借助自定义元素,Web 开发者可以创建新的 HTML 标记、增强现有 HTML 标记,或扩展其他开发者编写的组件。该 API 是网页组件的基础。它提供了一种基于 Web 标准的方法,只需使用纯 JS/HTML/CSS 即可创建可重复使用的组件。这样一来,应用中的代码量会减少,代码会更加模块化,并且重复使用率会提高。

简介

浏览器为我们提供了一个非常棒的 Web 应用结构工具。它叫做 HTML。您可能听说过!它是声明式的、可移植的、受良好支持的,并且易于使用。虽然 HTML 非常强大,但其词汇和可扩展性有限。HTML 活跃标准一直缺少一种方法来自动将 JS 行为与标记关联起来,但现在情况已然不同。

自定义元素是使 HTML 现代化、填补缺失部分以及将结构与行为捆绑在一起的答案。如果 HTML 无法解决问题,我们可以创建一个可以解决问题的自定义元素。自定义元素可向浏览器传授新技巧,同时保留 HTML 的优势

定义新元素

如需定义新的 HTML 元素,我们需要 JavaScript 的强大功能!

customElements 全局变量用于定义自定义元素并告知浏览器新标记。使用您要创建的标记名称和扩展基本 HTMLElement 的 JavaScript class 调用 customElements.define()

示例 - 定义移动抽屉式面板 <app-drawer>

class AppDrawer extends HTMLElement {...}
window.customElements.define('app-drawer', AppDrawer);

// Or use an anonymous class if you don't want a named constructor in current scope.
window.customElements.define('app-drawer', class extends HTMLElement {...});

用法示例:

<app-drawer></app-drawer>

请务必注意,使用自定义元素与使用 <div> 或任何其他元素没有区别。您可以在网页上声明实例、在 JavaScript 中动态创建实例、附加事件监听器等。请继续阅读,了解更多示例。

定义元素的 JavaScript API

自定义元素的功能是使用扩展了 HTMLElement 的 ES2015 class 定义的。扩展 HTMLElement 可确保自定义元素继承整个 DOM API,这意味着您添加到该类中的任何属性/方法都会成为该元素的 DOM 接口的一部分。本质上,使用该类为您的代码创建一个公共 JavaScript API

示例 - 定义 <app-drawer> 的 DOM 接口:

class AppDrawer extends HTMLElement {

  // A getter/setter for an open property.
  get open() {
    return this.hasAttribute('open');
  }

  set open(val) {
    // Reflect the value of the open property as an HTML attribute.
    if (val) {
      this.setAttribute('open', '');
    } else {
      this.removeAttribute('open');
    }
    this.toggleDrawer();
  }

  // A getter/setter for a disabled property.
  get disabled() {
    return this.hasAttribute('disabled');
  }

  set disabled(val) {
    // Reflect the value of the disabled property as an HTML attribute.
    if (val) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }

  // Can define constructor arguments if you wish.
  constructor() {
    // If you define a constructor, always call super() first!
    // This is specific to CE and required by the spec.
    super();

    // Setup a click listener on <app-drawer> itself.
    this.addEventListener('click', e => {
      // Don't toggle the drawer if it's disabled.
      if (this.disabled) {
        return;
      }
      this.toggleDrawer();
    });
  }

  toggleDrawer() {
    // ...
  }
}

customElements.define('app-drawer', AppDrawer);

在此示例中,我们将创建一个包含 open 属性、disabled 属性和 toggleDrawer() 方法的抽屉。它还会将属性反映为 HTML 属性

自定义元素的一个巧妙之处在于,类定义中的 this 是指 DOM 元素本身,即类的实例。在本示例中,this 是指 <app-drawer>。这就是该元素如何将 click 监听器附加到自身的方法!您不局限于使用事件监听器。整个 DOM API 都在元素代码中提供。使用 this 访问元素的属性、检查其子元素 (this.children)、查询节点 (this.querySelectorAll('.items')) 等。

创建自定义元素的规则

  1. 自定义元素的名称必须包含短划线 (-)。因此,<x-tags><my-element><my-awesome-app> 都是有效名称,而 <tabs><foo_bar> 则不是。此要求是为了让 HTML 解析器能够区分自定义元素和常规元素。它还可确保在向 HTML 添加新标记时向后兼容。
  2. 您不能多次注册同一代码。否则,系统会抛出 DOMException。告知浏览器有新代码后,您就无需再做任何操作。不支持退货。
  3. 自定义元素无法自闭合,因为 HTML 仅允许少数元素自闭合。始终编写结束标记 (<app-drawer></app-drawer>)。

自定义元素回应

自定义元素可以定义特殊的生命周期钩子,以便在其存在期间运行代码。这些回应称为自定义元素回应

名称 调用时机
constructor 创建或升级了元素的实例。适用于初始化状态、设置事件监听器或创建阴影 DOM。 如需了解您可以在 constructor 中执行的操作的限制,请参阅 规范
connectedCallback 每当将元素插入 DOM 中时调用。适用于运行设置代码,例如提取资源或渲染。一般来说,您应尽量推迟到此时间再开始工作。
disconnectedCallback 每当元素从 DOM 中移除时都会调用。适用于运行清理代码。
attributeChangedCallback(attrName, oldVal, newVal) 在添加、移除、更新或替换被观察的属性时调用。当解析器创建或升级元素时,也会调用此方法来获取初始值。注意:只有 observedAttributes 属性中列出的属性才会收到此回调。
adoptedCallback 自定义元素已移至新的 document(例如名为 document.adoptNode(el) 的某个元素)。

回应回调是同步的。如果有人对您的元素调用 el.setAttribute(),浏览器会立即调用 attributeChangedCallback()。同样,在元素从 DOM 中移除后(例如,用户调用 el.remove()),您会立即收到 disconnectedCallback()

示例:向 <app-drawer> 添加自定义元素回应:

class AppDrawer extends HTMLElement {
  constructor() {
    super(); // always call super() first in the constructor.
    // ...
  }

  connectedCallback() {
    // ...
  }

  disconnectedCallback() {
    // ...
  }

  attributeChangedCallback(attrName, oldVal, newVal) {
    // ...
  }
}

在适当的情况下定义回应。如果您的元素非常复杂,并在 connectedCallback() 中打开了与 IndexedDB 的连接,请在 disconnectedCallback() 中执行必要的清理工作。但请务必小心!您不能在所有情况下都依赖于元素从 DOM 中移除。例如,如果用户关闭标签页,系统将永远不会调用 disconnectedCallback()

属性和特性

将属性反映到属性

HTML 属性通常会将其值作为 HTML 属性反射回 DOM。例如,当 JS 中的 hiddenid 的值发生变化时:

div.id = 'my-id';
div.hidden = true;

这些值会作为属性应用于实时 DOM:

<div id="my-id" hidden>

这称为“将属性反映到属性”。HTML 中的几乎所有属性都会这样做。为什么呢?属性对于以声明方式配置元素也很有用,并且某些 API(例如无障碍功能和 CSS 选择器)依赖于属性才能正常运行。

在任何您希望让元素的 DOM 表示与其 JavaScript 状态保持同步的地方,反映属性都非常有用。您可能希望反映某个属性的一个原因是,在 JS 状态发生变化时应用用户定义的样式。

回想一下我们的 <app-drawer>。此组件的使用方可能希望在该组件处于停用状态时使其淡出和/或阻止用户互动:

app-drawer[disabled] {
  opacity: 0.5;
  pointer-events: none;
}

当 JS 中的 disabled 属性发生更改时,我们希望将该属性添加到 DOM,以便用户的选择器匹配。该元素可以通过将值反映到同名属性来提供此行为:

get disabled() {
  return this.hasAttribute('disabled');
}

set disabled(val) {
  // Reflect the value of `disabled` as an attribute.
  if (val) {
    this.setAttribute('disabled', '');
  } else {
    this.removeAttribute('disabled');
  }
  this.toggleDrawer();
}

观察属性的更改

HTML 属性是用户声明初始状态的便捷方式:

<app-drawer open disabled></app-drawer>

元素可以通过定义 attributeChangedCallback 来响应属性更改。每当 observedAttributes 数组中列出的属性发生更改时,浏览器都会调用此方法。

class AppDrawer extends HTMLElement {
  // ...

  static get observedAttributes() {
    return ['disabled', 'open'];
  }

  get disabled() {
    return this.hasAttribute('disabled');
  }

  set disabled(val) {
    if (val) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }

  // Only called for the disabled and open attributes due to observedAttributes
  attributeChangedCallback(name, oldValue, newValue) {
    // When the drawer is disabled, update keyboard/screen reader behavior.
    if (this.disabled) {
      this.setAttribute('tabindex', '-1');
      this.setAttribute('aria-disabled', 'true');
    } else {
      this.setAttribute('tabindex', '0');
      this.setAttribute('aria-disabled', 'false');
    }
    // TODO: also react to the open attribute changing.
  }
}

在此示例中,我们会在 disabled 属性发生更改时为 <app-drawer> 设置其他属性。虽然我们在这里没有这样做,但您还可以使用 attributeChangedCallback 让 JS 属性与其属性保持同步

元素升级

渐进增强型 HTML

我们已经了解到,自定义元素是通过调用 customElements.define() 来定义的。但这并不意味着您必须一次性定义和注册自定义元素。

自定义元素可以在其定义注册之前使用

渐进增强是自定义元素的一项功能。换句话说,您可以在页面上声明一堆 <app-drawer> 元素,并且在很久以后才调用 customElements.define('app-drawer', ...)。这是因为浏览器会因未知标记而对潜在的自定义元素采取不同的处理方式。调用 define() 并为现有元素赋予类定义的过程称为“元素升级”。

如需了解代码名称何时被定义,您可以使用 window.customElements.whenDefined()。它会返回一个 promise,该 promise 会在元素定义完毕时解析。

customElements.whenDefined('app-drawer').then(() => {
  console.log('app-drawer defined');
});

示例:延迟工作,直到一组子元素升级完毕

<share-buttons>
  <social-button type="twitter"><a href="...">Twitter</a></social-button>
  <social-button type="fb"><a href="...">Facebook</a></social-button>
  <social-button type="plus"><a href="...">G+</a></social-button>
</share-buttons>
// Fetch all the children of <share-buttons> that are not defined yet.
let undefinedButtons = buttons.querySelectorAll(':not(:defined)');

let promises = [...undefinedButtons].map((socialButton) => {
  return customElements.whenDefined(socialButton.localName);
});

// Wait for all the social-buttons to be upgraded.
Promise.all(promises).then(() => {
  // All social-button children are ready.
});

元素定义的内容

自定义元素可以使用元素代码中的 DOM API 来管理自己的内容。回应功能非常适合此用途。

示例 - 使用一些默认 HTML 创建元素:

customElements.define('x-foo-with-markup', class extends HTMLElement {
  connectedCallback() {
    this.innerHTML = "<b>I'm an x-foo-with-markup!</b>";
  }
  // ...
});

声明此标记将生成:

<x-foo-with-markup>
  <b>I'm an x-foo-with-markup!</b>
</x-foo-with-markup>

// TODO: DevSite - Code sample removed as it used inline event handlers

创建使用 Shadow DOM 的元素

Shadow DOM 提供了一种方法,可让元素拥有、渲染和设置与网页其余部分分离的 DOM 部分的样式。您甚至可以在一个代码中隐藏整个应用:

<!-- chat-app's implementation details are hidden away in Shadow DOM. -->
<chat-app></chat-app>

如需在自定义元素中使用 Shadow DOM,请在 constructor 中调用 this.attachShadow

let tmpl = document.createElement('template');
tmpl.innerHTML = `
  <style>:host { ... }</style> <!-- look ma, scoped styles -->
  <b>I'm in shadow dom!</b>
  <slot></slot>
`;

customElements.define('x-foo-shadowdom', class extends HTMLElement {
  constructor() {
    super(); // always call super() first in the constructor.

    // Attach a shadow root to the element.
    let shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.appendChild(tmpl.content.cloneNode(true));
  }
  // ...
});

用法示例:

<x-foo-shadowdom>
  <p><b>User's</b> custom text</p>
</x-foo-shadowdom>

<!-- renders as -->
<x-foo-shadowdom>
  #shadow-root
  <b>I'm in shadow dom!</b>
  <slot></slot> <!-- slotted content appears here -->
</x-foo-shadowdom>

用户的自定义文本

// TODO: DevSite - Code sample removed as it used inline event handlers

基于 <template> 创建元素

对于不熟悉 <template> 元素的用户,您可以使用该元素声明 DOM 的 fragment,这些 fragment 会被解析,在页面加载时处于不活跃状态,并且稍后可以在运行时激活。它是 Web 组件家族中的另一个 API 基元。模板是声明自定义元素结构的理想占位符

示例:使用通过 <template> 创建的 Shadow DOM 内容注册元素:

<template id="x-foo-from-template">
  <style>
    p { color: green; }
  </style>
  <p>I'm in Shadow DOM. My markup was stamped from a &lt;template&gt;.</p>
</template>

<script>
  let tmpl = document.querySelector('#x-foo-from-template');
  // If your code is inside of an HTML Import you'll need to change the above line to:
  // let tmpl = document.currentScript.ownerDocument.querySelector('#x-foo-from-template');

  customElements.define('x-foo-from-template', class extends HTMLElement {
    constructor() {
      super(); // always call super() first in the constructor.
      let shadowRoot = this.attachShadow({mode: 'open'});
      shadowRoot.appendChild(tmpl.content.cloneNode(true));
    }
    // ...
  });
</script>

这几行代码的威力不容小觑。我们来了解一下主要的变化:

  1. 我们将在 HTML 中定义一个新元素:<x-foo-from-template>
  2. 元素的 Shadow DOM 是通过 <template> 创建的
  3. 得益于 Shadow DOM,元素的 DOM 是元素的本地 DOM
  4. 得益于 Shadow DOM,元素的内部 CSS 会限定在该元素中

我现在在 Shadow DOM 中。我的标记是通过 <模板>盖章的。

// TODO: DevSite - Code sample removed as it used inline event handlers

为自定义元素设置样式

即使您的元素使用 Shadow DOM 定义了自己的样式,用户也可以从其页面为您的自定义元素设置样式。这些样式称为“用户定义的样式”。

<!-- user-defined styling -->
<style>
  app-drawer {
    display: flex;
  }
  panel-item {
    transition: opacity 400ms ease-in-out;
    opacity: 0.3;
    flex: 1;
    text-align: center;
    border-radius: 50%;
  }
  panel-item:hover {
    opacity: 1.0;
    background: rgb(255, 0, 255);
    color: white;
  }
  app-panel > panel-item {
    padding: 5px;
    list-style: none;
    margin: 0 7px;
  }
</style>

<app-drawer>
  <panel-item>Do</panel-item>
  <panel-item>Re</panel-item>
  <panel-item>Mi</panel-item>
</app-drawer>

您可能想知道,如果元素在 Shadow DOM 中定义了样式,CSS 特异性是如何运作的。在具体性方面,用户样式胜出。它们始终会覆盖元素定义的样式。请参阅创建使用 Shadow DOM 的元素部分。

对未注册的元素预设样式

在元素升级之前,您可以在 CSS 中使用 :defined 伪类定位该元素。这对于预先设置组件样式非常有用。例如,您可能希望通过隐藏未定义的组件并在其定义后逐渐淡入,来防止布局或其他视觉 FOUC。

示例:在定义 <app-drawer> 之前隐藏它:

app-drawer:not(:defined) {
  /* Pre-style, give layout, replicate app-drawer's eventual styles, etc. */
  display: inline-block;
  height: 100vh;
  opacity: 0;
  transition: opacity 0.3s ease-in-out;
}

定义 <app-drawer> 后,选择器 (app-drawer:not(:defined)) 不再匹配。

扩展元素

Custom Elements API 非常适合创建新的 HTML 元素,但也非常适合扩展其他自定义元素,甚至是浏览器的内置 HTML。

扩展自定义元素

扩展其他自定义元素的方法是扩展其类定义。

示例 - 创建扩展 <app-drawer><fancy-app-drawer>

class FancyDrawer extends AppDrawer {
  constructor() {
    super(); // always call super() first in the constructor. This also calls the extended class' constructor.
    // ...
  }

  toggleDrawer() {
    // Possibly different toggle implementation?
    // Use ES2015 if you need to call the parent method.
    // super.toggleDrawer()
  }

  anotherMethod() {
    // ...
  }
}

customElements.define('fancy-app-drawer', FancyDrawer);

扩展原生 HTML 元素

假设您想创建一个更精致的 <button>。与其复制 <button> 的行为和功能,不如使用自定义元素逐步增强现有元素。

自定义内置元素是一种自定义元素,用于扩展浏览器的内置 HTML 标记之一。扩展现有元素的主要好处是,可以获得其所有功能(DOM 属性、方法、无障碍功能)。若要编写渐进式 Web 应用,最好是逐步增强现有 HTML 元素

如需扩展元素,您需要创建一个从正确的 DOM 接口继承的类定义。例如,扩展 <button> 的自定义元素需要从 HTMLButtonElement 继承,而不是从 HTMLElement 继承。同样,扩展 <img> 的元素需要扩展 HTMLImageElement

示例 - 扩展 <button>

// See https://html.spec.whatwg.org/multipage/indices.html#element-interfaces
// for the list of other DOM interfaces.
class FancyButton extends HTMLButtonElement {
  constructor() {
    super(); // always call super() first in the constructor.
    this.addEventListener('click', e => this.drawRipple(e.offsetX, e.offsetY));
  }

  // Material design ripple animation.
  drawRipple(x, y) {
    let div = document.createElement('div');
    div.classList.add('ripple');
    this.appendChild(div);
    div.style.top = `${y - div.clientHeight/2}px`;
    div.style.left = `${x - div.clientWidth/2}px`;
    div.style.backgroundColor = 'currentColor';
    div.classList.add('run');
    div.addEventListener('transitionend', (e) => div.remove());
  }
}

customElements.define('fancy-button', FancyButton, {extends: 'button'});

请注意,在扩展原生元素时,对 define() 的调用会略有变化。必需的第三个参数用于告知浏览器您要扩展哪个代码。这是必要的,因为许多 HTML 标记共用相同的 DOM 接口。<section><address><em>(以及其他一些组件)都共用 HTMLElement<q><blockquote> 共用 HTMLQuoteElement;等等。指定 {extends: 'blockquote'} 可让浏览器知道您要创建的是增强型 <blockquote>,而不是 <q>。如需查看 HTML DOM 接口的完整列表,请参阅 HTML 规范

自定义内置元素的使用方可以通过多种方式使用该元素。他们可以通过在原生标记上添加 is="" 属性来声明它:

<!-- This <button> is a fancy button. -->
<button is="fancy-button" disabled>Fancy button!</button>

在 JavaScript 中创建实例:

// Custom elements overload createElement() to support the is="" attribute.
let button = document.createElement('button', {is: 'fancy-button'});
button.textContent = 'Fancy button!';
button.disabled = true;
document.body.appendChild(button);

或使用 new 运算符:

let button = new FancyButton();
button.textContent = 'Fancy button!';
button.disabled = true;

下面是另一个扩展 <img> 的示例。

示例 - 扩展 <img>

customElements.define('bigger-img', class extends Image {
  // Give img default size if users don't specify.
  constructor(width=50, height=50) {
    super(width * 10, height * 10);
  }
}, {extends: 'img'});

用户可以将此组件声明为:

<!-- This <img> is a bigger img. -->
<img is="bigger-img" width="15" height="20">

或在 JavaScript 中创建实例:

const BiggerImage = customElements.get('bigger-img');
const image = new BiggerImage(15, 20); // pass constructor values like so.
console.assert(image.width === 150);
console.assert(image.height === 200);

其他详细信息

未知元素与未定义的自定义元素

HTML 的使用非常宽松灵活。例如,在网页上声明 <randomtagthatdoesntexist>,浏览器会非常乐意接受它。为什么非标准代码会起作用?答案是,HTML 规范允许这样做。规范未定义的元素会被解析为 HTMLUnknownElement

自定义元素则不受此影响。如果潜在的自定义元素使用有效名称(包含“-”)创建,则会被解析为 HTMLElement。您可以在支持自定义元素的浏览器中检查这一点。启动控制台:Ctrl+Shift+J(在 Mac 上为 Cmd+Opt+J),然后粘贴以下代码行:

// "tabs" is not a valid custom element name
document.createElement('tabs') instanceof HTMLUnknownElement === true

// "x-tabs" is a valid custom element name
document.createElement('x-tabs') instanceof HTMLElement === true

API 参考文档

customElements 全局定义了用于处理自定义元素的实用方法。

define(tagName, constructor, options)

在浏览器中定义新的自定义元素。

示例

customElements.define('my-app', class extends HTMLElement { ... });
customElements.define(
    'fancy-button', class extends HTMLButtonElement { ... }, {extends: 'button'});

get(tagName)

给定有效的自定义元素标记名称,返回该元素的构造函数。 如果未注册任何元素定义,则返回 undefined

示例

let Drawer = customElements.get('app-drawer');
let drawer = new Drawer();

whenDefined(tagName)

返回一个 Promise,该 Promise 会在定义自定义元素时解析。如果元素已定义,请立即解析。如果标记名称不是有效的自定义元素名称,则会被拒绝。

示例

customElements.whenDefined('app-drawer').then(() => {
  console.log('ready!');
});

历史记录和浏览器支持

如果您在过去几年一直关注 Web 组件,就会知道 Chrome 36 及更高版本实现了使用 document.registerElement() 而非 customElements.define() 的 Custom Elements API 版本。该版本现在被视为该标准的已废弃版本,称为 v0。customElements.define() 是新热门技术,浏览器供应商也开始实现它。它称为自定义元素 v1。

如果您对旧版 v0 规范感兴趣,请参阅 html5rocks 文章

浏览器支持

Chrome 54(状态)、Safari 10.1(状态)和 Firefox 63(状态)支持自定义元素 v1。Edge 已开始开发

如需功能检测自定义元素,请检查是否存在 window.customElements

const supportsCustomElementsV1 = 'customElements' in window;

polyfill

在浏览器广泛支持之前,您可以使用适用于自定义元素 v1 的独立 polyfill。不过,我们建议您使用 webcomponents.js 加载器以最佳方式加载 Web 组件 polyfill。加载器使用功能检测来异步加载浏览器所需的必要的多功能插件。

安装该应用:

npm install --save @webcomponents/webcomponentsjs

用法:

<!-- Use the custom element on the page. -->
<my-element></my-element>

<!-- Load polyfills; note that "loader" will load these async -->
<script src="node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js" defer></script>

<!-- Load a custom element definitions in `waitFor` and return a promise -->
<script type="module">
  function loadScript(src) {
    return new Promise(function(resolve, reject) {
      const script = document.createElement('script');
      script.src = src;
      script.onload = resolve;
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }

  WebComponents.waitFor(() => {
    // At this point we are guaranteed that all required polyfills have
    // loaded, and can use web components APIs.
    // Next, load element definitions that call `customElements.define`.
    // Note: returning a promise causes the custom elements
    // polyfill to wait until all definitions are loaded and then upgrade
    // the document in one batch, for better performance.
    return loadScript('my-element.js');
  });
</script>

总结

自定义元素为我们提供了一种新工具,可用于在浏览器中定义新的 HTML 标记和创建可重复使用的组件。将它们与 Shadow DOM 和 <template> 等其他新的平台基元相结合,我们开始了解 Web Components 的大局:

  • 跨浏览器(Web 标准),用于创建和扩展可重复使用的组件。
  • 无需任何库或框架即可开始使用。原生 JS/HTML 最棒!
  • 提供熟悉的编程模型。它只是 DOM/CSS/HTML。
  • 与其他新的 Web 平台功能(Shadow DOM、<template>、CSS 自定义属性等)搭配使用效果出色
  • 与浏览器的 DevTools 紧密集成。
  • 利用现有的无障碍功能。