SVG 图标为什么在真机上被挤扁

有一类 UI bug 很迷惑:PC 正常,Chrome 模拟移动端也正常,到了真机上图标就扁了。

这次具体表现是:个人页左上角返回按钮在手机上像被横向压扁;基本信息页的行尾箭头也有类似问题;后来又发现「充值」入口图标被拉伸,其他图标却正常。第一眼看,很容易怀疑是响应式、rem、字体缩放或移动端 viewport 造成的。

但排查到最后,真正的问题在 SVG 资源本身:外层 DOM 尺寸是对的,内部 SVG 被按错误的比例拉伸了。

先证明不是外层尺寸问题

遇到图标变扁,第一步不要猜。先打开 DevTools,看三件事:

  1. 外层元素的 computed width / height。
  2. 图片或 SVG 元素自己的 DOMRect。
  3. 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,也不会仅因 none 变形。图形边界影响留白,不能和坐标映射比例混为一谈。

所以不能简单说「没写 preserveAspectRatio 就一定错」。SVG 没写时,默认通常是等比的。真正危险的是:

  • 资源里显式写了 preserveAspectRatio="none"。
  • 资源的 viewBox 和真实图形边界比例不一致,但组件又用错误的外壳比例渲染。
  • Figma 导出了内部某一层图形,组件再把它当完整 frame 拉满。

同一个 viewBox 放进正方形外壳时,meet 会保持比例,none 会直接拉伸填满导致变形

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 资源,而不是业务组件。

Figma 的交互 frame、透明内边距和内部真实图形最好保留在 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 和 DOMRect 都是正确的正方形,能排除外壳被横向压缩,却还不能判断内部 path、嵌套 transform、裁剪或图片适配方式是否有问题。本次是继续检查 SVG 资源后,才确认内容映射有误。

所以排查顺序应该是:

  1. 先看外层 DOMRect。
  2. DOMRect 错,再谈 rem、flex、断点、min-width。
  3. 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 策略」的回归测试。

这类测试守的是根因,不是某张截图里的偶然像素。

资源要带着 frame 进来

图标变扁时,不要一上来改响应式。

直接看三件事:

  1. 外层 DOM 尺寸对不对?
  2. SVG 的 viewBox 和 preserve 策略对不对?
  3. Figma 的完整 frame 有没有被保留下来?

如果外层尺寸对,内部图形变形,问题大概率在资源,而不是 CSS。

更稳的是把 Figma 里的交互 frame、透明内边距和内部图形位置都沉淀进 SVG 或 PNG 资源;业务组件只渲染这个完整 frame。这样不但 PC 和真机更一致,也能避免以后每加一个图标,就在 Vue 模板里再发明一张尺寸和偏移表。