Web Component
arduino
在了解 Shadow DOM , Custom element, HTML template 这些内容之前,先回想一下vue 和 react 中组件编写方式, template, jsx, 插槽,属性绑定, 父子组件通讯, 监听属性改变, 实时更新等...
一开始以为vue和react框架作者牛逼到可以凭空创建编程新范式,在了解了web component后才发现, 他们也是站在别人肩膀上进行框架设计的。
前提: 如何创建真正独立、可复用、且不与外部环境冲突的 UI 组件 现代响应式框架早就实现了这个需求, 如果是用原生三件套呢? 其实也是可以的, 基于这个特点,甚至可以封装跨框架,跨端的组件库,对个人项目有很大帮助。
Shadow DOM , Custom element, HTML template
这三个概念是 Web Components 的基石, 组合使用可以解决上面的问题
HTML Template
一个可以被复用且惰性的html片段(模版) 页面加载时不会渲染, 只能手动克隆模版添加到指定dom 这使得声明式的、结构化的 HTML 片段可以被重复使用,而无需在 JavaScript 中用字符串拼接的方式来创建 DOM,代码更清晰、更易于维护。
html
<!-- 这个模板在页面上是不可见的 -->
<template id="my-template">
<style>
p { color: blue; }
</style>
<p>你好,我是一个来自模板的段落!</p>
<button>点击我</button>
</template>
<div id="container"></div>
<script>
// 1. 获取模板
const template = document.getElementById('my-template');
// 2. 克隆模板内容 (true 表示深度克隆,包括所有子节点)
const clone = template.content.cloneNode(true);
// 3. 将克隆的内容添加到页面中
document.getElementById('container').appendChild(clone);
</script>
Custom Elements
自定义元素, web components 的核心 定义一个类,继承自 HTMLElement,然后通过 customElements.define() 方法将它注册为一个新的 HTML 标签。 特点: 语义化更强,代码更具可读性
html
<script>
// 1. 创建一个类,定义组件的逻辑
class MyGreeting extends HTMLElement {
// 当元素被插入到 DOM 时,这个方法会被调用
connectedCallback() {
// 获取属性 'name'
const name = this.getAttribute('name') || '朋友';
// 渲染元素内容
this.innerHTML = `<p>你好, ${name}!</p>`;
}
// 可以定义属性变化时的响应
static get observedAttributes() { return ['name']; }
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'name') {
this.innerHTML = `<p>你好, ${newValue}!</p>`;
}
}
}
// 2. 注册自定义元素,告诉浏览器 <my-greeting> 标签对应 MyGreeting 类
customElements.define('my-greeting', MyGreeting);
</script>
Shadow DOM
为组件提供一个"隔离的 DOM 空间", 相当于沙箱。 是实现组件"封装性"和"样式隔离"的关键。 解决了什么问题? 样式污染, DOM 冲突, 结构脆弱 element.attachShadow({ mode: 'open' }) 可以创建一个 Shadow DOM 实例
html
<style>
/* 外部样式,试图影响所有 p 标签 */
p { color: red; font-size: 20px; }
</style>
<p>我是一个普通的段落,我会被外部样式影响,变成红色。</p>
<div id="host"></div>
<script>
class ShadowComponent extends HTMLElement {
constructor() {
super();
// 1. 附加一个 Shadow DOM
const shadow = this.attachShadow({ mode: 'open' });
// 2. 在 Shadow DOM 中创建内容和样式
shadow.innerHTML = `
<style>
/* 这个样式只作用于 Shadow DOM 内部的 p 标签 */
p { color: green; font-style: italic; }
</style>
<p>我在 Shadow DOM 内部,外部样式影响不了我,我是绿色的!</p>
`;
}
}
customElements.define('shadow-component', ShadowComponent);
// 将组件添加到页面
document.getElementById('host').appendChild(document.createElement('shadow-component'));
</script>
这三者彼此协作,构建出完整的web component shadow dom 负责隔离 html template 定义结构 custom element 定义组件核心, 生命周期, 状态 , api
示例: Button组件
js
const template = document.createElement('template');
template.innerHTML = `
<style>
/* 使用 :host 选择器来为组件本身(即 <my-button>)设置样式 */
:host {
display: inline-block;
}
.button {
padding: var(--button-padding, 8px 16px);
border: none;
border-radius: var(--button-border-radius, 4px);
font-size: var(--button-font-size, 14px);
cursor: pointer;
transition: background-color 0.2s;
/* 通过属性来应用不同的样式类 */
}
.button.primary {
background-color: var(--button-primary-bg, #007bff);
color: var(--button-primary-color, white);
}
.button.primary:hover {
background-color: var(--button-primary-hover-bg, #0056b3);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 使用 slot 来允许用户自定义按钮内容 */
::slotted(*) {
pointer-events: none; /* 防止插槽内容干扰按钮点击 */
}
</style>
<button class="button">
<!-- slot 是一个占位符,外部传入的内容会显示在这里 -->
<slot></slot>
</button>
`;
class MyButton extends HTMLElement {
constructor() {
super();
// 1. 创建 Shadow DOM
this._shadowRoot = this.attachShadow({ mode: 'open' });
// 2. 克隆并附加模板内容
this._shadowRoot.appendChild(template.content.cloneNode(true));
// 3. 获取内部 button 元素的引用
this.$button = this._shadowRoot.querySelector('button');
}
// 当元素被插入到 DOM 时调用
connectedCallback() {
this._updateType();
this._updateDisabled();
// 监听内部 button 的点击事件,并对外派发一个新事件
this.$button.addEventListener('click', this._handleClick.bind(this));
}
// 当元素从 DOM 中移除时调用,做好清理工作
disconnectedCallback() {
this.$button.removeEventListener('click', this._handleClick);
}
// 声明需要监听的属性
static get observedAttributes() {
return ['type', 'disabled'];
}
// 当属性发生变化时调用
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
switch(name) {
case 'type':
this._updateType();
break;
case 'disabled':
this._updateDisabled();
break;
}
}
// --- 私有方法 ---
_updateType() {
const type = this.getAttribute('type') || 'primary';
this.$button.className = `button ${type}`;
}
_updateDisabled() {
const isDisabled = this.hasAttribute('disabled');
this.$button.disabled = isDisabled;
}
_handleClick(e) {
// 派发一个自定义事件,让外部可以监听
this.dispatchEvent(new CustomEvent('onClick', { detail: { value: 'clicked' } }));
}
}
// 注册组件
customElements.define('my-button', MyButton);
- Web Component 可以在 React、Vue、Angular 等任何框架中使用
- 对于一些简单的项目或微前端架构,可以显著减少对庞大框架的依赖
- Shadow DOM 提供了框架难以比拟的样式和 DOM 隔离
- 无需任何框架或库,浏览器原生支持,长期稳定
示例: UserCard
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>响应式自定义元素</title>
<style>
body { font-family: sans-serif; }
user-card {
display: inline-block;
border: 1px solid #ccc;
border-radius: 8px;
padding: 16px;
margin: 10px;
width: 200px;
text-align: center;
box-shadow: 2px 2px 8px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<h2>用户列表</h2>
<user-card name="张三" avatar="https://i.pravatar.cc/150?img=11"></user-card>
<user-card name="李四" avatar="https://i.pravatar.cc/150?img=32"></user-card>
<button id="change-name-btn">更改张三的名字</button>
<script>
class UserCard extends HTMLElement {
// 1. 声明要监听的属性
static get observedAttributes() {
return ['name', 'avatar'];
}
constructor() {
super(); // 必须首先调用 super()
console.log('构造函数:元素被创建了');
}
// 2. 元素被插入 DOM 时,进行初次渲染
connectedCallback() {
console.log('connectedCallback: 元素已连接到 DOM');
this._render();
}
// 3. 属性变化时,重新渲染
attributeChangedCallback(name, oldValue, newValue) {
console.log(`attributeChangedCallback: 属性 ${name} 从 ${oldValue} 变为 ${newValue}`);
// 只有当元素已经连接到 DOM 时才进行渲染
if (this.isConnected) {
this._render();
}
}
// 4. 元素从 DOM 移除时,进行清理
disconnectedCallback() {
console.log('disconnectedCallback: 元素已从 DOM 移除');
// 在这里可以移除事件监听器、取消定时器等
}
// 私有方法:负责渲染元素内容
_render() {
const name = this.getAttribute('name') || '匿名用户';
const avatar = this.getAttribute('avatar') || 'https://i.pravatar.cc/150';
this.innerHTML = `
<img src="${avatar}" alt="用户头像" style="width: 80px; height: 80px; border-radius: 50%;" />
<h3>${name}</h3>
<p>这是一个用户卡片组件。</p>
`;
}
}
// 注册组件
customElements.define('user-card', UserCard);
// 演示属性变化
document.getElementById('change-name-btn').addEventListener('click', () => {
const zhangsanCard = document.querySelector('user-card');
// 通过 setAttribute 修改属性,会触发 attributeChangedCallback
zhangsanCard.setAttribute('name', '张三丰');
});
</script>
</body>
</html>
构造函数 (constructor)必须首先调用 super(),适合进行一些与 DOM 无关的初始化工作(如创建私有变量)。 不要在构造函数中设置属性或操作 this 的子元素,因为此时元素可能还未完全连接到 DOM。 connectedCallback:是执行初始渲染、添加事件监听器、发起网络请求等"一次性"设置工作的最佳位置。 disconnectedCallback:是进行清理工作的关键,用于移除事件监听器、清除定时器等,以防止内存泄漏。 封装:虽然这个例子直接操作了 this.innerHTML,但在实际项目中,强烈建议将自定义元素与 Shadow DOM 结合使用。
示例: Shadow Dom
js
class MyComponent extends HTMLElement {
constructor() {
super();
// 1. 创建一个 'open' 模式的 Shadow DOM
const shadowRoot = this.attachShadow({ mode: 'open' });
// 2. 向 Shadow DOM 中添加内容和样式
shadowRoot.innerHTML = `
<style>
/* 这里的样式只作用于 Shadow DOM 内部 */
p { color: blue; }
</style>
<p>我在 Shadow DOM 内部,我的颜色是蓝色。</p>
`;
}
}
customElements.define('my-component', MyComponent);
mode: 'open':最常用的模式。它允许你通过 JavaScript 从外部访问组件内部的 Shadow DOM(使用 element.shadowRoot) mode: 'closed':它会阻止外部访问 element.shadowRoot(其值为 null)。更强的封装性,降低了灵活性,较少使用。
示例: shadow dom 版的userCard
html
<user-card name="张三" avatar="https://i.pravatar.cc/150?img=11">
<h3 slot="username">我是从外部插入的标题</h3>
<span>这是额外的信息</span>
</user-card>
<user-card name="李四" avatar="https://i.pravatar.cc/150?img=32"></user-card>
javascript
class UserCard extends HTMLElement {
constructor() {
super();
// 1. 创建一个 open 模式的 Shadow DOM
this._shadowRoot = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
// 2. 在 Shadow DOM 中渲染内容
this._render();
}
static get observedAttributes() {
return ['name', 'avatar'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (this.isConnected) {
this._render();
}
}
_render() {
const name = this.getAttribute('name') || '匿名用户';
const avatar = this.getAttribute('avatar') || 'https://i.pravatar.cc/150';
// 3. 将结构和样式全部写入 Shadow DOM
const template = `
<img class="avatar" src="${avatar}" alt="用户头像" />
<div class="name">
<slot name="username">${name}</slot>
</div>
<div class="slot-content">
<slot></slot>
</div>
`;
this._shadowRoot.innerHTML = template;
}
}
customElements.define('user-card', UserCard);
总结
- 在元素的构造函数中调用 this.attachShadow({ mode: 'open' }),确保 Shadow DOM 在元素被使用前就已准备好
- 通过this.shadowRoot 来访问和操作内部的 DOM,而不是直接操作 this
- 使用 slot 实现灵活性
- 将所有组件相关的 CSS 都写在 Shadow DOM 的 style 标签内
注意: 关于 自定义组件 以及 所有DOM元素都会有的一些属性
生命周期:
- constructor: 元素被创建时调用,用于初始化状态和属性 用途: 初始化组件的状态,设置初始 DOM 结构,创建shadow dom 注意: 不能设置属性 和 操作 DOM 元素 理解为vue中的create
- connectedCallback() 当元素被插入到 DOM 中时调用。这可能发生多次 用途: 执行初始化渲染:这是渲染元素内容的最佳时机。 添加事件监听器:监听 click、mouseover 等事件。 发起网络请求:获取组件所需的数据。 启动定时器。 简单除暴理解为 框架中的 mounted
- disconnectedCallback() 当元素从 DOM 中移除时调用。这也可能发生多次 用途: 移除事件监听器:确保不会内存泄漏。 清除定时器:防止内存泄漏。 理解为 框架中的 unmounted
- attributeChangedCallback(name, oldValue, newValue) 当元素的属性被添加、删除或更改时调用。 用途: 响应属性变化:当属性值改变时,重新渲染组件。 触发自定义事件:通知外部环境属性已改变。 理解为vue中的watch, computed1
- adoptedCallback(oldDocument, newDocument) 当元素被移动到新文档时调用。这可能发生在文档 fragment 中。 用途: 更新组件的状态:确保组件在新文档中正确运行。 重新初始化事件监听器:确保事件不会丢失。 很少用到
属性
生命周期与属性总结表
| 回调/属性 | 类型 | 何时调用/用途 |
|---|---|---|
| constructor() | 生命周期 | 元素实例化时。用于基本设置,必须调用 super()。 |
| connectedCallback() | 生命周期 | 元素插入 DOM 时。用于渲染、添加事件监听、启动任务。 |
| disconnectedCallback() | 生命周期 | 元素从 DOM 移除时。用于清理工作,防止内存泄漏。 |
| attributeChangedCallback() | 生命周期 | observedAttributes 中的属性变化时。用于响应外部数据更新。 |
| adoptedCallback() | 生命周期 | 元素被移动到新 document 时。不常用。 |
| observedAttributes | 静态属性 | static get observedAttributes() { return [...] }。声明要监听的属性列表。 |
| isConnected | 只读属性 | true/false。检查元素是否在 DOM 中。 |
| shadowRoot | 只读属性 | 对元素 Shadow DOM 的引用(如果 mode 为 'open')。 |
| setAttribute() | 方法 | 设置 HTML 属性,会触发 attributeChangedCallback。 |
| dispatchEvent() | 方法 | 派发自定义事件,用于组件向外通信。 |
实战用例
跑马灯组件
html
<my-marquee
:event-list="JSON.stringify(events)"
@event-click="handleEventClick">
</my-marquee>
在vue组件中使用该跑马灯组件,使用了动态绑定和事件监听 Vue会:
- 监听 events 变量的变化
- 当 events 变化时,自动调用 element.setAttribute('event-list', JSON.stringify(newValue))
- 监听Web Component派发的 event-click 自定义事件
Web Component 通过
js
static get observedAttributes() {
return ['event-list', 'type'];
}
attributeChangedCallback(name, _, newValue) {
if (name === 'event-list') {
this.events = JSON.parse(newValue);
this.duplicatedEvents = [...this.events, ...this.events, ...this.events];
this._render();
}
}
来实现属性监听和回调处理
javascript
class EventMarquee extends HTMLElement {
constructor() {
super();
this.events = [];
this.duplicatedEvents = [];
this.animationId = null;
this.offsetX = 0; // 水平偏移量
this.itemWidth = 500; // 每个项目的宽度
this.speed = 30; // 滚动速度(像素/秒)
this.isPaused = false; // 是否暂停
this.lastTimestamp = 0; // 上一帧时间戳
this._shadowRoot = this.attachShadow({ mode: 'open' });
}
formatTime(time) {
const date = new Date(time);
const now = new Date();
const diff = now - date;
// 小于1小时
if (diff < 60 * 60 * 1000) {
const minutes = Math.floor(diff / (60 * 1000));
return `${minutes}分钟前`;
}
// 小于24小时
if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / (60 * 60 * 1000));
return `${hours}小时前`;
}
// 超过24小时
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
return `${days}天前`;
}
static get observedAttributes() {
return ['event-list', 'type'];
}
attributeChangedCallback(name, _, newValue) {
if (name === 'event-list') {
this.events = JSON.parse(newValue);
this.duplicatedEvents = [...this.events, ...this.events, ...this.events];
this._render();
}
}
connectedCallback() {
// 添加鼠标悬停事件监听
this.addEventListener('mouseenter', () => this.pauseScroll());
this.addEventListener('mouseleave', () => this.resumeScroll());
}
disconnectedCallback() {
// 清理动画
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
// 移除事件监听
this.removeEventListener('mouseenter', () => this.pauseScroll());
this.removeEventListener('mouseleave', () => this.resumeScroll());
}
updateOffsets() {
this._render();
}
handleEventClick(eventId) {
location.href = `/#/home/leakageIncidentDetail?id=${eventId}`;
// const customEvent = new CustomEvent('event-click', {
// detail: { eventId },
// bubbles: true,
// composed: true
// });
// this.dispatchEvent(customEvent);
}
_addEventListeners() {
const eventItems = this._shadowRoot.querySelectorAll('.event-item');
eventItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const eventId = item.dataset.eventId;
this.handleEventClick(eventId);
});
});
}
// 动画函数
animate(timestamp) {
if (!this?.lastTimestamp) {
this.lastTimestamp = timestamp;
}
const elapsed = timestamp - this.lastTimestamp;
this.lastTimestamp = timestamp;
if (!this.isPaused) {
// 计算新的偏移量
this.offsetX -= (this.speed * elapsed) / 1000;
// 计算内容总宽度
const originalWidth = this.itemWidth * this.events.length;
// 如果滚动超过原始内容宽度,重置位置以实现无缝循环
if (Math.abs(this.offsetX) >= originalWidth) {
this.offsetX = this.offsetX % originalWidth;
}
// 应用transform样式到内容元素
const marqueeContent = this._shadowRoot.querySelector('.marquee-content');
if (marqueeContent) {
marqueeContent.style.transform = `translateX(${this.offsetX}px)`;
}
}
this.animationId = requestAnimationFrame((time) => this.animate(time));
};
startAnimation() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
this.lastTimestamp = 0;
this.animationId = requestAnimationFrame((time) => this.animate(time));
}
pauseScroll() {
this.isPaused = true;
}
resumeScroll() {
this.isPaused = false;
this.lastTimestamp = 0;
}
_render() {
const template = `
<style>
.event-marquee {
width: 100%;
overflow: hidden;
position: relative;
}
.event-title:hover{
text-decoration: underline;
}
.marquee-wrapper {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 4px;
background: #ffffff;
padding-left: 16px;
padding-right: 16px;
padding-top: 10px;
}
.marquee-container {
display: flex;
will-change: transform;
overflow: hidden;
}
.marquee-content {
display: flex;
width: fit-content;
}
.event-item {
min-width: 500px;
border-radius: 4px;
background: white;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
display: flex;
flex-direction: column;
justify-content: space-between;
backface-visibility: hidden;
transform: translateZ(0);
margin-right: 20px;
}
.event-item:hover {
transform: translateY(-3px) scale(1.02);
}
.event-header {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.event-type {
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
font-weight: 500;
margin-right: 8px;
}
.event-type.urgent {
background: #fef2f2;
color: #dc2626;
}
.event-title {
font-size: 14px;
font-weight: 500;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.event-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #9ca3af;
}
.event-time {
font-size: 12px;
color: #9ca3af;
}
@media (max-width: 768px) {
.event-item {
min-width: 300px;
}
}
@media (max-width: 480px) {
.event-item {
min-width: 250px;
}
}
</style>
<div class="event-marquee">
<div class="marquee-wrapper">
<div class="marquee-container" ref="marqueeContainer">
<div
class="marquee-content"
ref="marqueeContent"
>
${this.duplicatedEvents.map(event => `
<div class="event-item" data-event-id="${event.eventId}">
<div class="event-header">
<div class="event-title">
<span class="event-type urgent">消息</span>
<span class="event-title">${event.title}</span> -
<span class="event-time">${this.formatTime(event.time)}</span>
</div>
</div>
</div>
`).join('')}
</div>
</div>
</div>
</div>
`;
this._shadowRoot.innerHTML = template;
this._addEventListeners();
// 只有在有数据时才开始动画
if (this.duplicatedEvents.length > 0) {
this.startAnimation();
}
}
}
export default EventMarquee;