DOM – Web Components

前言

Web Components 已经听过很多年了, 但在开发中用纯 DOM 来实现还是比较少见的. 通常我们是配搭 Angular, React, Vue, Lit 来使用.

这篇就来讲讲纯 Web Components 长什么样子吧. Lit 和 Angular 的实现是尽可能依据规范的哦.

 

参考

YouTube – Web Components Crash Course

Web Components 基础入门-Web Components概念详解(上)

 

介绍

Web Components 其实是一个大概念, 它又可以被分为几个部分. 把几个部分拼凑一起才完整了 Web Components.

这些小概念分别是: Custom Elements, Shadow DOM 和 HTML Templates. 我们先把它们挨个挨个分开看. (它们单独也可以 working 的哦)

 

HTML Templates

它最简单, 所以先介绍它, 以前我们做动态内容是直接写 HTML Raw 的. 但这个方法对管理非常不友好.

现在都改用 Template 来管理了.

定义 template

<body>
  <template>
    <h1>dynamic title here...</h1>
    <p>dynamic content here...</p>
  </template>
</body>

template 是不会渲染出来的. 它只是一个模型.

使用 template

// step 1 : 获取 template
const template = document.querySelector<HTMLTemplateElement>("template")!;

// step 2 : clone and binding data
const templateContent = template.content.cloneNode(true) as DocumentFragment;
templateContent.querySelector("h1")!.textContent = "Hello World 1";
templateContent.querySelector("p")!.textContent =
  "Lorem ipsum dolor sit amet consectetur adipisicing elit. Accusantium, laborum.";

// step 3 : append to target
document.body.appendChild(templateContent);

记得要先 clone 了才能使用

Convert DocumentFragment to Raw String

template.content 是 DocumentFragment 来的, 有时候会需要把它转成 Raw HTML (比如我在 Google Map JavaScript API Info Window 的时候)

参考: Converting Range or DocumentFragment to string

有 2 招. 

1. 创建一个 div 把 frag 丢进去, 然后 .innerHTML 取出来.

const div = document.createElement("div");
div.appendChild(templateContent);
console.log("rawHtml", div.innerHTML);

2. 用 XMLSerializer

const serializer = new XMLSerializer();
const rawHtml = serializer.serializeToString(templateContent);
console.log("rawHtml", rawHtml);

个人感觉, 第一招会比较安全一些. 毕竟 XML 和 HTML 还是有区别的. 未必 XMLSerializer 能处理好 HTML (只是一个猜想而已)

 

Shadow DOM

参考:

Docs – Using shadow DOM

HTML slot 插槽元素深入

Shadow DOM 主要的功用是隔离 CSS. 任何一个 element 都可以开启一个 Shadow DOM 区域

在没有 Shadow DOM 的情况下, CSS Style 是全局互相影响的

<style>
  h1 {
    color: red;
  }
</style>
<h1>Outside Text</h1>
<div class="container">
  <style>
    h1 {
      color: blue;
    }
  </style>
  <h1>Inside Text</h1>
</div>

最终 outside 和 inside text 都是蓝色. 因为 container 里面的 style 会覆盖全局的 style.

而使用 Shadow DOM 就可以隔离它们, 有点像 iframe 的效果.(外面影响不了里面, 里面也影响不了外面, 相互独立)

Setup Shadow DOM

Shadow DOM 必须使用 JS 来设定, 同样上面的例子

const container = document.querySelector<HTMLElement>('.container')!;
container.attachShadow({ mode: 'open' }); // 开启 Shadow DOM
// 创建内容
// <style>
//   h1 {
//     color: blue;
//   }
// </style>
// <h1>Inside Text</h1>
const style = document.createElement('style');
style.textContent = `h1 { color: blue }`;
const h1 = document.createElement('h1');
h1.textContent = 'Inside Text';
// append 到 Shadow DOM 里面
container.shadowRoot!.appendChild(style);
container.shadowRoot!.appendChild(h1);

效果

inside text 的 style 没有覆盖全局的 style (同时 outside style 也无法影响 inside 的 element 哦)

用 DevTools 查看会发现多了 Shadow DOM

:host selector

虽说里面 CSS 影响不到外面, 但是它最多还是可以控制到 Shadow DOM element 的.

shadowRoot.innerHTML = `
  <style>
     :host { background: red; padding: 2px 5px; }
  </style>
`;

这样 .container element 的 background 就变成 red 了 (最多只能到 host element, 在外面就不可能影响的到了)

:host-context() selector

:host-content() 是让 Shadow DOM 内部 preset 一些 style for 外部控制. 比如

:host-context(.active) {
  h1 {
    background-color: green;
  }
}

上面的意思是当 host 或者 host 的 ancestor elements 任何一个包含 active class 的时候, 内部的 h1 会有绿色背景.

所以, 外部有能力通过 add class 等方式来控制内部的 style. but 前提是内部提前 preset 了这些逻辑. 如果外部想直接修改内部 style, 那还需要其它方法, 下面会介绍.

注:Firefox 和 Safari 都不支持 :host-context() 哦。

CSS variables pass through Shadow DOM

:host-context() 支持度不理想,替代方案是使用 CSS variables。

Shadow DOM 里面是可以拿到外面定义的 CSS variables 的,但反过来就不行哦。

相关提问:Stack Overflow – :host-context not working as expected in Lit-Element web component

mode: 'closed'

constructor() {
  super();
  const shadowRoot = this.attachShadow({ mode: 'open' });
  console.log(shadowRoot === this.shadowRoot); // true when open, false when closed
}

在调用 attachShodow 以后, 方法会返回 shadowRoot 对象.

如果是 open 那么这个 shadowRoot 之后可以通过 element.shadowRoot 访问. 算是一个公开的意思.

如果设置成 closed 那么 element.shadowRoot 将 != 返回的 shadowRoot.

之后要操作只能通过返回的 shadowRoot. element.shadowRoot 不可以使用.

closed 通常会在 Custom Element 中使用. 这样对外就是不公开的. 只有 element 内部保留了返回的 shadowRoot 并且可以使用它. 

<video> 也是用了这个概念. 对外不开放.

<slot>

slot 只能在 Shadow DOM 下使用. 它一般上是用在 Custom Elements 上的 (但其实只要 Shadow DOM element 都可以用的)

它的主要功用就是传递 HTML 结构到 custom element 中. 例子说明

<div class="container">
  <p>Lorem, ipsum dolor.</p>
</div>

container 将成为一个 Shadow DOM element. 它包裹的内容 (<p>) 将被 "转移" 到 Shadow DOM 内容的某个地方

const container = document.querySelector('.container')!;
const shadowRoot = container.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
  <h1>Hello World</h1>
  <slot></slot>
  <h1>Hello World End</h1>
`;

最终 container > p 会被转移到 Shadow DOM 的 <slot></slot> 这个位置里

效果

结构

multiple <slot>

上面的例子是传递一个 slot, 如果要传多个就需要加上命名

<div class="container">
  <p slot="first">1. Lorem, ipsum dolor.</p>
  <p slot="second">2. Lorem, ipsum dolor.</p>
</div>

JS

shadowRoot.innerHTML = `
  <h1>Hello World</h1>
  <slot name="first"></slot>
  <h1>Hello World End</h1>
  <slot name="second"></slot>
`;

效果

::slotted() selector

参考 MDN – ::slotted()

slot element 的 style 是跟外面跑的, 它不被 Shadow DOM 里面的 style 影响. 记得哦, 是外面负责 style.

如果想在 Shadow DOM 内部控制 slot element style 的话, 需要使用 ::slotted selector (改成里面负责 style)

<style>
    h1 {
      color: blue;
    }
    ::slotted(p) {
      color: blue !important; 
    }
</style>

之所以加上了 !important 是因为外面有定义了 style,需要 override。如果外部没有定义 style 那么里面就不需要 !important,但依然需要用 ::slotted 哦。

如果想指定其中一个 slot 可以这样写

slot[name='first']::slotted(p) {
  color: blue !important;
}

::part() selector

参考 MDN – ::part()

::slotted 是让 Shadow DOM 内部控制 slot element style, ::part 则是反过来让外部控制 Shadow DOM 内的 element style

在 Shadow DOM 内给某个 element 加上 attribute part="name", 外部并不能随心所欲 set 任何 element 的 style, 只有 attribute part 的 element 才能被外部 style 哦

外部的 CSS Style

.container::part(h1) {
  background-color: pink;
}

效果

Isolate querySelector

除了隔离 CSS, Shadow DOM 也隔离了 DOM query, 

比如上面例子里, 通过 

console.log(document.querySelectorAll('h1').length); // 1

我们只能获取到 1 个 h1. Shadow DOM 内的 h1 外部是无法 query 到的。

如果外部想 query Shadow DOM 内部,唯一的方法是透过 shadowRoot

document.querySelector('.container')!.shadowRoot!.querySelectorAll('h1');

但前提是 attachShadow 时 mode 一定要是 open。

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

另外 query ancestor 也是会被隔离掉

console.log(h1.closest('.container')); // null

Shadow DOM 里面是找不到外面 element。

唯一的方法是通过 parentNode + host 一层一层往上拿。

const shadowRoot = h1.parentNode as ShadowRoot;
const container = shadowRoot.host; // <div class="container"></div>

Query slotted elements

shadowRoot.querySelector 也无法获取到 slot 内的 elements (就是外部 transclude 进来的, 内部是 query 不到的)

但是可以通过 assignedElements 方法做到这一点

<div class="container">
  <h1>Title</h1>
  <p>Some description...</p>
</div>

container 是 shadow DOM, h1 和 p 是 transclude elements

const container = document.querySelector('.container')!;
const shadowRoot = container.attachShadow({ mode: 'closed' });
const div = document.createElement('div');
div.innerHTML = `
   <p>I love you</p>
   <slot></slot>
`;
shadowRoot.appendChild(div);

console.log(shadowRoot.querySelectorAll('p').length); // 1, query 不到 <slot> 内的 p
const slot = shadowRoot.querySelector('slot')!;
console.log(slot.assignedElements({ flatten: true })); // [h1, p], transclude elements

shadowRoot.querySelectorAll 拿不到 slot transclude 的 p。

可以通过 slot.assignedElements 获取到 transclude element h1 和 p。

flatten: true 的作用是 for 层中层 <slot>,比如 transclude 进来的是 parent 的 <slot> element。

assignedElements 只能拿到 element,如果有 Text / Comment 在最上层,那得使用 slot.assignedNodes。

Slotted elements parent 是谁?

请问 content 的 parent element 是谁?

答案是 my-parent,虽然最终显示的地方是 my-child 里,但是它是依据一开始 content 被定义在哪里,而不是最终它被放去哪里。

用 Chrome DevTools 会看的更明确。

 

Custom Elements

Custom Elements 算是 Web Components 的核心. Template 和 Shadow DOM 只是辅助它变得更好.

What is Element (or component)

先了解一下什么是 element. 

element 在 HTML 中是这样的

<div id="my-id" class="my-class"></div>

一个 HTML(XML) 的表达, 用面向对象来表达的话, 它就是一个 class instance. id 和 class 是属性, class name 就是 div

class HTMLDivElement {
  id!: string;
  classList!: string;
}

游览器在解析 HTML 后会实例化 class 创建出对象, then 我们可以用 JS 去获取到这个对象, 然后修改它的属性, 或者调用方法.

const el = document.querySelector<HTMLDivElement>('#my-id')!;
console.log(el.id);
console.log(el.classList);

而 class 内部就会去调整 element 内部结构.

记住: 它用 HTML 去表达 -> 由游览器去创建对象 -> 通过操作对象去改变 element 结构 (它的玩法就是这样)

Input Element

div 太简单, 我们拿 input 为例子

<input placeholder="Name" type="text" />

效果

世间万物都可以用 div + CSS + JS event 来实现.

如果我们想做一个和原生 input 一摸一样的 UI/UX design 是完全可以做到的.

抽象看 input 也是 HTML 声明 -> 游览器创建对象 -> JS 操作对象.

const input = document.querySelector<HTMLInputElement>('input')!;
input.checkValidity(); // 方法
console.log(input.validationMessage); // 属性

Custom Element

游览器可以搞 input, video 这些多交互的 element / component, 我们自然也可以搞. 

以前 div + CSS + JS 就可以做出很多东西了. 只是它们无法封装起来. 而 Custom Element 给了我们一种 "封装" 的能力.

游览器如何封装 input, 我们就如何封装 my-input, 就这么简单.

Define Custom Element

第一步声明 HTML, 有 tag 和 attribute 

<say-hi name="Derrick"></say-hi>

第二步是定义 class

class SayHiElement extends HTMLElement {
  constructor() {
    super();
    this.name = this.getAttribute('name')!;
    this.render();
  }

  name: string;
  setName(newName: string) {
    this.name = newName;
    this.render();
  }

  render(): void {
    this.innerHTML = `<h1>Hi, ${this.name}</h1>`;
  }
}

这个 class 里面有几个重点

1. attribute handle 

HTML 是声明式的, 它只提供表达. 如何把 attribute 变成 property 和内部 element 结构, 那是 class 内部负责的逻辑.

2. property 读写. Element 必须可以通过修改 property 来到达修改内部 element 结构的效果.

3. render. Custom element 最终依然是要输出 native element 的, render 方法可以用 innerHTML, createElement + appendChild, 或者 HTML Template 来实现. 

4. 生命周期

class SayHiElement extends HTMLElement {
  connectedCallback() {
    console.log('connected'); // 当被 append to document
  }
  disconnectedCallback() {
    console.log('disconnected'); // 当被 remove from document
  }
  attributeChangedCallback(attributeName: string, oldValue: string, newValue: string) {
    console.log([attributeName, oldValue, newValue]); // 当监听的 attributes add, remove, change value 的时候触发
  }
  static get observedAttributes() {
    return ['name']; // 声明要监听的 attributes
  }
}

从上面几个点可以感受到一个 custom element / component 它是如果 working 的. 

Register Custom Element

定义好 class 之后, 接着需要告知游览器, 让 element HTML tag 对应上这个 class 

window.customElements.define('say-hi', SayHiElement);
const sayHiElement = document.querySelector<SayHiElement>('say-hi')!;
sayHiElement.setName('keatkeat');

到这里, custom element 就可以解析成功了.

 

Web Components 3 in 1 Custom Elements + Shadow DOM + Template

这里给一个完整的例子.

一个计数器, 用 HTML + CSS + JS 实现是这样的

<div class="container">
  <style>
    .counter {
      display: flex;
      gap: 16px;
    }
    .counter :is(.minus, .plus) {
      width: 64px;
      height: 64px;
    }
    .counter .number {
      width: 128px;
      height: 64px;
      border: 1px solid gray;
      font-size: 36px;
      display: grid;
      place-items: center;
    }
  </style>
  <div class="counter">
    <button class="minus">-</button>
    <span class="number">0</span>
    <button class="plus">+</button>
  </div>
  <script>
    const number = document.querySelector('.counter .number');
    const minus = document.querySelector('.counter .minus');
    const plus = document.querySelector('.counter .plus');
    minus.addEventListener('click', () => {
      number.textContent = +number.textContent - 1;
    });
    plus.addEventListener('click', () => {
      number.textContent = +number.textContent + 1;
    });
  </script>
</div>
View Code

index.html

声明 counter-component

<div class="container">
  <counter-component></counter-component>
</div>

counter-component.html

负责 template & style

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <template id="counter-component-template">
      <style>
        .counter {
          display: flex;
          gap: 16px;
        }
        .counter :is(.minus, .plus) {
          width: 64px;
          height: 64px;
        }
        .counter .number {
          width: 128px;
          height: 64px;
          border: 1px solid gray;
          font-size: 36px;
          display: grid;
          place-items: center;
        }
      </style>
      <div class="counter">
        <button class="minus">-</button>
        <span class="number">0</span>
        <button class="plus">+</button>
      </div>
    </template>
  </body>
</html>

index.ts

class HTMLCounterElement extends HTMLElement {
  private _shadowRoot: ShadowRoot;

  constructor() {
    super();
    this._shadowRoot = this.attachShadow({ mode: 'closed' });
  }

  async connectedCallback() {
    const response = await fetch('/counter-component.html', {
      headers: {
        Accept: 'text/html; charset=UTF-8',
      },
    });
    const rawHtml = await response.text();
    const domParser = new DOMParser();
    const templateDocument = domParser.parseFromString(rawHtml, 'text/html');
    const template = templateDocument.querySelector<HTMLTemplateElement>(
      '#counter-component-template'
    )!;
    const templateContent = template.content.cloneNode(true) as DocumentFragment;
    this.bindingEvent(templateContent);
    this._shadowRoot.appendChild(templateContent);
  }

  private bindingEvent(templateContent: DocumentFragment) {
    const number = templateContent.querySelector('.counter .number')!;
    const minus = templateContent.querySelector('.counter .minus')!;
    const plus = templateContent.querySelector('.counter .plus')!;
    minus.addEventListener('click', () => {
      number.textContent = (+number.textContent! - 1).toString();
    });
    plus.addEventListener('click', () => {
      number.textContent = (+number.textContent! + 1).toString();
    });
  }
}

window.customElements.define('counter-component', HTMLCounterElement);

1. 定义 Custom Element, 

2. fetch 去拿 Template

3. 把 template 丢进 Shadow DOM

其它常用到的技术是 

1. 初始化通过 attribute 传入变量

2. 初始化通过 slot 传入 element 

3. 修改 property 达到调整结构和 style 的效果

4. 监听 component 触发的 event (包括许多 Custom Event)

posted @ 2022-09-29 23:40  兴杰  阅读(537)  评论(0编辑  收藏  举报