SVG 图标为什么在真机上被挤扁
有一类 UI bug 很迷惑:PC 正常,Chrome 模拟移动端也正常,到了真机上图标就扁了。
这次具体表现是:个人页左上角返回按钮在手机上像被横向压扁;基本信息页的行尾箭头也有类似问题;后来又发现「充值」入口图标被拉伸,其他图标却正常。第一眼看,很容易怀疑是响应式、rem、字体缩放或移动端 viewport 造成的。
但排查到最后,真正的问题在 SVG 资源本身:外层 DOM 尺寸是对的,内部 SVG 被按错误的比例拉伸了。
先证明不是外层尺寸问题
遇到图标变扁,先用 DevTools 核对外壳尺寸和资源映射:
- 外层元素的 computed
width/height。 - 图片或 SVG 元素自己的 DOMRect。
- SVG 文件里的
viewBox和preserveAspectRatio。
如果外层按钮是 40 x 40,img 也是 40 x 40,但里面的图形明显被压扁,可以先排除外壳被 flex 或 rem 改成非正方形。接着还要检查资源内容、嵌套 transform、裁剪和视口映射;外壳尺寸正确,本身并不能证明一定是 preserveAspectRatio。
如果不先区分外壳与内部图形,就容易把 size-10 换成 inline style、把 rem 换 px、给按钮加 min-width,甚至重写一套移动端样式。外壳越来越复杂,内部图形的映射问题却可能仍然存在。
preserveAspectRatio 到底影响什么
SVG 有一个 viewBox,表示内部坐标系;外层 CSS 又会给它一个实际显示尺寸。浏览器需要决定:怎么把 viewBox 里的内容映射到这个 CSS 盒子里?
默认情况下,SVG 的 preserveAspectRatio 相当于 xMidYMid meet:保持比例,居中放进盒子。如果盒子比例和 viewBox 比例不同,就留空边,不强行拉伸。
而 preserveAspectRatio="none" 会把 viewBox 非等比映射到当前视口。当两者宽高比不同,图形才会因此变胖、变瘦或变扁;两者比例相同,即使图形没有铺满 viewBox,也不会仅因 none 变形。图形边界影响留白,不能和坐标映射比例混为一谈。
所以不能简单说「没写 preserveAspectRatio 就一定错」。SVG 没写时,默认通常是等比的。真正危险的是:
- 资源里显式写了
preserveAspectRatio="none"。 - 资源的 viewBox 和真实图形边界比例不一致,但组件又用错误的外壳比例渲染。
- Figma 导出了内部某一层图形,组件再把它当完整 frame 拉满。
Figma 的 frame 不等于真实图形
Figma 里一个图标通常不是单个路径。它常常是:
- 外层
24 x 24或40 x 40的交互 frame。 - 内部一个更小的 group、vector 或 image。
- 透明内边距、旋转、偏移、mask、clip。
设计稿上你看到一个 24 x 24 的节点,不代表里面的图形应该铺满 24px。比如一个箭头可能实际只有 8 x 14,居中放在 24 x 24 frame 里;一个编辑图标可能外层是 24px,内部真实图形只有 17px 左右。
如果导出时只拿了内部 vector,再在代码里写:
.icon {
width: 24px;
height: 24px;
object-fit: contain;
}
它不一定能还原设计稿。因为设计稿需要的是「24px frame 里放一个带偏移的小图形」,而你保存的资源可能已经丢了 frame。
更稳的方式是:资源文件保留完整 frame,组件只负责摆放 frame。
SVG 自己应该长这样:
<svg
width="24"
height="24"
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
xmlns="http://www.w3.org/2000/svg"
>
<!-- 内部图形按 Figma 的真实位置放在 24x24 frame 里 -->
</svg>
组件侧只写:
<img class="menu-item__icon" src="./assets/ic-recharge.svg" alt="" />
.menu-item__icon {
@apply size-6 shrink-0 object-contain;
}
不要在 Vue 里维护一张 ICON_STYLE 表,给每个图标手写不同的 width、height、left、top。那些偏移关系本来应该属于 SVG 资源,而不是业务组件。
为什么 PC 模拟移动端没暴露,真机暴露了
真机和模拟环境可能使用不同的布局状态、资源版本或 WebView 内核,需要分别取证。这次确认了资源映射问题,但没有逐项隔离「为什么只有真机明显」的所有变量;不能仅凭设备像素比或图片解码策略,就解释等比映射为何改变。先对照两边实际加载的 SVG、视口尺寸和 transform,再判断差异来自哪一层。
定位时,用外壳尺寸区分检查方向:
- 如果 DOMRect 对,图形扁,查 SVG。
- 如果 DOMRect 不对,查 CSS。
- 如果真机和模拟器 DOMRect 不一样,查响应式、flex shrink、viewport、safe area。
这次的证据链属于第一类:外壳尺寸正确,内部图形被拉伸。移动端布局没有把按钮挤坏,真正需要修的是 SVG preserve / viewBox / frame 处理。
不要把 SVG 变形误判成 rem 问题
有些项目会用 postcss-pxtorem,CSS 里的 px 会统一转成 rem。这确实会带来尺寸策略问题:哪些尺寸要跟随字号,哪些尺寸应该固定,哪些应该靠响应式布局。
但这次图标变扁不能直接归因到 rem。computed size 和 DOMRect 都是正确的正方形,能排除外壳被横向压缩,却还不能判断内部 path、嵌套 transform、裁剪或图片适配方式是否有问题。本次是继续检查 SVG 资源后,才确认内容映射有误。
所以排查顺序应该是:
- 先看外层 DOMRect。
- DOMRect 错,再谈 rem、flex、断点、min-width。
- DOMRect 对,优先查 SVG 源码。
这一点也能避免过度修复。否则为了一个资源问题改掉全局尺寸策略,很容易把其它图标、头像、按钮热区也带出新问题。
哪些 SVG 可以 none
preserveAspectRatio="none" 不是永远错误。它适合那些本来就应该拉伸的资源:
- 背景光效。
- 渐变色块。
- 分割线或可变宽装饰。
- 卡片背景里明确要求铺满容器的纹理。
但交互图标一般不属于这一类。返回、关闭、更多、行尾箭头、充值、钱包、语言、设置这些图标,用户预期它们保持固定比例。它们如果需要更大的点击区域,就应该加外层 frame 或透明 padding,而不是拉伸内部 path。
所以项目规则可以写成:
交互图标默认显式保持等比;装饰背景需要拉伸时才允许
preserveAspectRatio="none",并写清楚它是可拉伸资产。
资源验收要看「可见边界」
导出 SVG 后,需要核对资源边界与设计稿、组件显示尺寸的对应关系:
- SVG 根节点有没有合理的
width/height/viewBox。 preserveAspectRatio是否符合图标语义。- 可见图形是不是居中在 frame 里。
- 透明边距是否和 Figma 一致。
- 组件侧有没有把内部图形当外层 frame 二次拉伸。
- 真机上局部截图是否和 PC 一致。
如果是 PNG,也要检查透明像素边界。比如设计稿是 36 x 36 frame,内部真实图形 27 x 27,入库资源就应该保留 36 x 36 透明外壳,而不是裁成 27 x 27 后再让组件猜怎么居中。
测试怎么守住
这类问题不一定适合整页截图快照。截图快照成本高,波动也多。更低成本的测试可以这样设计:
- 单测或 source test 检查关键 SVG 不含
preserveAspectRatio="none"。 - 对交互图标做资源扫描,确保根节点有 viewBox,且 preserve 策略符合预期。
- Playwright focused E2E 进入目标页面,断言图标 DOMRect 是正方形,必要时裁局部图做人工证据。
- 对已经出过问题的图标,补一条「资源根节点 preserve 策略」的回归测试。
资源检查约束 viewBox 和等比策略,DOMRect 断言检查外壳尺寸;内部图形是否仍有变形,再用局部截图核对。
总结
这几个图标的外壳尺寸是对的,修复落在 SVG 的坐标映射和 frame 处理上。完整 frame、透明内边距和图形位置留在资源里,组件就不必再维护一张尺寸和偏移表。不过 DOMRect 正确只能排除一部分布局问题;图形仍然变形时,实际加载的资源、内部坐标映射、嵌套 transform 和 CSS 变换都还要继续核对。