使用自定义元素

Boris Smus
Boris Smus

简介

网页严重缺乏表现力。如需了解我的意思,请查看 Gmail 等“现代”Web 应用:

Gmail

<div> 汤没有任何现代元素。但事实上,我们就是这样构建 Web 应用的。这很遗憾。 我们不应该对我们的平台提出更高的要求吗?

性感的标记。我们来解决这个问题

HTML 为我们提供了一个非常棒的文档结构化工具,但其词汇仅限于 HTML 标准定义的元素。

如果 Gmail 的标记不是很糟糕,会怎么样?如果它很漂亮,会怎么样?

<hangout-module>
    <hangout-chat from="Paul, Addy">
    <hangout-discussion>
        <hangout-message from="Paul" profile="profile.png"
            profile="118075919496626375791" datetime="2013-07-17T12:02">
        <p>Feelin' this Web Components thing.
        <p>Heard of it?
        </hangout-message>
    </hangout-discussion>
    </hangout-chat>
    <hangout-chat>...</hangout-chat>
</hangout-module>

真是太棒了!这个应用也非常实用。它有意义易于理解,最重要的是,它可维护。未来的我/您只需查看其声明式骨干,即可确切了解其用途。

使用入门

自定义元素 允许网站开发者定义新类型的 HTML 元素。该规范是 Web 组件伞下推出的几个新 API 原语之一,但很可能是最重要的一个。如果没有自定义元素解锁的功能,Web 组件就无法存在:

  1. 定义新的 HTML/DOM 元素
  2. 创建从其他元素延伸的元素
  3. 将自定义功能逻辑地捆绑到单个代码中
  4. 扩展现有 DOM 元素的 API

注册新元素

自定义元素是使用 document.registerElement() 创建的:

var XFoo = document.registerElement('x-foo');
document.body.appendChild(new XFoo());

document.registerElement() 的第一个参数是元素的标记名称。名称必须包含短划线 (-)。例如,<x-tags><my-element><my-awesome-app> 都是有效名称,而 <tabs><foo_bar> 则不是。此限制可让解析器区分自定义元素和常规元素,同时还可确保向 HTML 添加新标记时的向前兼容性。

第二个参数是描述元素 prototype 的可选对象。您可以在此处为元素添加自定义功能(例如公共属性和方法)。 稍后会详细介绍

默认情况下,自定义元素会继承 HTMLElement。因此,上例等同于:

var XFoo = document.registerElement('x-foo', {
    prototype: Object.create(HTMLElement.prototype)
});

调用 document.registerElement('x-foo') 会告知浏览器新元素的相关信息,并返回一个构造函数,您可以使用该构造函数创建 <x-foo> 实例。或者,如果您不想使用构造函数,也可以使用其他实例化元素的技术

扩展元素

借助自定义元素,您可以扩展现有的(原生)HTML 元素以及其他自定义元素。如需扩展元素,您需要向 registerElement() 传递要继承的元素的名称和 prototype

扩展原生元素

假设您对普通用户 <button> 不满意。您希望增强其功能,使其成为“超级按钮”。如需扩展 <button> 元素,请创建一个新元素,继承 HTMLButtonElementprototype 和元素的名称 extends。在本例中,“button”:

var MegaButton = document.registerElement('mega-button', {
    prototype: Object.create(HTMLButtonElement.prototype),
    extends: 'button'
});

从原生元素继承的自定义元素称为类型扩展自定义元素。它们从专用版本的 HTMLElement 继承,以表示“元素 X 是 Y”。

示例:

<button is="mega-button">

扩展自定义元素

如需创建扩展 <x-foo> 自定义元素的 <x-foo-extended> 元素,只需继承其原型并说明您要从哪个标记继承即可:

var XFooProto = Object.create(HTMLElement.prototype);
...

var XFooExtended = document.registerElement('x-foo-extended', {
    prototype: XFooProto,
    extends: 'x-foo'
});

如需详细了解如何创建元素原型,请参阅下文的添加 JS 属性和方法

元素的升级方式

您是否曾想过,为什么 HTML 解析器不会对非标准标记进行报错?例如,如果我们在网页上声明 <randomtag>,浏览器将非常乐意接受它。根据 HTML 规范

抱歉,<randomtag>!您是非标准的,并继承自 HTMLUnknownElement

自定义元素则并非如此。具有有效自定义元素名称的元素会继承 HTMLElement您可以启动控制台 Ctrl + Shift + J(在 Mac 上为 Cmd + Opt + J),然后粘贴以下代码行来验证这一点;它们会返回 true

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

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

未解析的元素

由于自定义元素是使用 document.registerElement() 通过脚本注册的,因此可以在浏览器注册其定义之前声明或创建它们例如,您可以在页面上声明 <x-tabs>,但最终在很久之后才调用 document.registerElement('x-tabs')

在元素升级为其定义之前,它们被称为未解析的元素。这些 HTML 元素具有有效的自定义元素名称,但尚未注册。

下表可能会对您有所帮助:

名称 继承自 示例
未解析的元素 HTMLElement <x-tabs><my-element>
未知元素 HTMLUnknownElement <tabs><foo_bar>

实例化元素

创建元素的常用方法仍然适用于自定义元素。与任何标准元素一样,它们可以在 HTML 中声明,也可以使用 JavaScript 在 DOM 中创建。

实例化自定义代码

声明它们:

<x-foo></x-foo>

在 JS 中创建 DOM

var xFoo = document.createElement('x-foo');
xFoo.addEventListener('click', function(e) {
    alert('Thanks!');
});

使用 new 运算符

var xFoo = new XFoo();
document.body.appendChild(xFoo);

实例化类型扩展元素

实例化类型扩展样式的自定义元素与自定义标记非常相似。

声明它们:

<!-- <button> "is a" mega button -->
<button is="mega-button">

在 JS 中创建 DOM

var megaButton = document.createElement('button', 'mega-button');
// megaButton instanceof MegaButton === true

如您所见,现在有一个过载的 document.createElement() 版本,它将 is="" 属性作为第二个参数。

使用 new 运算符

var megaButton = new MegaButton();
document.body.appendChild(megaButton);

到目前为止,我们已经了解了如何使用 document.registerElement() 告知浏览器新标记的存在,但这并没有什么用处。我们来添加一些属性和方法。

添加 JS 属性和方法

自定义元素的强大之处在于,您可以通过在元素定义中定义属性和方法,将量身定制的功能与元素捆绑在一起。不妨将其视为为元素创建公共 API 的一种方式。

以下是一个完整示例:

var XFooProto = Object.create(HTMLElement.prototype);

// 1. Give x-foo a foo() method.
XFooProto.foo = function() {
    alert('foo() called');
};

// 2. Define a property read-only "bar".
Object.defineProperty(XFooProto, "bar", {value: 5});

// 3. Register x-foo's definition.
var XFoo = document.registerElement('x-foo', {prototype: XFooProto});

// 4. Instantiate an x-foo.
var xfoo = document.createElement('x-foo');

// 5. Add it to the page.
document.body.appendChild(xfoo);

当然,构建 prototype 的方法有无数种。如果您不喜欢这样创建原型,下面提供了一个更精简的版本:

var XFoo = document.registerElement('x-foo', {
  prototype: Object.create(HTMLElement.prototype, {
    bar: {
      get: function () {
        return 5;
      }
    },
    foo: {
      value: function () {
        alert('foo() called');
      }
    }
  })
});

第一个格式允许使用 ES5 Object.defineProperty。第二种方法允许使用 get/set

生命周期回调方法

元素可以定义特殊方法,以便在其存续的特定时间内运行代码。 这些方法被恰当地命名为生命周期回调。每种类型都有特定的名称和用途:

回调名称 调用时机
createdCallback 创建元素的实例
attachedCallback 在文档中插入了实例
detachedCallback 从文档中移除了实例
attributeChangedCallback(attrName, oldVal, newVal) 添加、移除或更新了属性

示例:在 <x-foo> 上定义 createdCallback()attachedCallback()

var proto = Object.create(HTMLElement.prototype);

proto.createdCallback = function() {...};
proto.attachedCallback = function() {...};

var XFoo = document.registerElement('x-foo', {prototype: proto});

所有生命周期回调都是可选的,但请在有必要时定义它们。例如,假设您的元素足够复杂,并在 createdCallback() 中打开 IndexedDB 的连接。在它从 DOM 中移除之前,请在 detachedCallback() 中执行必要的清理工作。注意:您不应依赖于此方法(例如,如果用户关闭标签页),而应将其视为一种可能的优化钩子。

生命周期回调的另一个用例是用于在元素上设置默认事件监听器:

proto.createdCallback = function() {
  this.addEventListener('click', function(e) {
    alert('Thanks!');
  });
};

添加标记

我们创建了 <x-foo>,并为其提供了 JavaScript API,但它是空的!我们要给它一些 HTML 来呈现吗?

此时,生命周期回调非常有用。具体而言,我们可以使用 createdCallback() 为元素赋予一些默认 HTML:

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    this.innerHTML = "**I'm an x-foo-with-markup!**";
};

var XFoo = document.registerElement('x-foo-with-markup', {prototype: XFooProto});

实例化此代码并在 DevTools 中进行检查(右键点击,选择“Inspect Element”)应会显示以下内容:

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

在 Shadow DOM 中封装内部内容

Shadow DOM 本身就是一款强大的封装内容工具。将其与自定义元素搭配使用,即可产生神奇的效果!

Shadow DOM 为自定义元素提供了以下功能:

  1. 一种隐藏其内部结构的方法,从而使用户免受血腥的实现细节的困扰。
  2. 样式封装…免费。

通过 Shadow DOM 创建元素就像创建用于呈现基本标记的元素一样。差异在于 createdCallback()

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    // 1. Attach a shadow root on the element.
    var shadow = this.createShadowRoot();

    // 2. Fill it with markup goodness.
    shadow.innerHTML = "**I'm in the element's Shadow DOM!**";
};

var XFoo = document.registerElement('x-foo-shadowdom', {prototype: XFooProto});

我没有设置元素的 .innerHTML,而是为 <x-foo-shadowdom> 创建了一个阴影根,然后用标记填充了该根。在开发者工具中启用“显示 Shadow DOM”设置后,您会看到一个可展开的 #shadow-root

▾<x-foo-shadowdom>
  ▾#shadow-root
    **I'm in the element's Shadow DOM!**
</x-foo-shadowdom>

这就是阴影根目录!

通过模板创建元素

HTML 模板是另一种适合自定义元素领域的新 API 原语。

示例:注册使用 <template> 和 Shadow DOM 创建的元素:

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

<script>
  var proto = Object.create(HTMLElement.prototype, {
    createdCallback: {
      value: function() {
        var t = document.querySelector('#sdtemplate');
        var clone = document.importNode(t.content, true);
        this.createShadowRoot().appendChild(clone);
      }
    }
  });
  document.registerElement('x-foo-from-template', {prototype: proto});
</script>

<template id="sdtemplate">
  <style>:host p { color: orange; }</style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<div class="demoarea">
  <x-foo-from-template></x-foo-from-template>
</div>

这几行代码实现了丰富的功能。我们来了解一下发生的所有情况:

  1. 我们在 HTML 中注册了一个新元素:<x-foo-from-template>
  2. 元素的 DOM 是使用 <template> 创建的
  3. 使用 Shadow DOM 隐藏了元素的复杂细节
  4. Shadow DOM 可为元素提供样式封装(例如,p {color: orange;} 不会将整个页面都变成橙色)

太好了!

设置自定义元素的样式

与任何 HTML 标记一样,自定义代码的用户可以使用选择器为其设置样式:

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

<app-panel>
    <li is="x-item">Do</li>
    <li is="x-item">Re</li>
    <li is="x-item">Mi</li>
</app-panel>

为使用 Shadow DOM 的元素设置样式

当您将 Shadow DOM 纳入考量范围时,这个知识体系会变得更深使用 Shadow DOM 的自定义元素会继承其诸多优势。

Shadow DOM 可为元素注入样式封装。在阴影根中定义的样式不会从宿主泄漏,也不会从页面渗入。对于自定义元素,该元素本身就是宿主。样式封装的属性还允许自定义元素为自己定义默认样式。

Shadow DOM 样式是一个很大的话题!如果您想详细了解该功能,建议您参阅以下几篇文章:

使用 :unresolved 防范 FOUC

为了缓解 FOUC,自定义元素指定了新的 CSS 伪类 :unresolved。您可以使用它定位到未解析的元素,直到浏览器调用 createdCallback() 为止(请参阅生命周期方法)。发生这种情况后,该元素就不再是未解析的元素。升级过程已完成,元素已转换为其定义。

示例:在“x-foo”标记注册时淡入:

<style>
  x-foo {
    opacity: 1;
    transition: opacity 300ms;
  }
  x-foo:unresolved {
    opacity: 0;
  }
</style>

请注意,:unresolved 仅适用于未解析的元素,而不适用于从 HTMLUnknownElement 继承的元素(请参阅元素的升级方式)。

<style>
  /* apply a dashed border to all unresolved elements */
  :unresolved {
    border: 1px dashed red;
    display: inline-block;
  }
  /* x-panel's that are unresolved are red */
  x-panel:unresolved {
    color: red;
  }
  /* once the definition of x-panel is registered, it becomes green */
  x-panel {
    color: green;
    display: block;
    padding: 5px;
    display: block;
  }
</style>

<panel>
    I'm black because :unresolved doesn't apply to "panel".
    It's not a valid custom element name.
</panel>

<x-panel>I'm red because I match x-panel:unresolved.</x-panel>

历史记录和浏览器支持

功能检测

特征检测就是检查 document.registerElement() 是否存在:

function supportsCustomElements() {
    return 'registerElement' in document;
}

if (supportsCustomElements()) {
    // Good to go!
} else {
    // Use other libraries to create components.
}

浏览器支持

document.registerElement() 最初是在 Chrome 27 和 Firefox ~23 中通过标志启用的。不过,自那时起,该规范已发生很大变化。Chrome 31 是第一个真正支持更新版规范的版本。

在浏览器提供广泛支持前,可以暂时使用 polyfill,Google 的 Polymer 和 Mozilla 的 X-Tag 都使用了该 polyfill。

HTMLElementElement 怎么了?

跟进标准化工作的人应该知道,以前有 <element>。那真是太棒了。您可以使用它以声明方式注册新元素:

<element name="my-element">
    ...
</element>

很遗憾,升级流程、极端情况和末日般的场景存在太多时间问题,我们无法全部解决。<element> 不得不搁置。2013 年 8 月,Dimitri Glazkov 在 public-webapps 中发帖,宣布该项目至少目前已被移除。

值得注意的是,Polymer 使用 <polymer-element> 实现了声明式元素注册。如何估测?它使用 document.registerElement('polymer-element') 和我在使用模板创建元素中介绍的技术。

总结

自定义元素为我们提供了扩展 HTML 词汇、为其传授新技巧,以及穿越 Web 平台的虫洞的工具。将它们与 Shadow DOM 和 <template> 等新平台原语结合使用,我们可开始实现网络组件的宏大图景。标记代码又可以变得时尚了!

如果您有兴趣开始使用 Web 组件,建议您查看 Polymer。这足以让您顺利上手。