实现一个前端动态模块组件(Vite+原生JS)

1. 引言

在前面的文章《使用Vite创建一个动态网页的前端项目》中我们实现了一个动态网页。不过这个动态网页的实用价值并不高,在真正实际的项目中我们希望的是能实现一个动态的模块组件。具体来说,就是有一个页面控件同时在多个页面中使用,那么我们肯定想将这个页面控件封装起来,以便每个页面需要的时候调用一下就可以生成。注意,这个封装起来模块组件应该要包含完整的HTML+JavaScript+CSS,并且要根据从后端访问的数据来动态填充页面内容。其实像VUE这样的前端框架就是这种设计思路,同时这也是GUI程序开发的常见思维模式。

2. 实现

2.1 项目组织

在这里笔者实现的例子是一个博客网站上的分类专栏控件。分类专栏是一般通过后端获取的,但是这里笔者就将其模拟成直接域内获取一个数据categories.json,里面的内容如下:

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页面,代码如下所示:

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文件:

js 复制代码
import './components/category.js'

里面其实啥都没干,只是引入了一个category模块。那么就看一下这个category.js文件:

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中的内容很简单:

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函数,如下所示:

cpp 复制代码
// 使用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元素的子节点下。代码如下所示:

复制代码
js 复制代码
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文件如下所示:

css 复制代码
/* 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 {
    /* font-weight: bold; */
    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中引入,一个动态页面就组合起来了。

实现代码

相关推荐
进取星辰38 分钟前
33、魔法防御术——React 19 安全攻防实战
前端·安全·react.js
海天胜景1 小时前
vue3 el-table 行号
javascript·vue.js·ecmascript
小赖同学啊1 小时前
深度解析 Element Plus
前端·javascript·vue.js
二十雨辰1 小时前
[CSS3]百分比布局
前端·html·css3
大大。1 小时前
Vue3 与 Vue2 区别
前端·面试·职场和发展
EndingCoder1 小时前
从零基础到最佳实践:Vue.js 系列(3/10):《组件化开发入门》
前端·javascript·vue.js
职场马喽1 小时前
vue+luckysheet导出功能(解决了样式为null的报错问题)
前端·javascript·vue.js
北辰浮光1 小时前
[Vue]路由基础使用和路径传参
前端·javascript·vue.js
難釋懷1 小时前
Vue 简介
前端·javascript·vue.js
阿珊和她的猫2 小时前
Axios创建实例:灵活配置和模块化开发
前端·javascript