前两天给博客搞响应式的时候,碰到了一个贼离谱的 Bug,排查过程简直像在破案。这篇文章就当个笔记,把整个翻车现场和修复过程记录一下,也给后来人避个坑。
这 Bug 有多离谱
移动端打开博客一看,好家伙,半边天塌了:
- 点击事件全罢工:左边音乐播放器点啥都没反应,右边心之钢特效也不触发,跟摆设一样。
- 数据请求了但不显示:天气、黄历、星座组件白茫茫一片,但 F12 打开 Network 一看——接口明明返回了数据,就是不渲染。
- 最骚的操作来了:我试着把浏览器控制台拽宽到 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>看出来了吧?问题就在这——同一个组件,页面里渲染了两遍。
移动端要实现那种点击汉堡菜单滑出抽屉的效果,所以 LeftSideBarPanel 和 RightSideBarPanel 里又各自 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 归位。
改完之后爽在哪
- ID 冲突?不存在的:全局就一个侧边栏实例,
getElementById该找谁找谁,稳如老狗。 - 状态天然同步:移动端和 PC 端用的是同一个物理 DOM,你在移动端抽屉里放首歌,然后拉宽到 PC 端,音乐接着放,进度条都不带断的。
- DOM 少了一半:不废话,内存直接省一半。
总结一句
这次踩坑给我最大的教训就是:响应式设计里,如果一个复杂组件既要在 PC 端平铺,又要在移动端放进抽屉或者弹窗里,千万别在模板里写两遍。利用 CSS 的显示隐藏可以是一种方式(前提是内部不依赖 ID),但更优雅的做法是像这次一样——保持 DOM 唯一,需要的时候用 JS 搬过去。从根源上就把状态同步的坑给堵死了。
这种 Bug 不亲自踩一次真的很难想到,所以写下来记录一下,希望对各位哥们有帮助。