web components

本文详细介绍了Web Components技术,包括Custom Elements(自定义元素)、Shadow DOM和HTML模板。Custom Elements允许开发者创建带有特定行为和样式的自定义HTML标签,Shadow DOM用于封装组件样式和结构,HTML模板则提供了不立即呈现的结构。通过这三个技术,开发者可以构建可复用、可维护的网页组件。文章还讨论了自定义元素的生命周期方法、模板的使用方式,以及如何利用Shadow DOM实现样式隔离。此外,提到了一些高级工具,如lit-html和LitElement,帮助开发者更方便地创建高性能的Web Components。
摘要由CSDN通过智能技术生成

Web components 是什么?

web components 就是网页组件式开发的技术规范。

web components 由三个独立的技术组成:

  1. Custom Elements(自定义元素):是用户使用一组 JavaScript API 自己定义生成的包含行为和标记名称的自定义模板,是完全有效的 HTML 元素。
  2. shadow DOM(影子 DOM):能够隔离 CSS 和 JavaScript。
  3. HTML templates(HTML 模板):用户在 HTML 中定义的模板,在调用之前不会呈现(<template>....</template>)。

web components 在主流浏览器中的到支持,对于像 IE 这种经常不支持新内容的浏览器我们有 polyfills 。

组成 web components 的三种技术可以独立使用,也可以与其他任何一种或两种技术结合使用,他们之间并不互相排斥。

HTML template

HTML 模板不会立即呈现,但可以通过 js 调用,并允许我们重复引用其内部结构。下面是模板的简单形式:

<template id="book-template">
  <li><span class="title"></span> &mdash; <span class="author"></span></li>
</template>

<ul id="books"></ul>

示例模板在 js 调用之前不会呈现任何内容,需要我们实例化代码并告诉浏览器如何处理它。

// 选取模板的代码片段
const fragment = document.getElementById('book-template');
// 要展示的数据
const books = [
  { title: 'The Great Gatsby', author: 'F. Scott Fitzgerald' },
  { title: 'A Farewell to Arms', author: 'Ernest Hemingway' },
  { title: 'Catch 22', author: 'Joseph Heller' }
];

books.forEach(book => {
  // 复制模板中的内容,随后将其附加到相应的位置
  const instance = document.importNode(fragment.content, true);
  // 添加要展示的内容
  instance.querySelector('.title').innerHTML = book.title;
  instance.querySelector('.author').innerHTML = book.author;
  // 将要展示的结构附加到 DOM
  document.getElementById('books').appendChild(instance);
});

需要引起你注意的是 document.importNode 方法,此函数将创建模板内容的副本,并准备将其插入到另一个文档(或文档片段)中。函数的第一个参数是获取模板的内容,第二个参数是告诉浏览器对模板元素的 DOM 子树进行深层复制(即所有子节点)。

当然我们也可以不复制,直接使用 document.getElementById('books').appendChild(template.content),但是这样做会把模版的内容从模版元素中删除并附加到 body 中。如果后续再要使用模板的内容将得到空文档片段。使用 document.importNode 允许我们在多个位置重用相同模板内容。

<template></template> 具有多功能性,允许我们把 css 和 js 都放在其内部,使其具有特定的样式和行为。

下面我们创建一个将要在后文使用到的对话框模板,demo1:

<template id="one-dialog">
  <script>
    // 打开弹框的操作
    document.getElementById('launch-dialog').addEventListener('click', () => {
      const wrapper = document.querySelector('.wrapper');  // 弹框
      const closeButton = document.querySelector('button.close'); // 关闭按钮
      const wasFocused = document.activeElement; // 当前激活的元素
      wrapper.classList.add('open'); // 为弹框添加 open 属性
      closeButton.focus(); // 关闭按钮聚集
      closeButton.addEventListener('click', () => { // 关闭弹框的操作
        wrapper.classList.remove('open'); // 移除弹框上 open 属性
        wasFocused.focus(); // 激活元素聚焦 id=“launch-dialog” 的 button
      });
    });
  </script>
  <style>
    .wrapper { opacity: 0; transition: visibility 0s, opacity 0.25s ease-in; }
    .wrapper:not(.open) { visibility: hidden; }
    .wrapper.open {
      align-items: center; display: flex; justify-content: center;
      height: 100vh;
      position: fixed; top: 0; left: 0; right: 0; bottom: 0;
      opacity: 1; visibility: visible;
    }
    .overlay {
      background: rgba(0, 0, 0, 0.8);
      height: 100%; width: 100%;
      position: fixed; top: 0; right: 0; bottom: 0; left: 0;
    }
    .dialog { background: #ffffff;
      max-width: 600px;
      padding: 1rem;
      position: fixed;
    }
    button {
      all: unset;
      cursor: pointer;
      font-size: 1.25rem;
      position: absolute; top: 1rem; right: 1rem;
    }
    button:focus { border: 2px solid blue; }
  </style>
  <div class="wrapper">
    <div class="overlay"></div>
    <div class="dialog" role="dialog" aria-labelledby="title" aria-describedby="content">
      <button class="close" aria-label="Close">&#x2716;&#xfe0f;</button>
      <h1 id="title">Hello world</h1>
      <div id="content" class="content">
        <p>This is content in the body of our modal</p>
      </div>
    </div>
  </div>
</template>

 

<button id="launch-dialog">Launch dialog</button>
#launch-dialog {
  background: tomato;
  border-radius: 4px;
  color: #fff;
  font-family: Helvetica, Arial, sans-serif;
  padding: 0.5rem 1rem;
  position: static;
}
const template = document.getElementById('one-dialog');

document.body.appendChild(
  document.importNode(template.content, true)
);

当我们使用模板时,你会发现模板中的样式和脚本没有只作用于我们的模板,而是应用于整个文档,当我们将多个模板实例添加到 DOM 时,会出现不太理想的效果。在下文中,我们将创建自己的元素,使用该模板并封装元素的行为。

Custom Elements

custom elements  web components 的核心。

custom elements 是 HTML 元素,就像 <div><section> 或者 <article> 一样,我们可以通过浏览器 API 定义自己的名字,但是名字中必须包含一个或多个连接符(-),以便与原生 HTML 相区分,形如 <one-dialog></one-dialog>。同时,浏览器厂商不仅已经承诺不会在名称中创建包含连接符的新内置元素以防止冲突,还致力于保持规范持续向后兼容性。

自定义元素包含自己的语义,行为,标记,可以跨框架和浏览器共享。

从本质上讲,一个自定义元素由两个部分组成:标签名称和扩展了内置的 HTMLElement 。自定义元素的最基本版本如下所示:

class OneDialog extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<h1>Hello world</h1>`;
  }
}
    
customElements.define('one-dialog', OneDialog);

上面的例子中,我们创建了自己的 HTML 元素 <one-dialog>,此时你只需将其像正常元素一样在 HTML 中使用,你会在页面中得到 Hello world 的展示。

注:自定义元素类中的 this 是对自定义元素实例的引用。

上面的示例不足以满足我们对复杂业务的需求,下面我们直接引用 demo1 中的模板来创建自定义元素:

class OneDialog extends HTMLElement {
  connectedCallback() {
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    this.appendChild(node);
  }
}

customElements.define('one-dialog', OneDialog);

现在,我们创建了自定义元素 <one-dialog></one-dialog>,并告诉浏览器在自定义元素实例中呈现 demo1 中的模板。

下一步就是将逻辑移到我们的组件类中。

在我们实现逻辑之前先来了解自定义元素的生命周期方法:

与 React 或 Angular 一样,自定义元素也有生命周期方法。读完上文后你已经被动接受了 connectedCallback。

connectedCallback: 自定义元素被插入到 DOM 上时调用;

attributeChangedCallback: 每当属性更改时调用此方法;

disconnectedCallback: 自定义元素从 DOM 上删除时调用。该方法可以用来执行任何必要的清理工作,但当用户关闭浏览器或浏览器选项卡时不会调用此方法;

adoptedCallback 当元素被采用到 DOM 的另一部分时会被触发。

生命周期函数执行顺序:constructor  ➡️ attributeChangedCallback ➡️ connectedCallback ➡️disconnectedCallback

connectedCallback 与 constructor 不同。connectedCallback 通常用于向元素中添加内容,而 constructor 是对元素的初始化设置。

对于标准的内置元素,元素的状态通常通过元素的属性和这些属性的值来反映。在我们的示例中,我们仅查看一个属性:['open']。为此,我们需要关注该属性的更改,需要 observedAttributes 和 attributeChangedCallback 配合完成这项工作。只要 observedAttributes 内的属性更新,就会调用 attributeChangedCallback 生命周期函数。

这听起来令人生畏,但语法非常简单:

class OneDialog extends HTMLElement {
  static get observedAttributes() {
    return ['open'];
  }

  attributeChangedCallback(attrName, oldName, newName) {
    if (newName !== oldName) {
      this[attrName] = this.hasAttribute(attrName);
    }
  }

  connectedCallback() {
     /* ... */
  }
 
}

上面例子中,我们只关心 open 属性是否设置,不关心值。open 的 property 更新后我们将更新 open 的 attribute。property 存在于 JavaScript 对象上,而 attribute 存在于 HTMLElement 上。attributeChangedCallback 可以帮助我们保持二者的同步。当然,我们也可以通过设置属性的 getter 和 setter 函数来保持 property 与 attribute 的同步,当属性更改时,attributeChangedCallback 做相应操作。

class OneDialog extends HTMLElement {
   /** ... */

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

  set open(isOpen) {
    if (isOpen) {
      this.setAttribute('open', true);
    } else {
      this.removeAttribute('open');
    }
  }
}

getter 和 setter 将保持 open 的 attribute 和 property 的值同步。添加 open 的 attribute 将设置 element.open 为 true ,设置 element.open 为 true 将添加 open 的 attribute。我们这样做是为了确保元素的状态由其属性反映出来。这在技术上不是必需的,但被认为是创作自定义元素的最佳实践。

现在我们知道了对话框是否默认打开,接下来让我们添加一些逻辑以实现显示和隐藏功能:

class OneDialog extends HTMLElement {  
  /** ... */
  constructor() {
    super();
    this.close = this.close.bind(this);
  }
  
  set open(isOpen) {
    // 根据 isOpen 确定弹框是否显示,并设置弹框属性 'aria-hidden' 的值
    this.querySelector('.wrapper').classList.toggle('open', isOpen);
    this.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen);
    // 打开弹框的操作
    if (isOpen) {
      // 保存对当前激活元素的引用
      this._wasFocused = document.activeElement;
      // 为弹框添加 open 属性
      this.setAttribute('open', '');
      // 按键 esc 的键盘事件
      document.addEventListener('keydown', this._watchEscape);
      // 实例聚焦,关闭按钮聚焦
      this.focus();
      this.querySelector('button').focus();
    } else {
      // 保存的激活元素聚焦
      this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
      // 移除 open 属性,移除键盘监听器,关闭弹框
      this.removeAttribute('open');
      document.removeEventListener('keydown', this._watchEscape);
      this.close();
    }
  }
  
  close() {
    if (this.open !== false) {
      this.open = false;
    }
    const closeEvent = new CustomEvent('dialog-closed');
    this.dispatchEvent(closeEvent);
  }
  
  _watchEscape(event) {
    if (event.key === 'Escape') {
        this.close();   
    }
  }
}

我们还可以把一些事件监听器对应起来:

class OneDialog extends HTMLElement {
  /** ... */
  
  connectedCallback() {
    /** ... */
    this.querySelector('button').addEventListener('click', this.close);
    this.querySelector('.overlay').addEventListener('click', this.close);
    /** ... */
  }
  
  disconnectedCallback() {
    this.querySelector('button').removeEventListener('click', this.close);
    this.querySelector('.overlay').removeEventListener('click', this.close);
  }  
}

现在我们有一个运行良好,大部分情况下可访问的对话框元素。demo2:

<template id="dialog-template">
  <style>
    .wrapper { opacity: 0; transition: visibility 0s, opacity 0.25s ease-in; }
    .wrapper:not(.open) { visibility: hidden; }
    .wrapper.open {
      align-items: center; display: flex; justify-content: center;
      height: 100vh;
      position: fixed; top: 0; left: 0; right: 0; bottom: 0;
      opacity: 1; visibility: visible;
    }
    .overlay {
      background: rgba(0, 0, 0, 0.8);
      height: 100%; width: 100%;
      position: fixed; top: 0; right: 0; bottom: 0; left: 0;
    }
    .dialog {
      background: #ffffff;
      max-width: 600px;
      padding: 1rem;
      position: fixed;
    }
    button {
      all: unset;
      cursor: pointer;
      font-size: 1.25rem;
      position: absolute; top: 1rem; right: 1rem;
    }
    button:focus {
      border: 2px solid blue;
    }
  </style>
  <div class="wrapper">
  <div class="overlay"></div>
    <div class="dialog" role="dialog" aria-labelledby="title" aria-describedby="content">
      <button class="close" aria-label="Close">✖️</button>
      <h1 id="title">Hello world</h1>
      <div id="content" class="content">
        <p>This is content in the body of our modal</p>
      </div>
    </div>
  </div>
</template>

<one-dialog></one-dialog>

<button id="launch-dialog">Launch dialog</button>
#launch-dialog {
  background: tomato;
  border-radius: 4px;
  color: #fff;
  font-family: Helvetica, Arial, sans-serif;
  padding: 0.5rem 1rem;
  position: static;
}
class OneDialog extends HTMLElement {
  static get observedAttributes() {
    return ['open'];
  }
  
  constructor() {
    super();
    this.close = this.close.bind(this);
  }
  
  attributeChangedCallback(attrName, oldValue, newValue) {
    if (oldValue !== newValue) {
      this[attrName] = this.hasAttribute(attrName);
    }
  }
  
  connectedCallback() {
    const template = document.getElementById('dialog-template');
    const node = document.importNode(template.content, true);
    this.appendChild(node);
    
    
    this.querySelector('button').addEventListener('click', this.close);
    this.querySelector('.overlay').addEventListener('click', this.close);
    this.open = this.open;
  }
  
  disconnectedCallback() {
    this.querySelector('button').removeEventListener('click', this.close);
    this.querySelector('.overlay').removeEventListener('click', this.close);
  }
  
  
  get open() {
    return this.hasAttribute('open');
  }
  
  
set open(isOpen) {
    this.querySelector('.wrapper').classList.toggle('open', isOpen);
    this.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen);
    if (isOpen) {
      this._wasFocused = document.activeElement;
      this.setAttribute('open', '');
      document.addEventListener('keydown', this._watchEscape);
      this.focus();
      this.querySelector('button').focus();
    } else {
      this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
      this.removeAttribute('open');
      document.removeEventListener('keydown', this._watchEscape);
      this.close();
    }
  }
  
  
  close() {
    if (this.open !== false) {
      this.open = false;
    }
    const closeEvent = new CustomEvent('dialog-closed');
    this.dispatchEvent(closeEvent);
  }
  
  _watchEscape(event) {
    if (event.key === 'Escape') {
      this.close();   
    }
  }
}

customElements.define('one-dialog', OneDialog);

const button = document.getElementById('launch-dialog');
button.addEventListener('click', () => {
  document.querySelector('one-dialog').open = true;
})

Shadow DOM

shadow DOM 是什么?通常,文档范围内的任何内容都称为 light DOM, 而 shadow root 中的任何内容都称为 shadow DOMshadow DOM 是 DOM 的封装版本,允许作者有效地将 DOM 片段彼此隔离。 shadow DOM 节点内部的选择器和样式不会泄漏到 shadow root 之外,而 shadow root 之外的样式也不会渗透到  shadow root 内。

当使用 light DOM 时,可以通过使用 document.querySelector('selector') 来选择元素或通过使用 element.querySelector('selector') 来选择元素的子元素; 以同样的方式,可以通过调用 shadowRoot.querySelector 来定位 shadow root 的子节点,这里的 shadowRoot 是对文档片段的引用 - 不同之处在于 shadow root 的子节点不能在 light DOM 中选择。

在这方面,shadow DOM 的工作方式类似于 <iframe>,它将 shadow DOM 的内容与文档的其余部分隔断; 不过,当我们创建一个 shadow root 时,我们仍然可以完全控制页面的那一部分,但是作用于上下文。这就是我们所说的封装

demo2 的对话框组件具有特定的形状,结构和行为,但是它很大程度上依赖于外部 DOM 并且要求我们元素的消费者理解它的一般形状和结构,并且创建的对话框的样式也是全局样式。因为我们的对话框依赖于 id 为 “one-dialog” 的模板元素的内容,所以每个文档只能有一个模态的实例。但是,我们希望自定义元素拥有更具体的设计和结构约束,因此我们将把 shadow DOM 合并到我们的元素中。

要添加 shadow root(shadow tree 的基本节点/文档片段),我们需要调用元素的 attachShadow 方法:

class OneDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.close = this.close.bind(this);
  }
}

通过调用 attachShadow 并设置 mode: 'open',来告诉我们的元素在 element.shadowRoot 属性上保存对 shadow root 的引用。attachShadow 总是返回对 shadow root 的引用,在这里我们不需要对它做任何事情。

如果我们调用了方法的 mode: 'closed',则不会在元素上存储引用,我们必须使用 WeakMap 或 Object 创建自己的存储和检索方法,将节点本身设置为键,将 shadow root 设置为值:

const shadowRoots = new WeakMap();

class ClosedRoot extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'closed' });
    shadowRoots.set(this, shadowRoot);
  }

  connectedCallback() {
    const shadowRoot = shadowRoots.get(this);
    shadowRoot.innerHTML = `<h1>Hello from a closed shadow root!</h1>`;
  }
}

通常,shadow root 的封闭模式存在于 在其实现中 使用 shadow DOM 的原生元素中(如 <audio> 或 <video>)。此外,对于元素的单元测试,我们可能无法访问该 shadowRoots 对象,因此我们无法根据库的架构方式来定位元素内部的更改。对于关闭模式的 shadow roots 可能存在一些合理的用例,但是很少,所以我们将坚持使用开放的 shadow root 来实现对话框。

在我们直接在 demo2 中实现新的开放模式的 shadow root 之后(在 constructor 中调用 this.attachShadow({ mode: 'open' });),当我们尝试运行它时,元素完全被破坏了。

这是因为我们之前拥有的所有内容都被添加到传统 DOM(我们称之为轻量级 DOM)中并进行操作。既然我们的元素附加了一个 shadow DOM,那么 light DOM 就没有渲染的出口。让我们将内容移到 shadow DOM 中来解决这些问题:demo3

class OneDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.close = this.close.bind(this);
  }
  
  connectedCallback() {
    const { shadowRoot } = this;
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    shadowRoot.appendChild(node);
    // shadowRoot.innerHTML = `.....`;
    
    shadowRoot.querySelector('button').addEventListener('click', this.close);
    shadowRoot.querySelector('.overlay').addEventListener('click', this.close);
    this.open = this.open;
  }

  disconnectedCallback() {
    this.shadowRoot.querySelector('button').removeEventListener('click', this.close);
    this.shadowRoot.querySelector('.overlay').removeEventListener('click', this.close);
  }
  
  set open(isOpen) {
    const { shadowRoot } = this;
    shadowRoot.querySelector('.wrapper').classList.toggle('open', isOpen);
    shadowRoot.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen);
    if (isOpen) {
      this._wasFocused = document.activeElement;
      this.setAttribute('open', '');
      document.addEventListener('keydown', this._watchEscape);
      this.focus();
      shadowRoot.querySelector('button').focus();
    } else {
      this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
      this.removeAttribute('open');
      document.removeEventListener('keydown', this._watchEscape);
    }
  }

  get open() {
    return this.hasAttribute('open');
  }
  
  close() {
    this.open = false;
  }
  
  _watchEscape(event) {
    if (event.key === 'Escape') {
        this.close();   
    }
  }
}

customElements.define('one-dialog', OneDialog);

到目前为止,对话框的主要变化实际上很小,但它带来了很大的影响。首先,我们所有的选择器(包括我们的样式定义)都是内部作用域。例如,我们的对话框模板内部只有一个按钮,因此我们的 CSS 仅定位 button { ... },并且这些样式不会流失到 light DOM 中。

但是,我们仍然依赖于元素外部的模板。我们可以不复制模板内容,可以通过设置 shadow root 的 innerHTML 来实现 HTML 结构。

shadow DOM 规范允许将 shadow root 外部的内容放在自定义元素内部渲染。Web Components 中,可以使用 <slot> 元素完成。

一个简单的例子如下所示:

<my-component>
  <span>world</span>
  <span slot="mark">!</span>
</my-component>

<script>
  class MyComponent extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({mode: 'open'});
    }
    
    connectedCallback() {
      this.shadowRoot.innerHTML = `<p>Hello <slot></slot><slot name="mark"></slot></p>`
    }
  }

  customElement.define('my-component', MyComponent);
</script>

给定的 shadow root 可以具有任意数量的 slot 元素,这些 slot 元素可以用 name 属性区分。shadow root 目录中没有名称的第一个插槽将是默认插槽,未分配的所有内容将在该节点内流动。我们的对话框确实需要两个插槽:标题和内容。demo4:

<one-dialog>
  <span slot="heading">Hello world</span>
  <div>
    <p>Lorem ipsum dolor amet tilde bicycle rights affogato brooklyn. Whatever lomo subway tile sriracha gastropub edison bulb shabby chic tumeric meditation mustache raw denim.</p>
    
    <p>reegan ugh bespoke you probably haven't heard of them godard crucifix pabst. Selvage biodiesel vice copper mug lumbersexual cred plaid. <strong>Skateboard</strong> pitchfork listicle fashion axe. Chillwave viral butcher vegan wolf.</p>
  </div>
</one-dialog>

<button id="launch-dialog">Launch dialog</button>
class OneDialog extends HTMLElement {
  
  /* ...... */
  
  connectedCallback() {
    const { shadowRoot } = this;
    shadowRoot.innerHTML = `<style>
      /* ...... */ 
        ::slotted(span) {}
        ::slotted(div) {}
        ::slotted(strong) {} /* 设置无效,无法获取 */ 
      </style>
      <div class="wrapper">
      <div class="overlay"></div>
        <div class="dialog" role="dialog" aria-labelledby="title" aria-describedby="content">
          <button class="close" aria-label="Close">✖️</button>
          <h1 id="title"><slot name="heading"></slot></h1>
          <div id="content" class="content"><slot></slot></div>
        </div>
      </div>`;
    
    /* ...... */
  }
  
  /* ...... */
}

customElements.define('one-dialog', OneDialog);

const button = document.getElementById('launch-dialog');
button.addEventListener('click', () => {
  document.querySelector('one-dialog').open = true;
})

继续更改对话框的 HTML 部分并查看结果。light DOM 内部的所有内容都插入到分配给它的插槽中。尽管 Slotted 内容被渲染为好像在 shadow DOM 中,但它保留在 light DOM 中。这意味着这些元素仍然可以由想要控制其外观的消费者完全样式化。

shadow root 的作者可以使用 ::slotted() 伪选择器设置 light DOM 里的内容样式; 但是,插槽中的 DOM 树是折叠的,因此只有简单的选择器才能工作。换句话说,在前面的示例中,我们无法在展平的 DOM 树中设置 <p> 元素内  <strong> 元素的样式。

 

现在我们的对话框状态良好:它具有封装,语义标记,样式和行为; 但是,我们对话框的一些消费者可能仍然想要定义自己的模板。幸运的是,结合已经学过的两种技术,我们可以允许作者有选择地定义外部模板。

为此,我们将允许组件的每个实例引用可选的模板 ID。首先,我们需要为组件的 template 定义一个 getter 和 setter。

get template() {
  return this.getAttribute('template');
}

set template(template) {
  if (template) {
    this.setAttribute('template', template);
  } else {
    this.removeAttribute('template');
  }
  this.render();
}

和对 open 属性做的事情相同,我们将组件直接绑定到它相应的属性上。但是在底部,我们正在为组件引入一个新方法:render。我们将把 connectedCallback 中的行为删除,取而代之的是,在元素连接后调用 render:

connectedCallback() {
  this.render();
}

render() {
  const { shadowRoot, template } = this;
  const templateNode = document.getElementById(template);
  shadowRoot.innerHTML = '';
  if (templateNode) {
    const content = document.importNode(templateNode.content, true);
    shadowRoot.appendChild(content);
  } else {
    shadowRoot.innerHTML = `<!-- template text -->`;
  }
  shadowRoot.querySelector('button').addEventListener('click', this.close);
  shadowRoot.querySelector('.overlay').addEventListener('click', this.close);
  this.open = this.open;
}

我们的对话框现在有一些基本的默认样式,但也让消费者能够为每个实例定义一个新模板。如果我们想要,甚至可以使用 attributeChangedCallback 根据它当前指向的模板更新此组件:

static get observedAttributes() { return ['open', 'template']; }

attributeChangedCallback(attrName, oldValue, newValue) {
  if (newValue !== oldValue) {
    switch (attrName) {
      /** Boolean attributes */
      case 'open':
        this[attrName] = this.hasAttribute(attrName);
        break;
      /** Value attributes */
      case 'template':
        this[attrName] = newValue;
        break;
    }
  }
}

shadow DOM 的样式设置

  1. 到目前位置,我们为 shadow DOM 节点设置样式的唯一可靠方法是在 shadow root 的内部 HTML 中添加一个 <style> 元素,几乎在所有情况下都可以正常工作。
  2. 实际上,在 shadow root 中可以使用 <link> 元素,在样式标记中也可以包含 @import。但是,在多个应用程序中重用此组件时会出现问题,因为 CSS 文件可能无法保证在所有应用程序中位置一致。
  3. 值得一提的是,并非所有组件都需要应用我们在这里使用的样式。使用 CSS :host和 :host-context 选择器,可以简单地将更原始的组件定义为块级元素,并允许消费者提供类来设置样式,如背景颜色,字体设置等。
  4. 我们还可以使用 CSS 自定义属性(也称为 CSS 变量),优点是自定义属性会通过 shadow DOM 流失,自定义属性可以穿透阴影边界并影响阴影节点内的内容。这是设计上的,为组件作者提供了一个表层样式,允许从外部对其组件进行主题化和样式化。然而,要注意的是,由于 CSS 级联,在 shadow root 中进行自定义属性的更改不会回流。
  5. 另一个样式设置的方法是使用 constructible stylesheets,对 shadow DOM 和 light DOM 元素进行更多模块化样式。此功能允许作者在 JavaScript 文件中定义样式表,类似于编写普通 CSS 并在多个节点之间共享这些样式的方式。因此,单个样式表可以附加到多个 shadow roots 上,也可能附加到文档中。
const everythingTomato = new CSSStyleSheet();
everythingTomato.replace('* { color: tomato; }');

document.adoptedStyleSheets = [everythingTomato];

class SomeCompoent extends HTMLElement {
  constructor() {
    super();
    this.adoptedStyleSheets = [everythingTomato];
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `<h1>CSS colors are fun</h1>`;
  }
}

在上面的示例中,everythingTomato 样式表将同时应用于 shadow root 和文档的正文。此功能对于创建旨在跨多个应用程序和框架共享的设计系统和组件的团队非常有用。

     6. 用于样式化 Web Components 的另一个功能是 ::part() 和 ::theme() 伪选择器。::part() 规范将允许作者定义具有样式的自定义元素的部分:

class SomeOtherComponent extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>h1 { color: rebeccapurple; }</style>
      <h1>Web components are <span part="description">AWESOME</span></h1>
    `;
  }
}
    
customElements.define('other-component', SomeOtherComponent);

在全局 CSS 中,我们可以通过调用 CSS ::part() 选择器来定位名为 description 的元素。

other-component::part(description) {
  color: tomato;
}

::part() 和 ::theme() 之间的差异是 ::part() 必须被专门选择,而 ::theme() 可以在任何级别被嵌套。下面内容与上述 CSS 具有相同的效果,但也适用于整个文档树中包含 part="description" 的任何其他元素。

:root::theme(description) {
  color: tomato;
}

web components 的高级工具

有许多很棒的工具可用于创建自己的自定义元素。通过 npm 搜索 检索出了大量用于创建高反应性自定义元素的工具,但到目前为止最流行的是来自 Polymer 团队的 lit-html,更具体地说是 Web Components,LitElement

LitElement 是一个自定义元素基类,它提供了一系列 API,用于完成我们迄今为止所做的所有事情。它可以在没有构建步骤的浏览器中运行,如果你喜欢使用面向未来的工具(如装饰器),那么也可以使用它。

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值