Web Component使用指南

组件是前端的发展方向,现在流行的 React 和 Vue 都是组件框架。

谷歌公司由于掌握了 Chrome 浏览器,一直在推动浏览器的原生组件,即 Web Components API。相比第三方框架,原生组件简单直接,符合直觉,不用加载任何外部模块,代码量小。

自定义元素

自定义元素是简单的用户自定义HTML元素。它们通过使用CustomElementRegistry来定义。要注册一个新的元素,通过window.customElements中一个叫define的方法来获取注册的实例。

window.customElements.define('my-element', MyElement);

方法中的第一个参数定义了新创造元素的标签名字,我们可以非常简单的直接使用

<my-element></my-element>

为了避免和native标签冲突,这里强制使用中划线来连接。 这里的MyElement的构造函数需要使用ES6的class,这让JavaScript的class不像原来面向对象class那么让人疑惑。同样的,如果一个Object和Proxy可以被使用来给自定义元素进行简单的数据绑定。但是,为了保证你的原生HTML元素的拓展性并保证元素继承了整个DOM API,需要使用这个限制。 让我们写一个这个自定义元素class

class MyElement extends HTMLElement {
  constructor() {
    super();
  }

  connectedCallback() {
    // here the element has been inserted into the DOM
  }
}

这个自定义元素的class就好像一个常规的继承自nativeHTML元素的class。在它的构造函数中有一个叫connectedCallback额外添加的方法,当这个元素被插入DOM树的时候将会触发这个方法。你可以把这个方法与React的componentDidMount方法。

通常来说,我们需要在connectedCallback之后进行元素的设置。因为这是唯一可以确定所有的属性和子元素都已经可用的办法。构造函数一般是用来初始化状态和设置Shadow DOM。

元素的构造函数和connectCallback的区别是,当时一个元素被创建时(好比document.createElement)将会调用构造函数,而当一个元素已经被插入到DOM中时会调用connectedCallback,例如在已经声明并被解析的文档中,或者使用document.body.appendChild添加。

你同样可以用过调用customElements.get('my-element')来获取这个元素构造函数的引用,从而构造元素。前提是你已经通过customElement.define()去注册。然后你可以使用new element()来代替document.createElement()去实例一个元素。

customElements.define('my-element', class extends HTMLElement {...});

...

const el = customElements.get('my-element');
const myElement = new el();  // same as document.createElement('my-element');
document.body.appendChild(myElement);

与connectedCallback相对应的则是disconnectCallback,当元素从DOM中移除的时候将会调用它。但是要记住,在用户关闭浏览器或者浏览器tab的时候,不会调用这个方法。 还有adoptedCallback,当元素通过调用document.adoptNode(element)被采用到文档时将会被调用,虽然到目前为止,我还没有碰到这个方法被调用的时候。

另一个有用的生命周期方法是attributeChangedCallback,每当将属性添加到observedAttributes的数组中时,就会调用这个函数。这个方法调用时两个参数分别为旧值和新值。

class MyElement extends HTMLElement {
  static get observedAttributes() {
    return ['foo', 'bar'];
  }

  attributeChangedCallback(attr, oldVal, newVal) {
    switch(attr) {
      case 'foo':
        // do something with 'foo' attribute

      case 'bar':
        // do something with 'bar' attribute

    }
  }
}

这个方法只有当被保存在observedAttributes数组的属性改变时,就如这个例子中的foo和bar,被改变才会调用,其他属性改变则不会。 属性主要用在声明元素的初始配置,状态。理论上通过序列化可以将复杂值传递给属性,但是这样会影响性能,并且你可以直接调用组件的方法,所以不需要这样做。但是如果你希望像React和Angular这样的框架提供属性的绑定,那你可以看一下。Polymer

生命周期函数的顺序

顺序如下:

constructor -> attributeChangedCallback -> connectedCallback

为什么attributeChangedCallback要在connectedCallback之前执行呢?

回想一下,web组件上的属性主要用来初始化配置。这意味着当组件被插入DOM时,这些配置需要可以被访问了。因此attributeChangedCallback要在connectedCallback之前执行。 这意味着你需要根据某些属性的值,在Shadow DOM中配置任何节点,那么你需要在构造函数中引用这些节点,而不是在connectedCallback中引用它们。

例如,如果你有一个ID为container的组件,并且你需要在根据属性的改变来决定是否给这个元素添加一个灰色的背景,那么你可以在构造函数中引用这个元素,以便它可以在attributeChangedCallback中使用:

constructor() {
  this.container = this.shadowRoot.querySelector('#container');
}

attributeChangedCallback(attr, oldVal, newVal) {
  if(attr === 'disabled') {
    if(this.hasAttribute('disabled') {
      this.container.style.background = '#808080';
    }
    else {
      this.container.style.background = '#ffffff';
    }
  }
}

如果你一直等到connectedCallback再去创建this.container。然后在第一时间调用attributeChangedCallback,它还是不尅用的。因此尽管你应该尽可能的延后你组件的connectedCallback,但在这种情况下是不可能的。

同样重要的是,你可以在组件使用customElement.define()之前去使用它。当改元素出现在DOM或者被插入到DOM,而还没有被注册时。它将会是一个HTMLUnkonwElement的实例。浏览器将会这样处理未知的元素,你可以像处理其他元素一样与它交互,除此之前,它将不会有任何方法和默认样式。

然后当通过使用customElement.define()去定义它时,并可使用类来定义增加它,这个过程被称为升级。当使用customElement.whenDefined升级元素时,可以调用回调,并会返回一个promise。当这个元素被升级时。

customElements.whenDefined('my-element')
.then(() => {
  // my-element is now defined
})

Web Component的公共API

除了这些生命周期方法,你还可以定义可以从外部调用的方法,这对于使用React和Angular等框架目前是不可行的。例如你可以定义一个名为doSomething的方法:

class MyElement extends HTMLElement {
  ...

  doSomething() {
    // do something in this method
  }
}

然后你可以在外部使用它

const element = document.querySelector('my-element');
element.doSomething();

在你的元素上定义的任何方法,都会成为其公共JavaScript的一部分。通过这种方式,你可以给元素的属性提供setter来实现数据绑定。例如在元素的HTML中展示设置的属性值。由于本质上不可以将给属性设置除了字符串以外的值,所以应该讲像对象这样的复杂之作为属性传递给自定义元素。

除了生命组件的初始状态,属性还可以用于对应属性的值,以便将元素的Javascript状态反应到DOM的表现中。input元素的disabled属性就是一个很好的例子:

<input name="name">

const input = document.querySelector('input');
input.disabled = true;

在将input的disabled的属性设置为true后,改变也会相应的反映到disabled属性上。

<input name="name" disabled>

通过setter可以很容易的将property反应到attribute上。

class MyElement extends HTMLElement {
  ...

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

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

当attribute改变后需要执行某些操作时,将其添加到observedAttributes数组中。作为一种性能优化,只有在这被列举出的属性才会监测它们的改变。无论这个attribute什么时候改变了,都会调用attributeChangedCallback,参数分别是当前值和新的值。

class MyElement extends HTMLElement {  
  static get observedAttributes() {    
    return ['disabled'];  
  }

  constructor() {    
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `      
      <style>        
        .disabled {          
          opacity: 0.4;        
        }      
      </style>      

      <div id="container"></div>    
    `;

    this.container = this.shadowRoot('#container');  
  }

  attributeChangedCallback(attr, oldVal, newVal) {    
    if(attr === 'disabled') {      
      if(this.disabled) {        
        this.container.classList.add('disabled');      
      }      
      else {        
        this.container.classList.remove('disabled')      
      }    
    }
  }
}

现在无论何时disabled的attribute被改变时,this.container上面的名为disabled的class都会显示或隐藏,它是ShadowDOM的内在元素。 接下来让我们看一下。

Shadow DOM

使用Shadow DOM,自定义元素的HTML和CSS完全封装在组件内。这意味着元素将以单个的HTML标签出现在文档的DOM树种。其内部的结构将会放在#shadow-root。

实际上一些原生的HTML元素也使用了Shadow DOM。例如你再一个网页中有一个<video>元素,它将会作为一个单独的标签展示,但它也将显示播放和暂停视频的控件,当你在浏览器开发工具中查看video标签,是看不到这些控件。

这些控件实际上就是video元素的Shadow DOM的一部分,因此默认情况下是隐藏的。要在Chrome中显示Shadow DOM,进入开发者工具中的Preferences中,选中Show user agent Shadow DOM。当你在开发者工具中再次查看video元素时,你就可以看到该元素的Shadow DOM了。

Shadow DOM还提供了局部作用域的CSS。所有的CSS都只应用于组件本身。元素将只继承最小数量从组件外部定义的CSS,甚至可以不从外部继承任何CSS。不过你可以暴露这些CSS属性,以便用户对组件进行样式设置。这可以解决许多CSS问题,同时仍然允许自定义组件样式。 定义一个Shadow root:

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `<p>Hello world</p>`;

这定义了一个带mode: open的Shadow root,这意味着可以再开发者工具找到它并与之交互,配置暴露出的CSS属性,监听抛出的事件。同样也可以定义mode:closed,会得到与之相反的表现。

你可以使用使用HTML字符串添加到innerHtml的property属性中,或者使用一个<template>去给Shadow root添加HTML。一个HTML的template基本是惰性的HTML片段,你可以定义了延后使用。在实际插入DOM前,它是不可见也不可解析的。这意味着定义在内部的任何资源都无法获取,任何内部定义的CSS和JavaScript只有当它被插入DOM中时,才会被执行。当组件的HTML根据其状态发生更改时,例如你可以定义多个<template>元素,然后根据组件的状态去插入这些元素,这样可以轻松的修改组件的HTML部分,并不需要修改单个DOM节点。

当Shadow root被创建之后,你可以使用document对象的所有DOM方法,例如this.shadowRoot.querySelector去查找元素。组件的所有样式都被定义在style标签内,如果你想使用一个常规的<link rel="stylesheet">标签,你也可以获取外部样式。除此之外,还可以使用:host选择器对组件本身进行样式设置。例如,自定义元素默认使用display: inline,所以如果你想要将组件展示为款元素,你可以这样做:

:host {
  display: block;
}

这还允许你进行上下文的样式化。例如你想要通过disabled的attribute来改变组件的背景是否为灰色:

:host([disabled]) {
  opacity: 0.5;
}

默认情况下,自定义元素从周围的CSS中继承一些属性,例如颜色和字体等,如果你想清空组件的初始状态并且将组件内的所有CSS都设置为默认的初始值,你可以使用:

:host {
  all: initial;
}

非常重要,需要注意的一点是,从外部定义在组件本身的样式优先于使用:host在Shadow DOM中定义的样式。如果你这样做

my-element {
  display: inline-block;
}

它将会被覆盖

:host {
  display: block;
}

不应该从外部去改变自定义元素的样式。如果你希望用户可以设置组件的部分样式,你可以暴露CSS变量去达到这个效果。例如你想让用户可以选择组件的背景颜色,可以暴露一个叫 --background-color的CSS变量。 假设现在有一个Shadow DOM的根节点是 <div id="container">

#container {
  background-color: var(--background-color);
}

现在用户可以在组件的外部设置它的背景颜色

my-element {
  --background-color: #ff0000;
}

你还可以在组件内设置一个默认值,以防用户没有设置

:host {
  --background-color: #ffffff;
}

#container {
  background-color: var(--background-color);
}

当然你还可以让用户设置任何的CSS变量,前提是这些变量的命名要以--开头。

通过提供局部的CSS、HTML,Shadow DOM解决了全部CSS可能带来的一些问题,这样问题通常导致不断地添加样式表,其中包含了越来越多的选择器和覆盖。Shadow DOM似的标记和样式捆绑到自己的组件内,而不需要任何工具和命名约定。你再也不用担心新的class或id会与现有的任何一个冲突。

除此之外,还可以通过CSS变量设置web组件的内部样式,还可以将HTML注入到Web Components中。

通过slots组成

组合是通过Shadow DOM树与用户提供的标记组合在一起的过程。这是通过<slot>元素完成的,该元素基本是Shadow DOM的占位符,用来呈现用户提供的标记。用户提供的标记又可以成为 light DOM。合成会将light DOM和Shadow DOM合并成为一个新的DOM树。

例如,你可以创建一个<iamge-gallery>组件,并提供标准的img标签作为组件要呈现的内容:

<image-gallery>
  <img src="foo.jpg" slot="image">
  <img src="bar.jpg" slot="image">
</image-gallery>

组件现在将会获取两个提供的图像,并且使用slots将它们渲染到组件的Shadow DOM中。注意到slot="image"的attribute,这告诉了组件应该要在Shadow DOM的什么位置渲染它们。例如这样

<div id="container">
  <div class="images">
    <slot name="image"></slot>
  </div>
</div>

当light DOM中的节点被分发到Shadow DOM中时,得到的DOM树看起来是这样的:

<div id="container">
  <div class="images">
    <slot name="image">
      <img src="foo.jpg" slot="image">
      <img src="bar.jpg" slot="image">
    </slot>
  </div>
</div>

正如你看到的,任何用户提供的具有slot属性的元素,都将在slot元素中呈现。而slot元素具有name属性,其值与slot属性的值对应。 

 

它接受用户提供的option元素,并将它们呈现到下拉菜单中。 带有name属性的slot被称为具名slot,但是这个属性不是必须的。它仅用于需要将内容呈现在特定位置时使用。当一个或多个slot没有name属性时,将按照用户提供内容的顺序在其中展示。当用户提供的内容少于slot时,slot可以提供默认的展示。

看一下<image-gallery>的Shadow DOM:

<div id="container">
  <div class="images">
    <slot></slot>
    <slot></slot>
    <slot>
      <strong>No image here!</strong> <-- fallback content -->
    </slot>
  </div>
</div>

如果你再只给两个image的话,最后的结果如下:

<div id="container">
  <div class="images">
    <slot>
      <img src="foo.jpg">
    </slot>
    <slot>
      <img src="bar.jpg">
    </slot>
    <slot>
     <strong>No image here!</strong>
    </slot>
  </div>
</div>

通过slot在Shadow DOM中展示的元素被称为分发节点。这些组件被插入前的样式也将会被用于他们插入后。在Shadow DOM中,分发节点可以通过::sloted()来获取额外的样式

::slotted(img) {
  float: left;
}

::sloted()可以接受任何有效的CSS选择器,但它只能选择顶级节点,例如::slotedd(section img)的情况,将不会作用于this content

<image-gallery>
  <section slot="image">
    <img src="foo.jpg">
  </section>
</image-gallery>

在JavaScript中使用slots

你可以通过JavaScript与slots进行交互去监测哪个节点被分发到哪个slot,哪些slot被插入了元素,以及slotchange事件。

要找出哪些元素已经被分发给对应的slots可以使用 slot.assignedNodes() 如果你还想查看slot的默认内容,你可以使用 slot.assignedNodes({flatten: true}) 要找出哪些slot被分发的元素,可以使用element.assignedSlot 当slot内的节点发生改变,即添加或删除节点时,将会出发slotchange事件。要注意的是,只有当slot节点自身改变才会触发,而这些slot节点的子节点并不会触发。

slot.addEventListener('slotchange', e => {
  const changedSlot = e.target;
  console.log(changedSlot.assignedNodes());
});

在元素第一次初始化时,Chrome会触发slotchange事件,而Safari和Firefox则不会。

Shadow DOM中的事件

默认情况下,自定义元素(如鼠标和键盘事件)的标准事件将会从Shadow DOM中冒泡。每当一个事件来此Shadow DOM中的一个节点时,它会被重定向,因此该事件似乎来自元素本身。如果你想找出事件实际来自Shadow DOM中的哪个元素,可以调用event.composedPath()来检索事件经过的节点数组。然而,事件的target属性还是会指向自定义元素本身。

你可以使用CustomEvent从自定义元素中抛出任何你想要的事件。

class MyElement extends HTMLElement {
  ...

  connectedCallback() {
    this.dispatchEvent(new CustomEvent('custom', {
      detail: {message: 'a custom event'}
    }));
  }
}

// on the outside
document.querySelector('my-element').addEventListener('custom', e => console.log('message from event:', e.detail.message));

但是当一个事件从Shadow DOM的节点抛出而不是自定义元素本身,他不会从ShadowDOM上冒泡,除非它使用了composition: true来创建

class MyElement extends HTMLElement {
  ...

  connectedCallback() {
    this.container = this.shadowRoot.querySelector('#container');

    // dispatchEvent is now called on this.container instead of this
    this.container.dispatchEvent(new CustomEvent('custom', {
      detail: {message: 'a custom event'},
      composed: true  // without composed: true this event will not bubble out of Shadow DOM
    }));
  }
}

模板元素

除了使用this.shadowRoot.innerHTML来向一个元素的shadow root添加HTML,你也可以使用 <template>来做。template保存HTML供以后使用。它不会被渲染,并只有确保内容是有效的才会进行解析。模板中的JavaScript不会被执行,也会获取任何外部资源,默认情况下它是隐藏的。

当一个web component需要根据不同的情况来渲染不同的标记时,可以用不同的模板来完成:

class MyElement extends HTMLElement {
  ...

  constructor() {
    const shadowRoot = this.attachShadow({mode: 'open'});

    this.shadowRoot.innerHTML = `
      <template id="view1">
        <p>This is view 1</p>
      </template>

      <template id="view1">
        <p>This is view 1</p>
      </template>

      <div id="container">
        <p>This is the container</p>
      </div>
    `;
  }

  connectedCallback() {
    const content = this.shadowRoot.querySelector('#view1').content.clondeNode(true);
    this.container = this.shadowRoot.querySelector('#container');

    this.container.appendChild(content);
  }
}

这里两个模板都使用了innerHTML放在shadow root内,最初这两个模板都是隐藏的,自由container被渲染。在connectedCallback中我们通过this.shadowRoot.querySelector('#view1').content.clondeNode(true)获取了#view1的内容。模板content的属性以DocumentFragment形式返回模板的内容,可以勇士appendChild添加到另一个元素中。因为appendChild将在元素已经存在于DOM中时移除它,所以我们需要先使用cloneNode(true),否则模板的内容将会被移除,这意味着我们只能使用一次。

模板对于快速的更改HTML部分或者重写标记非常有用。它们不仅限于web components并且可以在任何DOM中使用。

扩展原生元素

到目前为止,我们一直在扩展HTMLElement来创建一个全新的HTML元素。自定义元素还允许使用扩展原生内置元素,支持增强已经存在的HTML元素,例如images和buttons。目前此功能仅在Chrome和Firefox中受支持。

扩展现有HTML元素的好处是继承了元素的所有属性和方法。这允许对现有元素进行逐步的增强。这意味着即使在不支持自定义元素的浏览器中,它仍是可用的。它只会降级到默认的内置行为。而如果它是一个全新的HTML标签,那它将会完全无法使用。

例如,我们想要增强一个HTML<button>标签

class MyButton extends HTMLButtonElement {
  ...

  constructor() {
    super();  // always call super() to run the parent's constructor as well
  }

  connectedCallback() {
    ...
  }

  someMethod() {
    ...
  }
}

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

我们的web component不在扩展更通用的HTMLElement,而是扩展HTMLButtonElement。当我们使用customElements.define()的时候还需要添加一个额外的参数 {extends: 'button'}来表示我们的类扩展的是<button>元素。这可能看起来有些多余,因为我们已经表明了我们想要扩展的是HTMLElementButton,但是这是必要的,因为一些元素共享一个DOM接口。例如 <q> 和 <blockquote>都共享 HTMLQuoteElement接口。

这个增强后的button可以通过is属性来被使用

<button is="my-button">

现在它将被我们的MyElement类增加,如果它加载在一个不支持自定义元素的浏览器中,它将降级到一个标准的按钮,真正的渐进式增强。

注意,在扩展现有元素时,不能使用Shadow DOM。这只是一种扩展原生HTML元素的方法,它继承了所有现有的属性、方法和事件,并提供了额外的功能。当然可以在组件中修改元素的DOM和CSS,但是尝试创建一个Shadow root将会抛出一个错误。

扩展内置元素的另一个好处就是,这些元素也可以应用于子元素被限制的情况。例如thead元素只允许tr作为其子元素,因此<awesome-tr>元素将呈现无效标记。这种情况下,我们可以拓展内置的tr元素。并像这样使用它:

<table>
  <thead>
    <tr is="awesome-tr"></tr>
  </thead>
</table>

这种创建web components的方式带来了巨大的渐进式增强,但是正如前面所提到,目前仅有Chrome和Firefox支持。Edge也将会支持,但不幸的是,目前Safari还没有实现这一点。


现在web component已经得到了广泛的支持,你可以会得出这样的结论:原生代码可以提供与框架相同的功能,但是性能更好,代码更少,复杂度更低。

使用原生web components的好处非常的清晰:

  • 原生不需要框架
  • 易于继承,不需要编译
  • 真正的局部CSS作用域
  • 标准,只有HTML,CSS,JavaScript

https://zhuanlan.zhihu.com/p/64619005

http://www.ruanyifeng.com/blog/2019/08/web_components.html

https://developer.mozilla.org/zh-CN/docs/Web/Web_Components

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值