《架构师》2019年5月
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

你的前端框架要被Web组件取代

作者 Danny Moerkerke译者 王强

还记得document.querySelector开始获得主流浏览器支持,并逐渐结束jQuery统治的历史吗?它终于让我们能够原生实现多年来使用jQuery做的事情,也就是轻松选择DOM元素。我相信类似的变革也会席卷像Angular和React这样的前端框架。

这些框架让我们得以实现过去难以达成的目标,亦即创建可复用的自治前端组件;但随之而来的代价是代码更加复杂、需要专用语法和更多的负载压力。

但这种情况即将改变。

现代Web API已发展到不再需要框架就能创建可复用前端组件的程度。只需要自定义元素和Shadow DOM就足够创建可在任何地方重复使用的自治组件了。

于2011年面世的Web Components是一套功能组件,让开发者可以使用HTML、CSS和JavaScript创建可复用的组件。这意味着你无需React或Angular等框架也能创建组件。不仅如此,这些组件还都可以无缝集成到这些框架中。

有史以来头一次,我们只要使用HTML、CSS和JavaScript就能创建可在任何现代浏览器中运行的可复用组件了。现在,桌面平台的Chrome、Safari、Firefox和Opera, iOS上的Safari和Android上的Chrome最新版本都支持Web Components。

Edge浏览器将在即将发布的19版中提供支持。还有一个polyfill用来兼容老旧的浏览器,可以让Web Components与IE11兼容。

这意味着你现在可以在任何浏览器,包括移动设备中使用Web Components。

你可以创建自定义的HTML标签,这些标签继承了它们扩展的HTML元素的所有属性,只需导入脚本即可在任何支持的浏览器中使用。组件内定义的所有HTML、CSS和JavaScript都完全限定在组件内部。

该组件将在浏览器的开发工具中显示为单个HTML标签,其样式和行为完全封装妥当,无需额外的处理、框架或转换。

我们来看看Web Components的主要功能。

自定义元素

自定义元素其实就是用户定义的HTML元素。它们是使用CustomElementRegistry定义的。要注册一个新元素时,需要通过window. customElements获取注册表实例并调用其define方法:

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

define方法的第一个参数是我们新创建元素的标签名称。我们加上下面一行就能使用它了:

        <my-element></my-element>

名称中的短划线(-)是必需的,以避免与任何原生HTML元素发生命名冲突。

不幸的是MyElement构造函数必须是一个ES6类,考虑到Javascript类(还)和传统的OOP类不太一样,这就容易让人头晕了。此外,如果允许使用对象,则还可以使用代理,从而为自定义元素启用简单数据绑定。但是,需要此限制才能启用原生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方法做对比。

通常来说,设置组件应尽可能地延迟到connectedCallback,因为只有这里你才能确保元素的所有属性和子元素都可用。一般而言,构造函数只能用来初始化状态和设置Shadow DOM。

元素的构造函数constructor和connectedCallback之间的区别在于,在创建元素时调用构造函数(例如,通过调用document.createElement),并在元素实际插入DOM时调用connectedCallback,例如当文档声明它已被解析或已与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中删除元素时调用后者。该方法可以用来执行任何必要的清理工作,但请记住,当用户关闭浏览器或浏览器选项卡时不会调用此方法。

当通过调用document.adoptNode(element)来将元素引入文档时还会调用adoptCallback。到目前为止,我从未遇到过这个回调的用例。

还有一个很有用的生命周期方法是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
            }
          }
        }

此回调仅对observeAttributes数组中存在的属性调用,在本例中为foo和bar。这个回调不会对其它变动过的属性调用。

属性主要用于声明元素的初始配置/状态。理论上讲,可以通过序列化将复杂值传递给属性,但这可能会降低性能表现;因为你可以访问组件的方法,所以不需要这样做。如果你想通过React和Angular等框架提供的属性进行数据绑定,你可以查看Polymer

生命周期方法的执行顺序

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

        constructor -> attributeChangedCallback -> connectedCallback

为什么在connectedCallback之前执行attributeChangedCallback?

回想一下,Web Components上属性的主要用途是初始配置。这意味着当组件插入DOM时,此配置需要处于可用状态,因此需要在connectedCallback之前调用attributeChangedCallback。

这意味着如果你需要根据某些属性的值配置Shadow DOM中的任何节点时,需要引用位于构造函数constructor中的节点,而不是在connectedCallback中引用它们。

例如,如果组件中有一个id=“container”的元素,并且每当观察到的属性禁用更改时你都需要将此元素设置为灰色背景,请在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时它就不可用了。因此,尽管你应该尽可能地将组件的设置延迟到connectedCallback,但在这里这是做不到的。

你也要明白你可以在使用customElements.define()注册之前就可以使用Web组件。当元素存在于DOM中或插入其中并且尚未被注册时,它将是一个HTMLUnknownElement的实例。浏览器会用这种方式处理陌生的HTML元素,你可以照常与它交互,但它不会有任何方法或默认的样式。

当它通过customElements.define()注册时,会通过类定义得到增强。此过程被称为升级。使用customElements.whenDefined升级元素时可以调用回调,前者在元素升级时会解析返回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中呈现属性值,诸如此类。由于除了字符串之外不能为属性赋予任何其他值,因此像对象这样的复杂值应作为属性传递给自定义元素。

除了声明一个Web组件的初始状态之外,attribute属性还能用来映射相关property属性的值,以便将元素的JavaScript状态映射到其DOM表达中。一个例子是input元素的disabled属性:

        <input name=”name”>
        const input = document.querySelector('input');
        input.disabled = true;

将输入的属性disabled property设置为true后,此更改将映射到相关的disabled attribute属性上:

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

如果需要在属性更改时执行某些操作,请将其添加到observedAttributes数组中。为提升性能,这里只会观察此处列出的属性以进行更改。一旦属性的值发生变动,就将使用属性的名称、其当前值及其新值调用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属性发生更改,就会在this.container上切换“disabled”类,这是元素Shadow DOM中的div元素。

下面我们进一步来看。

Shadow DOM

使用Shadow DOM时,自定义元素的HTML和CSS会完全封装在组件内部。这意味着该元素将在文档的DOM树中显示为单个HTML标签,其内部HTML结构则放在一个#shadow-root中。

其实Shadow DOM也用在几个原生HTML元素上。例如当你的网页中有<video>元素时,它会显示为单个标签;但它也会显示视频的播放控件,这个控件是不会显示在浏览器开发工具中的<video>元素上的。

这些控件实际上是<video>元素的Shadow DOM的一部分,因此默认情况下是隐藏的。要在Chrome中显示Shadow DOM,请转到开发工具设置中的“首选项”,然后选中“显示用户代理Shadow DOM”复选框。当你在开发工具中再次检查视频元素时就能看到并检查元素的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’的影子根,这意味着它可以在开发工具中检查,并通过查询、配置任何公开的CSS属性或监听它抛出的事件来交互。也可以用mode:'closed’定义影子根,但这里不推荐这样做,因为它不允许组件的使用者以任何方式与它交互;你甚至无法监听到它抛出的事件。

要将HTM添加到影子根,你可以为其innerHTML属性分配HTML字符串或使用<template>元素。HTML模板基本上是一个惰性HTML片段,你可以定义它以便以后使用。在实际插入DOM树之前,它将不会被显示或解析,这意味着在其中定义的任何外部资源都不会被提取,并且在将其插入DOM之前不会解析任何CSS和JavaScript。当组件的HTML根据其状态更改时,你可以定义多个<template>元素,从而根据组件的状态插入这些元素,诸如此类。这样你就可以轻松更改组件的大部分HTML内容,而无需摆弄单个DOM节点。

创建影子根后,你可以对它使用以往在document对象上使用的所有DOM方法,例如使用this.shadowRoot.querySelector来查找元素。组件的所有CSS都在<style>标签内定义,但如果你想使用常规的<link rel=“stylesheet”>标签,也可以获取外部样式表。除常规CSS外,你还可以使用:host选择器来设置组件本身的样式。例如,自定义元素默认使用display:inline,以便将组件显示为可以使用的块元素:

        :host {
          display: block;
        }

这样你也能使用上下文样式了。例如,如果要在组件具有disabled属性定义时将其显示为灰色,可以使用:

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

默认情况下,自定义元素会从周围的CSS继承一些属性,例如color和font等。但是如果你希望以纯净状态开始并将所有CSS属性重置为组件内的默认值,请使用:

        :host {
          all: initial; }

要注意的是,从外部对组件本身定义的样式优先于Shadow DOM中使用:host定义的样式。所以如果你要定义:

        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变量的唯一要求是它们要以“--”开头。

通过提供作用域CSS和HTML, Shadow DOM解决了CSS的全局特性所带来的特殊性问题,并且通常会产生巨大的仅添加样式表,其包含越来越多的特定选择器和覆盖。Shadow DOM可以将标签和样式捆绑到独立的组件中,而无需任何工具或命名约定。你永远不必再担心新的类或ID是否会与现有的类冲突。

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

通过Slot组合

组合(Composition)是将Shadow DOM树与用户提供的标记组合在一起的过程。这是通过<slot>元素完成的,该元素本质上是Shadow DOM中的占位符,其中呈现用户提供的标记。用户提供的标记称为Light DOM。组合会将Light DOM和Shadow DOM组成一个新的DOM树。

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

        <image-gallery>
          <img src=”foo.jpg” slot=”image”>
          <img src=”b.arjpg” slot=”image”>
        </image-gallery>

该组件现在将使用给定的两张图像并使用Slot在组件的Shadow DOM内呈现它们。注意图像上的slot =“image”属性。它告诉组件应该在其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属性的值相对应。

简单的<select>元素的工作方式与你在Chrome开发工具中检查时的效果完全相同(当你选择了显示用户代理Shadow DOM时,参见上文):

它采用用户提供的<option>元素并将它们呈现到下拉菜单中。

具有name属性的Slot元素称为named 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>

当再次给定同样的两张图像时,生成的DOM树将如下所示:

        <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中呈现之前就应用于这些节点的所有样式也将在分发后得到应用。在Shadow DOM中,分布式节点可以通过:: slotted()选择器获得额外的样式:

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

:: slotted()可以使用任何有效的CSS选择器,但它只能选择顶级节点。例如:: slotted(section img)就不适用于此内容:

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

使用JavaScript中的slot

你可以通过检查已分配给某个slot的节点、已分配给某个元素的slot以及slotchange事件来通过JavaScript与slot交互。

要找出哪些元素已分配给某个slot,可以调用slot.assignedNodes()。如果你还想检索任何后备内容,可以调用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内的节点而不是自定义元素本身抛出一个事件时,除非它使用composition:true创建,否则它不会从Shadow DOM中弹出。

        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将HTML添加到元素的影子根之外,你还可以使用<template>元素来执行此操作。模板会包含HTML供以后使用。它不会被呈现,最初只会被解析以确保其内容是有效的。模板内的JavaScript不会被执行,也不会获取任何外部资源。默认情况下它是隐藏的。

当Web组件需要根据不同情况呈现完全不同的标记时,可以使用不同的模板来完成此任务:

        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将两个模板放置在元素的影子根中。一开始两个模板都会被隐藏,只渲染容器。在connectedCallback中,我们使用this. shadowRoot.querySelector('view1')获取#view1中的内容。模板的content属性将模板的内容作为DocumentFragment返回,可以使用appendChild将其添加到另一个元素。由于appendChild将移动DOM中已经存在的元素,我们需要首先使用cloneNode(true)克隆它。否则,模板的内容将被移动而不是附加,这意味着我们只能使用它一次。

模板可以方便地用来快速更改大部分HTML或复用标记。它们不仅限于Web Components,还可以在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组件现在扩展了HTMLButtonElement,而不是更通用的HTMLElement。对customElements.define的调用现在还需要一个额外的参数{extends:'button'}来表示我们的类扩展了<button>元素。这似乎是多余的,因为我们已经指出我们想要扩展HTMLButtonElement,但是由于存在共享相同DOM接口的元素,所以这是必要的。例如,<q>和<blockquote>都共享HTMLQuoteElement接口。

增强的按钮现在可以与is属性一起使用:

        <button is=”my-button”>

它现在将通过我们的MyElement类增强,如果它在不支持自定义元素的浏览器中加载,它将简单地回退到标准按钮,这就是所谓渐进式的增强!

注意,在扩展现有元素时不能使用Shadow DOM。这只是通过继承所有现有属性、方法和事件并提供其他功能来扩展原生HTML元素的一种方法。当然可以从组件中修改元素的DOM和CSS,但是尝试创建影子根时将引发错误。

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

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

这种创建Web组件的方式是一种很好的渐进式增强,但如上所述,目前只有Chrome和Firefox支持它。Edge也将提供支持,但至少目前没有。

测试Web Components

与为Angular和React等框架编写测试相比,测试Web Components更加简单明了,坦率地说是轻而易举的。你不需要转换或复杂的设置,只需创建元素,将其附加到DOM并运行测试即可。

以下是使用Mocha测试的示例:

        import 'path/to/my-element.js';
        describe('my-element', () => {
          let element;
          beforeEach(() => {
            element = document.createElement('my-element');
            document.body.appendChild(element);
          });
          afterEach(() => {
            document.body.removeChild(element);
          });
          it('should test my-element', () => {
            // run your test here
          });
        });

这里第一行导入my-element.js文件,该文件将我们的Web Components暴露为ES6模块。这意味着测试文件本身也需要作为ES6模块加载到浏览器中。这需要下面的index.html才能在浏览器中运行测试。除了Mocha之外,这个设置还加载了WebcomponentsJS polyfill, Chai用于测试,Sinon用于spy和mock:

        <! doctype html>
        <html>
            <head>
                <meta charset=”utf-8”>
                <link rel=”stylesheet” href=”../node_modules/mocha/mocha.css”>
                  <script src=”../node_modules/@webcomponents/webcomponentsjs/
    webcomponents-loader.js”></script>
                <script src=”../node_modules/sinon/pkg/sinon.js”></script>
                <script src=”../node_modules/chai/chai.js”></script>
                <script src=”../node_modules/mocha/mocha.js”></script>
                <script>
                    window.assert = chai.assert;
                    mocha.setup('bdd');
                </script>
                    <script type=”module” src=”path/to/my-element.test.js”></
    script>
                <script type=”module”>
                    mocha.run();
                </script>
            </head>
            <body>
                <div id=”mocha”></div>
            </body>
        </html>

在加载了所需的脚本之后,我们将chai.assert作为全局变量暴露,因此我们可以在测试中使用assert()并设置Mocha来使用BDD接口。然后加载测试文件(在这个示例中只有一个文件),然后我们调用mocha.run()来运行测试。

注意,使用ES6模块时还需要将mocha.run()放在带有type=“module”的脚本中。这是因为ES6模块默认是延迟的,如果mocha.run()放在常规脚本标签内,它将在加载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 Components中具有相同的类名和ID并将它们加载到同一文档中就将发生冲突。此外,Shadow DOM CSS选择器:host()和:slotted()可能无法正常工作。

为了使其正常工作,你需要使用Shady CSS polyfill,这也意味着你必须(稍微)调整你的源代码才能使用它。我个人不喜欢这样,所以我创建了一个webpack加载器处理这个问题。你需要用它来做转换,但这样就不用改代码了。

Webpack加载器做了三件事:它为你的web组件的Shadow DOM中所有不以:: host或:: slotted开头的CSS规则添加元素标签名称前缀,从而提供正确的范围;之后它会解析所有:: host和:: slotted规则,以确保它们也能正常工作。

示例1:lazy-img

我创建了一个Web组件,一旦它在浏览器的可视端口中完全可见,就会平缓地加载一张图像。你可以在Github上找到它

该组件的主要版本将本机<img>标签包装在<lazy-img>自定义元素中:

        <lazy-img
          src=”path/to/image.jpg”
          width=”480”
          height=”320”
          delay=”500”
          margin=”0px”></lazy-img>

repo还包含extend-native分支,其中包含使用is属性扩展原生<img>标签的lazy-img:

        <img
          is=”lazy-img”
          src=”path/to/img.jpg”
          width=”480”
          height=”320”
          delay=”500”
          margin=”0px”>

这是关于原生Web Components功能的一个很好的例子:只需导入JavaScript文件,添加HTML标签或使用is属性扩展本地标签就可以干活了!

示例2:material-webcomponents

我使用自定义元素实现了Google的Material Design,也放到了Github上

h这个库还展示了CSS自定义属性的强大功能

我应该抛弃我的框架吗

一如既往,这取决于你的具体情况。

当下的前端框架有着数据绑定、状态管理和相当标准化的代码库等功能提供的附加价值。问题是你的应用是否真的需要它们。

如果你需要问自己,你的应用程序是否真的需要像Redux这样的状态管理,你可能其实并不需要它。当你真的用到它的时候再考虑也不迟。

你可能会觉得数据绑定很好用,但对于非原始值(如数组和对象)来说,原生Web Components已允许你直接设置属性。可以在属性上设置原始值,并且可以通过attributeChangedCallback观察对这些属性的更改。

虽然这种方式很有用,但与在React和Angular中执行此操作的声明方式相比,它只是更新DOM的一小部分就很麻烦了。这些框架允许定义包含在更改时更新的表达式的视图。

虽然有一个提议要扩展<template>元素以允许它实例化并使用数据更新,但原生Web Components仍未提供此类功能:

        <template id=”example”>
          <h1>{{title}}</h1>
          <p>{{text}}</p>
        </template>
        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

前端框架的另一个经常被提到的好处是,它们提供了一个标准的代码库,团队中的每位新人从一开始就很熟悉它。虽然我认可这一点,但我也觉得这种好处非常有限。

我使用Angular、React和Polymer开发了各种项目,尽管它们的确存在相似性,但就算使用相同的框架,这些代码库仍然存在很大差异。明确定义的工作方式和样式指南更有助于你维持代码库的一致性,仅仅使用框架是不够的。框架也带来了额外的复杂性,应该问问自己这是否真的值得。

现在Web Components得到了广泛支持,你可能会得出这样的结论:原生代码可以为你带来与框架相同的功能,但性能更强、需要的代码更少,更加简洁。

原生Web Components的好处很明显:

● 原生,无需框架;

● 易于集成,无需转换;

● 真正的作用域CSS;

● 标准化,只有HTML、CSS和JavaScript。

jQuery及其出色的遗产仍将存在一段时间,但现在有了更好的选择,所以新建设的项目很少会去用它了。我不认为现有的框架会很快消失,但是原生Web Components提供了更好的选项,并且正在快速扩张。我也希望这些前端框架去扮演新的角色,只要在原生Web Components周围充当一个简单的附加层就可以了。