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 把尺寸算错。问题在 SVG 内部:浏览器把 SVG 内容画进这个盒子时,采用了会拉伸的映射方式。
这一步很关键。否则很容易沿着错误方向改:把 size-10 换成 inline style,把 rem 换 px,给按钮加 min-width,甚至重写一套移动端样式。最后外壳越来越复杂,图标还是会在某些场景变形。
preserveAspectRatio 到底影响什么
SVG 有一个 viewBox,表示内部坐标系;外层 CSS 又会给它一个实际显示尺寸。浏览器需要决定:怎么把 viewBox 里的内容映射到这个 CSS 盒子里?
默认情况下,SVG 的 preserveAspectRatio 相当于 xMidYMid meet:保持比例,居中放进盒子。如果盒子比例和 viewBox 比例不同,就留空边,不强行拉伸。
而 preserveAspectRatio="none" 的意思是:不要保持比例,把 viewBox 直接拉满当前盒子。盒子是正方形,viewBox 是非正方形,或者真实图形所在区域和 viewBox 比例不一致时,图形就会变胖、变瘦、变扁。
所以不能简单说“没写 preserveAspectRatio 就一定错”。SVG 没写时,默认通常是等比的。真正危险的是:
- 资源里显式写了
preserveAspectRatio="none"。 - 资源的 viewBox 和真实图形边界比例不一致,但组件又用错误的外壳比例渲染。
- Figma 导出的不是完整图标 frame,而是内部某一层图形,组件再把它当完整 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 模拟移动端没暴露,真机暴露了
Chrome DevTools 模拟移动端很有用,但它不等于真机。
真机浏览器会叠加更多变量:设备像素比、字体缩放、地址栏收缩、不同浏览器 SVG 渲染细节、WebView 内核版本、图片解码策略、以及真实触摸滚动带来的布局状态。某些 SVG 拉伸问题在桌面 Chrome 上看起来不明显,到了手机上就很刺眼。
但这不代表真机问题无法定位。还是回到证据:
- 如果 DOMRect 对,图形扁,查 SVG。
- 如果 DOMRect 不对,查 CSS。
- 如果真机和模拟器 DOMRect 不一样,查响应式、flex shrink、viewport、safe area。
这次的证据链属于第一类:外壳尺寸正确,内部图形被拉伸,所以不是“移动端布局把按钮挤坏”,而是“SVG preserve / viewBox / frame 处理不对”。
不要把 SVG 变形误判成 rem 问题
有些项目会用 postcss-pxtorem,CSS 里的 px 会统一转成 rem。这确实会带来尺寸策略问题:哪些尺寸要跟随字号,哪些尺寸应该固定,哪些应该靠响应式布局。
但这次图标变扁不能直接归因到 rem。原因很简单:如果 computed size 已经是正确的正方形,说明外层 CSS 尺寸链路没坏。图形仍然变扁,只能说明 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 策略”的回归测试。
这类测试守的是根因,不是某张截图里的偶然像素。
最后总结
图标变扁时,不要一上来改响应式。
先问三个问题:
- 外层 DOM 尺寸对不对?
- SVG 的 viewBox 和 preserve 策略对不对?
- Figma 的完整 frame 有没有被保留下来?
如果外层尺寸对,内部图形变形,问题大概率在资源,而不是 CSS。
最稳的做法是:Figma 里的交互 frame、透明内边距和内部图形位置都沉淀进 SVG 或 PNG 资源;业务组件只渲染这个完整 frame。这样不但 PC 和真机更一致,也能避免以后每加一个图标,就在 Vue 模板里再发明一张尺寸和偏移表。
