深入了解Web组件的方方面面

我们都有自己不想从事的项目。代码变得难以管理,范围不断发展,快速修复应用在其他修复之上,并且结构在意大利面条式代码的重压下崩溃了。编码可能是一件很麻烦的事情。

项目受益于使用具有单一职责的简单、独立的模块。模块化代码被封装,因此无需担心实现。只要您知道在给定一组输入时模块将输出什么,您就不一定需要了解它是如何实现该目标的。

将模块化概念应用于单一编程语言很简单,但Web开发需要多种技术组合。浏览器解析HTML 、 CSS和JavaScript以呈现页面的内容、样式和功能。

它们并不总是很容易混合,因为:

  • 相关代码可以拆分为三个或更多文件,并且
  • 全局样式和JavaScript对象可能会以意想不到的方式相互干扰。

除了这些问题之外,语言运行时、框架、数据库和服务器上使用的其他依赖项也会遇到这些问题。

  1. 什么是Web组件?
  2. Web组件简史
  3. Web组件入门
  4. Web组件如何与其他元素交互
  5. 关于Web组件的批评和问题

什么是Web组件

Web组件是一种创建可在任何页面上重用的封装的、单一职责的代码块的方法。

考虑HTML<video>标签。给定URL ,查看者可以使用诸如播放、暂停、后退、前进和调整音量等控件。

虽然您可以使用各种属性和JavaScript API调用进行修改,但仍提供了样式和功能。任意数量<video>元素可以放在其他标签内,它们不会发生冲突。

如果您需要自己的自定义功能怎么办?例如,显示页面上字数的元素?还没有HTML<wordcount>标签。

React和Vue.js等框架允许开发人员创建Web组件,其中的内容、样式和功能可以在单个JavaScript文件中定义。这些解决了许多复杂的编程问题,但请记住:

  • 您必须学习如何使用该框架并随着它的发展更新您的代码。
  • 为一个框架编写的组件很少与另一个框架兼容。
  • 框架的流行度起起落落。您将变得依赖于开发团队和用户的奇思妙想和优先事项。
  • 标准Web组件可以添加浏览器功能,这在单独的JavaScript中是很难实现的(例如Shadow DOM)。

幸运的是,库和框架中引入的流行概念通常会进入Web标准。这花了一些时间,但Web组件已经到来。

Web组件简史

在许多特定于供应商的错误开始之后, Alex Russell在2011年的Fronteers Conference上首次引入了标准Web组件的概念。谷歌的Polymer库(一个基于当前提案的polyfill)在两年后问世,但早期的实现直到2016年才出现在Chrome和Safari中。

浏览器供应商花时间协商细节,但Web组件于2018年和20210年分别被添加到Firefox和Edge(当微软切换到Chromium引擎时)。

可以理解的是,很少有开发人员愿意或能够采用Web组件,但我们最终通过稳定的API达到了良好的浏览器支持水平。并非一切都是完美的,但它们是基于框架的组件的日益可行的替代方案。

即使您现在还不愿意放弃您最喜欢的,Web组件与每个框架都兼容,并且API将在未来几年内得到支持。

每个人都可以查看预先构建的Web组件库:

……但是编写自己的代码更有趣!

本教程完整介绍了在没有JavaScript框架的情况下编写的Web组件。您将了解它们是什么以及如何使它们适应您的Web项目。您将需要一些HTML5 、CSS和JavaScript 的知识。

Web组件入门

Web组件是自定义HTML元素,例如<hello-world></hello-world> 。该名称必须包含一个破折号,以免与HTML规范中正式支持的元素发生冲突。

您必须定义一个ES2015类来控制元素。它可以命名为任何名称,但HelloWorld是常见的做法。它必须扩展HTMLElement接口,它表示每个HTML元素的默认属性和方法。

注意: Firefox允许您扩展特定的HTML元素,例如HTMLParagraphElement、HTMLImageElement或HTMLButtonElement。这在其他浏览器中不受支持,并且不允许您创建Shadow DOM。

为了做任何有用的事情,该类需要一个名为connectedCallback()的方法,该方法在元素添加到文档时调用:

class HelloWorld extends HTMLElement {

  // connect component
  connectedCallback() {
    this.textContent = 'Hello World!';
  }

}

在此示例中,元素的文本设置为“Hello World”。必须使用CustomElementRegistry注册该类才能将其定义为特定元素的处理程序:

customElements.define( 'hello-world', HelloWorld );

现在,当您的JavaScript加载时,浏览器会将<hello-world>元素与您的HelloWorld <script type="module" src="./helloworld.js"></script> )。

您现在有一个自定义元素!

<iframe style="width: 100%;" title=" component" src="https://codepen.io/craigbuckler/embed/WNpaxPN?default-tab=html%2Cresult" frameborder="no" scrolling="no" allowfullscreen><br /> See the Pen <a href="https://www.wbolt.com/go?_=10bd3f320faHR0cHM6Ly9jb2RlcGVuLmlvL2NyYWlnYnVja2xlci9wZW4vV05wYXhQTg%3D%3D" rel="noopener noreferrer nofollow" ><br /> &lt;hello-world&gt; component</a> by Craig Buckler (<a href="https://www.wbolt.com/go?_=4f9db36814aHR0cHM6Ly9jb2RlcGVuLmlvL2NyYWlnYnVja2xlcg%3D%3D" rel="noopener noreferrer nofollow" >@craigbuckler</a>)<br /> on <a href="https://www.wbolt.com/go?_=09e7f4459caHR0cHM6Ly9jb2RlcGVuLmlv" rel="noopener noreferrer nofollow" >CodePen</a>.<br />

CodePen演示

这个组件可以像任何其他元素一样在 CSS 中设置样式:

hello-world {
  font-weight: bold;
  color: red;
}

添加属性

该组件无益,因为无论如何都会输出相同的文本。像任何其他元素一样,我们可以添加HTML属性:

<hello-world name="Craig"></hello-world>

这可能会覆盖文本,因此“Hello Craig!” 被陈列。为此,您可以向HelloWorld类添加一个constructor()函数,该函数在创建每个对象时运行。它必须:

  1. 调用super()方法来初始化父 HTMLElement,以及
  2. 进行其他初始化。在这种情况下,我们将定义一个name属性,该属性设置为默认值“World”:
    class HelloWorld extends HTMLElement {
    
      constructor() {
        super();
        this.name = 'World';
      }
    
      // more code...

     

您的组件只关心name属性。静态observedAttributes()属性应返回一组要观察的属性:

// component attributes
static get observedAttributes() {
  return ['name'];
}

当在HTML中定义属性或使用JavaScript更改属性时,将调用attributeChangedCallback()方法它传递了属性名称、旧值和新值:

// attribute change
attributeChangedCallback(property, oldValue, newValue) {

  if (oldValue === newValue) return;
  this[ property ] = newValue;

}

在此示例中,只会更新name属性,但您可以根据需要添加其他属性。最后,您需要调整connectedCallback()方法中的消息:

// connect component
connectedCallback() {

  this.textContent = `Hello ${ this.name }!`;

}

<iframe style="width: 100%;" title=" component with attributes" src="https://codepen.io/craigbuckler/embed/BaWqLOK?default-tab=html%2Cresult" frameborder="no" scrolling="no" allowfullscreen><br /> See the Pen <a href="https://www.wbolt.com/go?_=f111de1d1baHR0cHM6Ly9jb2RlcGVuLmlvL2NyYWlnYnVja2xlci9wZW4vQmFXcUxPSw%3D%3D" rel="noopener noreferrer nofollow" ><br /> &lt;hello-world&gt; component with attributes</a> by Craig Buckler (<a href="https://www.wbolt.com/go?_=4f9db36814aHR0cHM6Ly9jb2RlcGVuLmlvL2NyYWlnYnVja2xlcg%3D%3D" rel="noopener noreferrer nofollow" >@craigbuckler</a>)<br /> on <a href="https://www.wbolt.com/go?_=09e7f4459caHR0cHM6Ly9jb2RlcGVuLmlv" rel="noopener noreferrer nofollow" >CodePen</a>.<br />
CodePen 演示

生命周期方法

在Web组件状态的整个生命周期中,浏览器会自动调用六个方法。此处提供了完整列表,尽管您已经在上面的示例中看到了前四个:

constructor()

它在组件第一次初始化时被调用。它必须调用super()并且可以设置任何默认值或执行其他预渲染过程。

静态observedAttributes()

返回浏览器将观察到的一组属性。

attributeChangedCallback(propertyName, oldValue, newValue)

每当观察到的属性更改时调用。那些在HTML中定义的会被立即传递,但JavaScript可以修改它们:

document.querySelector('hello-world').setAttribute('name', 'Everyone');

发生这种情况时,该方法可能需要触发重新渲染。

connectedCallback()

当Web组件附加到文档对象模型时,将调用此函数。它应该运行任何需要的渲染。

disconnectedCallback()

当从文档对象模型中删除Web组件时调用它。如果您需要清理,例如删除存储的状态或中止Ajax请求,这可能很有用。

adoptedCallback()

当Web组件从一个文档移动到另一个文档时调用此函数。

Web组件如何与其他元素交互

Web组件提供了一些您在JavaScript框架中找不到的独特功能。

Shadow DOM

虽然我们在上面构建的Web组件可以工作,但它不能免受外部干扰,CSS或JavaScript可以对其进行修改。同样,您为组件定义的样式可能会泄漏并影响其他组件。

Shadow DOM通过将一个单独的DOM附加到Web组件来解决这个封装问题:

const shadow = this.attachShadow({ mode: 'closed' });

模式可以是:

  1. “open” ——外层页面的JavaScript可以访问Shadow DOM(使用Element.shadowRoot ),或者
  2. “closed” ——Shadow DOM只能在Web组件中访问。

Shadow DOM可以像任何其他DOM元素一样进行操作:

connectedCallback() {

  const shadow = this.attachShadow({ mode: 'closed' });

  shadow.innerHTML = `
    <style>
      p {
        text-align: center;
        font-weight: normal;
        padding: 1em;
        margin: 0 0 2em 0;
        background-color: #eee;
        border: 1px solid #666;
      }
    </style>

    <p>Hello ${ this.name }!</p>`;

}

<p>元素中呈现“Hello”文本并为其设置样式。它不能被组件外的JavaScript或CSS修改,尽管字体和颜色等一些样式是从页面继承的,因为它们没有明确定义。

<iframe style="width: 100%;" title=" component using a Shadow DOM" src="https://codepen.io/craigbuckler/embed/rNyqyJJ?default-tab=html%2Cresult" frameborder="no" scrolling="no" allowfullscreen><br /> See the Pen <a href="https://www.wbolt.com/go?_=de3e7497cfaHR0cHM6Ly9jb2RlcGVuLmlvL2NyYWlnYnVja2xlci9wZW4vck55cXlKSg%3D%3D" rel="noopener noreferrer nofollow" ><br /> &lt;hello-world&gt; component using a Shadow DOM</a> by Craig Buckler (<a href="https://www.wbolt.com/go?_=4f9db36814aHR0cHM6Ly9jb2RlcGVuLmlvL2NyYWlnYnVja2xlcg%3D%3D" rel="noopener noreferrer nofollow" >@craigbuckler</a>)<br /> on <a href="https://www.wbolt.com/go?_=09e7f4459caHR0cHM6Ly9jb2RlcGVuLmlv" rel="noopener noreferrer nofollow" >CodePen</a>.<br />
CodePen 演示

限定在此Web组件范围内的样式不能影响页面上的其他段落,甚至其他<hello-world>组件。

请注意,CSS :host选择器可以从Web组件中设置外部<hello-world>元素的样式:

:host {
  transform: rotate(180deg);
}

您还可以设置元素使用特定类时应用的样式,例如<hello-world class="rotate90">:

:host(.rotate90) {
  transform: rotate(90deg);
}

HTML模板

对于更复杂的Web组件,在脚本中定义HTML可能变得不切实际。模板允许您在页面中定义Web组件可以使用的HTML块。这有几个好处:

  1. 您可以调整HTML代码,而无需在JavaScript中重写字符串。
  2. 无需为每种类型创建单独的JavaScript类,即可自定义组件。
  3. 在HTML中定义HTML更容易——并且可以在组件呈现之前在服务器或客户端上对其进行修改。

模板是在<template>标记中定义的,分配一个ID很实用,这样您就可以在组件类中引用它。此示例使用三个段落显示“Hello”消息:

<template id="hello-world">

  <style>
    p {
      text-align: center;
      font-weight: normal;
      padding: 0.5em;
      margin: 1px 0;
      background-color: #eee;
      border: 1px solid #666;
    }
  </style>

  <p class="hw-text"></p>
  <p class="hw-text"></p>
  <p class="hw-text"></p>

</template>

Web组件类可以访问此模板、获取其内容并克隆元素以确保您在任何使用它的地方创建唯一的DOM片段:

const template = document.getElementById('hello-world').content.cloneNode(true);

DOM可以直接修改并添加到Shadow DOM中:

connectedCallback() {

  const

    shadow = this.attachShadow({ mode: 'closed' }),
    template = document.getElementById('hello-world').content.cloneNode(true),
    hwMsg = `Hello ${ this.name }`;

  Array.from( template.querySelectorAll('.hw-text') )
    .forEach( n => n.textContent = hwMsg );

  shadow.append( template );

}

<iframe style="width: 100%;" title=" component using a template" src="https://codepen.io/craigbuckler/embed/QWpZvdQ?default-tab=html%2Cresult" frameborder="no" scrolling="no" allowfullscreen><br /> See the Pen <a href="https://www.wbolt.com/go?_=ee5dea07ecaHR0cHM6Ly9jb2RlcGVuLmlvL2NyYWlnYnVja2xlci9wZW4vUVdwWnZkUQ%3D%3D" rel="noopener noreferrer nofollow" ><br /> &lt;hello-world&gt; component using a template</a> by Craig Buckler (<a href="https://www.wbolt.com/go?_=4f9db36814aHR0cHM6Ly9jb2RlcGVuLmlvL2NyYWlnYnVja2xlcg%3D%3D" rel="noopener noreferrer nofollow" >@craigbuckler</a>)<br /> on <a href="https://www.wbolt.com/go?_=09e7f4459caHR0cHM6Ly9jb2RlcGVuLmlv" rel="noopener noreferrer nofollow" >CodePen</a>.<br />
CodePen 演示

模板插槽Template Slots

插槽允许您自定义模板。假设您想使用Web组件<hello-world> ,但将消息放在Shadow DOM中的<h1>标题中你可以写这样的代码:

<hello-world name="Craig">

  <h1 slot="msgtext">Hello Default!</h1>

</hello-world>

(注意slot属性。)

您可以选择添加其他元素,例如另一个段落:

<hello-world name="Craig">

  <h1 slot="msgtext">Hello Default!</h1>
  <p>This text will become part of the component.</p>

</hello-world>

现在可以在您的模板中实现插槽:

<template id="hello-world">

  <slot name="msgtext" class="hw-text"></slot>

  <slot></slot>

</template>

将在名为“msgtext”的 <slot> 位置插入设置为“msgtext”(即<h1>)的元素slot属性。 <p>没有指定插槽名称,但在下一个可用的未命名<slot>中使用。实际上,模板变为:

<template id="hello-world">

  <slot name="msgtext" class="hw-text">
    <h1 slot="msgtext">Hello Default!</h1>
  </slot>

  <slot>
    <p>This text will become part of the component.</p>
  </slot>

</template>

事实并非如此简单。Shadow DOM中的<slot>元素指向插入的元素。只能通过定位<slot>然后使用.assignedNodes() 方法返回内部子级数组来访问它们。更新的connectedCallback()方法:

connectedCallback() {

  const
    shadow = this.attachShadow({ mode: 'closed' }),
    hwMsg = `Hello ${ this.name }`;

  // append shadow DOM
  shadow.append(
    document.getElementById('hello-world').content.cloneNode(true)
  );

  // find all slots with a hw-text class
  Array.from( shadow.querySelectorAll('slot.hw-text') )

    // update first assignedNode in slot
    .forEach( n => n.assignedNodes()[0].textContent = hwMsg );

}

<iframe style="width: 100%;" title=" component using slots" src="https://codepen.io/craigbuckler/embed/gOmBBvm?default-tab=html%2Cresult" frameborder="no" scrolling="no" allowfullscreen><br /> See the Pen <a href="https://www.wbolt.com/go?_=6ba84c82e2aHR0cHM6Ly9jb2RlcGVuLmlvL2NyYWlnYnVja2xlci9wZW4vZ09tQkJ2bQ%3D%3D" rel="noopener noreferrer nofollow" ><br /> &lt;hello-world&gt; component using slots</a> by Craig Buckler (<a href="https://www.wbolt.com/go?_=4f9db36814aHR0cHM6Ly9jb2RlcGVuLmlvL2NyYWlnYnVja2xlcg%3D%3D" rel="noopener noreferrer nofollow" >@craigbuckler</a>)<br /> on <a href="https://www.wbolt.com/go?_=09e7f4459caHR0cHM6Ly9jb2RlcGVuLmlv" rel="noopener noreferrer nofollow" >CodePen</a>.<br />
CodePen 演示

此外,您不能直接设置插入元素的样式,尽管您可以定位Web组件中的特定插槽:

<template id="hello-world">

  <style>
    slot[name="msgtext"] { color: green; }
  </style>

  <slot name="msgtext" class="hw-text"></slot>
  <slot></slot>

</template>

模板插槽有点不寻常,但一个好处是,如果JavaScript无法运行,您的内容将被显示。此代码显示了仅在Web组件类成功执行时才替换的默认标题和段落:

<hello-world name="Craig">

  <h1 slot="msgtext">Hello Default!</h1>
  <p>This text will become part of the component.</p>

</hello-world>

因此,您可以实现某种形式的渐进增强——即使它只是一条“You need JavaScript”的消息!

声明式Shadow DOM

上面的例子使用JavaScript构建了一个Shadow DOM。这仍然是唯一的选择,但正在为Chrome开发一个实验性的声明性Shadow DOM。这允许服务器端渲染并避免任何布局变化或无样式内容的闪烁。

HTML解析器检测到以下代码,它创建一个与您在上一节中创建的Shadow DOM相同的Shadow DOM(您需要根据需要更新消息):

<hello-world name="Craig">

  <template shadowroot="closed">
    <slot name="msgtext" class="hw-text"></slot>
    <slot></slot>
  </template>

  <h1 slot="msgtext">Hello Default!</h1>
  <p>This text will become part of the component.</p>

</hello-world>

该功能在任何浏览器中均不可用,并且不能保证它会到达Firefox或Safari。您可以找到有关声明式Shadow DOM的更多信息,polyfill很简单,但请注意实现可能会发生变化。

Shadow DOM事件

您的Web组件可以像在页面DOM中一样将事件附加到Shadow DOM中的任何元素,例如侦听所有内部子级上的单击事件:

shadow.addEventListener('click', e => {

  // do something

});

除非您stopPropagation ,否则该事件将冒泡到页面DOM中,但该事件将被重定向。因此,它似乎来自您的自定义元素,而不是其中的元素。

在其他框架中使用Web组件

您创建的任何Web组件都可以在所有JavaScript框架中工作。他们都不知道或关心HTML元素——您的<hello-world>组件将被视为与<div>相同,并被放置到DOM中,类将在其中激活。

custom-elements-everywhere.com提供了框架和Web组件注释的列表。尽管在React.js有一些问题,但大多数都是完全兼容的。可以在JSX中使用<hello-world>

import React from 'react';
import ReactDOM from 'react-dom';
import from './hello-world.js';

function MyPage() {

  return (
    <>
      <hello-world name="Craig"></hello-world> 
    </>
  );

}

ReactDOM.render(<MyPage />, document.getElementById('root'));

…但:

  • React只能将原始数据类型传递给HTML属性(而不是数组或对象)
  • React无法侦听Web组件事件,因此您必须手动附加自己的处理程序。

关于Web组件批评和问题

Web组件有了显着改进,但有些方面可能难以管理。

样式和难点

样式化Web组件会带来一些挑战,尤其是当您想覆盖范围样式时。有很多解决方案:

  1. 避免使用Shadow DOM。您可以将内容直接附加到您的自定义元素,尽管任何其他JavaScript都可能意外或恶意更改它。
  2. 使用:host类。正如我们在上面看到的,当类应用于自定义元素时,作用域CSS可以应用特定的样式。
  3. 查看CSS自定义属性(变量)。自定义属性级联到Web组件中,因此,如果您的元素使用var(--my-color) ,您可以在外部容器(例如:root --my-color ,它将被使用。自定义属性级联到Web组件中,因此,如果您的元素使用var(--my-color),您可以在外部容器(例如::root)中设置--my-color,然后使用它。
  4. 利用阴影部件。新的::part() 选择器选择器可以设置具有part属性的内部组件的样式,即<hello-world>组件内部的<h1 part="heading">可以使用选择器hello-world::part(heading) 设置样式。
  5. 传入一串样式。您可以将它们作为属性传递给<style>块。

没有一个是理想的,您需要计划其他用户如何仔细定制您的Web组件。

忽略输入

Shadow DOM中的任何<input><textarea><select>字段都不会在包含表单中自动关联。早期的Web组件采用者会将隐藏字段添加到页面DOM中,或者使用FormData接口来更新值。两者都不是特别实用的,都会破坏Web组件的封装。

新的ElementInternals接口允许Web组件连接到表单中,以便定义自定义值和有效性。它是用Chrome实现的,但是其他浏览器也可以使用polyfill

为了演示,您将创建一个基本的<input-age name="your-age"></input-age>组件。该类必须将静态formAssociated值设置为true,并且可以选择在外部窗体关联时调用formAssociatedCallback() 方法:

// <input-age> web component
class InputAge extends HTMLElement {

  static formAssociated = true;

  formAssociatedCallback(form) {
    console.log('form associated:', form.id);
  }

构造函数现在必须运行attachInternals()方法,该方法允许组件与表单和其他想要检查值或验证的JavaScript代码进行通信:

  constructor() {

    super();
    this.internals = this.attachInternals();
    this.setValue('');

  }

  // set form value

  setValue(v) {

    this.value = v;

    this.internals.setFormValue(v);

  }

ElementInternal的setFormValue()方法在此处为使用空字符串初始化的父窗体设置元素的值(也可以传递具有多个名称/值对的FormData对象)。其他属性和方法包括:

  • form:父表单
  • labels:标记组件的元素数组
  • 约束验证API选项,例如willValidate、checkValidity和validationMessage

connectedCallback()方法像以前一样创建Shadow DOM,但还必须监视字段的更改,以便可以运行setFormValue()

 connectedCallback() {

    const shadow = this.attachShadow({ mode: 'closed' });

    shadow.innerHTML = `
      <style>input { width: 4em; }</style>
      <input type="number" placeholder="age" min="18" max="120" />`;

    // monitor input values
    shadow.querySelector('input').addEventListener('input', e => {
      this.setValue(e.target.value);
    });

  }

您现在可以使用此Web组件创建一个HTML表单,其作用与其他表单字段类似:

<form id="myform">

  <input type="text" name="your-name" placeholder="name" />

  <input-age name="your-age"></input-age>

  <button>submit</button>

</form>

它有效,但不可否认,它感觉有点令人费解。在CodePen演示中查看有关更多信息,请参阅有关功能更强大的表单控件的文章

小结

在JavaScript框架的地位和能力不断提高的时候,Web组件很难获得一致和采用。如果您来自React、Vue或Angular,Web组件可能会显得复杂而笨拙,尤其是当您缺少数据绑定和状态管理等功能时。

有很多问题需要解决,但Web组件的未来是光明的。它们是框架无关的、轻量级的、快速的,并且可以实现单独使用JavaScript无法实现的功能。

十年前,很少有人会在没有jQuery的情况下解决网站问题,但浏览器供应商采用了优秀的部分,并添加了本地替代方案(如querySelector)。JavaScript框架也是如此,Web组件是第一个尝试性的步骤。