
如果你写过超过几个页面的网站大概率会遇到同一个问题页面里那块header、footer、侧边栏、卡片列表每次新增页面都要重新复制一遍。改一个链接全局搜、全局换漏一处就出bug。这种痛我做前端初期体会特别深。今天这篇总结就是想把JavaScript和HTML里常见的HTML代码复用方法摊开聊一遍从最原始的复制粘贴到服务端include再到JavaScript模板、组件化、Web Components我会结合自己的实操经验把每种方案的原理、适用场景、坑点都列出来希望能帮你在下一个项目里少走弯路。HTML代码复用的本质不是“把代码写少一点”而是“把改动的风险控制住”。复用做不好最直接的表现就是维护成本爆炸。比如页面上有10个相同的产品卡片你手动复制了10份某天产品经理说价格要加前缀你要改10个地方改漏一个就是线上事故。所以理解复用的思路比收藏代码片段更重要。1. 为什么HTML复用如此重要1.1 从复制粘贴到复用的演进早期的HTML页面都是静态的开发者最常用的“复用”方式就是复制粘贴。新建一个页面把上一个页面的头部、导航、底部整段拷过来改改中间内容就完事。这个方案在小规模个人网站、活动页里面完全够用因为页面少、改动频率低复制粘贴反而省事。但项目一旦进入多人协作、频繁迭代的阶段复制粘贴的弊端会指数级放大。举个例子导航栏里有五个链接A页面复制的时候漏掉了一个“产品中心”B页面又还在用旧链接用户在不同页面之间跳转体验是割裂的。你可能会说“用编辑器全局替换啊”但全局替换也有盲区不同页面可能因为历史原因代码结构略有差异有的包了一层div有的没有替换之后反而把原本正常的页面改坏了。所以复用的演进本质上是从“复制结果”到“共享源头”的转变。复制结果是一次性的共享源头的核心思想是页面片段只写一次所有需要它的地方都通过某种机制引用它。这种机制可以是服务端include、JavaScript函数、组件化框架也可以是浏览器的原生API。后面说的所有方法都是在解决一个问题如何让同一个片段被稳定、高效地重复使用。1.2 好复用的标准可维护、可组合、可解耦我见过很多开发者把“复用”简单理解成“能把代码抽出来”其实这只是第一步。真正好的复用方案至少要满足三个标准第一是可维护。我改了公共片段所有引用它的地方都同步生效不需要我手动去同步。比如修改页脚里的备案号只改一处全站更新。第二是可组合。一个复用单元不能太“死”它应该允许外部传入差异化的内容。比如一个卡片组件既能展示产品信息也能展示文章摘要而不是写死成某一种数据结构。第三是可解耦。公共片段不依赖页面里某个特定的变量、某个特定的脚本加载顺序。如果我在A页面引入这个组件会报错在B页面就不会那说明它耦合了页面环境复用价值就大打折扣。这三个标准可以作为后面所有方案的评判尺度。你会发现有些方案只能满足第一点有些方案能满足全部三点但代价是学更复杂。所以选方案不能只看功能还要看团队的维护能力和项目的实际体量。2. 服务端引入与静态片段方案2.1 服务端include的经典思路在JavaScript还没统治前后端的时候服务端include是最直接的复用方案。比如PHP里常见的include(header.php)就是把header这个片段完整插入到每个页面。还有服务端SSIServer Side Include在HTML里写!--#include virtual/header.html --由服务器处理之后再返回给浏览器。这个方案的核心优势是对浏览器完全透明。浏览器拿到的最终HTML就是一个完整页面没有异步加载没有阻塞渲染对SEO友好。而且服务端include的代码是可以直接写在原生HTML里的不需要预编译也不需要构建工具。但它的问题也很明显依赖服务端环境。你没法在纯静态托管平台比如某些对象存储上的静态网站上跑PHP或者SSI。另外如果你的前端是纯静态文件本地打开就能预览那种服务端include也没法工作。所以这个方案更适合有服务端渲染背景、且站点部署在自己可控服务器上的场景。我在一个传统企业站项目里用过PHP include做公共头尾当时效果很好因为那个站点没有复杂交互页面更新也不频繁。但后来换成静态托管到云存储上就得全部改成前端方案。所以服务端include更像是一个时代的经典解法今天不一定是首选但思路仍然值得理解。2.2 纯HTML的iframe嵌入如果你完全不写任何后端代码也没有构建工具服务器只托管静态文件那么最简单粗暴的复用方式就是iframe。把公共部分做成一个独立HTML文件然后每个页面通过iframe src/header.html/iframe嵌入。iframe的好处是真正的“隔离复用”内部的内容不会污染外部页面的CSS和JavaScript外部脚本也碰不到iframe内部。这在引入第三方组件比如评论框、地图、支付表单的时候特别有用。但放在页面内部导航这种场景iframe就是个灾难了。为什么首先iframe的高度自适应问题如果导航内容高度变化父页面不会自动调整iframe的高度你需要写JavaScript去监听iframe内部高度的变化手动设置父级iframe高度。其次是性能每多一个iframe浏览器就多创建了一个独立的文档窗口内存、请求数都会增加。更别提跨域通信的复杂性父页面和iframe里如果要交互比如点击iframe里的链接去改变父页面的菜单高亮你得用postMessage或者设置document.domain这比同页面里的JavaScript事件处理麻烦得多。所以我对iframe做页面复用的建议是不要用在主体页面结构的复用上只适合那些本身就需要隔离的模块比如第三方内容嵌入。2.3 对比与局限性服务端include和iframe的对比很有意思它们正好站在两个极端服务端include让浏览器拿到完整HTML但要求服务端支持iframe完全不要求后端但给浏览器带来了额外的文档环境两者都算不上现代的“组件化”思路。它们的共同局限是都无法灵活地传入数据。想根据当前页面不同的参数改变header里某个高亮状态服务端include需要你额外写逻辑拼接iframe更是基本只能靠Query参数或者postMessage传特别别扭。这就引出了后面的JavaScript方案把“片段复用”升级为“逻辑复用”数据驱动HTML生成。3. JavaScript层面的复用方案3.1 模板字符串拼接最基础但永远有用如果项目没有任何框架也不想引入所有依赖那么ES6的模板字符串可以算是最轻量的HTML复用方案。核心做法就是把公共片段封装成一个函数函数返回模板字符串外部需要时再插入到指定容器。function renderProductCard(product) { return div classproduct-card h3${product.name}/h3 p${product.desc}/p span classprice${product.price}/span /div ; } // 使用 document.querySelector(.grid).innerHTML productList.map(renderProductCard).join();这个方案的优势是直观、无门槛任何会基础JavaScript的人都能看懂。你不需要学新的模板语法也不需要构建工具浏览器直接运行。在简单的管理后台、代码生成器、活动页面中这种函数式复用的效率非常高。但它的隐患在于字符串拼接容易引发XSS安全问题。如果product.name来自用户输入返回的模板字符串里直接插入内容用户输入中如果有script或者恶意的img onerror就会被当成HTML执行。所以每次拼接都应该对变量进行HTML转义function escapeHtml(str) { return String(str) .replaceAll(, amp;) .replaceAll(, lt;) .replaceAll(, gt;) .replaceAll(, quot;) .replaceAll(, #039;); }我最早做前端的时候就是在这个问题上吃过亏的线上页面被注入了一个弹窗脚本排查了半天才发现是对接口返回的富文本内容没有转义。从那以后我给自己定了一个规矩只要模板里插变量就默认它是不可信数据强制转义或者用框架提供的转义机制。3.2 模板引擎摆脱字符串拼接的痛苦模板字符串虽好但逻辑复杂之后就很难读了。比如你要根据条件展示不同状态、循环渲染列表、嵌套复杂对象写一大堆三元表达式和map模板字符串会变得又长又难维护。这时候就轮到模板引擎上场了。前端最常用的有Mustache、Handlebars。它们的核心思路是在HTML层面写模板通过自定义标签比如{{name}}来占位然后在运行时传入数据解析出最终HTML。以Handlebars为例script idproduct-template typetext/x-handlebars-template div classproduct-card h3{{name}}/h3 p{{desc}}/p {{#if discount}} span classprice折扣价{{discountPrice}}/span {{else}} span classprice原价{{price}}/span {{/if}} /div /scriptconst source document.getElementById(product-template).innerHTML; const template Handlebars.compile(source); document.querySelector(.grid).innerHTML productList.map(template).join();模板引擎的优点是把“模板”和“逻辑”分开了。HTML结构看起来是一个完整的模板片段不再被JavaScript的三元表达式塞满。同时模板引擎自带HTML转义默认情况下输出变量都会被转义XSS风险大幅降低。它还支持partial局部片段可以把一个复杂页面拆成多个子模板重复嵌套比如一个商品卡片里嵌套一个价格标签的partial。缺点是需要额外引入库文件。如果你只是在一个很小的地方用几十行模板字符串就能搞定引入模板引擎反而显得“杀鸡用牛刀”。另外所有模板引擎的调试都有一点门槛语法错误有时候报得不够直观渲染结果和预期不一样时需要一点经验去定位。但如果你在做一个没有框架、但页面数量不少的传统站点——比如营销活动页、企业官网用Handlebars这一层的复用性价比是很高的。3.3 fetch innerHTML 动态加载公共片段还有一种方案是把公共片段比如header.html、footer.html单独保存成文件然后在页面加载后用JavaScript通过fetch拉取插入到指定位置。思路有点像前端版的include。div>async function loadIncludes() { const elements document.querySelectorAll([data-include]); for (const el of elements) { const url el.getAttribute(data-include); const response await fetch(url); const html await response.text(); el.innerHTML html; } } loadIncludes();这个方案的优点是代码非常少而且“片段文件”可以复用比如一个header.html可以被多个页面引用。它也不会像模板引擎那样依赖运行时数据写死的静态HTML片段用它来加载特别自然。但它的坑也很明显第一直接拉取本地文件有跨域限制。如果你是用file://协议直接打开本地的HTML页面fetch本地文件可能会被浏览器拦截必须要在本地起一个HTTP服务或者部署到服务器上才能正常工作。第二闪烁问题。页面先渲染出来顶部是空的几毫秒或者几十毫秒后header才被拉回来插进去用户会看到一个闪动。避免闪烁的方案是在header原本的位置放一个同高度的占位元素或者用内联样式一开始就隐藏页面主体等公共片段都加载完再显示。但这又引入了复杂度。第三重复请求。每个页面都要分别请求header.html、footer.html浏览器不会自动在页面之间缓存这些片段的“渲染结果”通常需要你额外配置HTTP缓存头。总体来说这个方法适合那种“公共部分完全是静态、且页面不多”的场景。如果你已经引入了React/Vue就完全没必要这么干组件化的方案会优雅得多。3.4 组件化封装函数式复用进阶上面的模板字符串和模板引擎都还是“一次返回一个字符串”的阶段。当你需要复用的不只是HTML结构还有对应的事件绑定、交互状态时就需要往组件化的方向走——用函数或者对象来封装HTML和相关逻辑。举个例子做一个模拟的“弹窗组件”function createModal(options) { const modal document.createElement(div); modal.className modal-overlay; modal.innerHTML div classmodal-box h2${options.title}/h2 p${options.content}/p button classmodal-close关闭/button /div ; modal.querySelector(.modal-close).addEventListener(click, () { modal.remove(); }); document.body.appendChild(modal); return modal; }使用的时候只需要createModal({ title: 提示, content: 操作成功 })。这个函数不仅复用了HTML结构还复用了“关闭弹窗”的逻辑。如果再结合CSS的样式复用你就已经在用“无框架组件化”的方式写页面了。在这个基础上可以进一步抽象成一个简单的渲染函数传入一个数据对象返回一个DOM节点DOM节点内部绑定了事件。这种方式的优点是没有框架依赖任何浏览器都能跑而且逻辑完全掌握在自己手中想怎么扩展都可以。缺点是当组件数量多起来你需要自己管理组件的生命周期创建、更新、销毁处理父子组件之间的数据传递。这些本来框架都帮你解决了你手写会越写越复杂。所以我通常的建议是在5个以下的组件规模用手写组件函数很舒服再往上就应该考虑引入一个真正的组件化框架或原生Web Components。4. 现代组件化方案Web Components4.1 自定义元素把HTML推进到原生组件时代Web Components是一组浏览器原生API的集合主要包括自定义元素Custom Elements、影子DOMShadow DOM和HTML模板template。它允许你像使用div一样使用自己定义的一个标签比如my-header/my-header浏览器自己会去渲染这个组件的内容。自定义元素的基本写法很简单class MyHeader extends HTMLElement { connectedCallback() { this.innerHTML header nav首页 | 关于 | 联系/nav /header ; } } customElements.define(my-header, MyHeader);然后所有页面里都可以直接写my-header/my-header不需要额外引入JavaScript文件之外的任何东西。这个方案解决了之前所有方案都没有解决的一个问题组件在自己的标签内形成封装。你可以在多个页面使用同一个自定义元素浏览器的自定义元素注册表会保证它只注册一次加载一次。自定义元素的另一个好处是生命周期清晰有connectedCallback挂载时、disconnectedCallback移除时、attributeChangedCallback属性变化时等回调可以方便地在关键时机去做初始化、销毁工作。我可以在connectedCallback里初始化一个事件监听器在disconnectedCallback里移除它这样就不会造成内存泄漏。4.2 Shadow DOM与slot真正的样式隔离Web Components里最让我喜欢的部分是Shadow DOM。简单说它给组件内部创建了一棵独立的DOM树外部页面的CSS选择器无法直接影响组件内部的元素组件内部的样式也不会泄漏到外部除非你主动穿透。class MyCard extends HTMLElement { constructor() { super(); const shadow this.attachShadow({ mode: open }); shadow.innerHTML style .card { border: 1px solid #ddd; padding: 16px; } /style div classcard slot/slot /div ; } } customElements.define(my-card, MyCard);这里的slot是Shadow DOM里的一个占位符它可以把外部传入的内容插入到组件内部的指定位置。比如my-card h2标题/h2 p内容/p /my-card那些外部内容会被渲染在组件内部的.card内部。这种组合能力非常强你可以做一套设计系统然后各个页面通过slot塞入不同的内容。不过Shadow DOM也有一个明显的坑表单控件和外部CSS交互的复杂度。比如组件内部用了input外部样式表对它的样式不会生效你必须在组件内部的style里完整定义输入框样式包括hover、focus状态。另外如果组件内部需要用到外部字体图标你需要额外引入字体样式到Shadow DOM内部不能直接继承页面样式。这些都在实际项目中遇到过的不是看了文档就能完全预判的。如果你不想被Shadow DOM的样式隔离限制住也可以不attachShadow只使用自定义元素 普通innerHTML。但那样就要靠团队的约定来避免样式冲突。我的建议是如果要做可复用的跨项目组件库建议还是开Shadow DOM隔离带来的好处远大于麻烦。4.3 实际示例和注意事项我来写一个完整的Web Components示例展示一个可复用的“用户信息卡片”template iduser-card-template style .user-card { border: 1px solid #eee; border-radius: 8px; padding: 16px; display: flex; align-items: center; } .avatar { width: 40px; height: 40px; border-radius: 50%; background: #4a90e2; color: #fff; display: flex; align-items: center; justify-content: center; margin-right: 12px; } .info .name { font-size: 16px; font-weight: bold; } .info .desc { font-size: 13px; color: #888; } /style div classuser-card div classavatar/div div classinfo div classname/div div classdesc/div /div /div /templateclass UserCard extends HTMLElement { constructor() { super(); const template document.getElementById(user-card-template); const shadow this.attachShadow({ mode: open }); shadow.appendChild(template.content.cloneNode(true)); } static get observedAttributes() { return [name, desc, avatar]; } attributeChangedCallback(attr, oldVal, newVal) { if (oldVal newVal) return; if (!this.shadowRoot) return; const nameEl this.shadowRoot.querySelector(.name); const descEl this.shadowRoot.querySelector(.desc); if (attr name) nameEl.textContent newVal; if (attr desc) descEl.textContent newVal; } } customElements.define(user-card, UserCard);使用user-card name张三 desc前端开发者/user-card这段代码里组件的数据通过HTML属性传入。如果你需要传入复杂对象可以序列化成JSON属性或者暴露一个JavaScript方法setData(object)。使用过程中有几个注意事项第一模板template的内容是惰性的不会在页面渲染时直接显示只有在被clone之后才真正渲染。这意味着你可以把一个模板放在页面任何位置不影响性能。第二observedAttributes和attributeChangedCallback要配合使用才能响应属性变化并更新UI。如果你只定义属性不在observedAttributes里声明属性变了组件不会感知。第三如果你需要封装多个组件并发布成“组件库”可以考虑用ES Module写单个文件然后通过import引入结构会更清晰。但要注意浏览器兼容性虽然现代浏览器都支持Web Components但如果你的用户在用老旧浏览器还是需要引入polyfill或降级方案。5. 常见问题排查与选型建议5.1 常见问题我在帮同事排查HTML复用相关的bug时积累了不少典型案例这里挑几个经典的分享问题一动态插入的HTML片段样式丢失用innerHTML插入了一段公共片段但发现本来应有的CSS样式没生效。这通常是因为公共片段里的CSS写在了单独的样式表中而样式表在页面里加载了但组件的类名与全局样式类名冲突或者组件内容在样式的“作用域”之外比如你把组件插入到某个容器容器有scoped样式。排查思路先在浏览器DevTools里看插入后的元素确认类名和样式表选择器是否匹配再检查是否被scoped属性或其他选择器优先级压制。问题二fetch加载公共片段时出现CORS错误如果你用file://直接打开本地页面fetch本地文件会遇到跨域限制。解决第一种方式是本地起一个服务比如python -m http.server或npx serve。第二种是如果你在写一个插件、工具页面需要文件嵌入可以考虑把公共片段转成JavaScript字符串或者用同一目录下的相对路径但相对路径配合服务端路径时又容易出错。所以我在做多页面静态站的时候通常会直接用硬编码的绝对路径/components/header.html但这又要求部署时组件目录必须固定。问题三自定义元素在动态创建时不能正常工作有时候你用JavaScript创建了一个自定义元素比如document.createElement(my-header)它没有被正确渲染。多半是因为你选择器的定义时机不对或者自定义元素与已存在元素的名称冲突。建议在页面初始化时就通过customElements.define注册所有自定义元素然后再开始创建元素。如果在框架环境里使用还要确保自定义元素的脚本在框架渲染前加载。问题四Shadow DOM组件内部的弹窗定位不了Shadow DOM内部的弹窗设置了position: fixed但它相对于组件自身定位而不是相对于视口或者会因为隔离而拿不到外层容器的宽度。这个在select下拉、tooltip等场景经常出现。解决办法是给Shadow DOM内部元素设置宽高、定位都尽量以组件自身为参照组件本身不要设置display: contents或溢出隐藏。如果你需要弹窗覆盖到整个页面可以考虑把弹窗容器放到Shadow DOM外部通过document.body.appendChild动态创建。5.2 选型对比表不同方案适合不同场景我整理一个对比表方便你快速定位方案后端依赖数据驱动样式隔离学习成本适用场景服务端include需要弱无低传统服务端项目iframe嵌入无弱强低第三方内容嵌入模板字符串函数无强无低简单卡片、小片段模板引擎无强无中多页面无框架站点fetch innerHTML无弱无低静态公共头尾手写组件函数无强无中5个左右小组件Web Components无强可隔离高跨项目组件库这里特别说明一下“数据驱动”这一列服务端include和fetch方案更适合“静态复用”也就是公共部分不随当前页面数据变化而模板字符串、模板引擎、Web Components则可以在复用HTML结构的同时根据数据动态渲染内容。如果你的需求是同一个组件在不同页面展示不同内容那一定要选数据驱动能力强的方案。5.3 个人经验怎么选才不后悔从我自己的项目经验来看选复用方案最重要的一点是不要为了技术炫技而过度设计。如果你只是在做一个几个页面的落地页服务器端include或者fetchfragment就足够了没必要引入Web Components和一堆构建工具。反过来说如果你要做一个长期维护的多业务项目从一开始就用组件化思路封装公共代码后面省下的维护时间是非常可观的。我做一个比较大型的管理后台时前端不需要框架因为后台部分区域是动态生成的然后把所有公共UI表格、表单、弹窗、分页器封装成了Web Components。这个项目运行了两年多后来迭代时修改表格组件的分页逻辑只改一处所有页面的表格立即生效团队协作效率提高了很多。但我也遇到过过度设计反噬的例子。某个项目最初只是想让header复用结果上了完整的组件化框架为了处理动态更新代码复杂度直线上升二三十个子组件嵌套后来换人维护时新同事学习成本巨大。所以我的建议是先列出实际的复用需求清单比如“我需要复用几个片段”、“这些片段是否需要根据不同数据变化”、“样式隔离重要吗”然后再选方案。另外想分享一个通用技巧无论你选哪个方案都把“公共代码”的命名、目录结构、编码规范提前约定好。比如组件库统一放在/components/下命名用my-前缀文件用一致的格式。你可以在每个公共片段顶部加注释写清楚“此文件被哪些页面引用修改会影响哪些页面”这样后续维护的人不用靠猜。最后再补充一个小技巧在做模板字符串拼接或模板引擎渲染时尽量用textContent而不是innerHTML来插入纯文本数据这是避免XSS最简单有效的方式。如果你必须要用innerHTML那就先转义再拼接。HTML复用好不好不仅要看能不能减少代码量更要看能不能帮你减少线上问题这往往比“代码写得漂亮”更重要。在这几种方案之间反复切换过之后我个人最大的感触是没有银弹。一个聪明的开发者会在项目初期就把复用需求盘清楚然后选择一个够用、可维护、团队能驾驭的方案。如果你还在犹豫可以从最简单的模板字符串函数开始功能不够了再平滑升级到模板引擎或Web Components至少这个路径不会让代码烂在手里。