在前端工程中我们都需要开发一些复杂的 HTML 页面,每次都单独编写这会是一个很大工作量,而每次编写的 HTML 样式可能会存在一些问题,也不能复用。做过前端开发的朋友都是使用现有的 Web UI 框架来实现,很少有人自己去通过原生的代码去编写页面,现在 Web 开发者都使用现有的 React 、Angular 、Vue 视图层的框架。如果不使用这些框架,我们自己如何实现一个自定义组件呢?现在有了新的选择可以通过 Web 组件计算来实现的 HTML 可复用样式组件。
传统开发
在现在前端开发中 HTML 和 CSS 已经成为构建应用程序界面的特定语言了,在早期的前端开发中使用 HTML 进行页面开发都是使用 HTML 超文本标记语言内置预定义好的 element
标签来构建我们想要的图形化界面,但是内置的 bottom
和 image
、p
、h1
等等标签只能算是内置的单一标签,开发者需要将这些标签通过 HTML 语句块组合起来,才能构建成为一个完整的界面,如下为一个 HTML 表单代码,依靠着 p
和 label
、input
标签构成的:
<form action="#" method="post" id="myForm">
<p>
<label> Name:
<input type="text" id="name"></input>
</label>
</br>
<label>
Phone:
<input type="tel" id="phone"></input>
</label>
</p>
<input type="submit" value="提 交"></button>
</form>
如果不想写这样的多个文档元素组成元素,这是可能就需要通过 js 代码将这些元素组合成为一个新的元素,如下的代码:
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Web Component</title>
</head>
<body>
<h1>Document Web Form Component</h1>
<script type="text/javascript">
// 创建一个能复用代码的,创建 form 元素
let form = document.createElement('form');
form.action = "#";
form.method = "post";
form.id = "myForm";
// 需要 HTML 元素组件的组成代码块
form.innerHTML = `
<p>
<label> Name:
<input type="text" id="name"></input>
</label>
<br>
<label>
Phone:
<input type="tel" id="phone"></input>
</label>
</p>
<input type="submit" value="提 交"></input>`;
// 向 body 中添加一个元素
document.body.appendChild(form);
</script>
</body>
</html>
最后当 DOM 重绘之后界面效果如下:
form
就可以达到使用它就能创建一个固定的代码元素组件的效果。此种方式虽然可以解决当前不用写太多代码的来实现一个 HTML 组件的复用性,但是并没有完全友好的解决这个问题,现在还有一个问题是需要通过 js 代码进行包装才能正常复用代码组件,并且还需要 js 频繁得操作 DOM 树结构,完成重绘界面。
Document Fragment
DocumentFragment
和 Element
最重要的区别是它没有父节点,插入到其他节点中时,其他节点子元素则为它子元素节点。
在下面这段代码就是采用 DocumentFragment 最为一个单元来使用添加到 HTMLBodyElement 中,一个临时的容器,可以用来保存一组 DOM 节点,但本身并不在 DOM 树中创建真实的节点。主要用于高效地操作 DOM ,可以将多个节点添加到 DocumentFragment 中,然后一次性添加到 DOM 中,减少 DOM 操作的性能开销,特别适合批量处理节点,与文章开头的 document.createElement
有着明显的区别:
<script type="text/javascript">
// 创建一个 DocumentFragment
const fragment = document.createDocumentFragment();
// 创建一组节点并添加到 DocumentFragment 中
for (let i = 0; i < 100; i++) {
// 不停的创建一个 p 节点
const p = document.createElement('p');
p.textContent = 'This is fragment ' + i;
fragment.appendChild(p);
}
// 将 DocumentFragment 添加到页面中
document.body.appendChild(fragment);
</script>
Web Components
。
Web Components
其实一个简单的网页和单页面程序完全没有必要进行组件的拆分,做过前端开发都使用过著名的 UI 框架 Bootstrap ,它的早期版本提倡的是通过已有 HTML 的元素加它自定义的 CSS 组合来实现页面元素封装和分离,例如下面的代码是一个 Images 例子:
像上面这样,我们开发复用只能复制整个 div
代码块,而不能把整个 div
自定义为一个 HTML 标签,理想的使用方法是 <card-img></card-img>
就能在页面上绘画出同样的一个组件。
此时现在有一个需求我们要构建一个类似于上面提到了 Bootstrap 一样的 Images 标签并且可以复用,这时 Web Components 就可以提供这样的功能,Web Components 有 3 个核心组件概念:
组件名 | 作用说明 |
---|---|
Custom Elements | 用于向全局注册自定义 HTML 元素,并指定元素的行为和功能,可以将其视为内置 HTML 元素一样在页面中使用。 |
Shadow DOM | 允许将封装的样式和行为附加到自定义元素上,可以实现组件内部样式和脚本对外部的隔离。 |
HTML Template | 可以复用 HTML 代码结构,让自定义标签能通过模版显示一个包含动态内容。 |
CardImg
元素,并且注册到全局中,这样就可以和使用正常的 HTML 标签元素一样来使用它,通过customElements.define
来注册和定义一个新的自定义标签,第一个参数为自定义元素标签名称,第二参数为需要传入一个只能继承于 HTMLElement
的元素,因为我们自定义的标签也算是一个 HTMLElement
元素:<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>自定义 Web 组件</title>
</head>
<body>
<!-- 使用示例 -->
<card-img></card-img>
<script type="text/javascript">
// 定义一个 card-img 组件
class CardImg extends HTMLElement {
// 构造函数
constructor() {
super();
// 创建 Shadow DOM
const shadow = this.attachShadow({ mode: "open" });
// 创建 card 的样式
const style = document.createElement("style");
style.textContent = `
.card {
border: 1px solid #ccc;
border-radius: 8px;
padding: 16px;
max-width: 300px;
margin: 16px;
}
img {
max-width: 100%;
border-radius: 8px;
}
h2 {
font-size: 1.5rem;
margin-top: 12px;
}
p {
margin-top: 8px;
}
`;
// 创建 card 的内容
const card = document.createElement("div");
card.className = "card";
// 创建 img 元素
const img = document.createElement("img");
// 设置图片地址
img.src = "https://img.ibyte.me/ys302w.png";
card.appendChild(img);
// 创建标题元素
const title = document.createElement("h2");
// 设置标题
title.textContent = "示例标题";
card.appendChild(title);
// 创建段落元素
const paragraph = document.createElement("p");
// 设置段落内容
paragraph.textContent = "这是一个自定义的 Web 组件,支持显示图片、标题和段落内容。";
card.appendChild(paragraph);
// 添加自定义样式和 card 到影子节点中
shadow.appendChild(style);
shadow.appendChild(card);
// 判断 shadow 是否为 DocumentFragment 对象
console.log(shadow instanceof DocumentFragment);
};
};
// 将自定义的组件标签注册到全局中
customElements.define("card-img", CardImg);
</script>
</body>
</html>
上面定义一个 CardImg
继承了 HTMLElement
,在是 js 新的支持语法特性,使得能和 OOP 特性语言一样设计程序,在 CardImg 中重写了 constructor
方法,当在 Body 中添加这个自定义的 CardImg 标签时,此 constructor 方法就会被执行,初始化对应的组件。中间的逻辑代码采用传统 document 对象的方法创建多个 element 对象,包括 img 用来显示图片,h 和 p 用来显示 card 的标题和文本内容,最后把这些 element 存放到 Shadow DOM
中,因为 shadow dom 影子节点可以起到样式完全独立的作用,不用担心样式发生冲突或者被覆盖掉的问题。当在外部使用 querySelectorAll
的时候常规 DOM 方法是不可见的,其中的 { mode: "open" }
参数表示是否可以让宿主节点有一个 shadowRoot
属性来操作它,这里的 shadow 是一个 DocumentFragment
对象实现。
<card-img></card-img>
标签就可以在 HTML 文档中渲染出来对应元素,但是目前的问题,card-img 标签的元素属性被修改了不会主动重新渲染,这时我们需要对其进行改进和修改,使得能自动进行属性发生改变时能重绘制 DOM 节点。
在 Custom Element 中规定在对象被添加到父 DOM 会执行从 HTMLElement 对象继承重写的 connectedCallback
方法,在此方法会进行初始化;还有 observedAttributes
方法,此方法返回的是一个字符串数组,该数字的作用为表示 DOM 中属性,这些属性发送了变化就被监听到从而调用 MutationObserver
对象监听属性改动,实现整个 DOM 重会工作,代码如下:
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>自定义 Web 组件</title>
<style type="text/css">
.input {
border: 1px solid #ccc;
border-radius: 8px;
padding: 16px;
max-width: 330px;
margin: 16px;
width: 800px;
font-size: 20px;
}
</style>
</head>
<body>
<!-- 使用示例 -->
<card-img img-src="https://img.ibyte.me/ys302w.png" title-text="示例标题"
paragraph-text="这是一个自定义的 Web 组件,支持显示图片、标题和段落内容。"></card-img>
<textarea class="input" id="paragraph-text-input" rows="5" cols="50" placeholder="请输入内容..."></textarea>
<script type="text/javascript">
// 定义一个 card-img 组件
class CardImg extends HTMLElement {
// 构造函数
constructor() {
super();
// 创建 Shadow DOM
const shadow = this.attachShadow({ mode: "open" });
// 创建 card 的样式
const style = document.createElement("style");
style.textContent = `
.card {
border: 1px solid #ccc;
border-radius: 8px;
padding: 16px;
max-width: 300px;
margin: 16px;
}
img {
max-width: 100%;
border-radius: 8px;
}
h2 {
font-size: 1.5rem;
margin-top: 12px;
}
p {
margin-top: 8px;
}
`;
// 创建 card 的内容
const card = document.createElement("div");
card.className = "card";
// 创建 img 元素
const img = document.createElement("img");
card.appendChild(img);
// 创建标题元素
const title = document.createElement("h2");
card.appendChild(title);
// 创建段落元素
const paragraph = document.createElement("p");
card.appendChild(paragraph);
shadow.appendChild(style);
shadow.appendChild(card);
// 获取 img, title, 和 paragraph 的引用
this.img = img;
this.title = title;
this.paragraph = paragraph;
}
// 当组件被连接到 DOM 时调用
connectedCallback() {
// 获取属性并设置内容
this.updateContent();
// 监听属性改动
const observer = new MutationObserver(() => this.updateContent());
observer.observe(this, { attributes: true });
}
// 需要被监听改动的属性
static get observedAttributes() { return ["img-src", "title-text", "paragraph-text"]; }
// 当观察到属性改变时,更新内容
updateContent() {
this.img.src = this.getAttribute("img-src");
// 获取 title 和 paragraph 的 DOM 元素,并设置它们的文本内容
const title = this.shadowRoot.querySelector("h2");
const paragraph = this.shadowRoot.querySelector("p");
title.textContent = this.getAttribute("title-text");
paragraph.textContent = this.getAttribute("paragraph-text");
}
};
// 注册组件
customElements.define("card-img", CardImg);
// 定义一个 input 组件
let input = document.getElementById("paragraph-text-input");
// 获取自定义的 card-img
let card = document.querySelector("card-img");
// 监听 input 输入事件
input.addEventListener("input", () => {
card.setAttribute("paragraph-text", input.value);
});
</script>
</body>
</html>
MutationObserver
被创建后调用了实例的 observe()
方法,在 observe()
方法接受两个参数分别为要观察的目标节点和一个选项对象。选项对象指定观察器应该观察什么样的变化,在这里 { attributes: true }
选项表示观察器应该仅观察目标节点的属性变化,实现整个自定义重绘逻辑,此处逻辑是自定义的 updateContent
方法。
最后为了测试自定义的组件是否能正常工作,定义一个 <textarea></textarea>
标签,并且给它绑定了输入监听事件,当有字符串被输入时,会对自定义的 <card-img>
标签的 paragraph-text
属性值进行动态更新。另外如果需要监测自定义 DOM 属性变化可以使用 attributeChangeCallback
,可以用于观察元素的属性变化,这个方法会在自定义元素的属性发生变化时被调用,从而允许您对属性变化做出相应的处理,可以将上面的代码修改为:
// 当观察到属性改变时,更新内容
attributeChangedCallback(name, oldValue, newValue) {
if (name === "img-src") {
this.img.src = newValue;
} else if (name === "title-text") {
this.title.textContent = newValue;
} else if (name === "paragraph-text") {
this.paragraph.textContent = newValue;
}
}
attributeChangeCallback
方法的参数列表中,分别为 name
为属性名,oldValue
对应属性的旧值, newValue
需要设置属性的新值。
如果需要创建一个复杂的自定义组件标签,内部元素很多,如果单一使用 document.createElement("p")
的方式在自定义 Class 构建对应的子标签元素,会很影响开发编写进度。这时可以 Web Components 为我们原生提供的 HTML 模版来进行开发,可以复用 HTML 代码结构,让创建自定义标签组件更快,如下代码内容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Complex Web Components Example</title>
</head>
<body>
<todo-list></todo-list>
<template id="todoTemplate">
<style>
/* 样式只作用于当前 Shadow DOM,不会影响外部样式 */
:host {
display: block;
border: 1px solid #ccc;
padding: 16px;
}
ul {
list-style: none;
padding: 0;
}
li {
margin-bottom: 8px;
}
</style>
<h2>Todo List</h2>
<input type="text" id="newTodo" placeholder="Add a new todo">
<button id="addButton">Add</button>
<ul id="todoList"></ul>
</template>
<script type="module">
// 自定义元素 TodoList
class TodoList extends HTMLElement {
constructor() {
super();
// 创建 Shadow DOM
const shadow = this.attachShadow({ mode: "open" });
// 获取模板内容
const template = document.getElementById("todoTemplate");
const content = template.content.cloneNode(true);
// 添加事件处理器
const addButton = content.getElementById("addButton");
addButton.addEventListener("click", () => this.addTodo());
// 将内容插入 Shadow DOM
shadow.appendChild(content);
// 初始化 todoList 数据
if (localStorage.getItem("tolist")) {
this.todos = JSON.parse(localStorage.getItem("tolist"));
} else {
this.todos = [];
}
}
// 通过 addTodo 自定义添加 todo 事件逻辑
addTodo() {
// 获取用户输入的内容
const newTodoInput = this.shadowRoot.getElementById("newTodo");
// 去掉前后空格
const newTodoText = newTodoInput.value.trim();
// 如果输入的内容不为空
if (newTodoText) {
// 添加到 todoList 中
this.todos.push(newTodoText);
this.renderTodos();
newTodoInput.value = "";
}
// 持久化,先序列化 JSON 之后在保存
localStorage.setItem("tolist", JSON.stringify(this.todos));
}
// 重绘方法
renderTodos() {
const todoList = this.shadowRoot.getElementById("todoList");
todoList.innerHTML = "";
this.todos.forEach((todo) => {
const li = document.createElement("li");
li.textContent = todo;
todoList.appendChild(li);
});
}
// 当自定义组件被添加到根 DOM 时触发方法
connectedCallback() {
this.renderTodos();
}
}
// 注册组件
customElements.define("todo-list", TodoList);
</script>
</body>
</html>
通过上面代码中的 document.getElementById
获取到已经编写的 template
复用的文档结构,最后使用 cloneNode
方法深度复制得到对应的 HTML 结构代码并且将起插入到 shadow
中,最后 this.shadowRoot
对应的结构就为模版代码结构,当这个自定义标签组件完成之后会实现一个 TodoList
功能组件,并且使用了 localStorage
来持久化添加的任务数据,被浏览器渲染效果:
至此 Web Components 解决的问题和使用方法已经在本篇博文中详细介绍,关于 Web Components 相关的更多功能可以阅读下面的链接。