在前端工程中我们都需要开发一些复杂的 HTML 页面,每次都单独编写这会是一个很大工作量,而每次编写的 HTML 样式可能会存在一些问题,也不能复用。做过前端开发的朋友都是使用现有的 Web UI 框架来实现,很少有人自己去通过原生的代码去编写页面,现在 Web 开发者都使用现有的 React 、Angular 、Vue 视图层的框架。如果不使用这些框架,我们自己如何实现一个自定义组件呢?现在有了新的选择可以通过 Web 组件计算来实现的 HTML 可复用样式组件。

在新的 W3C 标准中添加了 Web Components 功能,可以使用 Web Components 向 Web 文档和 Web 应用程序中创建可重用的小部件或组件,这和 Java 中的面向对象编程类似可以复用 Class 类,使得开发更关注的是不同对象之间的关系,而不是实现完成一个功能就敷衍了事。这就使得我们开发者可以构建能复用、自定义的 HTML 元素,可以将代码打包成一个封装的、独立的组件,然后可以在不同的 Web 应用中重复使用。使得我们开发应用更容易扩张和维护还有提高了代码的复用率,这篇文章我将会介绍关于 Web Components 由来和解决了什么样的问题?使用它有什么好处?阅读完成之后读者会对整个 Web Components 技术栈有新的认识。


传统开发

在现在前端开发中 HTML 和 CSS 已经成为构建应用程序界面的特定语言了,在早期的前端开发中使用 HTML 进行页面开发都是使用 HTML 超文本标记语言内置预定义好的 element 标签来构建我们想要的图形化界面,但是内置的 bottomimageph1 等等标签只能算是内置的单一标签,开发者需要将这些标签通过 HTML 语句块组合起来,才能构建成为一个完整的界面,如下为一个 HTML 表单代码,依靠着 plabelinput 标签构成的:

     <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

在前面的一篇关于的 HTML 渲染的文章 Ajax + Pjax 无刷新更新页面 中我详细介绍过页面渲染的过程,其实整个 DOM 就是一个文档组织成的树形的 Node 结构如下图所示。在前面我使用传统的方法向某一个 Node 节点下级添加自定义 Node 节点代码片段,这样操作会导致向 Node 树中子节点操作频繁,为了避免这样的问题,可以使用 DocumentFragment 节点操作,它的类型也是为 Node 类型,可以充当一组子节点的父节点,方便将一组子节点充当为一个完整的 Node 节点来使用。DocumentFragmentElement 最重要的区别是它没有父节点,插入到其他节点中时,其他节点子元素则为它子元素节点。

在下面这段代码就是采用 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>

虽然 DocumentFragment 解决了由频繁 DOM 操作引起的回流和重绘比第一种示例性能更好,但新的问题是,没有直接创建可复用的组件,只是用于批量创建和添加元素情况,所以现在要结合这两种方式特性来共同解决能复用并且性能的问题,为此一种新的技术解决方案出现了叫 Web Components


Web Components

其实一个简单的网页和单页面程序完全没有必要进行组件的拆分,做过前端开发都使用过著名的 UI 框架 Bootstrap ,它的早期版本提倡的是通过已有 HTML 的元素加它自定义的 CSS 组合来实现页面元素封装和分离,例如下面的代码是一个 Images 例子:

像上面这样,我们开发复用只能复制整个 div 代码块,而不能把整个 div 自定义为一个 HTML 标签,理想的使用方法是 <card-img></card-img> 就能在页面上绘画出同样的一个组件。

传统 Web 前端页面开发会原生 JS 或者 JQuery 来操作实现 DOM 树变化和重绘,当然这里的 PHP 排外,PHP 是一个特例它支持在 PHP 逻辑代码里面嵌入 HTML 代码实现动态网站效果,但是现在 Web 应用开发界面复杂。通过 UX 来设计的 UI 原型图比传统界面更为复杂,导致开发一个用户界面已经不单单是一个独立工程师就能去实现的代码工作量,另外现在主流的 iOS 和 Android 端都有各自的编程语言和 UI 风格,例如 Swift 和 SwiftUI 、Kotlin 和 Material Design 又或者采用新的 Dart 和 Flutter 来实现用户界面,这些技术栈无一例外都是采用组件化 UI 来实现模块化,方便多位工程师来实现一个完整 UI 界面给最终用户;最典型的落地实现是 React 框架,让你可以通过组件来构建用户界面。

此时现在有一个需求我们要构建一个类似于上面提到了 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 相关的更多功能可以阅读下面的链接。


其他资料

便宜 VPS vultr
最后修改:2023 年 08 月 22 日
如果觉得我的文章对你有用,请随意赞赏 🌹 谢谢 !