自定义元素:在 HTML 中定义新元素

2015-02-02 W.Y. 更多博文 » 博客 » GitHub »

Custom Elements

原文链接 https://bubkoo.github.io/2015/02/02/create-custom-elements/
注:以下为加速网络访问所做的原文缓存,经过重新格式化,可能存在格式方面的问题,或偶有遗漏信息,请以原文为准。


引言

现在的 web 严重缺乏表现力。你只要瞧一眼“现代”的 web 应用,比如 GMail,就会明白了:

现代 web 应用:使用 div 堆砌而成

堆砌 <div> 的方式一点都不现代。然而可悲的是,这就是我们构建 web 应用的方式。在现有基础上我们不应该有更高的追求吗?

<!--more-->

时髦的标记,行动起来!

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>
        <p>Heard of it?</p>
      </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 规范定义的元素,同时确保了 HTML 增加新标签时的向前兼容。

第二个参数是一个(可选的)对象,用于描述该元素的prototype。在这里可以为元素添加自定义功能(例如:公开属性和方法)。稍后详述。

自定义元素默认继承自HTMLElement,因此上一个示例等同于:

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

调用 document.registerElement('x-foo') 向浏览器注册了这个新元素,并返回一个可以用来创建 <x-foo> 元素实例的构造函数。如果你不想使用构造函数,也可以使用其他实例化元素的技术。

提示:如果你不希望将构造函数放在 window 全局对象上,还可以把它放进命名空间:

var myapp = {}; 
myapp.XFoo = document.registerElement('x-foo');

扩展原生元素

假设平淡无奇的原生 <button> 元素不能满足你的需求,你想将其增强为一个“超级按钮”,可以通过创建一个继承 HTMLButtonElement.prototype 的新元素,来扩展 <button> 元素:

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

要创建扩展自元素 B元素 A元素 A 必须继承元素 Bprototype

这类自定义元素被称为扩展型自定义元素。它们以继承某个特定 HTMLElement 的方式表达了“元素 X 是一个 Y”:

<button is="mega-button">

元素如何提升

为什么 HTML 解析器对非标准的标签没报错?比如,我们在页面中声明一个 <randomtag>,一切都很和谐。根据 HTML 规范的表述:

非规范定义的元素必须使用 HTMLUnknownElement 接口。

<randomtag> 是非标准的,它会继承 HTMLUnknownElement

对自定义元素来说,情况就不一样了。拥有合法元素名的自定义元素将继承HTMLElement。你可以按 Ctrl+Shift+J 打开控制台,运行下面这段代码,得到的结果将是 true

// “tabs”不是一个合法的自定义元素名
document.createElement('tabs').__proto__ === HTMLUnknownElement.prototype

// “x-tabs”是一个合法的自定义元素名
document.createElement('x-tabs').__proto__ == HTMLElement.prototype

在不支持 document.registerElement() 的浏览器中,<x-tabs> 仍为 HTMLUnknownElement

Unresolved 元素

由于自定义元素是通过脚本执行 document.registerElement() 注册的,因此 它们可能在元素定义被注册到浏览器之前就已经声明或创建过了。例如:你可以先在页面中声明 <x-tabs>,以后再调用 document.registerElement('x-tabs')

在被提升到其定义之前,这些元素被称为 unresolved 元素。它们是拥有合法自定义元素名的 HTML 元素,只是还没有注册成为自定义元素。

下面这个表格看起来更直观一些:

类型 继承自 示例
unresolved 元素 HTMLElement <x-tabs><my-element><my-awesome-app>
未知元素 HTMLUnknownElement <tabs><foo_bar>

把 unresolved 元素想象成尚处于中间状态,它们都是等待被浏览器提升的潜在候选者。浏览器说:“你具备一个新元素的全部特征,我保证会在赋予你定义的时候将你提升为一个元素”。

实例化元素

我们创建普通元素用到的一些技术也可以用于自定义元素。和所有标准定义的元素一样,自定义元素既可以在 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> “是一个”超级按钮 -->
<button is="mega-button">

在 JS 中创建 DOM:

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

这是接收第二个参数为 is="" 属性的 document.createElement() 重载版本。

使用 new 操作符:

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

现在,我们已经学习了如何使用 document.registerElement() 来向浏览器注册一个新标签。但这还不够,接下来我们要向新标签添加属性和方法。

添加 JS 属性和方法

自定义元素最强大的地方在于,你可以在元素定义中加入属性和方法,给元素绑定特定的功能。你可以把它想象成一种给你的元素创建公开 API 的方法。

这里有一个完整的示例:

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

// 1. 为 x-foo 创建 foo() 方法
XFooProto.foo = function() {
    alert('foo() called');
};

// 2. 定义一个只读的“bar”属性
Object.defineProperty(XFooProto, "bar", {value: 5});

// 3. 注册 x-foo 的定义
var XFoo = document.registerElement('x-foo', {prototype: XFooProto});

// 4. 创建一个 x-foo 实例
var xfoo = document.createElement('x-foo');

// 5. 插入页面
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> 并添加了 API,但它还没有任何内容。不如我们给点 HTML 让它渲染?

生命周期回调在这个时候就派上用场了。我们甚至可以用 createdCallback() 给一个元素赋予一些默认的 HTML:

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

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

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

实例化这个标签并在 DevTools 中观察(右击,选择“审查元素”),可以看到如下结构:

▾<x-foo-with-markup>
    <b>I'm an x-foo-with-markup!</b>
  </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. 为元素附加一个 shadow root。
  var shadow = this.createShadowRoot();

  // 2. 填入标记。
  shadow.innerHTML = "<b>I'm in the element's Shadow DOM!</b>";
};

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

我们并没有直接设置 <x-foo-shadowdom>innerHTML,而是为其创建了一个用于填充标记的 Shadow Root。在 DevTools 中,你就会看到一个可以展开的 #shadow-root

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

这就是 Shadow Root!

从模板创建元素

HTML 模板是另一组跟自定义元素完美融合的新 API。

<template> 元素可用于声明 DOM 片段。它们可以被解析并在页面加载后插入,以及延迟到运行时才进行实例化。模板是声明自定义元素结构的理想方案。

示例:注册一个由 <template> 和 Shadow DOM 创建的元素:

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

短短几行做了很多事情,我们挨个来看都发生了些什么:

  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 Root 中定义的样式不会暴露到宿主外部或对页面产生影响。对自定义元素来说,元素本身就是宿主。样式封装的属性也使得自定义元素能够为自己定义默认样式。

Shadow DOM 的样式是一个很大的话题!如果你想更多地了解它,推荐你阅读我写的其他文章:

使用 :unresolved 伪类避免无样式内容闪烁(FOUC)

为了缓解无样式内容闪烁的影响,自定义元素规范提出了一个新的 CSS 伪类 :unresolved。在浏览器调用你的 createdCallback()(见生命周期回调方法一节)之前,这个伪类都可以匹配到 unresolved 元素。一旦产生调用,就意味着元素已经完成提升,成为它被定义的形态,该元素就不再是一个 unresolved 元素了。

Chrome 29 已经原生支持 CSS :unresolved 伪类。

示例:注册后渐显的 <x-foo> 标签:

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

请记住 :unresolved 伪类只能用于 unresolved 元素,而不能用于继承自 HTMLUnkownElement 的元素(见元素如何提升一节)。

<style>
  /* 给所有 unresolved 元素添加边框 */
  :unresolved {
    border: 1px dashed red;
    display: inline-block;
  }
  /* unresolved 元素 x-panel 的文本内容为红色 */
  x-panel:unresolved {
    color: red;
  }
  /* 定义注册后的 x-panel 文本内容为绿色 */
  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>

了解更多 :unresolved 伪类的知识,请看 Polymer 文档元素样式指南

历史和浏览器支持

特性检测

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

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

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

浏览器支持

Chrome 27 和 Firefox 23 都提供了对 document.registerElement() 的支持,不过之后规范又有一些演化。Chrome 31 将是第一个真正支持新规范的版本。

在 Chrome 31 中使用自定义元素,需要开启 about:flags 中的“实验性 web 平台特性(Experimental Web Platform features)”选项。

在浏览器支持稳定之前,也有一些很好的兼容方案:

HTMLElementElement 怎么了?

一直关注标准的人都知道曾经有一个 <element> 标签。它非常好用,你只要像下面这样就可以声明式地注册一个新元素:

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

然而,很不幸,在它的提升过程、边界案例,以及末日般的复杂场景中,需要处理大量的时序问题。 因此被迫搁置。2013 年 8 月,Dimitri Glazkov 在 public-webapps 邮件组中宣告移除 <element>

值得注意的是,Polymer 实现了以 <polymer-element> 的形式声明式地注册元素。这是怎么做到的?它用的正是 document.registerElement('polymer-element') 以及我在从模板创建元素一节介绍的技术。

结语

自定义元素为我们提供了一个工具,通过它我们可以扩展 HTML 的词汇,赋予它新的特性,并把不同的 web 平台连接在一起。结合其他新的基本平台,如 Shadow DOM 和 <template>,我们领略了 web 组件的宏伟蓝图。标记语言将再次变得很时髦!

如果你对使用 web 组件感兴趣,建议你看看 Polymer,就它已经够你玩的了。