Web Component 深度解析:构建原生可复用组件
Web Components 是一套由浏览器原生支持的 Web API,它允许开发者创建可重用、封装良好的定制 HTML 元素,从而实现组件化的前端开发模式。本文将深入探讨 Web Components 的核心 API 及其使用方式,并通过丰富的代码示例展示如何构建强大的自定义组件。
什么是 Web Component?
Web Components 旨在解决代码复用和组件化管理的问题,它由三项主要技术组成:
- Custom Elements (自定义元素):允许开发者扩展 HTML 元素集合,通过定义新的标签来创建自定义组件
- Shadow DOM (影子 DOM):提供封装样式和结构的能力,使组件内部的 CSS 样式不会影响到外部环境,反之亦然
- HTML Templates (HTML 模板) :使用
<template>
和<slot>
元素定义组件的内容和可替换区域
这些技术可以一起使用来创建封装功能的定制元素,可以在任何地方重用,不必担心代码冲突。
自定义元素 (Custom Elements)
基本概念
自定义元素分为两种类型:
- 独立自定义元素 (Autonomous custom element) :继承自 HTML 元素基类
HTMLElement
,必须从头开始实现它们的行为 - 自定义内置元素 (Customized built-in element) :继承自标准的 HTML 元素,如
HTMLParagraphElement
或HTMLImageElement
,扩展标准元素的行为
创建自定义元素
自定义元素作为一个类来实现,该类可以扩展 HTMLElement
(在独立元素的情况下)或者你想要定制的接口(在自定义内置元素的情况下)。
scala
// 独立自定义元素的最小实现
class PopUpInfo extends HTMLElement {
constructor() {
super();
// 此处编写元素功能
}
}
// 自定义内置元素的最小实现,该元素定制了<p>元素
class WordCount extends HTMLParagraphElement {
constructor() {
super();
// 此处编写元素功能
}
}
注册自定义元素
要使自定义元素在页面中可用,需要调用 CustomElementRegistry.define()
方法:
php
// 注册独立自定义元素
customElements.define('popup-info', PopUpInfo);
// 注册自定义内置元素
customElements.define('word-count', WordCount, { extends: 'p' });
使用方式也有所不同:
xml
<!-- 使用独立自定义元素 -->
<popup-info></popup-info>
<!-- 使用自定义内置元素 -->
<p is="word-count"></p>
生命周期回调
自定义元素生命周期回调包括:
connectedCallback()
:每当元素添加到文档中时调用disconnectedCallback()
:每当元素从文档中移除时调用adoptedCallback()
:每当元素被移动到新文档时调用attributeChangedCallback()
:在属性更改、添加、移除或替换时调用
javascript
class MyCustomElement extends HTMLElement {
static observedAttributes = ["color", "size"];
constructor() {
super();
}
connectedCallback() {
console.log("自定义元素添加至页面。");
}
disconnectedCallback() {
console.log("自定义元素从页面中移除。");
}
adoptedCallback() {
console.log("自定义元素移动至新页面。");
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`属性 ${name} 已变更。`);
}
}
响应属性变化
为了有效地使用属性,元素必须能够响应属性值的变化。为此,自定义元素需要:
- 一个名为
observedAttributes
的静态属性,包含需要监听的属性名称数组 - 实现
attributeChangedCallback()
生命周期回调
javascript
class MyCustomElement extends HTMLElement {
static observedAttributes = ["size"];
constructor() {
super();
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`属性 ${name} 已由 ${oldValue} 变更为 ${newValue}。`);
// 根据属性变化更新组件
}
}
customElements.define('my-custom-element', MyCustomElement);
使用示例:
ini
<my-custom-element size="100"></my-custom-element>
Shadow DOM (影子 DOM)
基本概念
Shadow DOM 允许你将一个 DOM 树附加到一个元素上,并且使该树的内部对于在页面中运行的 JavaScript 和 CSS 是隐藏的。关键术语:
- 影子宿主 (Shadow host):影子 DOM 附加到的常规 DOM 节点
- 影子树 (Shadow tree):影子 DOM 内部的 DOM 树
- 影子边界 (Shadow boundary):影子 DOM 终止,常规 DOM 开始的地方
- 影子根 (Shadow root):影子树的根节点
创建 Shadow DOM
ini
const host = document.querySelector('#host');
const shadow = host.attachShadow({ mode: 'open' });
const span = document.createElement('span');
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
attachShadow()
方法接受一个配置对象,其中 mode
属性可以是:
open
:可以通过host.shadowRoot
获取影子 DOMclosed
:无法通过host.shadowRoot
获取影子 DOM(返回 null)
Shadow DOM 的样式封装
Shadow DOM 的一个重要特性是样式封装,组件内部的样式不会影响外部,外部的样式也不会影响组件内部。
css
class PopUpInfo extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
// 创建一些 CSS 应用于影子 DOM
const style = document.createElement('style');
style.textContent = `
.wrapper {
position: relative;
}
.info {
font-size: 0.8rem;
width: 200px;
display: inline-block;
border: 1px solid black;
padding: 10px;
background: white;
border-radius: 10px;
opacity: 0;
transition: 0.6s all;
position: absolute;
bottom: 20px;
left: 10px;
z-index: 3;
}
img {
width: 1.2rem;
}
.icon:hover + .info, .icon:focus + .info {
opacity: 1;
}
`;
shadow.appendChild(style);
// 添加其他元素...
}
}
HTML 模板 (HTML Templates)
<template>
元素
<template>
元素使你可以编写不在呈现页面中显示的标记模板,然后它们可以作为自定义元素结构的基础被多次重用。
xml
<template id="my-template">
<style>
/* 组件样式 */
</style>
<div class="container">
<slot></slot> <!-- 这里可以插入其他元素 -->
</div>
</template>
<slot>
元素
<slot>
元素作为插槽,允许你在使用自定义元素时插入自定义内容。
xml
const template = document.createElement('template');
template.innerHTML = `
<style>
label { display: block; }
.description { color: #a9a9a9; font-size: .8em; }
</style>
<label>
<input type="checkbox" />
<slot></slot>
<span class="description"><slot name="description"></slot></span>
</label>
`;
使用示例:
xml
<todo-item>
todo1
<span slot="description">其他描述</span>
</todo-item>
完整示例:实现一个下拉选择组件
让我们实现一个包含 select
和 option
的基础下拉选择组件。
Select 组件
ini
class Select extends HTMLElement {
constructor() {
super();
const template = document.createElement("template");
template.innerHTML = `
<style>
:host {
position: relative;
display: inline-block;
}
.select-inner {
height: 34px;
border: 1px solid #cdcdcd;
box-sizing: border-box;
font-size: 13px;
outline: none;
padding: 0 10px;
border-radius: 4px;
}
.drop {
position: absolute;
top: 36px;
left: 0;
width: 100%;
padding: 4px 0;
border-radius: 2px;
overflow: auto;
max-height: 256px;
box-shadow: 0 2px 4px rgba(0, 0, 0, .12), 0 0 6px rgba(0, 0, 0, .04);
display: none;
}
</style>
<input class="select-inner" readonly>
<div class="drop">
<slot></slot>
</div>
`;
const shadowEle = this.attachShadow({ mode: "open" });
const content = template.content.cloneNode(true);
shadowEle.appendChild(content);
this.input = shadowEle.querySelector(".select-inner");
this.dropEle = shadowEle.querySelector(".drop");
this.value = null;
this.input.addEventListener("click", () => {
this.dropEle.style.display = "block";
});
this.BodyClick = (ev) => {
if (ev.target !== this.input) {
this.dropEle.style.display = "none";
}
};
this.dropEle.addEventListener("click", (ev) => {
const target = ev.target;
const nodeName = target.nodeName.toLowerCase();
if (nodeName === "ivy-option") {
this.value = target.getAttribute("value");
this.input.setAttribute("value", target.innerHTML);
this.dispatchEvent(new CustomEvent("change", {
detail: { value: this.value }
}));
this.dropEle.style.display = "none";
}
});
}
connectedCallback() {
document.addEventListener("click", this.BodyClick, true);
}
disconnectedCallback() {
document.removeEventListener("click", this.BodyClick);
}
}
Option 组件
ini
class Option extends HTMLElement {
constructor() {
super();
const template = document.createElement("template");
template.innerHTML = `
<style>
:host {
position: relative;
}
.option {
height: 32px;
line-height: 32px;
box-sizing: border-box;
font-size: 13px;
color: #333333;
padding: 0 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.option:hover {
background-color: #f4f4f4;
}
</style>
<div class="option">
<slot></slot>
</div>
`;
const shadowELe = this.attachShadow({ mode: "open" });
const content = template.content.cloneNode(true);
shadowELe.appendChild(content);
}
static get observedAttributes() {
return ["value"];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "value" && oldValue !== newValue) {
// 处理value属性变化
}
}
}
注册和使用
makefile
customElements.define("ivy-select", Select);
customElements.define("ivy-option", Option);
vbnet
<ivy-select>
<ivy-option value="1">Apple</ivy-option>
<ivy-option value="2">Banana</ivy-option>
<ivy-option value="3">Orange</ivy-option>
</ivy-select>
扩展内置元素
Web Components 还允许你扩展内置 HTML 元素的功能。
kotlin
class ExpandableList extends HTMLUListElement {
constructor() {
super();
this.style.position = 'relative';
// 创建切换按钮
this.toggleBtn = document.createElement('button');
this.toggleBtn.style.position = 'absolute';
this.toggleBtn.style.border = 'none';
this.toggleBtn.style.background = 'none';
this.toggleBtn.style.padding = '0';
this.toggleBtn.style.top = '0';
this.toggleBtn.style.left = '5px';
this.toggleBtn.style.cursor = 'pointer';
this.toggleBtn.innerText = '>';
this.appendChild(this.toggleBtn);
// 定义点击事件
this.toggleBtn.addEventListener('click', () => {
this.dataset.expanded = !this.isExpanded;
});
}
get isExpanded() {
return this.dataset.expanded !== 'false' && this.dataset.expanded !== null;
}
static get observedAttributes() {
return ['data-expanded'];
}
attributeChangedCallback(name, oldValue, newValue) {
this.updateStyles();
}
updateStyles() {
const transform = this.isExpanded ? 'rotate(90deg)' : '';
this.toggleBtn.style.transform = transform;
[...this.children].forEach((child) => {
if (child !== this.toggleBtn) {
child.style.display = this.isExpanded ? '' : 'none';
}
});
}
connectedCallback() {
this.updateStyles();
}
}
customElements.define('expandable-list', ExpandableList, { extends: 'ul' });
使用示例:
xml
<ul is="expandable-list" data-expanded name="myul">
<li>apple</li>
<li>banana</li>
</ul>
预告:Polymer 和 Lit 库
虽然 Web Components 提供了强大的原生能力,但在实际开发中,我们可能会使用一些库来简化开发流程。下面简单介绍两个流行的 Web Components 库:
Polymer
Polymer 是一个开源的 JavaScript 库,由 Google 开发,旨在简化 Web 组件的开发过程。它提供了一系列语法糖和工具,使得创建和使用 Web Components 更加便捷。
Polymer 的核心特性包括:
- 声明式数据绑定(单向绑定使用
[[ ]]
,双向绑定使用{{ }}
) - 便捷的属性系统
- 简化的事件处理
javascript
<dom-module id="hello-world">
<template>
<style>
:host { display: block; padding: 10px; }
</style>
<h1>Hello, [[name]]!</h1>
</template>
<script>
Polymer({
is: 'hello-world',
properties: {
name: { type: String, value: 'World' }
}
});
</script>
</dom-module>
Lit
Lit 是一个轻量级的库,基于 Polymer 项目发展而来,旨在简化 Web 组件的开发。它提供了更简洁的 API 和更好的性能,是当前 Web Components 生态中的重要组成部分。
Lit 的核心特点:
- 简单的组件定义方式
- 高效的渲染
- 模板字面量支持
javascript
import { LitElement, html } from 'lit';
class MyElement extends LitElement {
static properties = {
name: { type: String }
};
constructor() {
super();
this.name = 'World';
}
render() {
return html`<h1>Hello, ${this.name}!</h1>`;
}
}
customElements.define('my-element', MyElement);
在下一篇文章中,我们将深入探讨 Polymer 和 Lit 这两个库的使用方法和最佳实践,帮助你更高效地开发 Web Components。
成熟组件库:Quarck Desgin
结语
Web Components 为 Web 开发带来了一种强大的组件化方式,让开发者能够更好地组织代码,提升代码复用性和维护性。通过深入学习和实践,你会发现 Web Components 在现代前端项目中的巨大价值。
虽然 Web Components 已经得到了所有现代浏览器的支持,但在实际项目中,你可能还需要考虑一些额外的因素,如浏览器兼容性、性能优化和与现有框架的集成等。Polymer 和 Lit 这样的库可以帮助你解决这些问题,让你更专注于业务逻辑的实现。
希望本文能够帮助你理解 Web Components 的核心概念和 API,并激发你尝试在自己的项目中使用这项强大的技术。