普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月24日首页

Ant Design Table 横向滚动条神秘消失?我是如何一步步找到真凶的

2026年4月24日 17:17

起因

项目中有一个设备管理页面,使用了 Ant Design 的 Table 组件,配置了横向和纵向滚动:

<Table
  scroll={{
    x: "100%",
    y: "calc(100vh - 300px)",
  }}
  // ... 其他属性
/>

某天测试同学反馈了一个诡异的问题:表格的滚动条会莫名其妙地消失

更离谱的是,滚动条虽然看不见了,但鼠标放在原来滚动条的位置仍然可以拖动一个"隐形"的滚动条!


第一步:确认复现路径

首先,我需要搞清楚滚动条在什么情况下会消失。经过反复测试,终于找到了稳定的复现路径:

  1. 在标签页 A 中打开设备管理页面,Table 正常显示横向和纵向滚动条 ✅
  2. 点击某个设备进入详情页,右键点击二维码,在新标签页 B 中打开手机端页面
  3. 在标签页 B 中按 F12 打开开发者工具,切换视图之后
  4. 切回标签页 A → 滚动条消失了!

关键发现:问题只在"标签页 B 切换设备仿真"后才会出现。如果不切换设备仿真,滚动条一直正常。

这说明问题跟 Chrome DevTools 的设备仿真有关。但为什么呢?设备仿真只影响当前标签页 B,为什么会影响到标签页 A?


第二步:排除 CSS 原因

我的第一反应是:是不是 CSS 样式污染了?

项目里有一个 device-details-mgmt.css,里面用全局的 ::-webkit-scrollbar 把所有滚动条设成了 5px 宽、浅灰色:

::-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 的问题?

我怀疑的方向有:

怀疑 1react-full-screen 组件的跨标签页事件干扰

设备详情页用了 react-full-screen,设备仿真可能触发了全屏变化事件。我在 onChange 中加了 document.fullscreenElement 检查,只允许当前标签页的全屏事件生效。

结果:无效。全屏事件根本没有被触发。

怀疑 2vh 单位被设备仿真重新计算

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 为了性能优化,会将这样的两个标签页放在同一个渲染进程中。

而我们的代码正是这样写的:

// DownloadSvgQRCode.js
window.open(
  `${window.location.origin}/#/ScanDeviceQRCode?device_id=${device_id}`,
  '_blank'
  // 没有第三个参数!
);

没有 noopener,所以 A 和 B 共享同一个渲染进程!


第六步:理解设备仿真对渲染进程的影响

那设备仿真又是怎么影响渲染进程的呢?

当你在 DevTools 中切换设备仿真时,Chrome 通过 CDP(Chrome DevTools Protocol) 发送命令:

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 参数:

// 修改前
window.open(url, '_blank');

// 修改后
window.open(url, '_blank', 'noopener');

noopener 做了两件事:

  1. 断开 opener 关系:新标签页的 window.opener 变为 null
  2. 强制分离渲染进程:Chrome 不再需要维护 opener 通信通道,新标签页被分配到独立渲染进程

修改后测试:✅ 问题完美解决! B 标签页的设备仿真不再影响 A 标签页的滚动条。


原因总结

用一张图说清楚整个因果链:

window.open('_blank') 没有加 noopener
        │
        ▼
AB 标签页建立 opener 关系
        │
        ▼
Chrome 将 AB 分配到同一个渲染进程
        │
        ▼
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,说明可能共享渲染进程

最终修复

// 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 攻击),还能避免渲染进程级别的副作用。

昨天以前首页

Ant Design Vue a-image 图片预览充满全屏?为啥?

2026年4月21日 15:44

Ant Design Vue 图片预览充满全屏?我从全局样式追到源码,终于找到了真相

前言

最近在项目中使用 Ant Design Vue 的 a-image 组件时,遇到了一个诡异的问题:点击图片预览后,图片直接撑满了整个屏幕,完全不是官方文档里那种优雅的居中预览效果。明明官方 Demo 好好的,怎么到了我的项目就变了样?

这篇文章记录了我从发现问题 → 排查问题 → 定位根因 → 解决问题的完整过程,希望能帮到遇到同样问题的同学。


一、发现问题

项目中有一个封装的图片组件 imgView.vue,使用 a-image 来展示图片:

<template>
  <a-image
    :src="imageSrc"
    :width="width"
    :height="height"
    :fallback="fallbackImage"
    :preview="preview"
  ></a-image>
</template>

功能很简单:传入 srcwidthheight,展示图片并支持预览。

但点击预览后,效果是这样的:图片直接铺满整个屏幕,没有居中,没有缩放,就像一张被强行拉伸的壁纸。

而 Ant Design Vue 官方的预览效果明明是:图片按原始比例居中显示,周围有半透明遮罩

这到底是怎么回事?


二、排查问题

第一轮排查:怀疑全局样式污染

我的第一反应是——全局样式污染。因为项目中确实在 global.scss 里对 .ant-image 做了全局样式设置:

.ant-image {
    width: 100%;
    height: 100%;

    .ant-image-img {
        width: 100%;
        height: 100%;
    }
}

这个样式的本意是让页面中的图片容器撑满父元素。但预览弹窗是通过 teleport 挂载到 body 下的,如果这个全局样式也影响到了预览弹窗,那图片确实会被拉伸到全屏。

于是我尝试用 :not() 排除预览弹窗:

.ant-image:not(.ant-image-preview-root .ant-image) {
    width: 100%;
    height: 100%;
    ...
}

结果:问题依然存在。

这说明全局样式 .ant-image 并不是直接原因。预览弹窗的 DOM 结构和页面中的图片组件使用的是不同的类名,根本不会被 .ant-image 选择器匹配到。

第二轮排查:深入源码,看预览弹窗到底长什么样

既然猜测不对,那就去看源码。我打开了 node_modules/ant-design-vue/es/vc-image/src/Preview.js,找到了预览弹窗的渲染逻辑:

// Preview.js(简化)
return _createVNode(Dialog, {
    "prefixCls": prefixCls,  // 这里是 "ant-image-preview"
    ...
}, {
    default: () => [
        // 操作栏
        _createVNode("div", { "class": `${prefixCls}-operations-wrapper` }, [...]),
        // 图片包裹层
        _createVNode("div", {
            "class": `${prefixCls}-img-wrapper`,
        }, [
            // 预览图片
            _createVNode("img", {
                "class": `${prefixCls}-img`,  // ant-image-preview-img
                "src": combinationSrc.value,
            })
        ]),
    ]
});

关键发现:预览弹窗中的图片类名是 .ant-image-preview-img,而不是 .ant-image-img

所以全局的 .ant-image-img { width: 100%; height: 100% } 根本不会影响预览图片。那问题到底出在哪?

第三轮排查:CSS-in-JS 样式注入

Ant Design Vue 4.x 使用 CSS-in-JS 在运行时动态注入组件样式。我打开了 ant-design-vue/es/image/style/index.js,找到了预览弹窗的样式定义:

// image/style/index.js(简化)
export const genImagePreviewStyle = token => {
    return [{
        [`${componentCls}-preview-root`]: {
            [`${previewCls}-img`]: {
                maxWidth: '100%',
                maxHeight: '100%',
                verticalAlign: 'middle',
                cursor: 'grab',
                ...
            },
            [`${previewCls}-img-wrapper`]: {
                display: 'flex',
                justifyContent: 'center',
                alignItems: 'center',
                ...
            }
        }
    }];
};

源码中明明定义了 maxWidth: '100%'maxHeight: '100%',还有 flex 居中布局。那为什么我的预览弹窗没有这些样式?

答案就在版本号上——项目使用的是 ant-design-vue: 4.0.0-rc.6,这是一个早期候选版本。CSS-in-JS 的样式注入在运行时动态生成,如果注入机制存在 bug 或者时序问题,这些关键样式可能根本没有被正确插入到 DOM 中。

第四轮排查::height 传参的隐藏坑

在阅读 Image.js 源码时,我还发现了一个容易被忽略的细节:

// Image.js(简化)
const imgCommonProps = {
    style: _extends({
      height  // ← height 从 attrs 直接设到了 <img> 的 inline style!
    }, style)
};

当我们在组件中写 :height="height" 时,这个 height 会通过 Vue 的 attrs 传入 a-image,然后在组件内部被直接设置到 <img> 标签的 inline style 上,变成 style="height: 100px"

这意味着:

  • 全局样式的 .ant-image-img { height: 100% } 会被 inline style 的 height: 100px 覆盖
  • 图片被强制设定为固定高度,而不是按比例自适应

三、定位根因

经过四轮排查,问题的根因终于清晰了——两个问题叠加导致了预览图片充满全屏:

原因 影响 严重程度
CSS-in-JS 样式注入不完整(4.0.0-rc.6 版本问题) 预览弹窗缺少 max-widthmax-height、flex 居中等关键样式,图片失去尺寸约束 核心原因
:height 传参方式错误 height 被设到 <img> 的 inline style 上,与全局样式冲突 加剧问题

问题链路:

用户点击图片预览
    ↓
Ant Design Vue 创建预览弹窗(teleport 到 body)
    ↓
预览弹窗中的 <img> 使用 .ant-image-preview-img 类名
    ↓
CSS-in-JS 注入不完整,缺少 max-width / max-height 约束
    ↓
图片以原始尺寸或被拉伸的方式充满整个预览区域

四、解决问题

修复一:改用 wrapperStyle 控制容器大小

文件src/components/img/imgView.vue

 <template>
   <a-image
     :src="imageSrc"
-    :width="width"
-    :height="height"
     :fallback="fallbackImage"
     :preview="preview"
+    :wrapper-style="{ width: width + 'px', height: height + 'px' }"
   ></a-image>
 </template>

为什么要改?

  • :width:height 作为 attrs 传入时,height 会被 a-image 内部直接设到 <img> 的 inline style 上
  • 改用 :wrapper-style 只控制外层容器 div 的大小,不会影响 <img> 标签本身
  • 图片通过全局 CSS 的 .ant-image-img { width: 100%; height: 100% } 来撑满容器

修复二:在全局样式中补全预览弹窗的关键样式

文件src/assets/scss/global.scss

// 页面中的图片容器撑满父元素
.ant-image {
    width: 100%;
    height: 100%;

    .ant-image-img {
        width: 100%;
        height: 100%;
        object-fit: cover;
    }
}

// 修复预览弹窗中的图片样式(兜底 CSS-in-JS 注入不完整的情况)
.ant-image-preview-root {
    .ant-image-preview-body {
        position: absolute;
        inset: 0;
        overflow: hidden;
    }

    .ant-image-preview-img-wrapper {
        position: absolute;
        inset: 0;
        display: flex;
        justify-content: center;
        align-items: center;
    }

    .ant-image-preview-img {
        max-width: 100%;
        max-height: 100%;
        object-fit: contain;
    }
}

每个选择器的作用

选择器 作用
.ant-image-preview-body 约束预览区域在视口内,防止溢出
.ant-image-preview-img-wrapper 用 flex 居中图片,确保图片不偏移
.ant-image-preview-img 限制图片不超出视口,保持原始比例

五、预览弹窗的 DOM 结构参考

排查问题时,了解预览弹窗的 DOM 结构非常关键,这里整理出来供参考:

body
└── .ant-image-preview-root
    └── .ant-image-preview-wrap
        └── .ant-image-preview (Dialog Content)
            ├── .ant-image-preview-operations-wrapper  (操作栏:关闭、放大、旋转等)
            ├── .ant-image-preview-body               (预览主体)
            │   └── .ant-image-preview-img-wrapper    (图片包裹层)
            │       └── img.ant-image-preview-img     (预览图片 ← 这就是出问题的元素)
            ├── .ant-image-preview-switch-left        (左切换)
            └── .ant-image-preview-switch-right       (右切换)

注意:预览弹窗通过 teleport 挂载到 body 下,组件内的 scoped 样式无法影响它,必须在全局样式中处理。


六、经验总结

1. a-imagewidth/height 不是你以为的那样

a-imagewidthheight 属性会作为 attrs 传递到内部 <img> 标签的 inline style 上,而不是控制外层容器。如果只想控制容器大小,应该使用 wrapperStyle prop。

2. CSS-in-JS 不是万能的

Ant Design Vue 4.x 全面转向 CSS-in-JS,样式在运行时动态注入。但早期版本(如 rc 版本)的注入机制可能不完善,关键样式可能丢失。对于核心交互样式,建议在全局 CSS 中做兜底处理。

3. 预览弹窗是"逃逸"的

Ant Design Vue 的预览弹窗通过 teleport 挂载到 body 下,脱离了组件的 DOM 树。这意味着:

  • 组件内的 scoped 样式对它无效
  • :deep() 穿透也够不到它
  • 必须在全局样式或非 scoped 的样式中处理预览弹窗的样式问题

4. 排查问题的方法论

遇到 UI 组件库的"奇怪"问题,排查思路可以是:

  1. 先排除自己的代码:检查全局样式、组件传参是否有问题
  2. 再看 DOM 结构:用浏览器 DevTools 检查实际渲染的 DOM 和样式
  3. 最后看源码:去 node_modules 里看组件的渲染逻辑和样式定义
  4. 关注版本:rc 版本、beta 版本往往有未修复的 bug,优先考虑升级到稳定版

写在最后

这个问题看似简单,但背后涉及了 CSS-in-JS 注入机制、Vue 的 attrs 传递行为、teleport 的样式隔离等多个知识点。排查过程也让我深刻体会到:当你觉得组件库"有 bug"时,先别急着甩锅,深入源码看看,往往会有意想不到的收获。

❌
❌