本文也是在阅读红宝书中了解到了 web component,文中提及的 lit-html 再上篇文章中已经提及过
web components 提供了一个官方的、原生的解决方案。它不是一个库,而是一套浏览器内置的标准,让我们能够创建独立、可复用、且不受任何框架限制的 UI 组件
目标:构建具备以下功能的 my-counter
- 可复用:使用自定义标签
- 样式隔离:使用 Shadow DOM
- 高性能:使用
lit-html高效更新 UI - 可配置:通过属性自定义初始值、步长等
# Part 1:Web Component 的三大支柱
# Step 1:搭建骨架 --Custom Elements(自定义元素)
class MyCounter extends HTMLElement { | |
constructor() { | |
super(); // 必须调用 | |
this.count = 0; | |
} | |
// 当元素被插入到 DOM 文档流时调用 | |
connectedCallback() { | |
// 基础渲染:内容会和页面的全局样式混在一起 | |
this.innerHTML = '<p>我是一个计数器</p>'; | |
} | |
} | |
// 注册组件,标签名必须包含连字符 | |
customElements.define('my-counter', MyCounter); |
- 通过继承
HTMLElement赋予组件生命力,使其能够被浏览器识别和管理 - 通过
connectedCallback方法,在元素插入 DOM 文档流时进行初始化渲染 - 这里的
this指向当前实例化的组件对象,即my-counter元素 - 通过
customElements.define方法,将组件注册到浏览器中,第一个参数为自定义标签名,第二个参数为组件类名
# Step 2:隔离保护 --Shadow DOM(影子 DOM)
为了解决样式冲突,我们为组件创建一个独立的、封装的 DOM 树
class MyCounter extends HTMLElement { | |
constructor() { | |
super(); | |
// 1. 开启影子 DOM 模式 | |
this.attachShadow({ mode: 'open' }); | |
this.count = 0; | |
} | |
connectedCallback() { | |
// 2. 把内容和内部样式写进 shadowRoot | |
this.shadowRoot.innerHTML = ` | |
<style> | |
p { | |
color: blue; /* 仅在组件内部生效 */ | |
font-weight: bold; | |
} | |
</style> | |
<p>我是被保护的计数器</p> | |
`; | |
} | |
} | |
customElements.define('my-counter', MyCounter); |
- 通过
attachShadow方法开启影子 DOM 模式,第一个参数为配置对象,mode为open表示允许外部访问,closed表示不允许外部访问 - 通过
shadowRoot属性访问影子 DOM 树,将内容和内部样式写进它
# Part 2:性能与响应式(lit-html)
# Step 3:引入神器 --lit-html
这个时候发现,每次创建标签时,都会调用 connectedCallback 方法,导致重复渲染
最开始的解决方法是,将模板写在 HTML 文件中,通过 document.getElementById 方法获取,然后克隆到 shadowRoot 中
<template id="my-counter-template"> | |
<!-- 基础模板 --> | |
</template> |
但是这样又不满足高内聚低耦合的原则,因为模板和组件的代码都写在 HTML 文件中
因此我们就使用 lit-html 库,将模板写在 JavaScript 文件中,通过 html 函数渲染到 shadowRoot 中,既满足高内聚低耦合的原则,又能够实现高效更新 UI
import { html, render } from 'https://unpkg.com/lit-html@^2.0.0/lit-html.js'; | |
class MyCounter extends HTMLElement { | |
constructor() { | |
super(); | |
this.attachShadow({ mode: 'open' }); | |
this.count = 0; | |
} | |
// 1. 定义增加的方法 (使用箭头函数锁定 this),注意!!!!!!! | |
_increment = () => { | |
this.count++; | |
this.render(); // 数据变了,请求更新界面 | |
} | |
// 2. 定义模板 (View) | |
get template() { | |
return html` | |
<style> | |
button { cursor: pointer; padding: 5px 10px; } | |
</style> | |
<div>当前计数: ${this.count}</div> | |
<button @click=${this._increment}>+1</button> | |
`; | |
} | |
// 3. 核心渲染方法 | |
render() { | |
render(this.template, this.shadowRoot); | |
} | |
connectedCallback() { | |
this.render(); // 首次挂载时渲染一次 | |
} | |
} | |
customElements.define('my-counter', MyCounter); |
- 通过
html函数定义模板,使用${}插值表达式插入动态数据 - 通过
render函数将模板渲染到shadowRoot中,render 的第一个参数为模板,第二个参数为目标 DOM 节点 - 通过
connectedCallback方法,在元素插入 DOM 文档流时进行初始化渲染
# Step 4:打通内外 -- 响应 HTML 属性变化
为了让组件可配置,我们需要监听外部的 HTML 属性( initial-count )的变化
- 通过
static get observedAttributes() { return [...] }告知浏览器只监听特定的属性 - 通过
attributeChangedCallback(name, oldVal, newVal)方法,在属性发生变化时更新组件状态
# 完整代码
# test.js
import { html, render } from 'https://unpkg.com/lit-html@^2.0.0/lit-html.js'; | |
class MyCounter extends HTMLElement { | |
static get observedAttributes() { | |
// 监听 initial-count 属性变化 | |
return ['initial-count']; | |
} | |
constructor() { | |
super(); | |
this.count = 0; | |
// 创建 Shadow DOM | |
this.attachShadow({ mode: 'open' }); | |
} | |
attributeChangedCallback(name, oldVal, newVal) { | |
if (name === 'initial-count' && oldVal !== newVal) { | |
// 当 initial-count 属性变化时,更新组件状态 | |
this.count = parseInt(newVal) || 0; | |
this.render(); | |
} | |
} | |
connectedCallback() { | |
// 首次挂载时渲染一次 | |
this.render(); | |
} | |
_increment = () => { | |
// 点击 +1 按钮时,增加计数并更新 initial-count 属性 | |
this.count ++; | |
// 同步更新 initial-count 属性 | |
this.setAttribute('initial-count', this.count); | |
this.render(); | |
} | |
get template() { | |
// 定义模板 (View) | |
return html` | |
<style> | |
/* :host 代表组件本身 */ | |
:host { | |
display: block; | |
font-family: sans-serif; | |
margin-bottom: 20px; | |
} | |
.container { | |
padding: 20px; | |
background-color: #f0f0f0; | |
border-radius: 8px; | |
text-align: center; | |
} | |
button { | |
padding: 8px 16px; | |
font-size: 16px; | |
cursor: pointer; | |
background-color: #007bff; | |
color: white; | |
border: none; | |
border-radius: 4px; | |
} | |
button:hover { | |
background-color: #0056b3; | |
} | |
</style> | |
<div class="container"> | |
<h2>Lit 计数器</h2> | |
<p>Count: ${this.count}</p> | |
<button @click=${this._increment}>+ Plus</button> | |
</div> | |
`; | |
} | |
render() { | |
// 核心渲染方法 | |
render(this.template, this.shadowRoot); | |
} | |
} | |
// 定义自定义元素 my-counter | |
customElements.define('my-counter', MyCounter); |
# test.html
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
<title>Document</title> | |
</head> | |
<body> | |
<script src="./test.js" type="module"></script> | |
<my-counter initial-count="1"></my-counter> | |
</body> | |
</html> |
# 总结
- Custom Elements:解决了 HTML 标签语义化和复用问题
- Shadow DOM:解决了 CSS 和 DOM 的封装和隔离问题
- lit-HTML:解决了原生 DOM 操作性能低下和繁琐问题
- Attribute Observation:解决了组件与外部世界进行数据通信和初始化的问题