通过JS模板引擎实现动态模块组件(Vite+JS+Handlebars)

1. 引言

在上一篇文章《实现一个前端动态模块组件(Vite+原生JS)》中,笔者通过原生的JavaScript实现了一个动态的模块组件。但是这个实现并不完善,最大的问题就是功能逻辑并没有完全分开。比如模块的HTML:

HTML 复制代码
<div class="category-section">
    <h3>分类专栏</h3>
    <ul class="category-list">
    </ul>
</div>

其实只是静态内容,动态的内容其实在JavaScript中实现:

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);

一般来说,HTML负责网页结构和内容,CSS控制样式和布局,JavaScript实现交互和动态功能。因此,最好把动态的部分也加入到HTML中去,不仅逻辑上更加清晰,像一些调试样式的操作也更加方便。不过这样的话,HTML部分就不是一些单纯的HTML元素了,而是一个生成HTML页面的模板字符串。

考虑一下如何实现从模板字符串展开成HTML元素的操作。如果只是单独的变量那好做,比如图表控件统计的格式,我们可以在模板字符串中加上一些特殊的标识符,比如使用"{{}}"将其包裹起来,然后在其展开之前通过正则表达式查找替换出成后端获取的变量即可。但是如果是数组变量怎么办呢?在展开之前我们是不知道数组变量的个数的,比如案例中分类专栏的个数。那么我们就要写类似于for循环的标识符,然后识别并展开成HTML元素。

这样的实现思路感觉就略显麻烦了,笔者反正是不愿意去碰很抽象的正则表达式的。好在其实这个问题早就有了解决方案,那就是模板引擎。前端的模板引擎有很多种,像Vue这样的前端框架甚至自带,笔者这里使用的是Handlebars。使用模板引擎不仅仅只有前面笔者论述的两点,但是这里的案例没有用到,笔者就不进行论述了。

2. 实现

2.1 安装依赖包

那么我们就使用Handlebars来改造之前的案例。首先需要安装Handlebars,通过VS Code打开的终端中输入如下指令:

shell 复制代码
npm install handlebars --save

Handlebars依赖包就安装到当前项目的环境中了,我们可以在package.json中看到:

json 复制代码
{
  "name": "my-native-js-app",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "vite": "^6.3.5"
  },
  "dependencies": {
    "handlebars": "^4.7.8"
  }
}

另外,在这里笔者就简单介绍一下依赖包的安装。对于一个前端项目来说,依赖包的安装是非常重要的,接手新项目的时候,往往是项目本地的代码没有问题,依赖库的安装反而很麻烦。一般来说,如果是初次接手项目,需要安装所有的依赖包:

shell 复制代码
npm install

但是有时候会遇到网络问题安装不上,可以通过设置代理解决,或者更换依赖包源地址。如果需要安装特定的包,那么指令就是:

shell 复制代码
npm install <package-name>

不过有时会遇到与项目的依赖包环境不匹配问题,或者网络漏洞问题,这个时候就需要升级或者降级一些依赖包。

另外,对于开发环境仅需的依赖(package.json中的devDependencies节点),可以使用 --save-dev 或 -D 标志来安装:

shell 复制代码
npm install <package-name> --save-dev

2.2 优化代码

既然使用Handlebars模板引擎了,那么表达网页结构和内容的部分就不再是HTML元素而是Handlebars模板了,因此将category.html修改成category.handlebars,其内容如下:

HTML 复制代码
<div class="category-section">
    <h3>分类专栏</h3>
    <ul class="category-list">
        {{#each categories}}
        <li>
            <a href="#" class="category-item">
                <img src="category/{{firstCategory.iconAddress}}" alt="{{firstCategory.name}}"
                    class="category-icon" />
                <span class="category-name">
                    {{firstCategory.name}}
                    <span class="article-count">
                        {{firstCategory.articleCount}}篇
                    </span>
                </span>
                <ul class="subcategory-list">
                    {{#each secondCategories}}
                    <li>
                        <a href="#" class="subcategory-item">
                            <img src="category/{{iconAddress}}" alt="{{name}}"
                                class="subcategory-icon">
                            <span class="subcategory-name">
                                {{name}} 
                                <span class="article-count">{{articleCount}}篇</span>
                            </span>
                        </a>
                    </li>                   
                    {{/each}}
                </ul>
            </a>
        </li>
        {{/each}}
    </ul>
</div>

HTML元素部分我们已经很熟悉了,关键在于Handlebars模板引擎部分。{{#each}}{{/each}}是Handlebars的一个块表达式,可以将其理解成foreach语句,用于遍历数组。这里我们分别遍历了一级分类专栏({{#each categories}})和二级分类专栏({{#each secondCategories}})。

另一个值得说明的是{{name}}{{iconAddress}}{{articleCount}}这些都是用来展示具体数据的占位符,Handlebars会在渲染时用实际的数据替换这些占位符。不过相信读者也发现了,一级分类的占位符({{firstCategory.name}})和二级分类的占位符({{name}})并不一致。其实这与传入到Handlebars模板进行展开时的数据参数有关,再次看一下数据:

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开发" }
    ]
  }
]

结合这个数据的结构来说,Handlebars使用了一种上下文或者作用域的概念:当进入一个{{#each}}循环时,当前上下文会变成数组中的当前元素。因此在第一层循环中获取分类专栏的名称是{{firstCategory.name}},而在第二层循环中分类专栏的名称则可以省略成{{name}},其他变量也是同理。应该来说,Handlebars模板内容与HTML结构的文本非常接近了,保证了动态特性的同时还隔离了HTML页面的结构组织和交互行为。最直观的说法就是,调试样式方便了,不用在HTML字符串中写class、id了,而是可以像在写在静态页面中一样写在模板中。

接下来看一下改进之后的category.js,具体代码如下:

js 复制代码
import "./category.css";
import Handlebars from "handlebars";
import templateSource from "./category.handlebars?raw";

async function loadCategory() {
  try {   
    const response = await fetch("/categories.json");
    if (!response.ok) {
      throw new Error("网络无响应");
    }
    const categories = await response.json();

    // 编译模板
    const template = Handlebars.compile(templateSource);

    // 渲染模板
    const renderedHtml = template({
      categories,
    });

    // 将渲染好的HTML插入到页面中
    document.getElementById("category-section-placeholder").innerHTML =
      renderedHtml;
  } catch (error) {
    console.error("获取分类专栏失败:", error);
  }
}

document.addEventListener("DOMContentLoaded", loadCategory);

相比之前的实现,使用Handlebars模板的实现真的是简洁多了,这就是使用轮子的好处吧。首先可以先看一下模块导入:

js 复制代码
import Handlebars from "handlebars";
import templateSource from "./category.handlebars?raw";

第一句表示导入Handlebars依赖包,第二局则是导入category模板。注意这里的?raw是不能省略的,这里意思是将category.handlebars按照裸数据导入,其实也就是文本字符串。这其实Vite项目中才提供的能力,也可以使用fetch语句来获取。

然后从远端获取数据,与之前的案例实现一样:

js 复制代码
const response = await fetch("/categories.json");
if (!response.ok) {
  throw new Error("网络无响应");
}
const categories = await response.json();

最后是将Handlebars模板展开成具体的HTML元素,加载到页面中:

js 复制代码
// 编译模板
const template = Handlebars.compile(templateSource);

// 渲染模板
const renderedHtml = template({
  categories,
});

// 将渲染好的HTML插入到页面中
document.getElementById("category-section-placeholder").innerHTML =
  renderedHtml;

如上所述,在真正模板展开的时候,要传递数据进行模板函数结构,比如这里的从远端获取的分类专栏数据categories。当然,如果想传其他的数据也行,将其组合成Object对象进入到template接口中即可。

2.3 运行结果

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. 结语

通过本例和上一篇文章《实现一个前端动态模块组件(Vite+原生JS)》 的对比可以体会到,模板引擎确实是一项顺理成章的技术,在实现了动态网页特性的同时,又兼顾了程序模块化的思维,值得进行学习和使用。

代码实现

相关推荐
gnip19 分钟前
链式调用和延迟执行
前端·javascript
SoaringHeart29 分钟前
Flutter组件封装:页面点击事件拦截
前端·flutter
杨天天.32 分钟前
小程序原生实现音频播放器,下一首上一首切换,拖动进度条等功能
前端·javascript·小程序·音视频
Dragon Wu42 分钟前
React state在setInterval里未获取最新值的问题
前端·javascript·react.js·前端框架
Jinuss42 分钟前
Vue3源码reactivity响应式篇之watch实现
前端·vue3
YU大宗师1 小时前
React面试题
前端·javascript·react.js
木兮xg1 小时前
react基础篇
前端·react.js·前端框架
ssshooter1 小时前
你知道怎么用 pnpm 临时给某个库打补丁吗?
前端·面试·npm
IT利刃出鞘2 小时前
HTML--最简的二级菜单页面
前端·html
yume_sibai2 小时前
HTML HTML基础(4)
前端·html