首页 / 资讯中心 / 文章详情

HTML模板与插槽复用实战:从template到Web Components

HTML模板与插槽复用实战:从template到Web Components ★ FEATURED ARTICLE
前不久帮一个朋友维护他的个人博客碰到一个特别典型的问题导航栏在五个页面里各有一份他改一个链接要全局搜索替换五次页脚那段备案信息就更离谱每篇文章底部都复制了一遍改一次要动十几个文件。我俩对着屏幕沉默了半天最后我说“你这不是缺编辑器是缺模板思维。”HTML 模板、插槽、片段复用这三个词看着基础真正能用好的人不多。大多数项目一开始都是“复制、粘贴、改一改”等页面多了、改动频繁了才意识到这是在给自己挖坑。这篇文章我打算从原生 HTML 的template标签讲起再拆解slot插槽的分发规则最后补上服务端 include、前端 fetch 片段引入和 Web Components 的组合玩法。不吹框架不整玄学就说实际项目里怎么选、怎么写、怎么避坑适合正在做中小型站点、或者被重复 HTML 折磨过的前端开发者对照着参考。1. 模板、插槽、片段复用——为什么它们总是被忽视1.1 一个反复改动导航栏的场景你大概率经历过这种场景新建了一个详情页顺手把头部导航复制过来又新建了一个落地页再把导航复制过来。表面上看没什么问题反正粘贴只要一秒。但需求一变你就知道疼了导航里加个“联系我们”的入口你得打开每一个页面去改。最要命的是这种重复不是简单的“多复制几次”能解决的——改漏一个页面线上就会出现一个旧版导航用户点进去还找不到入口。我见过一个项目就是因为这种散落各处的 HTML 片段出现过两个版本的底部信息同时在线的情况。这类问题的根源不是粗心而是你没有把“可变的部分”和“不变的部分”分开管理。1.2 模板、插槽、片段复用分别解决什么问题先给这三个概念划一下边界HTML 模板一段“沉睡”的 HTML不会在页面加载时渲染等需要时再克隆到文档里。解决的是“同一套结构在同一个页面里反复生成”的问题。插槽在模板里预置一个或多个占位区域外部可以向这些区域投放内容。解决的是“结构是固定的但每个实例的局部内容不同”的问题。片段复用把一段独立的 HTML 片段在多页面或多位置重复引入。解决的是“同一块内容散落在多个文件里每次改动都要全量同步”的问题。这三个东西经常被混为一谈但适用场景完全不同。模板管“生成”插槽管“分发”片段复用管“跨页面共享”。后面我会分别展开并且给出一套可以直接套用的选型逻辑。1.3 适用人群与方案定位这篇文章适合谁看首先是手写原生 HTML 的开发者你没有框架的编译期帮你处理复用更需要自己搭一套轻量方案其次是刚接触 Web Components 的初学者想搞清楚slot到底是怎么工作的还有一类是在团队里搞基建的同学想制定一套“导航、页脚、卡片这类通用片段怎么管理”的规范。我给的方案核心就一句话用原生技术解决原生问题少引入不必要的依赖。如果你的场景是纯静态站、多页站点、或者对首屏性能敏感这套东西比上个打包器再跑个组件框架轻得多。2. 原生 template 标签最基础、最容易忽略的模板单元2.1 惰性解析与 DocumentFragment为什么不会闪屏template标签出现很久了但很多前端开发者一直没拿它当正经工具。它最特别的一点是惰性解析浏览器解析到template时不会渲染内部的内容也不会请求内部的图片、脚本只是把内容存起来。这带来一个非常实用的效果如果你把一段复杂的列表项结构放在template里页面初始加载阶段它是完全不可见的也不会因为内部有img就去请求资源。等到数据回来了你再把模板克隆出来、填充数据、塞进列表这样用户看到的是一个干净的、统一的渲染结果不会出现“先闪一下空壳再突然跳出一堆内容”的现象。template的内容本身是一个DocumentFragment对象。不了解DocumentFragment的朋友可以把它理解成一个“虚拟容器”你把节点放进去它不会立刻触发页面的回流和重绘等所有节点都组装完毕你才把这个容器整体塞进页面浏览器一次性地完成布局计算。2.2 从 template 到真正渲染cloneNode 与 importNodetemplate本身不会被渲染你得主动去“取”它的内容。标准姿势是这样的template idcard-template div classcard h3 classcard-title/h3 p classcard-desc/p /div /templateconst template document.getElementById(card-template); const fragment template.content.cloneNode(true); fragment.querySelector(.card-title).textContent 第一个卡片; fragment.querySelector(.card-desc).textContent 这是卡片描述; document.getElementById(card-list).appendChild(fragment);注意cloneNode(true)这里的true是深克隆要把模板内的所有子节点完整拷贝不能漏。如果漏了你拿到的就是一个空壳。还有一个容易踩的坑如果你的模板是在一个文档里定义、要插入到另一个文档比如 iframe 或新开的 window不能直接用cloneNode得用document.importNode(template.content, true)先把节点“进口”到当前文档再做填充。很多人在跨文档操作时报错卡了半天其实问题就出在这。2.3 批量渲染用 fragment 减少回流我们在渲染列表时最常见的错误写法是每生成一个节点就appendChild一次。假设列表有 100 项那就要触发 100 次 DOM 插入每次插入都可能引起回流。性能压力虽然不是致命但真到了长列表或频繁更新的场景肉眼可见地卡顿。用模板加 fragment 的写法可以把这个过程压缩到一次const template document.getElementById(card-template); const list document.getElementById(card-list); const data [ { title: 标题一, desc: 描述一 }, { title: 标题二, desc: 描述二 }, // 更多数据... ]; const container document.createDocumentFragment(); data.forEach(item { const fragment template.content.cloneNode(true); fragment.querySelector(.card-title).textContent item.title; fragment.querySelector(.card-desc).textContent item.desc; container.appendChild(fragment); }); list.appendChild(container);这里我解释一下为什么最后一定要list.appendChild(container)container是一个DocumentFragment它不是一个真实存在的 DOM 节点你把所有克隆出来的内容都先“囤”在它里面页面不会有任何感知。到最后一次性放上去浏览器只处理一次插入。2.4 一套能直接抄的列表渲染模板代码结合上面的思路我给出一个完整的小例子。假设你要做一个“文章列表”页面结构是标题、摘要、发布时间还带一个“阅读全文”链接template idarticle-item article classarticle-item h2 classarticle-title/h2 p classarticle-summary/p span classarticle-date/span a classarticle-link href#阅读全文/a /article /templateasync function renderArticles() { const template document.getElementById(article-item); const list document.getElementById(article-list); const data await fetch(/api/articles).then(res res.json()); const container document.createDocumentFragment(); data.forEach(article { const fragment template.content.cloneNode(true); fragment.querySelector(.article-title).textContent article.title; fragment.querySelector(.article-summary).textContent article.summary; fragment.querySelector(.article-date).textContent article.date; fragment.querySelector(.article-link).href /article/${article.id}; container.appendChild(fragment); }); list.appendChild(container); } renderArticles();这套写法的好处是结构清晰、性能可控而且不需要引入任何模板引擎。数据多的时候fragment能明显减少回流数据少的时候代码可读性也远超一堆字符串拼接。关于字符串拼接的坑我在后面第六章会专门讲。3. slot 插槽的机制拆解默认插槽、命名插槽与内容分发3.1 插槽是占位符不是直接替换很多人第一次接触slot时的误解是“插槽就是把一段 HTML 替换到另一个位置。”这个理解太简单了。更准确的说法是插槽是模板内部预留的出口外部内容通过这个出口投影到模板里。最典型的场景是在 Web Components 中你写一个组件模板希望使用者往里面塞自定义内容但组件内部的结构和样式保持不变。看一个简单例子template idprofile-template div classprofile-card slot nameavatar默认头像/slot slot默认描述文字/slot /div /templatemy-profile img slotavatar srcavatar.png alt头像 p这是一段自定义描述/p /my-profile注意slot nameavatar和slot的配合带name的是命名插槽不带name的是默认插槽。外部内容通过slotavatar属性指定自己要去哪个插槽而没有slot属性的内容会自动进默认插槽。3.2 命名插槽与回退内容命名插槽的价值在于它允许你把一个组件内部的多个区域开放出来。比如一个弹窗组件顶部标题区、中间内容区、底部按钮区都是可变的但弹窗的遮罩、边框、圆角这些结构是固定的。你可以这样设计template iddialog-template div classdialog-mask div classdialog-body header classdialog-header slot nametitle默认标题/slot /header main classdialog-main slot默认内容/slot /main footer classdialog-footer slot nameactions button typebutton默认确定/button /slot /footer /div /div /template外部使用my-dialog h3 slottitle删除确认/h3 p删除后不可恢复确定继续吗/p div slotactions button取消/button button classdanger删除/button /div /my-dialog这里有个容易被忽略的细节插槽内的内容比如slot nametitle默认标题/slot叫做回退内容。只有当外部没有提供对应内容时才显示。如果外部传了内容回退内容会被覆盖。这个机制最适合做空状态的默认提示省去组件内部的复杂条件判断。3.3 slotchange 与 assignedElements事件驱动的插槽插槽还有个很实用的能力监听外部内容变化。slotchange事件会在插槽分配的内容发生变化时触发配合assignedElements()可以拿到当前实际渲染到插槽里的 DOM 元素。常见的业务场景是组件内部要根据插槽里的内容个数调整布局。比如你的操作栏插槽里只有一个按钮居右显示有两个按钮居左和居右分开。这时不能光靠 CSS你得知道插槽中实际有几个元素class MyDialog extends HTMLElement { connectedCallback() { this.shadowRoot this.attachShadow({ mode: open }); this.shadowRoot.appendChild( document.getElementById(dialog-template).content.cloneNode(true) ); const actionsSlot this.shadowRoot.querySelector(slot[nameactions]); actionsSlot.addEventListener(slotchange, () { const buttons actionsSlot.assignedElements(); if (buttons.length 1) { this.shadowRoot.querySelector(.dialog-footer).classList.add(single-action); } }); } } customElements.define(my-dialog, MyDialog);这里我调整了一个细节attachShadow({ mode: open })模式可以把组件内部结构暴露给外部调试线上如果不想被随意操作可以改成closed但closed模式在调试时非常难受我建议开发阶段一律用open。3.4 框架里的插槽设计Vue 与 React 的对比视角如果你用过 Vue再看原生 slot会发现熟悉感很强。Vue 的具名插槽、默认插槽、作用域插槽都能够直接在 Web Components 里找到对应物——命名插槽对应具名插槽默认插槽对应默认内容而作用域插槽可以说是一种更高级的“运行时传参”方案。React 体系则偏向组合思维不是用插槽而是把整段 JSX 当作 props 传进子组件组件内部再决定渲染到哪里。这本质上也是一种内容分发只是表达方式更函数式。如果你是在一个轻量项目里想使用 Web Components 的插槽而不引入框架原生方案完全够用而且没有构建步骤体积接近零。如果你已经在一个组件化框架里了也不需要纠结用哪种框架的插槽机制本来就是基于这套原理封装出来的。4. 片段复用的三套组合拳服务端 include、前端 fetch 与 Web Components4.1 服务端 include还是最快的方式回到开头那个导航栏的例子。如果你的站点是服务端渲染的比如用 PHP、Node 模板引擎最直接的片段复用方法是服务端 include。示例以常见的服务端模板语法为例!-- header.html -- header classsite-header nav a href/首页/a a href/blog博客/a a href/about关于/a /nav /header在每个页面顶部{{ include partials/header.html }}好处很明显改一次全部页面同步首次访问时内容就在 HTML 里不需要额外的请求。这适合内容型站点、文档站、营销页这类对首屏要求高的场景。它的局限是必须依赖服务端环境没法在纯静态托管平台直接使用除非你用了某个支持模板语法的静态站点生成器。4.2 客户端片段引入fetch template 的标准写法没有服务端环境怎么办比如你把自己写好的静态文件丢到某个静态托管平台上没法跑服务端 include这时客户端引入是一种零依赖的替代方案。原理很简单用fetch请求片段文件拿到 HTML 字符串后放进template再克隆插入目标位置。async function loadPartial(url, targetSelector) { const response await fetch(url); if (!response.ok) { throw new Error(片段加载失败${url}); } const html await response.text(); const template document.createElement(template); template.innerHTML html; document.querySelector(targetSelector).appendChild( template.content.cloneNode(true) ); } // 用法 loadPartial(/partials/header.html, #site-header); loadPartial(/partials/footer.html, #site-footer);把 HTML 字符串塞进template而不是直接设置到某个元素的innerHTML是为了避免脚本执行和潜在的安全问题。template.innerHTML不会执行内部的script也不会触发文档级的事件相对更可控。这个方案适合“纯静态站点 少量公共片段”的场景。要注意的是它会额外发起请求而且如果片段里包含图片图片会在插入后才加载。如果对加载速度有要求可以在关键页面里直接把公共片段内联到 HTML而不是全部走 fetch。4.3 Web Components 把模板、插槽、样式封装在一起如果复用不仅是 HTML还包括交互逻辑和样式fetch 片段的方式就不太够用了。这时应该考虑 Web Components。它的核心吸引力在于封装性。模板、样式、脚本都关在 Shadow DOM 里外面的页面环境很难干扰到它它也很难泄漏样式到外面。这对团队协作特别友好——你写好一个组件别人只管用不用担心规则冲突。class SiteFooter extends HTMLElement { constructor() { super(); this.attachShadow({ mode: open }); } connectedCallback() { this.shadowRoot.innerHTML style footer { padding: 24px; text-align: center; border-top: 1px solid #ddd; } /style footer slot namecopyright/slot p由我的站点提供支持/p /footer ; } } customElements.define(site-footer, SiteFooter);使用时site-footer p slotcopyright© 2025 我的站点/p /site-footer这样页面上只需要写一个site-footer标签所有细节都封装在组件内部。内容需要变时通过插槽传进去结构要改时只动组件文件。4.4 组合使用的一个实例有时候最头疼的问题是又想服务端渲染保证首屏又想复用组件又想随手改内容。我去年接手的小项目就是这三者混着用。首页和博客列表页用服务端 include 去渲染通用导航文章详情页因为是异步加载数据用 fetch 方式在客户端插入侧边栏搜索框这种带交互逻辑的模块直接做成 Web Components每个页面只要写一个标签。分层的逻辑很简单全站不变的骨架导航、页脚 → 服务端 include个别页面需要的附加模块侧边栏、相关推荐 → fetch 片段带交互和样式的模块搜索框、弹窗 → Web Components这套方案的好处是每个模块的职责明确不会出现“为了一点交互把所有片段都升级成组件”的过度设计。5. 实战选型同场景下最佳方案的选择逻辑前面把三种能力都过了一遍但这还不够。真正动手时你往往不是“不会写”而是“不知道怎么选”。我总结一套自己的判断逻辑不一定适合所有项目但大概率能帮你少走弯路。5.1 场景一静态站导航/页脚复用判断条件站点没有服务端运行时只有托管静态文件公共片段不改动或者极少改动。推荐方案直接用 fetch template 在页面加载时引入。不要在打包工具里做 HTML import也不要引入一个完整框架只为了 include。原因很简单静态站的核心诉求是轻量、少依赖fetch 方案十几行代码就能实现而且支持按需加载。首次访问时导航区域会晚一点点出现但如果你把关键样式内联到页面的style里视觉上是感觉不出来的。5.2 场景二动态列表渲染判断条件同一个页面内结构相同的卡片、条目需要根据数据循环渲染数量在几十到几百之间不需要复杂的模板语法。推荐方案原生templateDocumentFragment。不要为这个场景去安装一个客户端模板渲染框架。数据填充用textContent而不是innerHTML能规避大部分 XSS 风险。如果列表超过 500 项优先考虑分页或虚拟滚动而不是优化模板引擎——模板本身从来不是长列表的性能瓶颈。5.3 场景三业务组件多次复用判断条件同一个 UI 模块在多页面出现既有固定结构又有自定义内容还带交互逻辑你希望团队成员只暴露一个自定义标签不用关心内部实现。推荐方案Web Components 命名插槽。尤其适合不打算引入前端框架的团队。用customElements.define注册组件用 Shadow DOM 隔离样式用slot开放可变区。如果你需要跨页面共享数据可以在组件里监听自定义事件或者读取>// 错误的做法 const template document.getElementById(card-template); document.getElementById(list).appendChild(template.content);这么写第一次可能没报错但第二次执行时会发现列表是空的。因为template.content是同一个DocumentFragment对象第一次插入后它里面的节点已经被移走了第二次再插入的是一个空容器。正确做法是每次插入都用template.content.cloneNode(true)拷贝一份。这个细节成为我后来 code review 时必查的项目。6.2 在模板里做事件绑定的常见错误很多人在模板里直接写onclickhandleClick()。这在模板插入到页面后通常也能工作但有两个隐患一是模板里的函数必须是全局函数模块化后很容易失效二是如果模板被克隆多次每个克隆体得重新绑定否则事件会丢失或错乱。我建议的统一做法是模板只负责结构事件绑定放到 JS 里统一处理。克隆出节点后用querySelector找到目标元素再addEventListener。虽然代码多几行但职责清晰后续维护不会为了“某个按钮点击无效”翻找半天。6.3 插槽样式与全局样式的冲突Web Components 的 Shadow DOM 能隔离样式但它不是绝对的。外部样式比如全局的a { color: blue }无法穿透 Shadow DOM这看起来是好事但如果你的组件里希望继承外部主题色就会不太方便。解决办法是使用继承性属性比如font-family、color这类可以继承的样式会从外部作用域流入组件内部而background、padding这类非继承属性不会。还有一个常见坑在 Shadow DOM 内部给slot元素写样式有时不生效。因为插槽内容的最终归属还是外部文档样式规则应用在组件内部但对实际渲染的投影内容控制力有限。如果你想让插槽内容拥有某些样式原则是要么在组件内部给包裹插槽的容器设置样式要么约定外部内容自行处理样式。6.4 字符串拼接 HTML 的 XSS 教训在一次稍微复杂的列表渲染里我图省事用字符串拼接构造 HTMLdiv title /div。结果某个数据的标题里带了一小段 HTML直接把页面结构给破坏了。后来数据还包含用户输入如果那段 HTML 里混入恶意脚本后果就不只是破坏结构了。从那以后我给自己定的规矩是凡是会放入用户输入内容的字段一律用textContent赋值绝不用innerHTML拼接。模板加克隆的方案天然适合这个规矩——先把结构克隆出来再逐字段用textContent填充。这样写出来的代码安全性比字符串拼接高一个数量级。结尾最后分享一个我在实际项目中的体会模板、插槽、片段复用说到底都是为了一个目标——让 HTML 像软件工程一样可维护。你不需要把每个页面都写成组件也不需要一上来就搞微前端。很多时候一个template标签、一个slot占位、一个fetch小函数就能解决八成的问题。如果你正准备重构一个被重复 HTML 折磨的项目我的建议是从最痛的地方开始找那个改一处要动十个文件的公共片段用 fetch 或服务端 include 先收拢起来。改完之后你会立刻感受到“改一处全站生效”的畅快感这种反馈比看任何技术文章都让人上头。下一步再把页面里反复出现的卡片、列表项收进 template你就正式进入“结构化复用”的阵营了。希望这篇内容能帮你少踩几个我当年踩过的坑。
阅读完成 · 觉得有帮助?
咨询建站