起因
项目中有一个设备管理页面,使用了 Ant Design 的 Table 组件,配置了横向和纵向滚动:
css
<Table
scroll={{
x: "100%",
y: "calc(100vh - 300px)",
}}
// ... 其他属性
/>
某天测试同学反馈了一个诡异的问题:表格的滚动条会莫名其妙地消失。
更离谱的是,滚动条虽然看不见了,但鼠标放在原来滚动条的位置仍然可以拖动一个"隐形"的滚动条!
第一步:确认复现路径
首先,我需要搞清楚滚动条在什么情况下会消失。经过反复测试,终于找到了稳定的复现路径:
- 在标签页 A 中打开设备管理页面,Table 正常显示横向和纵向滚动条 ✅
- 点击某个设备进入详情页,右键点击二维码,在新标签页 B 中打开手机端页面
- 在标签页 B 中按
F12打开开发者工具,切换视图之后 - 切回标签页 A → 滚动条消失了!
关键发现:问题只在"标签页 B 切换设备仿真"后才会出现。如果不切换设备仿真,滚动条一直正常。
这说明问题跟 Chrome DevTools 的设备仿真有关。但为什么呢?设备仿真只影响当前标签页 B,为什么会影响到标签页 A?
第二步:排除 CSS 原因
我的第一反应是:是不是 CSS 样式污染了?
项目里有一个 device-details-mgmt.css,里面用全局的 ::-webkit-scrollbar 把所有滚动条设成了 5px 宽、浅灰色:
ruby
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1; /* 浅灰滑块 */
}
::-webkit-scrollbar-track {
background: #f1f1f1; /* 浅灰轨道 */
}
5px 宽 + 浅灰色,在浅色背景下确实不太看得清。我试着把这些样式限定到设备详情容器内,避免影响 Table。
结果:滚动条照样消失。
这说明 CSS 不是根因。但我还是不死心,又试了几种 CSS 方案:
| 尝试的方案 | 结果 |
|---|---|
overflow: scroll !important 强制显示滚动条 |
❌ 无效 |
scrollbar-gutter: stable 保留滚动条空间 |
❌ 无效 |
scrollbar-color + scrollbar-width 标准属性 |
❌ 无效 |
所有 CSS 方案全部无效!
这让我意识到,问题不在 CSS 层面,而是更底层的原因。
第三步:排除 JS 原因
既然 CSS 搞不定,那是不是 JS 的问题?
我怀疑的方向有:
怀疑 1 :react-full-screen 组件的跨标签页事件干扰
设备详情页用了 react-full-screen,设备仿真可能触发了全屏变化事件。我在 onChange 中加了 document.fullscreenElement 检查,只允许当前标签页的全屏事件生效。
结果:无效。全屏事件根本没有被触发。
怀疑 2 :vh 单位被设备仿真重新计算
Table 的 scroll.y 用了 calc(100vh - 300px),设备仿真可能改变了 vh 的值。我改用 useRef + getBoundingClientRect() 动态计算高度。
结果:无效。高度计算完全正确,滚动条消失不是因为高度问题。
怀疑 3:标签页切回时需要强制重渲染
我监听了 visibilitychange 事件,当标签页重新可见时,通过临时切换 overflow 属性强制浏览器重新渲染滚动条。
结果:无效。重新渲染后滚动条仍然是透明的。
JS 方案也全部无效!
第四步:换个思路------为什么 B 标签页能影响 A 标签页?
CSS 和 JS 都试过了,问题依然存在。我不得不重新审视一个最基本的问题:
为什么标签页 B 的操作,能影响到标签页 A?
在正常的认知中,浏览器的每个标签页是相互隔离的。一个标签页的 JS、CSS、DOM 不应该影响另一个标签页。
但事实摆在眼前:B 的设备仿真确实影响了 A 的滚动条。
这说明 A 和 B 之间存在某种共享。那共享的是什么?
第五步:认识 Chrome 渲染进程
我开始研究 Chrome 的多进程架构,发现了一个关键知识点:
Chrome 会将具有 opener 关系的标签页分配到同一个渲染进程(Renderer Process)中。
什么是 opener 关系?当你用 window.open(url, '_blank') 打开新标签页时,新标签页可以通过 window.opener 访问原标签页。Chrome 为了性能优化,会将这样的两个标签页放在同一个渲染进程中。
而我们的代码正是这样写的:
javascript
// DownloadSvgQRCode.js
window.open(
`${window.location.origin}/#/ScanDeviceQRCode?device_id=${device_id}`,
'_blank'
// 没有第三个参数!
);
没有 noopener,所以 A 和 B 共享同一个渲染进程!
第六步:理解设备仿真对渲染进程的影响
那设备仿真又是怎么影响渲染进程的呢?
当你在 DevTools 中切换设备仿真时,Chrome 通过 CDP(Chrome DevTools Protocol) 发送命令:
php
Emulation.setScrollbarsHidden({ hidden: true })
Emulation.setDeviceMetricsOverride({ mobile: true, ... })
关键在于 setScrollbarsHidden------它的效果是修改渲染进程级别的滚动条模式,将经典滚动条(Classic Scrollbar)切换为覆盖式滚动条(Overlay Scrollbar)。
而 Overlay 滚动条的特点是:半透明、自动隐藏。这就是为什么滚动条看起来"消失"了,但拖动区域还在------滚动条其实还在,只是变成了透明的 overlay 模式!
因为 A 和 B 共享同一个渲染进程,所以 B 的设备仿真修改了进程级滚动条模式,A 也被影响了!
第七步:验证------noopener 分离渲染进程
既然根因是共享渲染进程,那解决方案就是让 A 和 B 使用独立的渲染进程。
方法很简单:给 window.open 添加 noopener 参数:
javascript
// 修改前
window.open(url, '_blank');
// 修改后
window.open(url, '_blank', 'noopener');
noopener 做了两件事:
- 断开 opener 关系 :新标签页的
window.opener变为null - 强制分离渲染进程:Chrome 不再需要维护 opener 通信通道,新标签页被分配到独立渲染进程
修改后测试:✅ 问题完美解决! B 标签页的设备仿真不再影响 A 标签页的滚动条。
原因总结
用一张图说清楚整个因果链:
css
window.open('_blank') 没有加 noopener
│
▼
A 和 B 标签页建立 opener 关系
│
▼
Chrome 将 A 和 B 分配到同一个渲染进程
│
▼
B 标签页切换设备仿真
│
▼
CDP 发送 Emulation.setScrollbarsHidden({ hidden: true })
│
▼
渲染进程级别的滚动条模式从 Classic 切换为 Overlay
│
▼
A 标签页的滚动条也变成 Overlay 模式(半透明、自动隐藏)
│
▼
A 标签页的滚动条"消失"了!
修复 :添加 noopener,让 B 使用独立渲染进程,B 的设备仿真不再影响 A。
延伸知识
Chrome 渲染进程与标签页的关系
| 打开方式 | 是否共享渲染进程 |
|---|---|
window.open(url, '_blank') |
✅ 共享(同一站点) |
window.open(url, '_blank', 'noopener') |
❌ 独立 |
| 用户手动 Ctrl+T 打开新标签页 | ❌ 独立 |
| 从书签栏打开 | ❌ 独立 |
两种滚动条模式的区别
| Classic(经典) | Overlay(覆盖式) | |
|---|---|---|
| 外观 | 始终可见 | 半透明,自动隐藏 |
| 布局 | 占据空间 | 浮在内容上方 |
CSS ::-webkit-scrollbar |
✅ 有效 | ❌ 无效 |
scrollbar-gutter: stable |
✅ 有效 | ❌ 无效 |
| 触发条件 | 桌面模式(默认) | 移动端 / DevTools 设备仿真 |
CDP 命令的影响范围
| CDP 命令 | 影响范围 |
|---|---|
Emulation.setDeviceMetricsOverride |
仅当前标签页 |
Emulation.setScrollbarsHidden |
⚠️ 整个渲染进程 |
Emulation.setTouchEmulationEnabled |
仅当前标签页 |
如何确认标签页是否共享渲染进程
- 方法 1 :按
Shift+Esc打开 Chrome 任务管理器,查看是否有多个标签页共用同一个进程 ID - 方法 2 :地址栏输入
chrome://process-internals,查看每个标签页的进程信息 - 方法 3 :在 Console 中执行
console.log(window.opener),如果不为null,说明可能共享渲染进程
最终修复
javascript
// DownloadSvgQRCode.js
// 修改前
window.open(
`${window.location.origin}/#/ScanDeviceQRCode${device_id ? `?device_id=${device_id}` : ''}`,
'_blank'
);
// 修改后 ------ 只加了第三个参数 'noopener'
window.open(
`${window.location.origin}/#/ScanDeviceQRCode${device_id ? `?device_id=${device_id}` : ''}`,
'_blank',
'noopener'
);
一行代码,问题解决。noopener 不仅是安全最佳实践(防止 tabnapping 攻击),还能避免渲染进程级别的副作用。