在新的 W3C 标准中添加了 Web Components 功能,可以使用 Web Components 向 Web 文档和 Web 应用程序中创建可重用的小部件或组件,这和 Java 中的面向对象编程类似可以复用 Class 类,使得开发更关注的是不同对象之间的关系,而不是实现完成一个功能就敷衍了事。这就使得我们开发者可以构建能复用、自定义的 HTML 元素,可以将代码打包成一个封装的、独立的组件,然后可以在不同的 Web 应用中重复使用。使得我们开发应用更容易扩张和维护还有提高了代码的复用率。
这篇文章我将会介绍关于 Web Components 由来和解决了什么样的问题?使用它有什么好处?阅读完成之后读者会对整个 Web Components 技术栈有新的认识。
<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>`; // 堆代码 duidaima.com // 向 body 中添加一个元素 document.body.appendChild(form); </script> </body> </html>最后当 DOM 重绘之后界面效果如下:
此时在使用 form 就可以达到使用它就能创建一个固定的代码元素组件的效果。此种方式虽然可以解决当前不用写太多代码的来实现一个 HTML 组件的复用性,但是并没有完全友好的解决这个问题,现在还有一个问题是需要通过 js 代码进行包装才能正常复用代码组件,并且还需要 js 频繁得操作 DOM 树结构,完成重绘界面。
<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>
虽然 DocumentFragment 解决了由频繁 DOM 操作引起的回流和重绘比第一种示例性能更好,但新的问题是,没有直接创建可复用的组件,只是用于批量创建和添加元素情况,所以现在要结合这两种方式特性来共同解决能复用并且性能的问题,为此一种新的技术解决方案出现了叫 Web Components 。
传统 Web 前端页面开发会原生 JS 或者 JQuery 来操作实现 DOM 树变化和重绘,当然这里的 PHP 排外,PHP 是一个特例它支持在 PHP 逻辑代码里面嵌入 HTML 代码实现动态网站效果,但是现在 Web 应用开发界面复杂。通过 UX 来设计的 UI 原型图比传统界面更为复杂,导致开发一个用户界面已经不单单是一个独立工程师就能去实现的代码工作量,另外现在主流的 iOS 和 Android 端都有各自的编程语言和 UI 风格,例如 Swift 和 SwiftUI 、Kotlin 和 Material Design 又或者采用新的 Dart 和 Flutter 来实现用户界面,这些技术栈无一例外都是采用组件化 UI 来实现模块化,方便多位工程师来实现一个完整 UI 界面给最终用户;最典型的落地实现是 React 框架,让你可以通过组件来构建用户界面。
组件名 | 作用说明 |
---|---|
Custom Elements | 用于向全局注册自定义 HTML 元素,并指定元素的行为和功能,可以将其视为内置 HTML 元素一样在页面中使用。 |
Shadow DOM | 允许将封装的样式和行为附加到自定义元素上,可以实现组件内部样式和脚本对外部的隔离。 |
HTML Template | 可以复用 HTML 代码结构,让自定义标签能通过模版显示一个包含动态内容。 |
<html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>堆代码 duidaima.com</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 节点。
<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 方法。
// 当观察到属性改变时,更新内容 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 需要设置属性的新值。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>堆代码 duidaima.com</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 来持久化添加的任务数据,被浏览器渲染效果: