1. 引言
在前面的文章《使用Vite创建一个动态网页的前端项目》中我们实现了一个动态网页。不过这个动态网页的实用价值并不高,在真正实际的项目中我们希望的是能实现一个动态的模块组件。具体来说,就是有一个页面控件同时在多个页面中使用,那么我们肯定想将这个页面控件封装起来,以便每个页面需要的时候调用一下就可以生成。注意,这个封装起来模块组件应该要包含完整的HTML+JavaScript+CSS,并且要根据从后端访问的数据来动态填充页面内容。其实像VUE这样的前端框架就是这种设计思路,同时这也是GUI程序开发的常见思维模式。
2. 实现
2.1 项目组织
在这里笔者实现的例子是一个博客网站上的分类专栏控件。分类专栏是一般通过后端获取的,但是这里笔者就将其模拟成直接域内获取一个数据categories.json,里面的内容如下:
[ {"firstCategory": {"articleCount": 4,"iconAddress":"三维渲染.svg","name":"计算机图形学" },"secondCategories": [ {"articleCount": 2,"iconAddress":"opengl.svg","name":"OpenGL/WebGL" }, {"articleCount": 2,"iconAddress":"专栏分类.svg","name":"OpenSceneGraph" }, {"articleCount": 0,"iconAddress":"threejs.svg","name":"three.js" }, {"articleCount": 0,"iconAddress":"cesium.svg","name":"Cesium" }, {"articleCount": 0,"iconAddress":"unity.svg","name":"Unity3D" }, {"articleCount": 0,"iconAddress":"unrealengine.svg","name":"Unreal Engine" } ] }, {"firstCategory": {"articleCount": 4,"iconAddress":"计算机视觉.svg","name":"计算机视觉" },"secondCategories": [ {"articleCount": 0,"iconAddress":"图像处理.svg","name":"数字图像处理" }, {"articleCount": 0,"iconAddress":"特征提取.svg","name":"特征提取与匹配" }, {"articleCount": 0,"iconAddress":"目标检测.svg","name":"目标检测与分割" }, {"articleCount": 4,"iconAddress":"SLAM.svg","name":"三维重建与SLAM" } ] }, {"firstCategory": {"articleCount": 11,"iconAddress":"地理信息系统.svg","name":"地理信息科学" },"secondCategories": [] }, {"firstCategory": {"articleCount": 31,"iconAddress":"代码.svg","name":"软件开发技术与工具" },"secondCategories": [ {"articleCount": 2,"iconAddress":"cplusplus.svg","name":"C/C++" }, {"articleCount": 19,"iconAddress":"cmake.svg","name":"CMake构建" }, {"articleCount": 2,"iconAddress":"Web开发.svg","name":"Web开发" }, {"articleCount": 7,"iconAddress":"git.svg","name":"Git" }, {"articleCount": 1,"iconAddress":"linux.svg","name":"Linux开发" } ] }]
这个数据的意思是将分类专类分成一级分类专栏和二级分类专栏,每个专栏都有名称、文章数、图标地址属性,这样便于我们填充到页面中。
新建一个components目录,在这个目录中新建category.html、category.js、category.css这三个文件,正如前文所说的,我们希望这个模块组件能同时具有结构、行为和样式的能力。这样,这个项目的文件组织结构如下所示:
my-native-js-app
├── public
│ └── categories.json
├── src
│ ├── components
│ │ ├── category.css
│ │ ├── category.html
│ │ └── category.js
│ └── main.js
├── index.html
└── package.json
2.2 具体解析
先看index.html页面,代码如下所示:
<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vite App</title> </head> <body> <div id="app"> <div id="category-section-placeholder"></div> </div> <script type="module" src="/src/main.js"></script> </body></html>
基本都没有什么变化,只是增加了一个名为category-section-placeholder
的元素,这个元素会用来挂接在js中动态创建的分类专栏目录元素。
接下来看main.js文件:
import './components/category.js'
里面其实啥都没干,只是引入了一个category模块。那么就看一下这个category.js文件:
import"./category.css";// 定义一个变量来存储获取到的分类数据let categoriesJson = null;// 使用MutationObserver监听DOM变化const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if ( mutation.type ==="childList" && mutation.target.id ==="category-section-placeholder" ) { // 在这里调用函数来填充数据 populateCategories(categoriesJson); } });});// 配置观察选项const config = { childList: true, subtree: true };// 开始观察目标节点const targetNode = document.getElementById("category-section-placeholder");observer.observe(targetNode, config);// 获取分类数据async function fetchCategories() { try { const backendUrl = import.meta.env.VITE_BACKEND_URL; const response = await fetch("/categories.json"); if (!response.ok) { throw new Error("网络无响应"); } categoriesJson = await response.json(); // 加载Category.html内容 fetch("/src/components/category.html") .then((response) => response.text()) .then((data) => { document.getElementById("category-section-placeholder").innerHTML = data; }) .catch((error) => { console.error("Failed to load Category.html:", error); }); } catch (error) { console.error("获取分类专栏失败:", error); }}// 填充分类数据function populateCategories(categories) { if (!categories || !Array.isArray(categories)) { console.error("Invalid categories data:", categories); return; } const categoryList = document.querySelector(".category-list"); categories.forEach((category) => { const categoryItem = document.createElement("li"); categoryItem.innerHTML = ` <a href="#" class="category-item"> <img src="category/${category.firstCategory.iconAddress}" alt="${category.firstCategory.name}" class="category-icon"> <span class="category-name">${category.firstCategory.name} <span class="article-count">${category.firstCategory.articleCount}篇</span></span>`; if (category.secondCategories.length != 0) { categoryItem.innerHTML += ` <ul class="subcategory-list"> ${category.secondCategories .map( (subcategory) => ` <li><a href="#" class="subcategory-item"> <img src="category/${subcategory.iconAddress}" alt="${subcategory.name}" class="subcategory-icon"> <span class="subcategory-name">${subcategory.name} <span class="article-count">${subcategory.articleCount}篇</span></span> </a></li> ` ) .join("")} </ul> </a> `; } categoryList.appendChild(categoryItem); });}// 确保DOM完全加载后再执行document.addEventListener("DOMContentLoaded", fetchCategories);
这个文件里面的内容比较多,那么我们就按照代码的执行顺序进行讲解。
document.addEventListener("DOMContentLoaded", fetchCategories);
表示当index.html这个页面加载成功后,就执行fetchCategories
这个函数。在这个函数通过fetch
接口获取目录数据,通过也通过fetch接口获取category.html。category.html中的内容很简单:
<div class="category-section"> <h3>分类专栏</h3> <ul class="category-list"> </ul></div>
fetch接口是按照文本的方式来获取category.html的,在这里的document.getElementById("category-section-placeholder").innerHTML = data;
表示将这段文本序列化到category-section-placeholder
元素的子节点中。程序执行到这里并没有结束,通过对DOM的变化监听,继续执行populateCategories
函数,如下所示:
// 使用MutationObserver监听DOM变化const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if ( mutation.type ==="childList" && mutation.target.id ==="category-section-placeholder" ) { // 在这里调用函数来填充数据 populateCategories(categoriesJson); } });});// 配置观察选项const config = { childList: true, subtree: true };// 开始观察目标节点const targetNode = document.getElementById("category-section-placeholder");observer.observe(targetNode, config);
populateCategories
的具体实现思路是:现在分类专栏的数据已经有了,根节点元素category-list
也已经知道,剩下的就是通过数据来拼接HTML字符串,然后序列化到category-list
元素的子节点下。代码如下所示:
const categoryList = document.querySelector(".category-list");categories.forEach((category) => {const categoryItem = document.createElement("li");categoryItem.innerHTML = ` <a href="#" class="category-item"> <img src="category/${category.firstCategory.iconAddress}" alt="${category.firstCategory.name}" class="category-icon"> <span class="category-name">${category.firstCategory.name} <span class="article-count">${category.firstCategory.articleCount}篇</span></span>`;if (category.secondCategories.length != 0) { categoryItem.innerHTML += ` <ul class="subcategory-list"> ${category.secondCategories .map( (subcategory) => ` <li><a href="#" class="subcategory-item"> <img src="category/${subcategory.iconAddress}" alt="${subcategory.name}" class="subcategory-icon"> <span class="subcategory-name">${subcategory.name} <span class="article-count">${subcategory.articleCount}篇</span></span> </a></li> ` ) .join("")} </ul> </a> `;}categoryList.appendChild(categoryItem);
其实思路很简单对吧?最后根据需要实现组件的样式,category.css文件如下所示:
.category-section { background-color: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 1rem; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); font-family: Arial, sans-serif; max-width: 260px; overflow: hidden; }.category-section h3 { font-size: 1.2rem; color: #333; border-bottom: 1px solid #e0e0e0; padding-bottom: 0.5rem; margin: 0 0 1rem; text-align: left; }.category-list { list-style: none; padding: 0; margin: 0;}.category-list li { margin: 0.5rem 0;}.category-item,.subcategory-item { display: flex; align-items: center; text-decoration: none; color: #333; transition: color 0.3s ease;}.category-item:hover,.subcategory-item:hover { color: #007BFF;}.category-icon,.subcategory-icon { width: 24px; height: 24px; margin-right: 0.5rem;}.category-name,.subcategory-name { display: flex; justify-content: space-between; width: 100%; color:#000}.article-count { color: #000; font-weight: normal; }.subcategory-list { list-style: none; padding: 0; margin: 0.5rem 0 0 1.5rem;}.subcategory-list li { margin: 0.25rem 0;}.subcategory-list a { text-decoration: none; color: #555; transition: color 0.3s ease;}.subcategory-list a:hover { color: #007BFF;}
最后显示的结果如下图所示:
3. 结语
总结一下前端动态模块组件的实现思路:JavaScript代码永远是主要的,HTML页面就好比是JavaScript的处理对象,过程就跟你用C++/Java/C#/Python读写文本文件一样,其实没什么不同。DOM是浏览器解析处理HTML文档的对象模型,但是本质上HTML是个文本文件(XML文件),需要做的其实就是将HTML元素、CSS元素以及动态数据组合起来,一个动态模块组件就实现了。最后照葫芦画瓢,依次实现其他的组件模块在index.html中引入,一个动态页面就组合起来了。