边学边译JS工作机制---19.自定义元素机制

191 阅读12分钟

本系列其他译文请看[JS工作机制 - 小白1991的专栏 - 掘金 (juejin.cn)] (juejin.cn/column/6988…

本章阅读指数:5
本章讲解自定义元素的机制,这一块目前的支持还不是很完善,但是极有可能这一部分会成为下一代前端框架组件化的基础,希望前端的同学可以看看,并思考一下跟当前的前端框架相比,原生的组件化有什么样的优势。

概览

之前我们讨论过web 组件这个大蓝图中的一部分,比如影子DOM。web组件标准的全部目标,是通过创建颗粒化,模块化和可复用的元素来扩展 HTML 内置功能。它虽然是W3C的新标准,但是主流的浏览器已经都支持了,并且在polyfill的帮助下可以在生产环境中使用

浏览器已经给我们提供了一些构建应用和网站的工具,比如HTML,CSS和JS。HTML搭结构,CSS让它更好看,JS则带来了交互行为。但是,在web 组件引入之前,关联JS行为和HTML结构并不容易。

这一章我们要讨论web组件的基础---自定义元素。自定义元素可以让你创建内含JS逻辑和CSS样式的HTML元素。很多人会把影子DOM和自定义元素搞混。但是他们是不同的,并且是无法互相替代的。

一些框架(比如Angular, React)尝试用自己的方式解决这些问题。你可以用自定义元素类别一个Angular指令或者React组件。但是,自定义元素是浏览器原生支持的,除了JS,HTML,CSS不需要别的了。不过,这并不意味着有必要用它来取代一个典型的 JavaScript 框架。现代框架不仅仅为开发者提供模仿自定义元素行为的能力。因此,可以同时使用框架和自定义元素。 API

我们先快速看一下有关的API。customElements的目标对象,有这些方法:

  • define(tagName, constructor, options) — 定义一个新的自定义元素。有三个参数,自定义元素的标签名,自定义元素的类定义,以及一个选项参数对象。当前选项支持一个:指定想要扩展的 HTML 内置元素名称的extends字符串。用来创建一个自定义的内构元素。
  • get(tagName) — 返回自定义元素的结构,如果该元素没有定义,就范围undefined。只有一个参数:一个自定义元素的有效的标签名。
  • whenDefined(tagName) — 返回一个promise,一旦自定义元素被定义,这个promise就被解析。如果这个元素已经被定义了,也会立即解析。如果标签名没有对应一个有效的自定义元素,promise就会被rejected。只有一个参数:一个自定义元素的有效标签名。

如何创建一个自定义元素

创建一个自定义元素太简单了,只要做两个事情:创建一个继承自HTMLElement类元素类定义,并给这个元素注册一个名字。

class MyCustomElement extends HTMLElement {
  constructor() {
    super();
    // …
  }

  // …
}

customElements.define('my-custom-element', MyCustomElement);

也可以使用匿名类以防止弄乱当前作用域

customElements.define('my-custom-element', class extends HTMLElement {
  constructor() {
    super();
    // …
  }

  // …
});

自定义元素解决了什么问题

到底有什么问题呢?比如Div soups---它是一个在现代应用中非常常见的结构,如果你有很多嵌套的div元素的话。

<div class="top-container">
  <div class="middle-container">
    <div class="inside-container">
      <div class="inside-inside-container">
        <div class="are-we-really-doing-this">
          <div class="mariana-trench">
            …
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

这种结构是需要的,但是却又很难阅读和维护。

假如我们需要这样一个组件:

image.png
使用传统的HTML看起来像这样:

<div class="primary-toolbar toolbar">
  <div class="toolbar">
    <div class="toolbar-button">
      <div class="toolbar-button-outer-box">
        <div class="toolbar-button-inner-box">
          <div class="icon">
            <div class="icon-undo">&nbsp;</div>
          </div>
        </div>
      </div>
    </div>
    <div class="toolbar-button">
      <div class="toolbar-button-outer-box">
        <div class="toolbar-button-inner-box">
          <div class="icon">
            <div class="icon-redo">&nbsp;</div>
          </div>
        </div>
      </div>
    </div>
    <div class="toolbar-button">
      <div class="toolbar-button-outer-box">
        <div class="toolbar-button-inner-box">
          <div class="icon">
            <div class="icon-print">&nbsp;</div>
          </div>
        </div>
      </div>
    </div>
    <div class="toolbar-toggle-button toolbar-button">
      <div class="toolbar-button-outer-box">
        <div class="toolbar-button-inner-box">
          <div class="icon">
            <div class="icon-paint-format">&nbsp;</div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

但是如果我们可以这么做:

<primary-toolbar>
  <toolbar-group>
    <toolbar-button class="icon-undo"></toolbar-button>
    <toolbar-button class="icon-redo"></toolbar-button>
    <toolbar-button class="icon-print"></toolbar-button>
    <toolbar-toggle-button class="icon-paint-format"></toolbar-toggle-button>
  </toolbar-group>
</primary-toolbar>

明显更好一点的。可读性和维护性都变好了。

另一个问题是复用。我们不仅要写代码,还要维护代码。提升可维护性的一个方式就是复用代码,而不是一遍又一遍的写。

看一个简单的例子就明白了:

<div class="my-custom-element">
  <input type="text" class="email" />
  <button class="submit"></button>
</div>

假如我们在HTML的其他地方反复使用这点代码。现在我们需要给每一个这些元素进行一些变更,那我们就不得不一遍又一遍的查找,然后做同样的改变。 若使用如下码岂不会更好?

<my-custom-element></my-custom-element>

现代web应用不仅仅是静态的HTML,你需要跟应用交互,这是JS实现的。通常来说,你需要做的是创建一些元素然后在上面监听事件以响应用户输入。点击,拖拽或者悬浮事件等等。

var myDiv = document.querySelector('.my-custom-element');

myDiv.addEventListener('click', () => {
  myDiv.innerHTML = '<b> I have been clicked </b>';
});
<div class="my-custom-element">
  I have not been clicked yet.
</div>

使用自定义元素API,所有的逻辑可以在封装在元素内部。以下代码可以实现和上面代码一样的功能:

class MyCustomElement extends HTMLElement {
  constructor() {
    super();

    var self = this;

    self.addEventListener('click', () => {
      self.innerHTML = '<b> I have been clicked </b>';
    });
  }
}

customElements.define('my-custom-element', MyCustomElement);
<my-custom-element>
  I have not been clicked yet
</my-custom-element>

一开始看上去,自定义元素需要更多的JS代码。但是在实际应用中,创建不需复用的单一组件的情况是很少见的。另外在现代应用中,很多元素是动态创建的。那么,开发者就需要分别处理使用 JavaScript 动态添加元素或者使用 HTML 结构中预定义内容。这些都可以使用自定义元素来完成。
总的来说,自定义元素让你的代码更加好理解和维护了,并且带来了更小的,可复用的,封装好的模块。

要求

在你创建第一个自定义元素之前,需要遵循一些原则。

  • 元素名必须包含一个(-)。这样HTML解析器就知道哪个元素是自定义的,那个内置的。它也确保不会跟内置元素的名字冲突,例如<my-custom-element> 是有效的,而<myCustomElement> 和 <my_custom_element>是无效的
  • 不能重复注册相同的标签名。这会引起一个DOMException。你也不能重写自定义元素
  • 自定义元素不能自封闭。HTML解析器只允许内置元素可以自封闭(比如<img><link><br>

能力

自定义元素能做什么呢?答案是:很多。 最棒的特性是,元素的类定义实际指向的的DOM元素本身,因此你可以使用this 直接添加事件监听,属性访问,子节点访问等等

class MyCustomElement extends HTMLElement {
  // ...

  constructor() {
    super();

    this.addEventListener('mouseover', () => {
      console.log('I have been hovered');
    });
  }

  // ...
}

自然,这就给了你能力去重写元素的子节点。但是不推荐这么做,可能会引发意外的行为表现。元素里面的标记被其它内容所取代,用户会觉得很奇怪。

在元素生命周期的特定阶段,开发者可以在一些生命周期钩子中执行代码。

constructor

元素创建或者升级的时候会调用它。一般在这个阶段进行初始化状态,添加事件监听,创建影子DOM等等。需要谨记,一定要调用super()

connectedCallback

元素被添加到DOM时被调用。这个阶段可以延迟执行一些东西,直到元素被真正加载到页面上(比如获取资源)

disconnectedCallback

当元素从DOM中卸载时调用。这个阶段一般用来释放资源。需要谨记,如果用户关闭了tab那么这个方法永远不会被调用。所以在一开始初始化的时候就要小心。 attributeChangedCallback

一旦元素增加,删除,更新或者替换一个属性,就会调用这个方法。它当解析器创建的时候也会调用。但是,请注意只有在 observedAttributes 属性白名单中的属性才会触发。

addoptedCallback

调用document.adoptNode(...) 之后会触发这个方法,将元素移动到不同的文档。

以上所有的回调都同步的。例如,当把元素添加进 DOM 的时候只会触发连接回调。

属性反射

内置元素提供了一个非常棒的能力:属性反射。这意味这一些属性的值可以直接反射到DOM的属性中,比如id

myDiv.id = 'new-id';

将会更新DOM

<div id="new-id"> ... </div>

反过来也可以这么用。这样你就可以声明式的配置元素

自定义元素没有获得这个能力,但是有办法自己实现。我们可以给元素属性定义getters 和 setters

class MyCustomElement extends HTMLElement {
  // ...

  get myProperty() {
    return this.hasAttribute('my-property');
  }

  set myProperty(newValue) {
    if (newValue) {
      this.setAttribute('my-property', newValue);
    } else {
      this.removeAttribute('my-property');
    }
  }

  // ...
}

扩展元素

自定义元素API不仅允许你新建一个HTML元素,还允许你扩展一个现有的元素。而且该接口在内置元素和其它自定义元素中工作得很好。仅仅只需要扩展元素的类定义即可。

class MyAwesomeButton extends MyButton {
  // ...
}

customElements.define('my-awesome-button', MyAwesomeButton);

或者,当扩展内置元素时,为 customElements.define(...) 函数添加第三个 extends 的参数,参数值为需要扩展的元素标签名称。由于许多内置元素共享相同的 DOM 接口,extends 参数会告诉浏览器需要扩展的目标元素。若没有指定需要扩展的元素,浏览器将不会知道需要扩展的功能类别 。

class MyButton extends HTMLButtonElement {
  // ...
}

customElements.define('my-button', MyButton, {extends: 'button'});

原生元素的扩展,也叫做自定义内置元素。 一个推荐的规则是,尽量扩展已存在的元素。然后,一点点往里添加功能。这样你可以保持之前的所有特性(属性,方法,)

自定义内置元素,现在只在Chrome 67+支持。它将会被其他浏览器也实现,除了Safari。

升级元素

我们使用customElements.define(...)方法来注册一个自定义元素。但是这不是我们必须要做的第一个事情。

注册自定义元素,是可以推迟的。即使元素被添加到DOM了。这个过程叫元素的升级。浏览器提供了customElements.whenDefined(...) 方法,让开发者知道到底定义了而什么元素。这个方法传递元素的标签名。然后返回一个promise,当元素注册之后,promise会被解析。

customElements.whenDefined('my-custom-element').then(_ => {
  console.log('My custom element is defined');
});

例如。你或许期望延迟执行一些事情,直到所有的子元素被定义。当你有嵌套元素时,这个非常有用。优势父元素或者依赖它的子节点的实现。这种场景下,需要确保子元素在父元素之前定义。

影子 DOM

前面已经说了,自定义元素和影子DOM不同。前者是用来封装JS逻辑的,后者是为一小段 DOM 创建一个不为外部影响的隔绝环境。 在自定义元素中使用影子DOM,需要调用this.attachShadow

class MyCustomElement extends HTMLElement {
  // ...

  constructor() {
    super();

    let shadowRoot = this.attachShadow({mode: 'open'});
    let elementContent = document.createElement('div');
    shadowRoot.appendChild(elementContent);
  }

  // ...
});

Templates

之前已经讨论过模板了。这里简单看一下,在创建自己的自定义元素时,如何使用模板。使用<template>你可以声明DOM结构片段,它可以被解析,但是不会被渲染。

<template id="my-custom-element-template">
  <div class="my-custom-element">
    <input type="text" class="email" />
    <button class="submit"></button>
  </div>
</template>
let myCustomElementTemplate = document.querySelector('#my-custom-element-template');

class MyCustomElement extends HTMLElement {
  // ...

  constructor() {
    super();

    let shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.appendChild(myCustomElementTemplate.content.cloneNode(true));
  }

  // ...
});

现在我们已经组合了自定义元素,影子DOM和模板。我们创建的新元素作用域和其它元素隔绝且把 HTML 结构和 JavaScript 逻辑完美地隔离开来。

样式化

如何从外部样式化我们的元素呢?很简单----就像样式化内置元素一样

my-custom-element {
  border-radius: 5px;
  width: 30%;
  height: 50%;
  // ...
}

注意,我们从外部定义的样式权限更高,会覆盖元素内部的样式。

开发者需要明白有时候页面渲染,然后会在某些时刻会发现无样式内容闪烁(FOUC)。开发者可以通过为未定义组件定义样式及当元素已定义的时候使用一些动画过渡效果。使用 :defined 选择器来达成这一效果。

my-button:not(:defined) {
  height: 20px;
  width: 50px;
  opacity: 0;
}

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

HTML相当的灵活,运行你声明各种标签。如果浏览器没有识别这个标签,就会将其解析为HTMLUnknownElement

var element = document.createElement('thisElementIsUnknown');

if (element instanceof HTMLUnknownElement) {
  console.log('The selected element is unknown');
}

但是,自定义元素没有这样。记得我们之前讨论的自定义元素的命名规则么?如果浏览器看到一个有效的自定义元素名,浏览器会把它解析为 HTMLElement ,然后浏览器会把它看作一个未定义的自定义元素。

var element = document.createElement('this-element-is-undefined');

if (element instanceof HTMLElement) {
  console.log('The selected element is undefined but not unknown');
}

看起来 HTMLElement 和 HTMLUnknownElement没啥区别。但是解析器对他们的处理是不同的。有效的自定义元素具有自定义的实现,在定义实现细节之前,它会被当做一个空的div元素。而一个未定义元素没有实现任何内置元素的任何方法或属性。

浏览器支持

自定义元素现在到了V1版本,从Chrome 54 和 Safari 10.1之后就支持了。 但只有一部分的webkit 浏览器是完全支持的。好在,有一些profill可以帮助我们,即使在IE11上也能使用

可用性检查

检查window对象中是否存在customElements属性,可以判断浏览器是否支持自定义元素。

const supportsCustomElements = 'customElements' in window;

if (supportsCustomElements) {
  // 可以使用自定义元素接口
}

否则需要使用prolyfill库:

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

// Lazy load the polyfill if necessary.
if (supportsCustomElements) {
  // 浏览器原生支持自定义元素
} else {
  loadScript('path/to/custom-elements.min.js').then(_ => {
    // 加载自定义元素垫片
  });
}

总的来说,网页组件标准中的自定义元素为开发者提供了如下功能:

  • 给HTML元素关联JS和CSS
  • 扩展现有的HTML元素(内置和自定义的都可以)
  • 无需额外的库或者框架就可以开始,最多需要一些polyfill库来让浏览器支持自定义元素。
  • 很容易和其他的web组件搭配使用(影子dom,模板,插槽等等) -- 和浏览器开发者工具紧密集成在一起。
  • 使用已知的可访问功能