Web 组件势必取代前端框架?

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

 

Web 组件势必取代前端框架?_第1张图片

以下为译文:

还记得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方法的第一个参数是要创建的新元素的标签名称。接下来,你只需要下面的代码就可以使用该元素:

 

 

 

名称中的横线(-)是必须的,这是为了避免与原生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属性:

 

 



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

 

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

 


 

用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 = `      
            

      
         `;     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。例如,如果在网页上放置一个

 

这些元素实际上是

 

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

 

定义影子root的方式如下:

 

 

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

Hello world

`;

 

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

 

要给影子root添加HTML,可以将HTML字符串赋值给影子root的innerHTML属性,也可以使用