【Chrome官方文章翻译】可重用的Web Components

欢迎关注微信公众号:前端阅读室

Web Components可以让开发者创造标签,开发者可以增强现有HTML标签,扩展其他开发者编写的标签。

它提供了基于web标准的方式,可以让你用少量的、模块化的代码去编写可重用的组件。

定义一个新元素

使用window.customElements.define可以定义一个新元素。

它的第一个参数是标签名,第二个参数是一个继承HTMLElement的类。

class AppDrawer extends HTMLElement {}

window.customElements.define('app-drawer', AppDrawer);
复制代码

那么如何使用它呢?你只要像使用正常的html标签一样使用它就可以了!

<!DOCTYPE html>
<html>
  <head>
    <title>Web Components</title>
  </head>
  <body>
    <app-drawer>app-drawer</app-drawer>
    <script>
      class AppDrawer extends HTMLElement {}

      window.customElements.define("app-drawer", AppDrawer);
    </script>
  </body>
</html>
复制代码

自定义的元素和普通的HTML元素没有区别。它的实例可以在页面中声明,然后使用JS定义它。它也可以使用事件监听等HTML元素具有的特性。这在后面将会详细介绍。

自定义元素的JS API

自定义元素的功能使用ES2015 class实现,由于它继承了HTMLElement,所以拥有完整的DOM API。这也意味着JS里的属性、方法成了DOM接口的一部分,就像是我们在用JS为标签创建API。

例子:

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();
  }

  // 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 => {
      this.toggleDrawer();
    });
  }

  toggleDrawer() {
  }
}

customElements.define('app-drawer', AppDrawer);
复制代码

在这个例子中,我们创建了拥有open属性和toggleDrawer()方法的app-drawer元素。

在定义元素的class语法中,this指向元素本身。在这个例子中它可以获取属性、监听事件。其实其他DOM API它都可以使用,比如获取children(this.children)、选择元素(this.querySelectorAll(‘.items’))等等。

自定义元素命名规则

  1. 必须包含破折号(-),这是为了区分自定义元素和HTML元素,并且确保兼容性(即使HTML增加新标签也不会导致冲突)

  2. 不能重复注册相同标签,否则会抛DOMException,因为这完全没必要。

  3. 不支持自闭合标签的写法,因为在HTML规范里只有一些标签是允许自闭合的。所以只有<app-drawer></app-drawer>这样写才是正确的。

自定义元素的生命周期(custom element reactions)

自定义元素有它的生命周期hooks。罗列如下:

名称 触发时机
constructor 实例被创建或升级时执行,通常用来初始化一些状态、设置事件监听或者创建shadow dom
connectedCallback 当元素被添加到DOM中触发,通常在这个时候进行数据请求等
disconnectedCallback 元素在DOM中被移除时触发,通常做一些清理操作
attributeChangedCallback(attrName, oldVal, newVal) 当监听的属性(在observedAttributes属性列表中)被增加、删除、更新、替换时触发。在元素被解析器创建或升级相应属性值初始化时也会触发
adoptedCallback 元素被移动到一个新文档中触发(如:调用document.adoptNode(el)))

例子:

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

  }
  disconnectedCallback() {

  }
  attributeChangedCallback(attrName, oldVal, newVal) {

  }
}
复制代码

回调函数是同步的。比如当调用el.setAttribute(),attributeChangedCallback()会立刻执行。

这些回调函数不是在所有情况下都是可靠的,比如用户关闭标签页时disconnectedCallback不会执行。

JS和HTML属性

使用JS设置HTML属性

使用JS设置HTML属性是很常见的,比如在JS中执行

div.id = 'my-id';
div.hidden = true;
复制代码

html属性就会变为

<div id="my-id" hidden>
复制代码

这是很有用的功能。比如你想实现样式根据JS状态改变。

在下面这个例子中我们可以通过点击切换app-drawer元素的透明度,并为它设置一些属性。

<!DOCTYPE html>
<html>
  <head>
    <title>Web Components</title>
  </head>
  <style>
    app-drawer[disabled] {
      opacity: 0.5;
    }

    app-drawer {
      opacity: 1;
    }
  </style>
  <body>
    <app-drawer>app-drawer</app-drawer>
    <script>
      class AppDrawer extends HTMLElement {
        static get observedAttributes() {
          return ["disabled"];
        }

        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.
        }

        // 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 => {
            this.toggleDrawer();
          });
        }

        toggleDrawer() {
          this.disabled = Math.random() > 0.5 ? true : false;
        }
      }

      customElements.define("app-drawer", AppDrawer);
    </script>
  </body>
</html>
复制代码

元素升级

我们前面学习了使用customElements.define定义元素,它的定义和注册(使用)是一起的,但其实你可以在定义之前就注册(使用)这个元素。也就说你先注册<app-drawer>,但不执行customElements.define('app-drawer', ...)也是可以的。

因为浏览器对未知标签会区别对待。

如果你先元素标签,后面再调用define()方法定义元素,这种方式就叫做元素升级。

你可以使用window.customElements.whenDefined()方法来监听元素在什么时候被定义。

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>

<script>
// 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.
  // do some thing
});
</script>
复制代码

元素定义

自定义元素可以在它的内部代码中使用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>
复制代码

使用Shadow DOM创建元素

Shadow DOM可以在页面中提供一块区域让元素有隔离的渲染和样式。你甚至可以将整个应用隐藏到一个标签中。

在constructor中调用this.attachShadow方法就可以使用Shadow DOM了。

<!DOCTYPE html>
<html>
  <head>
    <title>Web Components</title>
  </head>
  <body>
    <x-foo-shadowdom>
      <p><b>User's</b> custom text</p>
    </x-foo-shadowdom>
    <script>
      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));
          }
        }
      );
    </script>
  </body>
</html>
复制代码

网页呈现结果

<x-foo-shadowdom>
  #shadow-root
    <b>I'm in shadow dom!</b>
    <slot></slot> <!-- slotted content appears here -->
</x-foo-shadowdom>
复制代码

使用<template>创建元素

template可以让你很方便地声明元素的结构。

例子 – 使用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. 定义了一个新标签<x-foo-from-template>

  2. 使用template创建Shadow DOM

  3. 由于Shadow DOM的存在,元素DOM是内置的

  4. 由于Shadow DOM的存在,元素的CSS也是内置的,并且样式作用限定在了元素的内部

给自定义元素加样式

即使你的元素使用Shadow DOM限定了样式,元素的样式也会受页面样式的影响。

页面的样式也被称为用户定义样式(user-defined styles)。如果它和Shadow DOM有相同的样式,则用户自定义样式生效。

给未定义的元素设置样式

元素未定义(升级)之前,你可以使用:not(:defined)伪类为它设置样式。

这种预设样式也是有用的,比如你可以让元素占有一定布局空间,即使它还没被定义。

下面这个例子,元素在被定义之前也占据一定空间。当然了,当元素被定义后,app-drawer:not(:defined)选择器也就失效了。

app-drawer:not(:defined) {
  /* Pre-style, give layout, replicate app-drawer's eventual styles, etc. */
  display: inline-block;
  height: 100vh;
  width: 100vh;
}
复制代码

扩展元素

自定义元素API不仅对创建新的HTML元素有用的,对于扩展其他自定义元素甚至是浏览器内置元素也是有用的。

扩展自定义元素

拓展自定义元素使用继承它的class定义来完成。

例子 – 创建<fancy-app-drawer>继承自<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元素的功能。

这种继承自HTML元素的自定义元素也被称作定制内置元素(customized built-in element)。它不仅能获得原生元素的特性(属性、方法、可访问性),还能增强元素的功能。没有比使用定制内置元素去编写一个逐步增强的web应用更好的方式了。

注:定制内置元素不是所有浏览器都支持。

例子 – <FancyButton>

// 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()需要指定继承了哪个浏览器标签。这是必要的,因为即使是不同的标签也可能继承相同的DOM接口。比如<q><blockquote>就都继承HTMLQuoteElement。

定制内置元素可以为原生标签添加is=””属性。

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

<script>
  // 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);
</script>
复制代码

或者使用new操作符

let button = new FancyButton();
button.textContent = 'Fancy button!';
button.disabled = true;
复制代码

例子 – 扩展<img>

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

<script>
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'});
</script>
复制代码

或者创建一个image实例

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);
复制代码

一些细节

未知元素vs未定义元素

HTML是开放、灵活的。比如你声明一个<randomtagthatdoesntexist>标签是不会抛错的。这是因为html规范允许这么做,规范中这个标签会被解析为HTMLUnknownElement。

所以对于自定义元素,不合法的自定义元素命名可能会被解析为HTMLElement(或HTMLUnknownElement)。

API参考

全局customElements上的defines可以用来定义元素。

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,当元素被定义时会执行resolve。如果元素已经被定义,则立即执行resolve。当传入tagName是无效命名时执行reject。

例子:

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

历史和浏览器支持

历史

Chrome 36+实现了v0版的自定义元素API,使用的是document.registerElement(不是customElements.define)来定义元素。v0这个版本已经弃用了。

目前浏览器供应商们使用的都是现在的v1版,使用customElements.define()来定义元素。

浏览器支持

Chrome 54, Safari 10.1, Firefox 63都实现了v1版,Edg也在开发中。

你可以使用下面代码来判断浏览器是否支持v1版。

const supportsCustomElementsV1 = 'customElements' in window;
复制代码

Polyfill

到浏览器广泛支持之前,v1版可以使用Polyfill。

我们建议使用webcomponents.js loader去实现web components polyfill的最优加载。它使用了特征检测,只有在需要的时候才会异步加载polyfill。

polyfill安装

npm install --save @webcomponents/webcomponentsjs
复制代码

polyfill使用

<!-- 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>
复制代码

注:defined css伪类不能被polyfill。

总结

自定义元素让我们可以定义新的HTML标签,创建可复用的组件。结合其他的特性比如Shadow DOM、<template>等,我们开始逐渐意识到Web Components这个蓝图。

它可以:

  1. 跨浏览器(web标准)创建和扩展可复用组件

  2. 不需要库或框架的开发(强大的JS/HTML)

  3. 熟悉的编程模型(DOM/CSS/HTML)

  4. 和其他新的web平台特性完美融合( (Shadow DOM, <template>, CSS自定义属性等)

  5. 和浏览器DevTools的紧密集成

  6. 使用已有的可访问特性。

本文翻译自

Engineer @ Google working on web tooling: Headless Chrome, Puppeteer, Lighthouse

欢迎关注微信公众号:前端阅读室

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享