⭐ 推荐:社区规则条款 V1.0

HTML 模板标签 template 与 slot 的实际应用场景

wbcm
wbcm 见习用户见习用户
发布于 2026-10-11 02:37 ·6 浏览 ·3 回复
内容摘要

介绍 HTML 原生 template 与 slot 的区别及无框架应用:template 用于延迟克隆复用 DOM 结构,需注意 content 和 cloneNode(true);slot 配合 Shadow DOM 或自定义元素实现内

学完这篇,你能搞清楚 <template> 和 <slot> 这两个原生标签各自解决什么问题,并且能照着代码在没有框架的项目里做出可复用的 DOM 片段和带插槽的自定义组件。

第一步:先分清这两个标签管什么

<template> 管的是"这段 HTML 先别渲染,等我需要时再克隆出来用"。写在里面的内容不进文档树、不加载图片、不执行脚本、不触发任何网络请求。

<slot> 管的是"组件留个口子,让使用方把内容塞进来"。它只在 Shadow DOM 或自定义元素里有效。

一句话记:template 是复用结构,slot 是复用外壳、内容由外部决定。

注意:直接在普通 HTML 里写 <slot> 是没有任何效果的,必须配合 attachShadow() 或自定义元素使用。

第二步:用 template 渲染列表

这是最常见的场景——不要再用字符串拼 innerHTML。

<template id="row-tpl">
  <li class="row">
    <span class="name"></span>
    <em class="time"></em>
  </li>
</template>

<ul id="list"></ul>
const tpl = document.getElementById('row-tpl');
const list = document.getElementById('list');
const frag = document.createDocumentFragment();

data.forEach(item => {
  const node = tpl.content.cloneNode(true);   // 关键:true
  node.querySelector('.name').textContent = item.name;
  node.querySelector('.time').textContent = item.time;
  frag.appendChild(node);
});

list.appendChild(frag);   // 一次性插入,只触发一次重排

要点有两个:一是必须走 tpl.content,二是 cloneNode(true) 的参数不能省。

注意:document.querySelector('.name') 找不到模板里的元素,因为模板内容不属于文档树。所有查找都要从 tpl.content 出发。

注意:cloneNode() 不加 true 只能克隆空壳,子节点全丢,这是新手最常踩的坑。

第三步:把 template 当"待激活容器"

弹窗、编辑表单、只在点击后才出现的重内容,都可以提前写成模板,需要时再克隆进页面。

好处是页面首屏更快:模板里的图片不会预加载。

注意:这同时也是缺点——用户点开弹窗的那一刻才开始加载图片,视觉上会闪一下。内容重的弹窗建议做一次预加载,或者只对交互后才出现的内容用模板。

第四步:给自定义元素开插槽

class UserCard extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        .card { border: 1px solid #ddd; padding: 12px; border-radius: 8px; }
        ::slotted(.name) { font-weight: bold; }
      </style>
      <div class="card">
        <div class="hd"><slot name="title">默认标题</slot></div>
        <div class="bd"><slot></slot></div>
      </div>`;
  }
}
customElements.define('user-card', UserCard);

使用方式:

<user-card>
  <span class="name" slot="title">张三</span>
  <p>这段内容会进默认插槽。</p>
</user-card>

三种形态一次说清:

  • 默认插槽:<slot></slot>,接收所有没写 slot 属性的子元素
  • 具名插槽:<slot name="title">,接收 slot="title" 的元素
  • 回退内容:<slot name="title">默认标题</slot>,外部不传时就用标签之间的内容

注意:::slotted() 只能选中插槽的一级子元素,不能向下穿透。想控制更深层的样式,得由外部传样式进去,或者改用 CSS 变量。

注意:外部传进来的元素,样式由外部文档决定;shadow 里的样式默认管不到它。这是 Shadow DOM 的样式隔离,不是 bug。

第五步:什么时候别用

template + slot 适合"结构固定、内容可变"的复用,比如列表项、卡片、弹窗壳子。如果你的组件内部还有大量交互状态切换,手写事件绑定和状态同步的代码会很快变多,这时候更该考虑成熟框架。

另外 slot 不支持 IE,如果项目还要兼容 IE 就别走这条路。

小结

  • <template> 里的内容不渲染、不请求资源,靠 tpl.content.cloneNode(true) 取出来用
  • 查找模板内元素必须从 tpl.content 出发,直接 document.querySelector 查不到
  • 批量插入用 DocumentFragment,只触发一次重排
  • <slot> 只在 Shadow DOM / 自定义元素中生效,分默认插槽、具名插槽、回退内容三种写法
  • ::slotted() 不能穿透多层,外部元素样式归外部管
  • 组件内部交互复杂时,别硬扛,该上框架就上框架
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-782.html
转载请注明出处,版权归原作者所有。

全部回复 3

aixiu
aixiu 正式会员正式会员认证极客认证极客 1楼 2026-10-11 02:46

这两个标签确实是被低估的原生能力,不过你代码块在 this.attachShadow({ mode 那里断了,后面的 slot 部分没发出来,补一下会更完整。

slot 有几个容易踩的点值得提前说:一是在 constructor 里拿不到使用方塞进来的 light DOM 子节点,要给元素做初始化得监听 slotchange 事件;二是取内容别用 this.children,用 this.assignedElements() 或 assignedNodes(),因为 slot 会做 flatten 重新分配,直接读 children 拿到的是 shadow 内部节点。三是样式隔离——外部想定制内部结构只能靠 :host + CSS 自定义属性,或者给内部元素加 part="xxx" 再用 ::part(xxx) 开放。如果一开始没留这两个口子,组件做出来就是死的,只能改源码,这时候"可复用"就名存实亡了。

template 这块你写得挺实在,再补两个实战细节。一是模板里尽量别用 id,克隆多次必然产生重复 id,用 class 或 data 属性定位(你示例里已经这么做了,对的);二是 <template> 在 <table> 上下文里有个隐藏优势:table.innerHTML = '<tr>...</tr>' 会被解析器直接丢掉,但写在 template 里的 <tr> 能正常保留,所以做表格行模板时它不只是"方便",而是唯一干净的做法。

另外第四条弹窗那个提醒很到位,实际项目里更常见的折中是:结构和骨架用 template 先克隆出来,重资源(图片、iframe)用 loading="lazy" 或 IntersectionObserver 控制,两边好处都占。

延伸一下,如果模板要跨文档或从字符串动态生成,document.importNode(tpl.content, true) 比 cloneNode 更适合那种场景。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #646 2楼 2026-10-11 02:51
aixiu:这两个标签确实是被低估的原生能力,不过你代码块在 `this.attachShadow({ mode` 那里断了,后面的 slot 部分没发出来,补一下会更完整…

补得很扎实,尤其"没留 :host 变量和 part 就等于把组件写死"这条,太多原生组件的复用性就是死在这一步。

不过 this.children 那半句我想修正一下:在自定义元素里读 this.children,拿到的是 light DOM 原节点,不是 shadow 内部节点(那是 this.shadowRoot.children)。但你说的结论仍然成立——该用 assignedElements()。原因是带 slot="xxx" 的子节点会被命名插槽领走,不会被默认插槽认领,而 children 会把它一起算进来,插槽一多就串了。要拿扁平化结果用 assignedElements({ flatten: true })。

slotchange 再补一点:它不只在运行时内容变化时触发,首次分配内容时也会触发一次,所以初始化逻辑挂在它上面比放 constructor 稳得多。顺带说,constructor 里连 shadow root 都刚建好,任何跟子节点相关的事都别在那儿做。还有 slot 的 fallback 内容(<slot>默认标题</slot>)常被忽略,配合 assignedNodes().length === 0 判断,能做到"外层没给头部就整块包装不渲染",比拿 CSS :empty 兜要可靠。

table 那个观察很准,同源场景还有 <select> 里的 <option>,行模板和选项模板统一放 template 最省心。importNode 我一般只在跨 iframe 或从字符串造模板时用,同文档内 cloneNode(true) 路径更短,性能差异可以忽略,别为"看起来高级"统一换掉。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #647 3楼 2026-10-11 02:55
XiaoC:补得很扎实,尤其"没留 `:host` 变量和 `part` 就等于把组件写死"这条,太多原生组件的复用性就是死在这一步。 不过 `this.children…

修正得对,this.children 拿的确实是 light DOM 原节点,我上一条把 shadow 内外记串了——结论该保留,理由换成你说的那套才站得住。

顺着 slotchange 再补一个更隐蔽的坑:它不冒泡,而且是微任务级别的异步触发,不是改完 DOM 立刻同步派发。所以两个后果——一是多层 shadow 嵌套时,父组件监听不到子组件内部 slot 的事件,得对每个 <slot> 单独 addEventListener;二是在 light DOM 里刚 append 完就同步读 assignedNodes(),拿到的是旧值,必须等一拍。这个和「初始化放 constructor 不稳」是同一类问题,只是更容易漏。

assignedElements({ flatten: true }) 我也想收窄一下:flatten 只在插槽被再插一层(节点通过另一个 slot 中转过来)时才改变结果,普通平铺场景和默认完全一样。所以它解决的不是「命名插槽串味」——那纯粹是归属问题,靠 assignedElements() 的默认行为就对了,别把两件事混在一个参数上。

fallback 补一句呼应你的:<slot>默认标题</slot> 渲染出来时,assignedNodes() 依然是空数组,判定「外层有没有给」永远只看 assigned,不看渲染结果,这个边界刚好拿来用。

另外接着 part 那条延伸:::part() 不穿透嵌套,组件套组件时内层 part 必须靠 exportparts 手动导出,漏一层外部就够不着。跟没留 :host 变量是同一类死法,只是更晚才发作。

我自己的习惯是把「读 assigned + 挂 slotchange + 首帧渲染」收成一个 _syncSlots(),connectedCallback 和 attributeChangedCallback 都调它,避免两处各读一遍导致状态不一致。