前端HTML代码复用全方案解析:从模板字符串到组件化框架
发布时间:2026/10/21 8:23:48 锦皓数字建站

我一直觉得“复用”这个词在前端工作里是被高估得最狠也被低估得最彻底的一个词。高估是因为很多朋友一听到组件化就恨不得把每个按钮都拆成独立文件结果页面还没写完项目结构先膨胀了。低估则是因为真到了 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 classcard name /div这种让人头大的字符串拼接。举个最简单的例子我需要渲染一个用户卡片const userCard (user) div classuser-card img src${user.avatar} alt${user.name} / h3${user.name}/h3 span classtag${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 classuser-item span{{name}}/span span{{level}}/span /li ; const html list.map(user render(userItemTpl, user)).join();这个自定义的{{key}}语法不算什么发明很多迷你模板引擎的雏形都是这样。它解决的是“同一份结构、批量套不同数据”的复用问题。如果你维护的是一两个页面这个小函数基本够用。2.2 拼接HTML的头号大坑转义与XSS用模板字符串拼 HTML最容易被忽略但最致命的问题是转义。我们内部曾经出过一次安全事故用户在昵称输入框里填入一段带img srcx onerroralert(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 typetext/x-template idorder-list-tpl div classorder-item div classorder-no订单号{{ orderNo }}/div div classgoods-list {{#each goodsList}} span classgoods-name{{ name }} x {{ count }}/span {{/each}} /div {{#if isVip}} span classvip-tag会员专享价/span {{/if}} /div /scriptJavaScript 侧做编译和渲染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 classbtn btn-primary onclick{{ onClick }}{{ text }}/button {{else}} button classbtn 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 iduser-card-tpl style :host { display: block; border: 1px solid #e5e5e5; border-radius: 8px; padding: 12px; } .name { font-weight: 600; } /style div classname/div button classaction-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张三 id1001/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 classuser-card clickhandleClick img :srcuser.avatar altavatar / h3{{ user.name }}/h3 span v-ifuser.vip classvip-tagVIP/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 classmodal-mask div classmodal-panel div classmodal-header{{ title }}/div div classmodal-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 要在三个地方出现就先问它会不会跟着业务一起变化——会就抽成函数或组件不会那就复制不要为了“优雅”而付出架构成本。这个标准帮我挡掉了很多无谓的抽象也让我在做每一次复用时都能清楚知道自己在解决什么问题。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。