☰
前端HTML代码复用全方案解析:从模板字符串到组件化框架
2026/10/21 8:23:44 网站建设 项目流程

我一直觉得,“复用”这个词在前端工作里,是被高估得最狠,也被低估得最彻底的一个词。

高估,是因为很多朋友一听到组件化,就恨不得把每个按钮都拆成独立文件,结果页面还没写完,项目结构先膨胀了。低估,则是因为真到了 JavaScript / HTML 层面要处理重复代码时,大多数人的第一反应仍然是复制粘贴——改一个公共区块,全站搜索替换,心惊胆战。

这篇文章是我前几年在维护一个内部运营后台时,被大量重复的 HTML 结构逼着折腾出来的经验总结。从最原始的模板字符串拼接,到模板引擎,再到浏览器原生组件和组件化框架,我按实际踩过坑的顺序梳理一遍。适合刚接触前端组件化、想弄懂原理的新手,也适合被重复代码折磨了很久、想系统梳理方案的人。我不打算把每种方案吹得天花乱坠,只讲我真实用下来后,什么样的情况该选什么样的方案,以及每条路上都有哪些让你想砸键盘的细节。

1. 被重复HTML拖垮的日常:我在什么场景下开始认真考虑复用

1.1 一个让我彻底放弃复制粘贴的业务场景

那会儿我负责的是一个多租户运营面板,页面右侧有个“用户概览卡片”,显示头像、昵称、会员等级、最后登录时间;卡片下方跟着一组订单状态标签,比如“待付款”“已发货”“售后中”。这个组合几乎出现在所有的管理页面上:用户列表、订单详情、售后工单、数据报表……我一开始图省事,全部复制粘贴。

真正崩溃的时刻是需求方提出:用户卡片右上角要加一个“发券”入口按钮。

我当时以为是小改动,实际做了整整一个下午。十几个页面里各有一份结构相近但样式细节略有差异的卡片代码,有的页面按钮位置偏左,有的偏右,有的标签颜色被单独覆盖过。我逐个页面改,改完还得逐个核对,仍然漏了两个入口。后来测试在工单详情页发现了缺失,质检单直接打回。从那天起我就明白,HTML 复用不是一个“代码优雅度”问题,它是一个工程维护问题。

1.2 重复带来的真正代价,远不止多打几行字

那些反对“为了少打字而做复用”的人,其实没理解问题的核心。复制粘贴带来的痛点是三个维度的:

第一,单一事实来源缺失。同一段 HTML 结构在代码库里存在 N 份拷贝,当需求变更时,你无法保证全部同步。很多项目越到后期越不敢改公共区块,就是因为根本不知道哪里还藏着一份旧代码。

第二,视觉与行为的一致性失控。A 页面复制后改过边距,B 页面复制后换过字体,C 页面加过临时促销角标。名义上是同一个卡片,实际上已经是三个不同的组件。用户不会知道这是历史原因,他们只会觉得这个系统很粗糙。

第三,业务逻辑被分散到各处。当“是否显示 VIP 标签”这个规则需要根据用户等级判断时,每个页面都要写一遍判断逻辑。一旦规则从“等级大于 3 显示”变成“等级大于 3 且最近 30 天有消费才显示”,意味着所有页面都要重新排查一遍。

也有人会问:为什么不在服务端做模板渲染?我的回答是,很多项目本身就是前后端分离的,静态资源部署在 CDN 上,页面结构由前端动态生成;另外还有相当一部分区块要根据浏览器端的用户状态、实时反馈来更新,比如下单后按钮态的变化。这部分复用,只能在 JavaScript 里解决。这篇文章谈的,就是前端这一侧的方案。

2. 模板字符串拼HTML:最适合小块的轻量复用方案

2.1 从反引号开始,写一个能复用的渲染函数

最早期的方案听起来非常朴素:直接用 ES6 的模板字符串把 HTML 拼出来,再插入页面。但它确实解决了一个痛点——你不再需要到处写'<div class="card">' + name + '</div>'这种让人头大的字符串拼接。

举个最简单的例子,我需要渲染一个用户卡片:

const userCard = (user) => ` <div class="user-card"> <img src="${user.avatar}" alt="${user.name}" /> <h3>${user.name}</h3> <span class="tag">${user.vip ? 'VIP' : '普通用户'}</span> </div> `; document.querySelector('.panel').innerHTML = userCard(currentUser);

这个方法的好处是直观、零依赖、门槛极低。函数即组件,传参即数据,返回的是纯字符串。如果你只是三五处重复,这比引入任何工具都划算。

后来我发现,光有模板还不够。当同一个页面里有多个地方要渲染同样结构的列表时,我得写一个通用的渲染工具,把“模板”和“数据”分离出来。于是我做了一个很轻量的 render 函数:

function render(tpl, data) { return tpl.replace(/\{\{(\w+)\}\}/g, (match, key) => data[key] ?? ''); } const userItemTpl = ` <li class="user-item"> <span>{{name}}</span> <span>{{level}}</span> </li> `; const html = list.map(user => render(userItemTpl, user)).join('');

这个自定义的{{key}}语法不算什么发明,很多迷你模板引擎的雏形都是这样。它解决的是“同一份结构、批量套不同数据”的复用问题。如果你维护的是一两个页面,这个小函数基本够用。

2.2 拼接HTML的头号大坑:转义与XSS

用模板字符串拼 HTML,最容易被忽略但最致命的问题是转义。

我们内部曾经出过一次安全事故:用户在昵称输入框里填入一段带<img src=x onerror=alert(1)>的内容,结果在下游展示页面里被当成 HTML 执行了。原因就是渲染函数直接拼字符串,原样输出了用户输入。

从那以后,我的所有模板字符串方案里都会先过一层转义函数:

function escapeHTML(str) { return String(str) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#039;'); }

然后在模板里统一写escapeHTML(user.name)。规则很简单:凡是从接口回来的数据、用户可输入的数据,或者任何不可信的字段,一律先转义再拼。别嫌麻烦,这个习惯能救你无数次。

提示:字符串做 HTML 复用只适合“结构简单、数据量小、逻辑不深”的场景。一旦你的模板里开始出现嵌套的三元表达式、多层 if/else,我建议你别硬撑,尽快往下一层方案迁移。

2.3 模板字符串失控的信号

我是在什么时候意识到模板字符串方案顶不住的?是当我在渲染一个商品卡片时,模板里同时要处理:是否有折扣、折扣展示形式、库存是否为 0、是否在活动期内显示角标、会员是否显示专享价……五个条件叠在一起,模板字符串里全是user.vip ? (order.count > 0 ? ... : ...) : ...。代码能跑,但谁也看不懂,改需求时谁都不敢动。

这种信号其实很典型:HTML 结构本身已经不够稳定,而是随着业务状态发生变化。这时候需要的是更接近“模板”的语法——HTML 保持它该有的样子,循环和条件用专门的指令来表达,而不是寄生在 JS 表达式里。这就是模板引擎登场的时机。

3. 模板引擎接入:如何让循环和条件不再寄生在JavaScript表达式里

3.1 模板引擎解决的,是逻辑与标记分离的问题

模板字符串方案最大的问题是逻辑和 HTML 混写。一个个${}把判断、循环、三元表达式全部缝在模板里,可读性随着复杂度直线下降。模板引擎要做的就是把这些东西从${}中解放出来,用类似{{#each}}、{{#if}}的标签来写循环和条件。

我第一次把一个商品列表从模板字符串迁移到模板引擎时,最大的感受是:模板终于像 HTML 了。静态结构在字面上清晰可见,动态部分又保留了占位的感觉。这对于后续接手的人来说,友好程度不是一点半点。

3.2 一个典型模板的长相和使用流程

拿一个订单列表来举例。如果我用模板引擎的语法,模板写出来大概是这样的:

<script type="text/x-template" id="order-list-tpl"> <div class="order-item"> <div class="order-no">订单号:{{ orderNo }}</div> <div class="goods-list"> {{#each goodsList}} <span class="goods-name">{{ name }} x {{ count }}</span> {{/each}} </div> {{#if isVip}} <span class="vip-tag">会员专享价</span> {{/if}} </div> </script>

JavaScript 侧做编译和渲染:

const templateStr = document.getElementById('order-list-tpl').innerHTML; const compiled = compileTemplate(templateStr); const html = orderList.map(order => compiled(order)).join(''); document.querySelector('.order-list-container').innerHTML = html;

相比纯模板字符串,这里有两点提升很关键:一是循环和条件不再嵌套在 JS 表达式里,模板阅读者不需要在脑内执行一遍三元表达式才能理解 UI 状态;二是模板和数据被明确分开,同一份模板可以复用于列表、弹窗、甚至导出邮件正文。

3.3 局部模板复用:把页头、页脚、按钮抽成部件

模板引擎真正体现“HTML 复用”能力的地方,在于局部模板(partial)。我在运营后台里最常用的做法,是把“数据筛选栏”“表格操作按钮组”“分页条”这类存在感强但结构稳定的区块单独抽成局部模板。

比如我定义一个button-tpl局部模板:

{{#if isEnabled}} <button class="btn btn-primary" onclick="{{ onClick }}">{{ text }}</button> {{else}} <button class="btn btn-disabled" disabled>{{ text }}</button> {{/if}}

然后在宿主模板里通过{{> button}}这种形式引用。这就和函数复用差不多了:改一个局部模板,所有引用位置同步更新。我在运营后台改造中,把十余处重复的分页条统一成一个局部模板后,后续加“每页条数切换”功能只花了十分钟。

但局部模板不是万能的。它复用的是模板片段,不是行为逻辑。如果某个部件除了要渲染结构,还要处理点击、请求、状态切换,那模板引擎就有点吃力了——状态同步还是要靠外部 JS 手动维护。这也是我后来转向 Web Components 和框架组件的重要原因。

3.4 模板引擎使用中的几个典型坑

我用了小半年模板引擎,踩过的坑值得列一下:

第一,注意转义是默认开还是默认关。有的引擎默认对输出内容做 HTML 转义,这是好事;但如果你确实要输出一段受信任的富文本,需要显式声明“不转义”。我见过同事把接口文档描述直接插入模板,结果&全部变成了&amp;,排查半天。

第二,不要在模板里写复杂计算。模板里适合做展示逻辑,不适合做业务计算。我后来习惯在渲染前把数据预处理成“模板友好”的形态,该算好的折扣、该拼好的标签文字,都在 JS 侧算完再塞进模板。模板保持纯粹,调试成本会低很多。

第三,编译结果一定要缓存。在循环里反复编译同一个模板字符串,是真实的性能杀手。正确的做法是像上面示例那样,compileTemplate只执行一次,拿到编译后的渲染函数,循环里只调用渲染函数。

第四,模板错误的调试非常痛苦。模板引擎的报错往往只是“第 XX 行附近语法错误”,不会精确到具体标签。我的止损方式是:坚持模板嵌套层级不超过三层,一旦超过,就拆局部模板。

模板引擎适合的是“结构相对固定、逻辑多为展示判断”的中型应用。但如果你要的是一个可以携带状态、自带样式、跨页面独立响应的组件,它的能力就不太够了。这时候我看上了浏览器原生方案。

4. Web Components:浏览器原生提供的一套组件复用能力

4.1 它不是框架,是浏览器自带的组件化能力

Web Components 是由 Custom Elements、Shadow DOM 和 template 标签共同组成的一套标准。它不需要引入任何运行时,只要浏览器支持,就能像使用<div>一样使用你自己的标签。

我为什么在模板引擎之后认真研究它?因为模板引擎解决的只是“HTML 片段复用”,而 Web Components 想解决的是“组件复用”:HTML 结构、样式、行为封装在一个自定义标签里,对外只暴露属性和事件。这让它在跨系统、跨技术的场景里非常有价值。

4.2 template 标签配合 Custom Elements 的典型做法

先说 template 标签。它的特点是:页面加载时不会渲染其内容,但你可以通过content属性拿到这段文档片段,随时克隆使用。这正好用来存放组件内部的 HTML 骨架。

下面这段代码定义了一个user-card自定义标签,包含一个名字和一个按钮:

<template id="user-card-tpl"> <style> :host { display: block; border: 1px solid #e5e5e5; border-radius: 8px; padding: 12px; } .name { font-weight: 600; } </style> <div class="name"></div> <button class="action-btn">查看详情</button> </template>

然后在 JavaScript 里注册组件:

class UserCard extends HTMLElement { connectedCallback() { if (this.shadowRoot) return; const tpl = document.getElementById('user-card-tpl'); const node = tpl.content.cloneNode(true); node.querySelector('.name').textContent = this.getAttribute('name'); node.querySelector('.action-btn').addEventListener('click', () => { this.dispatchEvent(new CustomEvent('user-click', { detail: { id: this.getAttribute('id') } })); }); this.attachShadow({ mode: 'open' }).appendChild(node); } } customElements.define('user-card', UserCard);

使用方式极其自然:

<user-card name="张三" id="1001"></user-card>

这个方案在当时让我眼前一亮:原来一个组件可以做到这种程度。结构在 template 里,样式在 Shadow DOM 内隔离,行为在 class 里声明,对外就是一个语义化标签。页面上的调用方完全不需要关心内部实现。

4.3 实际使用中必须注意的 Web Components 细节

第一,克隆和重连。connectedCallback在元素被插入 DOM 时会触发,但如果你把元素从一个容器移到另一个容器,它可能会再次触发。如果不加if (this.shadowRoot) return;这行判断,同一个组件会被重复初始化。这个坑我是在移动弹窗里遇到的,当时样式叠加了好几份,排查了很久。

第二,Shadow DOM 的样式隔离是一把双刃剑。它确实防止了外部全局样式污染组件内部,但同时也意味着组件内部默认继承不到外部的大量基础样式。我在封装按钮组件时,发现全局的字体变量、颜色变量在组件内部失效了。解决方案是通过:host或 CSS 自定义属性显式传值,比如在:host里写font-family: var(--font-family, sans-serif)。这算是一种“有边界的继承”,需要主动设计。

第三,老环境的兼容性。Web Components 在较老的浏览器里需要 polyfill,而且部分框架的历史版本对自定义标签的支持也不够好。如果你要嵌入第三方系统,必须先确认对方的前端环境。

第四,没有响应式系统。Web Components 底层的属性变化监听能力是有的,但数据变了之后要触发局部更新,还是需要自己写大量逻辑。你可以监听attributeChangedCallback,但字段一多,代码量会指数级上升。

所以我的结论是:Web Components 非常适合做设计系统、跨框架组件、嵌入式片段;但如果你要在一个有复杂状态管理的业务页面里大量使用,还是得上框架级的方案。

5. 组件化框架控场:从单文件组件到props与插槽的协作

5.1 为什么最终大型项目都会走到框架组件这条路上

当我从标准组件的深坑里爬出来之后,回头再看组件化框架,才理解它到底解决了什么:响应式状态更新、模板编译优化、社区生态、团队约定。框架把一个组件真正变成了“数据进、视图出”的闭环单元。

举例来说,同样是用户卡片,在组件化框架里往往用一个单文件组件来表达。它把模板、脚本、样式放在同一个地方,从文件层面就完成了内聚:

<template> <div class="user-card" @click="handleClick"> <img :src="user.avatar" alt="avatar" /> <h3>{{ user.name }}</h3> <span v-if="user.vip" class="vip-tag">VIP</span> <slot></slot> </div> </template> <script> export default { name: 'UserCard', props: { user: { type: Object, required: true } }, methods: { handleClick() { this.$emit('select', this.user.id); } } }; </script> <style scoped> .user-card { border: 1px solid #e5e5e5; border-radius: 8px; padding: 12px; } </style>

你会发现,模板引擎时代的“局部模板复用”、Web Components 时代的“自定义标签封装”,在这里全都实现了。而且方式更统一,团队协作时不太需要商量“你们用什么模板语法”。

但真正让我觉得框架组件是复用终局的,是它的两大机制:props 和 slot。

5.2 props 和 slot:复用数据边界,也复用内容边界

props 负责把“外部数据”传入组件内部,让同一个组件在不同页面展示不同内容。这解决了复用时的“数据差异”。

slot 负责把“外部内容”传入组件内部,让同一个组件在不同页面拥有不同子结构。这解决了复用时的“结构差异”。

举个很常见的例子:一个确认弹窗组件,壳子、遮罩、关闭按钮、动画都是一样的,但弹窗中间的内容千差万别——有的页面要放表单,有的页面要放一段说明文字,有的页面要放一个统计表格。如果强行把内容也塞进弹窗组件,这个组件会被各种业务逻辑污染。正确的做法是用 slot 把弹窗的外壳和业务内容分开:

<!-- 弹窗组件内部 --> <div class="modal-mask"> <div class="modal-panel"> <div class="modal-header">{{ title }}</div> <div class="modal-body"> <slot></slot> </div> </div> </div>

使用方传入什么,body 就显示什么。这就是内容级别的复用,比把整个弹窗复制三遍要优雅得多。

我的一个深刻体会是:props 控制的是“同一个组件能接收哪些变量”,slot 控制的是“同一个组件能容纳哪些子结构”。两者叠加,组件才真正称得上可复用、可扩展。

5.3 复用粒度的选择:不是越细越好

进入框架时代后,另一个问题浮现出来,“组件复用粒度”该如何定。我见过把“带图标的按钮”拆成三个组件的项目,也见过一个页面级组件有三十多层嵌套的项目。组件切太细,调用方要传的 props 越来越多,改动一两个字段就要向上传一层,反而更累。

我的经验是:按变化点来切,不按视觉单元来切。

如果一个结构在多个地方出现,并且只有数据不同、结构不变,可以抽组件;如果连结构都变化很大,那更合适的是用 slot 组合,而不是强行让一个组件内部做一堆v-if。我在运营后台里重构时,把“表格操作列”抽成一个组件,但保留操作按钮的 slot;这样有的页面放两个按钮,有的页面放三个按钮加一个下拉菜单,组件本身不用改。

5.4 框架也有代价:约束和体积

选择框架组件,本质上是选择一套约束。你需要团队的构建流程统一,需要所有成员遵守单项数据流,需要接受包体积增加。对于大型项目这完全值得,因为团队成员越多,约定越值钱;但对于一个十几 KB 的静态页面,或者一个纯展示的营销落地页,上框架的边际成本就太高了。

我自己的项目里,正式业务项目全部走了框架组件路线,而那些“一次性工具页”和“邮件模板”,我反而会用模板字符串或模板引擎去做。方案没有绝对的好,只有适不适合场景。

6. 方案选型的方法论:什么时候该用哪种,我踩过的坑和现在的判断标准

6.1 一张好用的决策表

把前面几种方案放在一起对比,会清楚很多:

方案学习成本性能开销适用范围我最推荐的使用场景我不推荐的使用场景
模板字符串拼接最低极低小型片段、一次性渲染工具页、列表项、简单卡片嵌套逻辑多、状态频繁变化
模板引擎低编译有少量成本中型页面、结构固定的重复区块运营后台列表、邮件模板需要对状态做细粒度更新
Web Components中浏览器原生,开销低跨框架组件、设计系统嵌入第三方系统、公共组件库复杂业务状态管理
组件化框架中高需要引入运行时正式业务系统、团队协作项目多页面、多角色、长期维护一次性页面、纯静态原型

这张表不是绝对的,但能帮你快速排除掉明显不合适的选项。

6.2 三个值得讲清楚的真实踩坑经历

第一个坑,是过早抽象。

我在某次功能开发时,一开始就搭了一个“用户卡片”组件,预留了头像、名称、等级、标签、操作按钮等十二个 props。结果实际使用下来,大多数页面只用到了三个。每次维护都要翻组件源码确认参数,代码量没减少,沟通成本反而增加了。后来我给自己定了一条规矩:同一段结构重复出现至少三次,并且可以明确说出它在哪些页面、哪些数据下会改变,才抽组件。在第一次和第二次出现时,宁可复制。

第二个坑,是模板引擎的全量刷新。

在线表格里,我只改了某一行的状态,但当时用的是整表模板渲染:数据一变,整个表格innerHTML都重来一遍。结果输入框失焦、滚动位置重置、用户心态爆炸。后来我改成行级容器各自渲染,只更新发生变化的那一行。这个坑让我明白:复用 HTML 结构和复用渲染性能,是两回事。结构可以复用,但 DOM 更新要尽量局部化。

第三个坑,是 Shadow DOM 的样式隔离副作用。

用 Web Components 封装一个通用按钮时,组件内部发现全局的.btn-primary类名不生效,因为外部样式进不了 Shadow DOM。我当时的解决方式是复制了一份样式到组件内,后来又发现主题色一变,组件内颜色没跟着变。最终使用的是 CSS 自定义属性方案:外部通过--btn-bg-color传值,组件内读取变量。这个过程让我意识到,组件边界设计不是只看结构,还要设计样式变量的接口。

6.3 我现在的工作流:从需求倒推技术选型

不敢说这是标准答案,但这是我在实际项目中反复打磨出来的判断流程。

第一步,看项目里是否已经用了组件化框架。如果已经是框架项目,那就别纠结了,直接用框架组件规范做。不要为了“原生”而引入第二套技术栈。

第二步,看项目是否要嵌入第三方系统,或者需要跨框架共享。如果是,优先考虑 Web Components,因为它在各个技术栈里都像普通标签一样可用。

第三步,如果只是内部页面且重复片段数量不多,就先用模板字符串封装函数解决。哪怕以后要升级,改动范围也完全可控。

第四步,如果重复片段多、结构固定、还带明显的循环和条件,但又不想引入框架,模板引擎是很划算的中间方案。

第五步,不管走哪条路,都要把数据预处理放在模板之外,把转义放在数据入口,把编译结果缓存起来。这些细节比方案本身更能决定你是否踩坑。

折腾了这么多种方式之后,我发现 HTML 代码复用最大的意义不在于省几个字符,而是让业务逻辑有一个集中的出口。我现在的判断标准很简单:如果一段 HTML 要在三个地方出现,就先问它会不会跟着业务一起变化——会,就抽成函数或组件;不会,那就复制,不要为了“优雅”而付出架构成本。这个标准帮我挡掉了很多无谓的抽象,也让我在做每一次复用时,都能清楚知道自己在解决什么问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询