Web 组件势必取代前端?

640?wx_fmt=gif

在现代Web API的发展下,创建可重用的前端组件终于不再需要框架了。

640?wx_fmt=jpeg

作者 | Danny Moerkerke,JavaScript程序员,自由职业者

译者 | 弯月

责编 | 郭芮

以下为译文:

还记得document.querySelector第一次获得浏览器的广泛支持,终结了jQuery一统天下的局面的时刻吗?我们终于拥有了一个原生的方法来代替多年来一直需要通过jQuery来提供的功能:简单地选择DOM元素的方法。我相信,同样的情况也会发生在前端框架上,比如Angular和React。

有了这些框架,我们就能完成一些一直想做但一直没办法实现的事情——创建可重用的自动化前端组件。然而,这些框架会增加复杂性,增加专有的语法,还会增大负担。

一切终将变化。

在现代Web API的发展下,创建可重用的前端组件终于不再需要框架了。有了自定义元素和影子DOM,我们就可以创建能够随意复用的组件。

Web组件(Web Component)的概念最初于2011年提出,组件包括一系列功能,可以仅通过HTML、CSS和JavaScript就能创建可重用的组件。也就是说,创建组件不需要再使用React或Angular之类的框架。更妙的是,这些组件还能够无缝地集成到这些框架中。

有史以来我们第一次能够仅通过HTML、CSS和JavaScript创建组件并在任何现代浏览器上运行。现在,最新版本的Chrome、Safari、Firefox和Opera桌面版,以及Safari的iOS版、Chrome的Android版都支持Web组件。

Edge将在下一个版本(版本19)中支持Web组件。旧版本浏览器还可使用polyfill(https://github.com/webcomponents/webcomponentsjs),最低能在IE11上实现Web组件。

也就是说,现在几乎能在任何浏览器(包括移动浏览器)上使用Web组件。

你可以创建自定义的HTMl标签,它能够从被扩展的HTML元素那里继承所有的属性,然后只需要简单地导入一段脚本,就可以在任何支持Web组件的浏览器中使用。组件中定义的所有HTML、CSS和JavaScript的定义域都仅限于组件内部。

在浏览器的开发者工具中,组件将显示为单个HTML标签,所有的样式和行为都完全被封装,不需要任何额外的技巧,不需要框架,也不需要编译。

我们来看看Web组件的主要功能。

自定义元素

自定义元素(Custom Elements)就是用户自定义的HTML元素,可以使用CustomElementRegistry定义自定义元素。如果你想注册新的元素,只需通过window.customElements获得registry的实例,然后调用其define方法:

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

define方法的第一个参数是要创建的新元素的标签名称。接下来,你只需要下面的代码就可以使用该元素:

<my-element></my-element> 

名称中的横线(-)是必须的,这是为了避免与原生HTML元素的命名冲突。

MyElement构造函数必须是ES6类,然而很不幸的是,由于Javascript类不同于传统的OOP语言的类,这很容易造成混乱。而且,因为这里可以使用Object,所以Proxy也是可行的,这样就能在自定义元素上实现简单的数据绑定。但是,如果想实现对原生HTML元素的扩展,这个限制是必须的,这样才能保证你的元素能够继承整个DOM API。

下面我们来为自定义元素写一个类:

class MyElement extends HTMLElement {
 constructor() {
    super();
  }
  connectedCallback() {
    // here the element has been inserted into the DOM
  }
}

我们自定义元素的类只是普通的JavaScript类,它扩展了原生的HTMLElement。除了构造函数之外,它还有个方法叫做connectedCallback,当元素被插入到DOM树之后该方法会被调用。你可以认为它相当于React的componentDidMount方法。

一般来说,组件的设置应当尽可能低推迟到connectdedCallback中进行,因为这是唯一一个能够确保所有属性和子元素都存在的地方。一般来说,构造函数应该仅初始化状态,以及设置影子DOM(Shadow DOM)。

元素的constructor和connectedCallback的区别在于,constructor在元素被创建时调用(例如通过调用document.createElement创建),而connectedCallback是在元素真正被插入到DOM中时调用,例如当元素所在的文档被解析时,或者通过document.body.appendChild添加元素时。

你也可以通过customElements.get('my-element')来获取自定义元素的构造函数的引用,通过该方法来创建元素,假设该元素已经通过customElements.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相对的就是disconnectedCallback,当元素从DOM中移除时会调用该方法。在这个方法中可以进行必要的清理工作,但要记住这个方法不一定会被调用,比如用户关闭浏览器或关闭浏览器标签页的时候。

还有个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等框架提供的功能那样,在属性上实现数据绑定,可以看看Ploymer(https://polymer-library.polymer-project.org/)。

生命周期方法的顺序

生命周期方法的执行顺序为:

constructor -> attributeChangedCallback -> connectedCallback

为什么attributeChangedCallback会在connectedCallback之前被调用?

回忆一下,Web组件的属性的主要目的是初始化配置。也就是说,当组件被插入到DOM中时,配置应当已经被初始化过了,所以attributeChangedCallback应当在connectedCallback之前被调用。

也就是说,如果想根据特定属性的值,在影子DOM中配置任何结点,那就需要在constructor中引用属性,而不能在connectedCallback中进行。

例如,如果组件中有个id="container",而你需要在属性disabled发生改变时,将这个元素设置为灰色背景,那么需要在constructor中引用该属性,这样它才能出现在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被调用时,this.container不存在。所以,尽管你应当尽量将组件的设置推迟到connectedCallback中进行,但这是个例外情况。

另一点很重要的是,要意识到你可以在通过customElements.define()注册Web组件之前就使用它。当元素存在于DOM中,或者被插入到DOM中时,如果它还没有被注册,那么它将成为HTMLUnknownElement的实例。浏览器会对于任何它不认识的HTML元素的处理方法是,你依然可以像使用其他元素那样使用它,只是它没有任何方法,也没有默认的样式。

在通过customElements.define()注册之后,该元素就会通过类定义得到增强。该过程称为“升级”(upgrading)。可以在元素被升级时通过customElements.whenDefined调用一个回调函数,该方法返回一个Promise,在元素被升级时该Promise得到解决:

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

Web组件的公共API

除了生命周期方法之外,你还可以在元素上定义方法,这些方法可以从外部调用。这个功能是React和Angular等框架无法实现的。例如,你可以定义一个名为doSomething的方法:

class MyElement extends HTMLElement {
  ...

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

然后在组件外部像这样调用它:

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

任何在元素上定义的属性都会成为它的公开JavaScript API的一部分。这样,只需给元素的属性提供setter,就可以实现数据绑定,从而实现类似于在元素的HTML里渲染属性值等功能。因为原生的HTML属性(attribute)值仅支持字符串,因此对象等复杂的值应该作为自定义元素的属性(properties)。

除了定义Web组件的初始状态之外,HTML属性(attribute)还用来反映相应的组件属性(property)的值,因此元素的JavaScript状态可以反映到其DOM表示中。下面的例子演示了input元素的disabled属性:

<input name="name">

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

在将input的disabled属性(property)设置为true后,这个改动会反映到相应的disabled HTML属性(attribute)中:

<input name="name" disabled>

用setter可以很容易实现从属性(property)到HTML属性(attribute)的映射:

class MyElement extends HTMLElement {
  ...

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

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

如果需要在HTML属性(attribute)发生变化时执行一些动作,那么可以将其加入到observedAttributes数组中。为了保证性能,只有加入到这个数组中的属性(attribute)才会被监视。当HTML属性(attribute)的值发生变化时,attributeChangedCallback就会被调用,同时传入HTML属性的名称、当前值和新值:

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(即元素的影子DOM中的div元素)上的“disabled”就会随之改变。

影子DOM

使用影子DOM,自定义元素的HTML和CSS可以完全封装在组件内部。这意味着在文档的DOM树中,元素会显示为单一的HTML标签,其实际内部HTML结构会出现在#shadow-root中。

实际上,好几个原生HTML元素也在使用影子DOM。例如,如果在网页上放置一个<video>元素,它会显示为单一的标签,但同时显示的播放、暂停按钮等在开发者工具中查看<video>元素时是看不到的。

这些元素实际上是<video>元素的影子DOM的一部分,因此默认是隐藏的。要在Chrome中显示影子DOM,可以在“偏好设置”中的开发者工具中找到设置,勾选“显示用户代理的影子DOM”。在开发者工具中重新检查<video>元素,就能看到元素的影子DOM。

影子DOM还支持真正的CSS范围(scope)。所有定义在组件内部的CSS只对组件本身有效。元素仅从组件外部定义的CSS中继承最小量的属性,甚至,连这些属性都可以配置为不继承。但是,你可以暴露一些CSS属性,允许组件的使用者给组件添加样式。这种机制解决了现有的CSS的许多问题,同时依然支持自定义组件的样式。

定义影子root的方式如下:

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

这段代码在定义影子root时使用了mode: 'open',其含义是它可以通过开发者工具进行查看和操作,可以查询,也可以配置任何暴露的CSS属性,也可以监听它抛出的事件。影子root的另一个模式是mode: 'closed',但这个选项不推荐使用,因为使用者将无法与组件进行人和交互,甚至都不能监听其抛出的事件。

要给影子root添加HTML,可以将HTML字符串赋值给影子root的innerHTML属性,也可以使用<template>元素。HTML模板基本上是一段HTML片段,供以后使用。在插入到DOM树中之前,它不可见,也不会被解析,也就是说其内部定义的任何外部资源都不会被下载,任何CSS和JavaScript在插入到DOM之前也不会被解析。例如,你可以定义多个<template>元素,当组件的HTML根据组件状态而发生变化时,将相应的模板插入到DOM中。这样就可以很容易地改变组件的大量HTML,而不需要逐个处理DOM结点。

创建影子root之后,就可以在上面使用所有DOM的方法,就像平常处理document对象那样,如使用this.shadowRoot.querySelector来查找元素。组件的所有CSS都可以定义在<style>标签中,但也可以通过通常的<link rel="stylesheet">来读取外部样式表。除了一般的CSS之外,还可以使用:host选择器给组件自己定义样式。例如,自定义元素默认使用display: inline,使用下面的CSS可以将其定义为块元素:

:host {
  display: block;
}

这还可以实现上下文样式。例如,如果想在组件定义了disabled属性时灰掉,可以这样做:

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

默认情况下,自定义元素会从周围的CSS继承一些属性,如color、font等。但是如果你希望从全新的状态开始,使组件的所有CSS属性重置到默认值,可以这样做:

:host {
  all: initial;
}

有一点很重要:外部定义在组件上的样式的优先级要高于在影子DOM中使用:host定义的样式。因此,如果定义了:

my-element {
  display: inline-block;
}

它将会覆盖:

:host {
  display: block;
}

外部不可能给自定义元素内部的任何元素定义样式。但如果希望使用者能够给组件(中的部分元素)定义样式,那么可以通过暴露CSS变量来实现。例如,如果希望使用者能选择组件的背景颜色,那么可以暴露名为--background-color的CSS变量。

假设组件的影子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范围(scope)的支持,影子DOM解决了CSS的全局性带来的问题——会导致巨大的、只能添加的样式表,其中的选择器的规则越来越具体,充满了各种覆盖。影子DOM使得开发者可以将标记语言和样式打包到组件内部,而不需要任何工具或命名规则。这样就不用担心新的class或id会与已有的冲突。

除了能够通过CSS变量给Web组件内部设置样式之外,还可以给Web组件注入HTML。

通过slot进行组合

组合就是将影子DOM树与使用者提供的标记语言组合在一起。<slot>元素可以实现这一过程,可以认为它是影子DOM中的一个占位符,使用者提供的标记语言将在此处渲染。使用者提供的标记语言称为“轻量DOM”(light DOM)。组合过程将轻量DOM和影子DOM结合在一起,形成新的DOM树。

例如,你可以创建一个<image-gallery>组件,使用该组件时,提供两个标准的<img>标签供组件渲染用:

&lt;image-gallery&gt;
  &lt;img src="foo.jpg" slot="image"&gt;
  &lt;img src="bar.jpg" slot="image"&gt;
&lt;/image-gallery&gt;

该组件将接受两个图像,并在组件的影子DOM内部渲染。注意图像上的slot="image"属性。该属性告诉组件图像在影子DOM中渲染的位置。影子DOM的样子可能如下:

&lt;div id="container"&gt;
  &lt;div class="images"&gt;
    &lt;slot name="image"&gt;&lt;/slot&gt;
  &lt;/div&gt;
&lt;/div&gt;

当轻量DOM中的元素被分配到元素的影子DOM中后,得到的DOM树如下所示:

&lt;div id="container"&gt;
  &lt;div class="images"&gt;
    &lt;slot name="image"&gt;
      &lt;img src="foo.jpg" slot="image"&gt;
      &lt;img src="bar.jpg" slot="image"&gt;
    &lt;/slot&gt;
  &lt;/div&gt;
&lt;/div&gt;

可见,用户提供的带有slot属性的元素将被渲染到slot元素内部,slot元素的name属性值必须匹配相应的slot属性的值。

<select>元素就使用了这种方式,你可以在Chrome的开发者工具中查看(如果你勾选了“显示用户代理的影子DOM”选项,如上文所示):

640?wx_fmt=png

它接受用户提供的<option>元素,将其渲染成下拉菜单。

带有name属性的slot元素称为命名slot,但该属性并不是必须的。name属性只是用来将内容渲染到特定的位置。如果一个或多个slot没有name属性,内容将会按照使用者提供的顺序进行渲染。如果使用者提供的内容少于slot的个数,slot还可以提供默认内容。

假设<image-gallery>的影子DOM如下所示:


&lt;div id="container"&gt;
  &lt;div class="images"&gt;
    &lt;slot&gt;&lt;/slot&gt;
    &lt;slot&gt;&lt;/slot&gt;
    &lt;slot&gt;
      &lt;strong&gt;No image here!&lt;/strong&gt; &lt;-- fallback content --&gt;
    &lt;/slot&gt;
  &lt;/div&gt;
&lt;/div&gt;

提供上文中的两个图像时,产生的DOM树如下:

&lt;div id="container"&gt;
  &lt;div class="images"&gt;
    &lt;slot&gt;
      &lt;img src="foo.jpg"&gt;
    &lt;/slot&gt;
    &lt;slot&gt;
      &lt;img src="bar.jpg"&gt;
    &lt;/slot&gt;
    &lt;slot&gt;
     &lt;strong&gt;No image here!&lt;/strong&gt;
    &lt;/slot&gt;
  &lt;/div&gt;
&lt;/div&gt;

影子DOM内部通过slot渲染的元素称为分配结点。这些结点的样式会在渲染到组件内部的影子DOM(即“分配”)后依然有效。在影子DOM内部,分配结点还可以通过::slotted()选择器获得额外的样式:

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

::slotted()可以接受任何有效的CSS选择器,但只能选择顶层结点。例如,::slot(section img)在这种情况下无法使用:

&lt;image-gallery&gt;
  &lt;section slot="image"&gt;
    &lt;img src="foo.jpg"&gt;
  &lt;/section&gt;
&lt;/image-gallery&gt;

用JavaScript处理slot

JavaScript也可以处理slot,可以查看某个slot被分配了什么结点,查看某个元素被分配到了哪个slot,还可以使用slotchange事件。

调用slot.assignedNodes()可以访问slot分配到的结点。如果想获取任何默认内容,可以调用slot.assignedNodes({flatten: true})。

查看element被分配到的slot,可以访问element.assignedSlot。

每当slot内部的结点发生变化(结点被添加或删除)时会产生slotChange事件。注意该事件仅在slot结点本身上触发,而不会在slot结点的子元素上触发。

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

Chrome会在元素首次初始化时触发slotchange事件,而Safari和Firefox在此情况下不会。

影子DOM中的事件

自定义元素产生的标准事件(如鼠标和键盘事件等)默认情况下会从影子DOM中冒泡出来。如果事件从影子DOM内部的结点产生,那么它的目标会被重新设置,使之看起来像是从自定义元素本身产生的。如果想知道事件到底产生于影子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 =&gt; console.log('message from event:', e.detail.message));

但是,任何影子DOM内部的结点抛出的事件则不会冒泡到影子DOM外面,除非它是使用composed: 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
    }));
  }
}

template元素

除了使用this.shadowRoot.innerHTML给影子root中的元素添加HTML之外,还可以使用<template>来实现这一点。模板用来提供一小段代码供以后使用。模板中的代码不会被渲染,初始化时它的内容会被解析,但仅仅用来保证其内容是正确的。模板内部的JavaScript不会被执行,任何外部资源也不会被获取。默认情况下它是隐藏的。

如果Web组件需要根据不同的情况渲染完全不同的标记,那么可以使用不同的模板来实现这一点:

class MyElement extends HTMLElement {
  ...

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

    this.shadowRoot.innerHTML = `
      &lt;template id="view1"&gt;
        &lt;p&gt;This is view 1&lt;/p&gt;
      &lt;/template&gt;

      &lt;template id="view1"&gt;
        &lt;p&gt;This is view 1&lt;/p&gt;
      &lt;/template&gt;

      &lt;div id="container"&gt;
        &lt;p&gt;This is the container&lt;/p&gt;
      &lt;/div&gt;
    `;
  }

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

    this.container.appendChild(content);
  }
}

这里两个模板都通过innerHTML放到了影子root内。一开始时两个模板都是隐藏的,只有容器被渲染。在connectedCallback内我们调用this.shadowRoot.querySelector('#view1').content.cloneNode(true)获取了#view1的内容。模板的content属性返回的模板内容为DocumentFragment实例,该实例可以通过appendChild添加到另一个元素中。由于appendChild在元素已存在于DOM中的情况下会移动元素,所以我们首先需要使用cloneNode(true)来复制它。否则,模板的内容将会被移动而不会被添加,意味着我们只能使用其内容一次。

模板在需要快速改变一大片HTML或重用HTML的情况下非常有用。模板也不限于Web组件,可以用在DOM中的任何地方。

扩展原生元素

到目前为止,我们一直在扩展HTMLElement来创建全新的HTML元素。自定义元素还可以用来扩展内置的原生元素,从而实现对图像、按钮等已有HTML元素的增强。在撰写本文时,该功能仅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组件没有扩展更通用的HTMLElement,而是扩展了HTMLButtonElement。现在调用customElements.define时还带了另一个参数{extends: 'button'},来指明我们的类扩展了<button>元素。这看起来有点多余,因为我们已经指明过要扩展HTMLButtonElement了,但这是必要的,因为有可能有其他元素使用了同一个DOM接口。例如,<q>和<blockquote>都使用同一个HTMLQuoteElement接口。

增强后的按钮可以使用is属性了:

&lt;button is="my-button"&gt;

该按钮被我们的MyElement类增强。如果它加载到不支持自定义元素的浏览器中,它就会变成普通的按钮。这是真正的渐进式增强!

注意,在扩展已有元素时不能使用影子DOM。这仅仅是通过继承所有属性、方法和事件并提供额外的功能来扩展原生HTML的方法。当然,在组件内部修改元素的DOM和CSS是可能的,但试图创建影子root则会抛出错误。

扩展内置元素的另一个好处就是,它可以用于元素限制父子关系的情况。例如,<thead>元素仅允许<tr>元素作为子结点,那么使用<awesome-tr>元素将被视为非法标记。这种情况下我们可以扩展内置的<tr>元素,并这样使用:

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr is="awesome-tr"&gt;&lt;/tr&gt;
  &lt;/thead&gt;
&lt;/table&gt;

这样使用Web组件可以带来非常好的渐进式增强,但正如前面所说,目前只有Chrome和Firefox支持。Edge将来也会支持,但在本文撰写之时,Safari并不支持。

测试Web组件

测试Web组件非常容易、直接,与Angular、React等框架相比,测试Web组件简直是小菜一碟。不需要任何编译,也不需要复杂的设置。只需创建元素,添加到DOM中,然后运行测试即可。

下面是使用Mocha进行测试的例子:

import 'path/to/my-element.js';

describe('my-element', () =&gt; {
  let element;

  beforeEach(() =&gt; {
    element = document.createElement('my-element');

    document.body.appendChild(element);
  });

  afterEach(() =&gt; {
    document.body.removeChild(element);
  });

  it('should test my-element', () =&gt; {
    // run your test here
  });
});

这里,第一行导入了my-element.js,该文件将Web组件暴露为ES6模块。这就是说,测试文件也需要作为ES6组件加载到浏览器中。因此,需要在浏览器中使用下面的html文件来运行测试。除了Mocha之外,我们还加载了WebcomponentsJS polyfill,还有Chai用于测试断言,还有Sinon用于监视(spy)和模拟(mock):

&lt;!doctype html&gt;
&lt;html&gt;
    &lt;head&gt;
        &lt;meta charset="utf-8"&gt;
        &lt;link rel="stylesheet" href="../node_modules/mocha/mocha.css"&gt;
        &lt;script src="../node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"&gt;&lt;/script&gt;
        &lt;script src="../node_modules/sinon/pkg/sinon.js"&gt;&lt;/script&gt;
        &lt;script src="../node_modules/chai/chai.js"&gt;&lt;/script&gt;
        &lt;script src="../node_modules/mocha/mocha.js"&gt;&lt;/script&gt;

        &lt;script&gt;
            window.assert = chai.assert;
            mocha.setup('bdd');
        &lt;/script&gt;
        &lt;script type="module" src="path/to/my-element.test.js"&gt;&lt;/script&gt;
        &lt;script type="module"&gt;
            mocha.run();
        &lt;/script&gt;

    &lt;/head&gt;
    &lt;body&gt;
        &lt;div id="mocha"&gt;&lt;/div&gt;
    &lt;/body&gt;
&lt;/html&gt;

加载完必须的脚本之后,我们chai.assert暴露为全局变量,这样就可以在测试中简单地通过assert()进行断言,并设置Mocha使用BDD接口。接下来加载测试文件(本例中只有一个),然后调用mocha.run()运行测试。

注意,在使用ES6模块时,还需要将mocha.run()放在带有type="module"的script内。这是因为默认情况下ES6模块是被延迟加载的,如果mocha.run()在正常的script标签内,它将在my-element.test.js加载之前被执行。

在旧的浏览器中使用Polyfill

目前,最新版本的Chrome、Firefox、Safari和Opera桌面版都支持自定义元素,Edge 19也将支持。iOS和Android上的Safari、Chrome和Firefox也支持。

对于旧的浏览器,可以使用WebcomponentsJS这个polyfill:

npm install --save @webcomponents/webcomponentsjs

可以使用webcomponents-loader.js,该文件会进行功能检测,只有在必要时才会加载polyfill。使用polyfill就可以使用自定义元素,而不需要改动源代码。但是,它并不能提供真正的CSS范围,意味着如果不同的Web组件中的元素拥有同样的class名和id,它们将会冲突。而且,影子DOM的CSS选择器:host()和:slotted()可能无法正确工作。

想要让这两个选择器正确工作,你需要加载Shady CSS polyfill,还需要(少量)修改源代码。我个人不喜欢这一点,所以我写了个Webpack加载器来帮你实现这一点。这意味着你需要编译代码,但不再需要修改源代码了。

Webpack加载器完成三项工作:它给Web组件的影子DOM中的所有不是以::host或::slotted开头的CSS规则添加前缀,前缀为元素的名称,从而提供正确的范围。之后,它会解析所有::host和::slotted规则,保证它们正确工作。

示例#1:lazy-img

我创建了一个Web组件,可以懒加载图像,只有图像完全出现在浏览器的窗口中时才进行加载。代码在Github(https://github.com/DannyMoerkerke/lazy-img)上。

组件的正式版本是将原生的<img>标签包裹在<lazy-img>自定义元素内:

&lt;lazy-img
  src="path/to/image.jpg"
  width="480"
  height="320"
  delay="500"
  margin="0px"&gt;&lt;/lazy-img&gt;

代码仓库还有个extend-native分支,它利用is属性扩展原生的<img>为lazy-img:

&lt;img
  is="lazy-img"
  src="path/to/img.jpg"
  width="480"
  height="320"
  delay="500"
  margin="0px"&gt;

这是个用来演示原生Web组件的非常好的例子:只需要导入JavaScript文件,添加HTML标签或利用is扩展已有的原生标签就可以了!

示例#2:material-webcomponents

我利用自定义元素实现了Google的Material Design,代码也在Github(https://dannymoerkerke.github.io/material-webcomponents)上。

该库也演示了CSS自定义属性(https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties)的强大功能。

那么,我还应该使用框架吗?

还是那句话,需要视情况而定。

当前的前端框架通过数据绑定、状态管理和非常标准化的代码带来了很多额外的价值。问题就是你的应用程序是否需要这些。

如果你不清楚应用程序是否真的需要Redux等状态管理,那么很大可能你并不需要。需要时你肯定会感受到。

数据绑定也许会给你带来好处,但Web组件已经支持直接将属性设置为数组、对象等非简单值了。简单值可以通过HTML属性(attribute)来设置,属性的改变可以通过atributeChangedCallback来监视。

尽管这一流程完全有道理,但会让更新一小部分DOM的操作变得很麻烦,而React和Angular的描述性方式更容易。这些框架可以定义一个包含表达式的视图,在表达式发生变化时进行更新。

原生的Web组件(还)不提供这样的功能,尽管已经有提案建议扩展<template>元素以支持使用数据进行初始化和更新:

&lt;template id="example"&gt;
  &lt;h1&gt;{{title}}&lt;/h1&gt;

  &lt;p&gt;{{text}}&lt;/p&gt;
&lt;/template&gt;

const template = document.querySelector('#example');
const instance = template.createInstance({title: 'The title', text: 'Hello world'});
shadowRoot.appendChild(instance.content);

//update
instance.update({title: 'A new title', text: 'Hi there'});

目前可用的库中,能够有效地更新DOM的是lit-html(https://lit-html.polymer-project.org/)。

前端框架的另一个经常被提及的好处就是,它们提供了标准的代码,团队中的每个新成员都能从一开始就很熟悉。我相信这是正确的做法,但我也认为这个好处非常有限。

我曾在多个项目中使用过Angular、React和Polymer,尽管它们之间有相似性,但即使是使用同一个框架,代码结构也会大相径庭。一个清晰的工作方式和样式指南,为代码提供的一致性远远好于仅依赖框架。框架也会带来额外的复杂性,所以应该问问自己这样做是否值得。

现在,Web组件已经得到了广泛的支持,你也许可以看出,原生代码可以提供与框架媲美的功能,但性能更好,代码量更小,复杂度更低。

原生Web组件的优势很明显:

  • 原生,不需要框架

  • 很容易集成,不需要编译

  • 真正的CSS范围

  • 标准化,仅使用HTML、CSS和JavaScript

jQuery及其优异的遗产依然会继续存在一段时间,但现在很少有新项目再使用它们,因为我们有了更好的选择。我并不认为现在的框架会很快消失,但作为更好的选择,原生Web组件已经出现,而且迅速得到了关注。我认为,这些前端框架的角色也会改变,它们会在原生Web组件的基础上提供一个简单的层。

我对于原生Web组件的未来非常乐观,而且我还会继续发表有关这方面技术的文章。如果你利用Web组件做了有意思的东西,请在下方留言告诉我们!

原文:https://www.dannymoerkerke.com/blog/web-components-will-replace-your-frontend-framework


 热 文 推 荐 

☞ 作为一个开发者,我创业了

☞ 如何优雅处理前端的异常?

☞ 使用 webpack 各种插件提升你的开发效率

☞ 3 分钟搞瘫阿里内网,他是唯一能让马云睡安稳的男人!

640?wx_fmt=png

640?wx_fmt=png

喜欢就点击“在看”吧!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值