实现一个前端动态模块组件(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中引入,一个动态页面就组合起来了。

实现代码

相关推荐
用户214118326360224 分钟前
首发!即梦 4.0 接口开发全攻略:AI 辅助零代码实现,开源 + Docker 部署,小白也能上手
前端
gnip2 小时前
链式调用和延迟执行
前端·javascript
SoaringHeart2 小时前
Flutter组件封装:页面点击事件拦截
前端·flutter
杨天天.2 小时前
小程序原生实现音频播放器,下一首上一首切换,拖动进度条等功能
前端·javascript·小程序·音视频
Dragon Wu3 小时前
React state在setInterval里未获取最新值的问题
前端·javascript·react.js·前端框架
Jinuss3 小时前
Vue3源码reactivity响应式篇之watch实现
前端·vue3
YU大宗师3 小时前
React面试题
前端·javascript·react.js
木兮xg3 小时前
react基础篇
前端·react.js·前端框架
ssshooter3 小时前
你知道怎么用 pnpm 临时给某个库打补丁吗?
前端·面试·npm
IT利刃出鞘4 小时前
HTML--最简的二级菜单页面
前端·html