前言
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
参考:
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, 在外面就不可能影响的到了)
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
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 哦.
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>
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)