1. 为什么会走到模板代码模块化这一步
接手那个模板代码已经膨胀到五千多行的项目时,我第一反应是"这玩意儿居然还能跑"。那是一个典型的活动聚合页项目,页面结构其实不复杂:顶部Banner、导航Tab、内容列表、底部版权,但每个区块的DOM结构几乎都是复制粘贴出来的,只是数据不同。业务方今天说要给某个Tab加角标,我就要全页面搜一遍相关的class名,然后一行一行地改。某次需求上线,我改了十三处同样的结构,漏了一处,线上出现了半个页面角标消失的问题。
这类问题在业内特别常见,尤其是营销页面、活动专题、后台管理系统这类偏模板化的场景。所谓的"模板代码模块化设计",本质上就是把页面的结构、样式、数据、逻辑拆成互相独立又能自由组合的单元,让"重复的页面结构"被封装起来,需要的时候引用一次,传入不同的数据,渲染出不同的内容。它解决的核心痛点就三个:重复代码的维护成本、多人协作时的改稿冲突、以及需求频繁变动下的扩展能力。
这篇文章适合谁?前端页面开发、全栈工程师、甚至做模板开发的设计师都能用上。我讲的内容不绑定某一种框架,用的是HTML模板引擎层面的思路,也就是在纯HTML层面就能落地的那套方案。如果你正在被一堆复制粘贴的页面结构折磨,或者想给团队沉淀一套页面开发的规范,这篇内容应该能帮你少走不少弯路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 模块化的三种拆法:结构、样式、数据要分开看
2.1 先搞清楚"拆什么":模板不等于HTML
很多人在做模块化时最容易犯的一个错误,是把"模块化"理解成"把HTML拆成几个文件"。但真正落地下来你会发现,模板代码的模块化要拆的是三样东西:结构、样式、数据。这三者的拆分粒度不同,组合方式也不同,混在一起拆必然会在后续维护时崩掉。
结构层是最直观的。一个完整页面可以被拆成Header、Footer、Banner、CardList、TabBar这些大块,再往下还能拆成Card、Button、Tag这类更小的单元。拆分的关键不是看着像就拆,而是要有明确的"边界"意识:一个结构单元必须能独立描述一个完整的UI语义。比如"Card"就是一个卡片,它包含图片、标题、描述、按钮,无论放在首页还是详情页,它的结构骨架是一样的。
样式层的拆分原则是"隔离"和"继承"要平衡。完全隔离会让样式重复率飙升,完全共用会让样式互相污染。我的做法是:基础原子样式(间距、字号、颜色变量)全局共享,模块自己的样式独立成文件,只在需要覆盖的场景下才允许写针对某个模块的定制样式。
数据层的拆分往往被忽略,但它才是模块化真正省力的地方。一个模板模块内部不该写死任何文案、图片地址、跳转链接,所有这些都要从"数据源"传入。这么做的直接好处是:页面结构不变,只换数据,就能产生完全不同的页面。营销页的逻辑基本就是换数据、换排期、换配置,数据剥离出去之后,每次上新活动我甚至不用改模板文件。
2.2 边界划分的五个判断标准
拆分的粒度到底多细才能"刚刚好"?我跟团队定过一个简单标准:如果一个结构块在一个项目里出现两次以上,就值得做成模块;如果只是单个页面特有,就先不做,保持页面内的私有片段。但这只是粗筛,真正决定边界是否合理的是这五个判断标准:
一是单一职责。一个模块只干一件事。如果有"卡片列表模块"这种组合型模块,它应该被拆成"列表容器"和"卡片"两个层次,而不是揉在一起。二是依赖方向要单向。模块可以依赖基础组件,但基础组件绝不能反过来依赖业务模块,否则改一处全盘崩。三是数据接口要精简。模块接收的数据字段控制在五个以内,如果超过五个,说明这个模块承担的职能太多,要么拆分,要么把数据整理成嵌套结构。四是可配置性要适度。要有开关和配置项,但不要为了"灵活"搞出几十个参数,别人根本不知道该怎么用。五是替换成本要低。模块被换掉时,页面其他部分不应该受影响。
举个例子,我拆过一个"商品卡片"模块。最初它的数据接口有十个字段:商品名、价格、原价、折扣标签、图片、店铺名、销量、好评率、按钮文案、跳转链接。看着都能用上,但问题在于不同场景下这些字段的展示优先级完全不同:秒杀场景要突出价格,直播场景要突出店铺,普通列表场景要突出销量。如果按照十个字段的列表来设计,每次调用都要传一堆不一定用得上的值。最后我把数据拆成了两个层次:基础商品数据(名称、图、价格、链接)和场景配置(展示模式、强调字段、按钮行为),调用方按需组合。这个调整看起来简单,却直接让模块的复用率翻了一倍。
2.3 一个能落地的目录结构设计
拆分的思路定了之后,目录结构就顺理成章了。我给一个活动页项目设计过这样的模板目录:
text复制templates/
├── layout/ # 页面骨架
│ ├── page.html # 整页框架
│ ├── header.html # 页头模块
│ └── footer.html # 页脚模块
├── components/ # 通用组件(可跨页面复用)
│ ├── card/
│ │ ├── card.html
│ │ ├── card.css
│ │ └── card.js
│ ├── tab-bar/
│ ├── tag/
│ └── banner/
├── sections/ # 页面分区(业务组合层)
│ ├── product-section.html
│ ├── video-section.html
│ └── activity-section.html
└── data/ # 数据源(与结构分离)
├── product-list.json
└── page-config.json
这个结构的关键点在于把 components 和 sections 分开。components 是纯展示组件,不关心业务的叫法;sections 是业务层面的组合,它决定页面上这一块展示什么内容、用哪些组件拼装。业务需求变动时,大概率只动 sections 或 data,components 保持稳定。这个分层的消费关系也是单向的:页面 → sections → components,反过来不行。
3. 实操:把模板片段改造成可复用的模块单元
3.1 模板引擎选型与语法选择
在纯HTML层面做模块化,没有模板引擎是不可能的。市面常用的几个我都有过实际使用经验:ejs 的 include 语法简单直观,适合逻辑不多的场景;pug(原名jade)的缩进语法写起来快,但嵌套深了之后可读性下降,新人上手有门槛;art-template 对条件渲染和循环的支持比较顺手,而且渲染性能不错;nunjucks 的宏(macro)能力很强,适合做复杂组件。我的建议是:项目规模不大、团队以HTML为主的话,选 ejs 最稳妥,因为它就是用HTML语法的思路,出了一行问题也能快速定位到对应的标签。
我这里用 ejs 举例,因为它写起来最像普通HTML,大家看起来也最不容易走神。一个最简单的模块化调用长这样:
html复制<%- include('./components/card.html', {
data: productList,
mode: 'sale'
}) %>
这行的意思就是:引入卡片的模板文件,把 productList 作为数据传进去,并且告诉卡片当前以"促销模式"渲染。就这一句话,原本几十行的重复结构就变成了一次调用。多端复用的时候,你只需要在不同端项目的页面里写同样一行代码,传入不同数据结构,就能看到完全不同风格的展示效果。
3.2 组件内部的三层结构:骨架、插槽、增强
设计一个可复用模块的时候,我会在内部拆成三层来写。骨架层是模块的基础结构,也就是不管什么场景都一定存在的DOM;插槽层是留出来的扩展位,允许调用方在特定位置插入自定义内容;增强层是可选展示,用条件控制的方式来渲染,比如某个标签只在特定模式下出现。
用商品卡片来举例,骨架层就是外层容器、图片区、文本区、操作按钮区这四个最基础的部分。插槽层我会在卡片底部留一个 extraContent 的位置,调用方如果想塞个倒计时条、促销横幅之类的东西,不需要改卡片本身,只要传入一段HTML进去就行。增强层则通过数据里的布尔值来控制,比如传了 showTag: true 就渲染角标,不传就不渲染。
html复制<!-- components/card/card.html -->
<div class="card <%= modeClass %>">
<img class="card-cover" src="<%= data.cover %>" alt="<%= data.title %>" />
<div class="card-body">
<h3 class="card-title"><%= data.title %></h3>
<p class="card-desc"><%= data.desc %></p>
<% if (displayConfig.showPrice) { %>
<p class="card-price">
<span class="price-now">¥<%= displayConfig.priceAlign === 'promotion' ? data.promotionPrice : data.price %></span>
<% if (displayConfig.showOriginPrice) { %>
<del class="price-origin">¥<%= data.price %></del>
<% } %>
</p>
<% } %>
<% if (extraContent) { %>
<div class="card-extra"><%- extraContent %></div>
<% } %>
<a class="card-btn" href="<%= data.link %>"><%= displayConfig.btnText %></a>
</div>
</div>
这套结构的价值在于,调用方对卡片的能力边界一目了然:能传什么数据、能配什么展示、能往哪里插内容,都清清楚楚。既保留了足够的灵活性,又不会出现"为了满足一种场景把组件改到面目全非"的情况。
3.3 数据驱动的渲染方式:模板不写死任何值
模板模块化最容易被忽略却又最实用的部分,是数据源的处理。模板文件里不应该出现任何文字、图片地址、链接这类业务数据,严格意义上,模板只负责"怎么干活",不负责"干什么活"。业务方改了文案需求,理想情况下应该只需要改数据文件,而不需要动模板。这套思路直接决定了后续需求变动的响应速度。
我给活动页项目切换成数据驱动模式后做过一次统计:一个包含十二个模块的页面,业务方提出八处文案和图片调整,我改数据文件花了二十分钟,模板文件一行没动。放在以前,这类改动至少要在一个大HTML文件里上百行的密集结构里翻找位置,花两三个小时是常态。
实践下来我推荐两层数据管理方式。项目级配置管页面整体信息,比如页面标题、页头顶部文案、底部版权信息,这些是全局数据;模块级数据管各模块的具体内容,每个模块在数据配置里对应一个独立的字段块。建议所有数据收口到JSON文件里集中管理,避免散落到模板各处,这样排查问题的时候一眼就能找到出错的数据位置。
4. 构建工具里如何配合模块化设计
4.1 模块化不是"拆文件"就够了,构建层要跟上
如果你的模板代码只是靠后端模板引擎来拼接,比如 ejs 的 include,那构建层基本不需要额外操心。但如果是纯前端项目,页面是静态托管的,就需要处理好"一堆模板文件如何被打包成最终的HTML"这个问题。这个环节处理不好,前面拆得再漂亮,上线时也会被各种路径问题、重复注入问题折腾到崩溃。
实际项目中我用的是 Webpack 配合 html-webpack-plugin,再加上 ejs-loader(或者 html-loader 的 ejs 处理能力),让Webpack在构建时自动解析模板文件中的include语句,打成最终的静态HTML。核心配置思路如下:
javascript复制// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'js/[name].[contenthash:8].js'
},
module: {
rules: [
{
test: /\.html$/,
// 让html文件先走ejs编译,这样include语法会被处理
loader: 'ejs-loader',
options: {
esModule: false
}
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: './templates/layout/page.html',
filename: 'index.html',
// 注入页面数据(来自数据配置文件)
templateParameters: {
pageConfig: require('./templates/data/page-config.json'),
productList: require('./templates/data/product-list.json')
}
})
]
};
在这个配置里,templateParameters 承担了"向模板注入数据"的角色,相当于告诉ejs引擎:渲染页面的时候,哪些数据是全局可用的。这样模板文件始终可以保持纯结构状态,不掺杂业务数据。
还有一点值得留意:多页面项目要小心入口发散。每个页面都用一套配置,构建时间会明显变长。遇到过一位朋友的项目,十几个页面全部单独配置 HtmlWebpackPlugin,一次构建三分钟起。我的优化思路是按页面模板的 import 关系自动关联需要引入的JS和CSS,而不是每个页面都全量引入公共资源。具体做法是给每个模板文件配置独立的 chunks 白名单,或者在模块内部直接用 require 引入与它强相关的资源,让Webpack通过依赖关系自动收集。
4.2 环境变量注入让同一个模板适配多端
模块化做到一定程度后,多端适配成了绕不开的需求。同一个模板,在移动端要展示精简内容、在PC端要充分展示数据、在运营后台要展示编辑界面,三种场景的数据内容和展示逻辑都不一样。你要是维护三份模板代码,相当于回到最初的"复制粘贴"老路,模块化就算白做了。
正确的做法是:一个模块维护一份结构代码,环境差异通过环境变量来控制。具体实现就是在构建配置里根据当前环境注入不同的数据文件和配置项:
javascript复制const isMobile = process.env.BUILD_TARGET === 'mobile';
new HtmlWebpackPlugin({
template: './templates/layout/page.html',
filename: 'index.html',
templateParameters: {
isMobile,
// 移动端和桌面端加载不同数据源
pageConfig: require(
isMobile
? './templates/data/page-config.mobile.json'
: './templates/data/page-config.pc.json'
)
}
})
然后在模板里用条件渲染配合设计好的模块来做差异化展示:
html复制<% if (isMobile) { %>
<%- include('./sections/product-section.html', { limit: 6 }) %>
<% } else { %>
<%- include('./sections/product-section.html', { limit: 12, showDetail: true }) %>
<% } %>
这个做法的本质是"一套结构、多套数据、条件化拼装"。移动端和PC端的差异只体现在数据拉取和个别展示开关上,结构本身完全复用。你会发现,真正需要单独维护的模板文件就那么几个,而不再是整整两套页面。
4.3 样式与脚本的模块化:比HTML更早崩盘的地方
模板代码的模块化如果只停留在HTML结构层,大概率会遇到另一个坑:组件拆好了,但组件的样式没有跟着拆。你会遇到三种项目状态:样式全写在全局文件里,模块重命名一次要全局搜一遍;样式跟着组件拆了,但组件一多、加载顺序一变,样式互相覆盖;为了隔离样式起了各种"独特"的class名,最后class名比样式还难理解。
我的做法是给组件样式定三条规则。第一条是命名空间前缀,每个组件的class都以组件名开头,比如 .card-cover、.tab-bar-item,从名字上杜绝跨组件样式冲突。第二条是样式文件跟着组件走,一个组件一个CSS文件,并且只在引用了这个组件的页面里加载,不全局注入。第三条是CSS变量负责全局统一,颜色、间距、圆角这些基础变量放在全局变量表里,组件样式不写死具体值,统一引用变量,这样换主题的时候不用翻每个组件文件。
脚本逻辑也是一样,组件自己的JS就放在组件目录下,通过模块引用的方式来连接。这样无论是样式还是脚本,它们的生命周期和模板结构完全一致,一个组件从引入到卸载,所有相关资源跟着走。上线不需要的组件,直接删掉模板引用,它的样式和脚本也不会残留在项目里。这个"资源跟随组件"的思维,是整个模块化设计落到实处的关键一环。
5. 真正落地时最容易翻车的几个细节
5.1 动态数据对象里的一整块引入问题
这里分享一个真实踩坑案例。某个模块内部使用处理过的数据结构渲染列表,最初实现的时候,我直接把整个数据对象传给了子模块,想的是"数据量大没关系,反正渲染的时候只取需要的那部分"。结果上线后页面渲染速度下降了接近一倍。
查了之后发现,问题出在大对象在模板的循环中被反复读取,每次读取都会触发整个数据对象的getter逻辑。优化方式也很简单,就是把子模块真正需要的字段预先提取成 renderData 对象,再传入子模板。这样渲染循环中的每次读取都只是简单属性访问,不再有额外开销。
这个案例给我留下的经验是:模块之间的数据传递要做瘦身,只传这个模块需要的最少字段。不要图省事,把一整个数据上下文全部塞进去。数据瘦身不仅能提升性能,还能让模块边界更清晰——调用方自己就知道"这个模块只需要这几样东西",不会无意间让模块和数据绑死。
5.2 插槽内容里的"裸HTML"安全问题
插槽机制很好用,但有一个必须处理的隐患:调用方传入的 extraContent 字符串如果直接输出到页面里,一旦包含恶意脚本或错误结构,会直接影响线上页面的安全性。某次给一个运营同学配活动页的时候,她传入的插槽内容里多了一个未闭合的<div>标签,结果整个页面的布局直接乱掉,排查了很久才定位到是模块插槽渲染出来的结构问题。
我的做法是分场景处理:如果是内部运营配置的内容,插槽内容要经过白名单过滤后再渲染,可以用类似 sanitize-html 的库过滤掉script标签和危险属性;如果是有外网用户输入内容的场景,插槽干脆就不开放,直接改用明确的数据字段来控制展示。安全性红线不能省,这一步做不好,宁可牺牲一些"灵活"也要保住稳定。
5.3 依赖方向反了,模块一改全站崩
模块化做得越彻底,依赖关系就越重要。某次改版中我把一个通用按钮的颜色改了,结果发现有几十个页面都变了,有些是期望内的,有些是根本没想让它变的。一查原因,是因为某个业务组件直接给通用的按钮类名做了样式覆盖,把骨架上通用的样式给"偷换"了。这类问题的本质是依赖反了:原本应该是业务组件依赖基础组件,结果基础组件的样式被业务组件反向污染了。
排查这类问题,除了代码审查,还可以在构建阶段做依赖检查。我用一个简单的脚本扫描模板中的include关系,确认所有模板文件之间的引用关系是单向的,一旦发现反向引用就直接报错。检测思路可以归纳为:
bash复制# 脚本思路:分析模板import关系
# 1. 收集所有模板文件中的 include/import 语句
# 2. 构建依赖关系图
# 3. 检查是否存在循环依赖或反向依赖
# 4. 出现异常直接中断构建
这个检查脚本看起来简单,却是让模块化保持长期整洁的核心保障。模块化在项目初期很容易维持,到了中后期,大家都赶时间,"临时改一下"的诱惑越来越大,没有硬性检查机制的话,架构迟早被改回一锅粥的状态。
5.4 容易忽略的两个细节:命名规范与注释约定
模块化设计做得好不好,很大程度看命名和注释。命名不统一是模块化项目最常见的隐性债。比如同一个"商品区块",有的人叫 product-card,有的人叫 goods-item,有的人叫 shop-entry,全站搜索要搜三个词才能找全相关模板。团队定规范的时候,我强烈建议把"命名即语义"作为第一原则:模块名必须能让人不用看代码就知道这个模块是干什么的,同时前后缀统一用连字符分隔,类名和文件名保持一致。
注释约定同样值得提。组件模板顶部建议固定写一段轻量header注释,标明模块名称、版本、数据入口、使用示例。别小看这几行注释,项目过去三个月后,新同事接手时能不能快速上手,很大程度上就靠这几行信息。我见过不少模块化拆分的项目,文件拆得干净利落,但注释为零,新来的同事根本不知道某个模块的数据字段从哪来,最后只能靠猜来填数据。
注意:模板模块的注释不要写成'这里改一下'这类无意义说明,而是要写清楚"这个模块接收什么数据、产出什么结构、在哪些场景下可以被引用"。这样的注释才有实际价值。
6. 支撑模块化的基础工程设施
6.1 模板片段预览:开发阶段的核心工具
拆成模块之后,开发态的一个核心问题是:单个模块在页面上长什么样?以前写整页的时候,只要打开完整页面就能看到效果。模块化之后,开发一个卡片组件,总不能每次都起完整页面来看效果,那又回到了"牵一发动全身"的困境。我的做法是搭建一个独立的"组件预览页",把常用组件和数据样例渲染在同一张页面上,开发阶段直接看这一页就能确认组件是否正确。
这个预览页本身也是一份模板,里面把所有组件的示例全部铺开,数据都是定死的样例。它不参与线上的任何渲染,只用做开发调试。有了它之后,组件的联调效率明显提升,因为组件开发不需要等待页面同步,模块之间通过真实数据交互的问题也能更早暴露。
6.2 模板代码的版本管理策略
模块化之后,模板文件的数量会从几个变成几十个,版本管理策略一定要跟上。常见的错误是:大量模块文件全部放在同一个Git仓库里,每个模块独立发版的需求没法满足。到后面你会发现,某个页面只改了组件A,却要跟着整个仓库一起走完整的发布流程,效率很低。
有一种相对轻量的做法是"单仓库但分模块目录"模式:所有模板模块都在一个仓库里管理,但每个模块的变更记录独立维护,发布时只输出变更涉及的模块产物。这样既避免了多个仓库之间的依赖地狱,也保留了单模块独立发版的能力。而如果项目本身足够大、模块足够独立,也可以考虑将各模块按边界拆成独立子仓库,按版本号引入,但这类方案对团队协同和自动化发布的要求会高很多,小团队不建议一开始就上,容易把自己拖死。
6.3 从工具层面收口规范:脚手架与检查脚本
最后一个建议是:能用工具自动化检查的,不要依赖人工审查。模块化规范再清晰,如果靠人在每次代码评审时肉眼检查,早晚会漏。我的习惯是在项目里维护一个检查脚本,把它挂到提交前钩子里,每次改动模板文件的时候自动跑一遍。
检查项包括:模板中是否出现了不该出现的硬编码内容(比如文案、图片地址)、样式类名是否带有组件前缀、是否存在反向依赖关系、引用的资源文件是否真的存在。这些检查脚本写起来都不难,几百行代码就能搞定,但在长期维护中能挡掉一大批低级的架构破坏。
模块化设计这件事,从来没有一步到位的银弹。它是在一次次的"改一处崩多处"痛苦中逼出来的思路,也是在一次次"组件拆太细导致过度设计"教训中逐渐成熟的方法。我个人在实际操作中的体会是:宁可拆得保守一点,也不要一上来就搞复杂的抽象层;等真实需求出现了第二次、第三次,再动手做通用化,往往比提前设计出来的方案更贴合实际场景。
还有一个小技巧分享给你:模块化的推进会遇到团队内阻力,这时候不要急着讲架构、讲思路,直接找出一个重复最多、改动最频繁的模块,当场拆给人看,把"改一处全页面生效"的效果演示出来。这种看得见摸得着的对比,比任何PPT都更能说服人。
最后想说,模板代码模块化不是一个做完就拉倒的任务,而是一个需要持续维护的长期习惯。每次新写一段模板前先问自己一句:这段结构以后还会不会再被别处用到?哪怕答案是"可能",也值得为它多花十分钟去设计一个接口明确的独立模块。这些"多花的时间",会在未来每一次需求变更里,连本带利地还给你。
