1451 字
7 分钟
记录一次移动端侧边栏组件失效的 Bug 排查与架构重构

前两天给博客搞响应式的时候,碰到了一个贼离谱的 Bug,排查过程简直像在破案。这篇文章就当个笔记,把整个翻车现场和修复过程记录一下,也给后来人避个坑。

这 Bug 有多离谱#

移动端打开博客一看,好家伙,半边天塌了:

  1. 点击事件全罢工:左边音乐播放器点啥都没反应,右边心之钢特效也不触发,跟摆设一样。
  2. 数据请求了但不显示:天气、黄历、星座组件白茫茫一片,但 F12 打开 Network 一看——接口明明返回了数据,就是不渲染。
  3. 最骚的操作来了:我试着把浏览器控制台拽宽到 PC 端宽度,你猜怎么着?所有内容瞬间就冒出来了,交互也全好了。再缩回移动端,又没了。

我当时盯着屏幕心想,这他妈到底是什么鬼东西?

翻开底牌一看,问题根源在这#

先看看原来的布局结构是怎么写的:

<!-- PC 端侧边栏(移动端用 Tailwind 的 lg:block hidden 隐藏) -->
<div class="main-grid">
<LeftSideBar class="lg:block hidden" />
<main id="swup-container">...</main>
<RightSideBar class="lg:block hidden" />
</div>
<!-- 移动端抽屉面板(又引了一遍侧边栏组件) -->
<div id="left-sidebar-panel" class="lg:hidden hidden">
<LeftSideBar />
</div>
<div id="right-sidebar-panel" class="lg:hidden hidden">
<RightSideBar />
</div>

看出来了吧?问题就在这——同一个组件,页面里渲染了两遍

移动端要实现那种点击汉堡菜单滑出抽屉的效果,所以 LeftSideBarPanelRightSideBarPanel 里又各自 import 了一遍侧边栏组件。初衷是好的:PC 端显示在网格布局里,移动端显示在抽屉面板里,各管各的。

但这直接炸了——DOM ID 冲突

侧边栏内部大量使用了 id 选择器,比如 <div id="weather-content"><div id="music-player"> 之类的。渲染两遍之后,页面上同时存在两个 id="weather-content"

这时候 JS 执行 document.getElementById('weather-content') 会怎样?浏览器永远只返回第一个匹配的元素——就是上面那个被 hidden 隐藏的 PC 端组件。

所以所有诡异现象都解释得通了:

  • 接口请求了但不显示? JS 拿到数据后渲染到了被隐藏的 PC 端 DOM 里,移动端的那个是空的,当然啥也看不到。
  • 点击事件没反应? 事件绑定到了被隐藏的 PC 端按钮上,移动端面板里的按钮就是个没有灵魂的空壳。
  • 拉宽到 PC 端就好了? 因为真正绑了事件、渲染了数据的那个 PC 端侧边栏终于露面了,当然一切正常。

更坑的是,音乐播放器如果有两个实例同时存在,播放状态会互相打架,根本没法同步。

怎么修的?——DOM 动态迁移#

一开始我试过把 getElementById 换成 querySelectorAll 然后遍历处理,但说实话这种方案太蠢了,治标不治本,事件绑定和状态同步还是一堆坑,而且多渲染一倍的 DOM 纯属浪费内存。

最后拍板了——直接从架构层面干掉重复渲染:保证全局只有一个侧边栏 DOM 实例,需要的时候把它”搬”过去就行了。

具体怎么搞的#

第一步:别再重复渲染了

把移动端面板里引入的 <LeftSideBar /><RightSideBar /> 全删了,只留个空容器等着接客:

<div id="mobile-left-sidebar-container">
<!-- Sidebar will be moved here dynamically -->
</div>

第二步:打开面板时,把 DOM 搬过去

function openLeftSidebar() {
initSidebarRefs();
const mobileContainer = document.getElementById('mobile-left-sidebar-container');
if (originalSidebar && mobileContainer) {
originalSidebar.classList.remove('hidden', 'lg:block');
mobileContainer.appendChild(originalSidebar);
}
// ... 面板滑出动画
}

这里的核心就是 appendChild——当你对一个已经存在于 DOM 树中的节点调用 appendChild 时,浏览器不会克隆它,而是直接把它从原来的位置拔出来,插到新位置。这就是整个方案的灵魂。

第三步:关面板时,把 DOM 搬回去

function closeLeftSidebarPanel() {
// ... 面板收起动画
setTimeout(() => {
if (originalSidebar && originalParent) {
originalSidebar.classList.add('hidden', 'lg:block');
const swupContainer = document.getElementById('swup-container');
if (swupContainer) {
originalParent.insertBefore(originalSidebar, swupContainer);
}
}
}, 300);
}

关闭的时候等动画播完(300ms),再把侧边栏塞回它原来的位置。左边侧边栏插在 #swup-container 前面,右边侧边栏插在它后面(用 swupContainer.nextSibling)。

第四步:别让 Swup 捣乱

博客用了 Swup 做无刷新页面切换,页面内容会被替换掉,如果不处理的话面板可能会出各种幺蛾子。加个 Swup 钩子,页面切换时自动关掉面板:

if (window.swup && window.swup.hooks) {
window.swup.hooks.on('content:replace', () => {
if (leftSidebarPanel && !leftSidebarPanel.classList.contains('hidden')) {
closeLeftSidebarPanel();
}
});
}

另外还加了个 resize 监听,万一用户在面板打开的时候把浏览器拉宽到 PC 尺寸,也会自动关掉面板并把 DOM 归位。

改完之后爽在哪#

  1. ID 冲突?不存在的:全局就一个侧边栏实例,getElementById 该找谁找谁,稳如老狗。
  2. 状态天然同步:移动端和 PC 端用的是同一个物理 DOM,你在移动端抽屉里放首歌,然后拉宽到 PC 端,音乐接着放,进度条都不带断的。
  3. DOM 少了一半:不废话,内存直接省一半。

总结一句#

这次踩坑给我最大的教训就是:响应式设计里,如果一个复杂组件既要在 PC 端平铺,又要在移动端放进抽屉或者弹窗里,千万别在模板里写两遍。利用 CSS 的显示隐藏可以是一种方式(前提是内部不依赖 ID),但更优雅的做法是像这次一样——保持 DOM 唯一,需要的时候用 JS 搬过去。从根源上就把状态同步的坑给堵死了。

这种 Bug 不亲自踩一次真的很难想到,所以写下来记录一下,希望对各位哥们有帮助。

记录一次移动端侧边栏组件失效的 Bug 排查与架构重构
https://blog.hoppinzq.com/posts/responsive-sidebar-dom-migration-fix/
作者
HOPPINZQ
发布于
2026-04-03
许可协议
CC BY-NC-SA 4.0

AI助手

有问题随时问我

你好!我是HOPPINAI助手,有什么可以帮助你的吗?

你可能想:

刚刚

按 Enter 发送,Shift+Enter 换行

在线