简介这是一份面向前端初学者与页面交互开发者的HTML标签页组件示例聚焦“多窗口切换可删除”这一常见交互需求。资源以Bootstrap的nav-tabs与tab-content为基础搭建导航与面板结构再借助jQuery为每个标签补充删除按钮点击后同步移除对应导航项与内容面板并自动重置激活状态解决多视图管理中动态增删标签的痛点。压缩包共3个文件包含2个JavaScript脚本与1个HTML页面分别承担组件逻辑与页面结构演示整体约12KB轻量易读便于直接运行和二次修改。目前已有697人学习下载适合想快速掌握标签页动态删除、状态切换与DOM操作思路的读者参考借鉴。1. 从一次后台改版说起为什么我最后选了原生 HTML 手写 Tab 多窗口去年帮一个做内部运维面板的团队改前端需求很朴素一个页面里塞进七八个功能模块每个模块都要能独立打开、切换、关掉关掉之后还能从侧边栏重新拉起来。一开始我图省事上了某个组件库的 Tabs结果发现它只支持固定标签动态增删要改内部状态样式还跟设计稿对不上。折腾两天之后我干脆推倒重来用原生 HTML CSS 一小段 JavaScript 手写了一套可删除的 Tab 多窗口。这套东西不依赖任何框架扔进任何页面都能跑核心逻辑不到一百行但把「打开、切换、关闭、重开」这四个动作全兜住了。这篇文章拆的就是这份「html 实现 tab 多窗口 可删除」的源码包。它解决的不是什么高深问题而是很多后台系统、配置面板、低代码编辑器都会遇到的一个具体场景同一块屏幕里管理多个并列视图用户要能自由开关。适合谁看如果你正在做管理后台、IDE 式的多文档界面、或者任何需要「标签页 动态内容」的页面又不想被组件库的 API 绑死这份东西可以直接抄。下面我按「结构怎么搭、状态怎么管、关闭怎么不翻车」的顺序把里面每个技术点拆开讲。2. 结构先行Tab 多窗口的 DOM 骨架与 CSS 布局怎么定2.1 三层结构标签栏、内容区、模板手写 Tab 最容易犯的错是把标签按钮和内容面板混在一个容器里删的时候连坐。这套源码用的是清晰的三层分离最外层一个.tab-container负责整体定位里面上半部分是.tab-bar只放标签按钮下半部分是.tab-content只放内容面板。标签和面板通过>!-- 整体容器相对定位方便内容区绝对定位铺满 -- div classtab-container !-- 标签栏只放按钮横向排列超出可滚动 -- div classtab-bar idtabBar button classtab-item active>.tab-container { display: flex; flex-direction: column; height: 100%; } .tab-bar { display: flex; overflow-x: auto; border-bottom: 1px solid #e0e0e0; background: #fafafa; } .tab-item { flex-shrink: 0; /* 标签不被压缩 */ padding: 8px 16px; border: none; background: transparent; cursor: pointer; display: flex; align-items: center; gap: 6px; } .tab-item.active { background: #fff; border-bottom: 2px solid #1677ff; } .tab-content { position: relative; flex: 1; overflow: auto; } .tab-pane { position: absolute; inset: 0; display: none; padding: 16px; } .tab-pane.active { display: block; }参数上唯一要留意的是inset: 0它等价于top/right/bottom/left全为 0让面板铺满内容区。如果你的项目要兼容很老的浏览器把它拆成四行写。另外.tab-bar的overflow-x: auto在移动端会带出滚动条可以用scrollbar-width: none隐藏但别把滚动能力也去掉否则标签一多就点不到了。2.3 用 template 预置面板避免字符串拼 HTML新增标签时如果直接拼innerHTML内容里带引号或特殊字符就容易出问题。这套源码用template标签预置了一个面板骨架克隆之后填内容安全又干净。template idpaneTemplate div classtab-pane h3 classpane-title/h3 div classpane-body/div /div /template克隆出来的节点通过querySelector填标题和正文不碰innerHTML也就绕开了 XSS 和转义两个坑。这一步在纯静态页面里可能显得多余但只要你的面板内容来自用户输入或接口返回这个习惯能省掉后面很多麻烦。3. 状态管理用 Map 管标签打开与切换的完整逻辑3.1 为什么用 Map 而不是数组标签的状态无非是「有哪些标签、当前激活的是哪个」。用数组存标签 id 也能做但删除的时候要splice查找要indexOf代码里到处是下标运算容易越界。换成Map之后tabMap.set(id, {title, paneEl})存元数据tabMap.delete(id)直接删tabMap.has(id)判断存在语义清楚也不会因为顺序变化出 bug。当前激活的 id 单独用一个变量activeId存不塞进 Map避免遍历时还要过滤。// 全局状态id - { title, paneEl }activeId 记录当前激活标签 const tabMap new Map(); let activeId null; let tabSeq 0; // 自增序号保证 id 唯一 // 打开或激活一个标签 function openTab(title, content) { // 已存在同名标签则直接激活不重复开 for (const [id, meta] of tabMap) { if (meta.title title) { activateTab(id); return id; } } const id tab-${tabSeq}; // 克隆模板生成面板 const pane document.getElementById(paneTemplate) .content.cloneNode(true).querySelector(.tab-pane); pane.id pane-${id}; pane.querySelector(.pane-title).textContent title; pane.querySelector(.pane-body).textContent content; document.getElementById(tabContent).appendChild(pane); // 生成标签按钮 const btn document.createElement(button); btn.className tab-item; btn.dataset.tabId id; btn.innerHTML span classtab-title/span span classtab-close>function activateTab(id) { if (!tabMap.has(id)) return; // 清掉旧激活态 if (activeId) { document.querySelector(.tab-item[data-tab-id${activeId}]) ?.classList.remove(active); tabMap.get(activeId)?.paneEl.classList.remove(active); } // 设置新激活态 activeId id; document.querySelector(.tab-item[data-tab-id${id}]) ?.classList.add(active); tabMap.get(id).paneEl.classList.add(active); }这里用了可选链?.是因为关闭标签的瞬间可能旧标签节点已经没了直接访问会报错。切换逻辑里没有重排、没有重建只是 class 的增删所以哪怕开二十个标签切换也是瞬时的。如果你的面板里有图表或视频记得在切换时触发一次 resize 或暂停这是另一个话题但思路是在activateTab里留一个回调钩子。3.3 事件委托一个监听器管所有标签标签是动态生成的给每个按钮单独绑事件既费事又容易漏。正确做法是在.tab-bar上挂一个监听器靠event.target判断点的是关闭还是切换。document.getElementById(tabBar).addEventListener(click, (e) { const closeEl e.target.closest([data-close-id]); if (closeEl) { // 点的是关闭按钮 closeTab(closeEl.dataset.closeId); return; } const tabEl e.target.closest(.tab-item); if (tabEl) activateTab(tabEl.dataset.tabId); });closest从点击目标往上找最近的匹配祖先这样即使用户点的是标签里的文字或图标也能正确识别。先判断关闭再判断切换顺序不能反否则关闭按钮会被当成标签点击。这个委托模式在标签数量变化时完全不用重新绑定是动态列表的标准写法。4. 关闭与重开删除标签时最容易翻车的三个地方4.1 关闭当前标签后激活谁这是手写 Tab 最经典的坑关掉当前激活的标签如果不处理activeId页面会变成所有面板都隐藏用户以为卡死了。合理的策略是「关掉当前激活右边一个右边没有激活左边一个都没有就是空状态」。function closeTab(id) { if (!tabMap.has(id)) return; const meta tabMap.get(id); // 先移除 DOM document.querySelector(.tab-item[data-tab-id${id}])?.remove(); meta.paneEl.remove(); tabMap.delete(id); if (activeId ! id) return; // 关的不是当前标签无需切换 // 关的是当前标签找下一个激活目标 const ids [...tabMap.keys()]; if (ids.length 0) { activeId null; // 空状态可显示占位提示 return; } // 取原位置的后一个没有则取最后一个 const idx Math.min(ids.length - 1, ids.indexOf(id) 0 ? 0 : ids.length - 1); activateTab(ids[idx]); }上面这段里ids.indexOf(id)在删除后必然返回 -1所以实际取的是最后一个。更直观的写法是关闭前先记下当前标签在列表里的位置关闭后按位置取。不管哪种核心是保证「只要 Map 里还有标签就一定有且只有一个 active」。4.2 重开时状态要不要保留很多场景下用户关掉一个标签再打开希望看到的是全新内容而不是上次的残留。这套源码默认每次openTab都新建面板状态自然重置。但如果你做的是编辑器类应用可能需要缓存草稿。常见做法是在closeTab时把面板内容序列化存进一个cache对象openTab时先查缓存。const paneCache new Map(); // id 或 key - 序列化后的内容 function closeTabWithCache(id) { const meta tabMap.get(id); if (meta) { // 存下当前内容key 用标题或业务 key paneCache.set(meta.title, meta.paneEl.innerHTML); } closeTab(id); }缓存键用标题是图省事正式项目建议用业务唯一 key。缓存本身要有上限不然开开关关内存会涨简单做法是超过 20 条就删最早的一条。这一步不是必须的但如果你发现用户抱怨「关掉再开东西没了」就该把缓存加上。4.3 关闭按钮的点击热区与误触×如果太小用户点不中太大又容易在切标签时误关。源码里给.tab-close设了padding: 2px 6px实际热区大概 20×20同时用margin-left: 4px跟标题拉开距离。移动端还要再放大并且可以考虑长按关闭或滑动关闭。另一个细节是×的cursor: pointer要单独设别继承标签的默认光标否则用户不知道这里能点。提示关闭按钮的click事件里最好加一句e.stopPropagation()虽然事件委托已经用closest区分了但在某些嵌套结构下阻止冒泡能避免意外触发标签切换。5. 避坑与排查手写 Tab 多窗口的五个血泪经验5.1 现象标签删了面板还在页面越用越卡原因只移除了.tab-bar里的按钮忘了移除.tab-content里对应的面板节点。DOM 节点没释放开开关关几十次之后内存里堆了一堆隐藏面板。解决把「删标签」和「删面板」封装在同一个closeTab函数里任何地方关闭都走这个入口。排查时可以在closeTab末尾打印document.querySelectorAll(.tab-pane).length跟tabMap.size对不上就说明有泄漏。5.2 现象快速连点关闭报Cannot read property classList of null原因activateTab里访问了已经被删除的标签节点。关闭是同步的但如果你在关闭后还有异步逻辑比如动画回调去操作旧节点就会拿到 null。解决所有对 DOM 的访问都加可选链或先判断存在像 3.2 里那样。更彻底的做法是给每个标签一个「已销毁」标记异步回调里先检查。5.3 现象标签标题太长把关闭按钮挤出可视区原因.tab-item没有限制最大宽度长标题把按钮撑开×被推到滚动区外面。解决给.tab-title设max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;超出显示省略号。关闭按钮设flex-shrink: 0保证永远可见。这个参数按你的标签栏宽度调一般 100 到 160 之间。5.4 现象切换标签后里面的 iframe 或图表白屏原因面板用display: none隐藏时iframe 和 canvas 的尺寸计算为 0重新显示后没有触发重绘。解决在activateTab里对当前面板派发一个自定义事件面板内部监听后调用resize或chart.resize()。不要用visibility: hidden代替display: none那样虽然尺寸还在但隐藏的面板仍占资源。5.5 现象用innerHTML拼标签标题里的或显示错乱原因直接把用户输入或接口返回的标题拼进 HTML 字符串特殊字符被当成标签解析。解决统一用textContent设置标题或者写一个escapeHtml函数处理。源码里所有标题都走textContent就是为了避开这个坑。如果你非要用innerHTML至少把 五个字符转义掉。6. 进阶技巧把 Tab 状态同步到 URL 与本地存储基础功能跑通之后我一般会再加两个能力刷新页面后标签还在以及能把当前标签分享给别人。前者靠localStorage存tabMap的标题列表和activeId后者靠 URL 的 hash 或 query 参数。实现上不复杂但有几个参数要拿捏。先看持久化。页面加载时读localStorage把上次的标签重新openTab一遍最后激活activeId。注意存的时候只存标题和业务 key不存 DOM因为 DOM 没法序列化。重开时内容重新拉取这样也顺便保证了数据新鲜。// 保存只存元数据不存 DOM function persistTabs() { const list [...tabMap.entries()].map(([id, meta]) ({ id, title: meta.title })); localStorage.setItem(openTabs, JSON.stringify({ list, activeId })); } // 恢复页面初始化时调用 function restoreTabs() { const raw localStorage.getItem(openTabs); if (!raw) return; try { const { list, activeId: savedActive } JSON.parse(raw); list.forEach(item openTab(item.title, 加载中...)); if (savedActive tabMap.has(savedActive)) activateTab(savedActive); } catch (e) { localStorage.removeItem(openTabs); // 数据坏了就清掉别让页面崩 } }try/catch不能省localStorage里的内容可能被用户手动改坏或者版本升级后结构对不上解析失败时直接清掉比让整个页面报错强。存的时候用JSON.stringify读的时候用JSON.parse这是标准配对。再看 URL 同步。把当前激活标签的 key 写进location.hash监听hashchange事件做反向激活。这样用户复制链接给别人对方打开就能直接落到同一个标签。参数上建议用业务 key 而不是自增 id因为 id 每次刷新都会变分享出去没意义。同步方式存什么触发时机注意点localStorage标签标题列表 activeId打开/关闭/切换后只存元数据设上限URL hash当前标签业务 key切换标签时用业务 key不用自增 idsessionStorage同上但仅当前会话同上适合敏感面板关浏览器即清最后说一个我自己的习惯每次改完 Tab 逻辑我都会手动走一遍「开五个、关中间、关当前、刷新、再关到空」这五步。这五步能覆盖 90% 的状态 bug比写一堆单元测试还快。从那以后我每次交付带标签页的页面都强制走一遍这个流程省下了不少返工。希望帮到你。本文还有配套的精品资源点击获取