点一次就变大一点?CSS 循环尺寸依赖的隐蔽陷阱

2295 字
11 分钟
点一次就变大一点?CSS 循环尺寸依赖的隐蔽陷阱

真机上点一个列表项,它微微变大了。再点,又大了一点。连续点十次,肉眼可见地膨胀。桌面浏览器和模拟器里完全无法复现。

这篇文章拆解这个 bug 背后的布局原理,以及一条容易被忽视的 CSS 规则:尺寸依赖必须单向流动

最小复现场景#

不需要复杂的项目,以下结构就能触发问题:

<!-- CSS Grid 容器 -->
<div class="grid" style="display: grid; grid-template-columns: repeat(4, 1fr);">
<div class="grid-item" style="display: flex; padding: 8px 0;">
<!-- 子元素拉伸到 Grid 行高 -->
<div class="card" style="height: 100%; width: 100%;">
<!-- 图片容器:宽度驱动高度 -->
<div class="thumb" style="width: 100%; aspect-ratio: 0.77;">
<!-- 内层重复声明 aspect-ratio -->
<div class="image" style="width: 100%; height: 100%; aspect-ratio: 0.77;">
<img src="..." />
</div>
</div>
<span>标题文字</span>
</div>
</div>
<!-- ... 更多 grid-item -->
</div>

关键条件:

  1. CSS Grid 布局,行高由内容撑开
  2. 子元素用 height: 100% 拉伸到 Grid 行高
  3. 内部有 aspect-ratio 根据宽度计算高度
  4. 父子元素重复声明同一 aspect-ratio

在桌面 Chrome 上看起来完全正常。但在移动端 webview(微信小程序、WebView 嵌入的 H5 等)里,每次点击都会让元素微微膨胀。

怎么定位到的#

值得记录的不是答案,而是走过的弯路。第一次遇到这个 bug 时,我的猜测顺序是这样的:

第一反应:是不是动画? 怀疑是 :active 伪类、transform: scale()、过渡动画在某次结束后没有还原。检查了所有相关 class,没有任何 transform 或 transition 涉及尺寸。排除。

第二猜:是不是字体 / 内容撑开? 怀疑点击触发了某个状态变化,文字变粗或行高变化把整体撑大。但实测发现:清空所有文字内容、只留一个空 div,问题依旧。排除。

第三猜:是不是 container-type / 容器查询的副作用? 项目里用过 container-type: inline-size,怀疑是它在 webview 上有 bug。注释掉所有容器查询,问题仍在。排除。

关键转折:开 Devtools 看实际像素值 真机连上调试,每次点击后 inspect 元素的实际宽高。发现一个反常现象——宽度是稳定的,高度在每次点击后增加约 0.5–1px。这就把范围一下子缩小了:跟”高度怎么算出来的”有关。

追查高度来源 顺着 DOM 往上看哪里决定了这个元素的高度:

  1. 元素自身有 aspect-ratio: 0.77
  2. 父元素也有 aspect-ratio: 0.77
  3. 再往上的 grid item 用 height: 100% 拉到 grid 行高
  4. grid 行高是由 cell 内容(也就是这堆元素)撑出来的

画出依赖图的瞬间,闭环就显形了:行高 → height:100% → aspect-ratio 重算高度 → 反过来撑大行高。

教训:遇到”诡异的尺寸抖动”,第一件该做的事不是猜,而是 inspect 真实像素 + 画依赖图。前面那些猜测都是在没看数据的情况下凭印象排查,绕了一大圈。

为什么会这样#

正常情况:尺寸单向流动#

一个健康的布局,尺寸计算是单向的:

Grid 列宽 → 元素宽度 → aspect-ratio 算出高度 → 确定 Grid 行高 → 结束

每一步的输出是下一步的输入,没有回路,一次 layout pass 就能稳定。

出问题的情况:闭环#

当子元素加了 height: 100%,尺寸流向出现了一条反向路径:

Grid 行高 → height:100% 拉伸子元素 → 子元素内部 aspect-ratio 重算高度 → 影响 Grid 行高
↑ |
└────────────────────────────────────────────────────────────────────────┘

这是一个闭环。Grid 行高既是输入也是输出。

为什么桌面浏览器没事#

我没有读过 Blink/WebKit 源码,下面是基于 CSS 规范和实测现象的推断,不是定论。

CSS 规范在处理 aspect-ratio 与显式 height 共存时有优先级规则:当 height 是 “definite”(确定值)时,aspect-ratio 让位。问题在于 height: 100% 在父高未定的 Grid cell 里不一定算作 definite——这是一个边界 case,规范本身留了模糊空间。

桌面 Chrome 看起来在这个边界 case 上做了某种首次解算后就锚定的处理:第一次 layout 算出的高度被视为最终值,后续 re-layout 不再重新解算这个闭环。注意”看起来”——我没有验证源码,只是没观察到累积现象。

为什么移动端 webview 出问题#

观察到的事实是:

  • iOS WKWebViewAndroid Chromium-based WebView 的内核版本通常落后于桌面 Chrome 数个大版本
  • aspect-ratio 作为相对较新的属性,在老内核上的边界行为没有完全对齐最新规范
  • 在受影响的 webview 上,每次点击触发的 re-layout 会重新解算闭环,而不是使用首次锚定的值

至于为什么”每次重算之后值会变大而不是变小或不变”,我的猜测是亚像素取整方向不对称:

计算值 123.45px → ceil 取整 → 124px
下一轮以 124px 为输入 → 算出 124.x px → 再 ceil → 125px

如果取整方向是 ceil(或者 round 在某个边界上偏向上),闭环就不收敛。

这是猜测。要证实需要在 webview 内核里 print 每轮的中间值,我没做这件事。文章给出的修复方法不依赖于这个解释正确——只要打断闭环,无论取整方向如何,问题都不会发生。

如果你恰好读过相关源码或做过精确测量,欢迎告诉我真实机制。

修复思路#

核心原则:打断闭环,让尺寸单向流动

改动一:移除 height: 100%#

<div class="card" style="height: 100%; width: 100%;">
<div class="card" style="width: 100%;">

子元素不再拉伸到 Grid 行高,而是由内容自然撑高。Grid 行高仍然由最高的 cell 决定,但 cell 内部不再反向依赖行高。

那同行卡片的等高效果会丢吗?不会。 Grid item 默认的 align-self: stretch 会让 cell 自身被拉到行高,等高效果由 grid 的对齐机制保留,不需要在内部用 height: 100% 强行拉伸。这两者看起来在做同一件事,区别在于:前者是 grid 容器单向指派高度给 cell,不形成闭环;后者是 cell 内部反向索取行高,形成闭环。

改动二:移除冗余的 aspect-ratio#

<div class="image" style="width: 100%; height: 100%; aspect-ratio: 0.77;">
<div class="image" style="width: 100%; height: 100%;">

父级已经用 aspect-ratio 约束了比例,子级用 width: 100%; height: 100% 填充即可。重复声明 aspect-ratio 不是”双保险”,而是给布局引擎制造歧义——尤其是当 height: 100%aspect-ratio 同时存在时,不同引擎的优先级处理可能不一致。

两行改动,闭环断开,尺寸变回单向流动,问题消失。

通用防御原则#

1. 画尺寸依赖图,确认没有环#

遇到布局异常时,把每一层元素的宽高来源画出来。如果发现某个值既是上游的输出又是下游的输入,就找到了嫌疑点。

✅ 单向:容器宽 → aspect-ratio → 高 → 结束
❌ 闭环:容器宽 → aspect-ratio → 高 → height:100% → 反推容器高 → 影响行高

2. 重复声明同一约束不是”双保险”,是引入歧义#

aspect-ratiomin-heightmax-width 这类约束,在父子元素上只应出现在一个层级。很多人下意识觉得”父子都写一遍更保险”,其实恰恰相反:当 layout 引擎遇到两个本该相等的来源时,需要决定哪个优先,而不同引擎、不同版本的优先级处理可能不一致。双保险在 CSS 里通常是双歧义。

3. 警惕 height: 100% + aspect-ratio 的组合#

这两个属性同时出现在一个元素上时,语义是矛盾的:

  • height: 100% 说”高度跟父级走”
  • aspect-ratio 说”高度由宽度算”

规范有优先级规则,但取决于父高是否 definite,而 Grid/Flex 容器里的”父高 definite 性”本身就是个绕口的话题。能避免就不要同时出现。

4. 移动端 webview 是另一个世界#

桌面浏览器通过的布局,在移动端 webview 不一定稳定。特别是微信小程序、各厂商 Android WebView(版本碎片化严重)、嵌入式 WKWebView。涉及 Grid/Flex + 百分比高度 + aspect-ratio 的组合时,真机验证是唯一可靠的测试,模拟器过了不算数。

检查清单#

写 CSS Grid/Flex 布局时,快速过一遍:

  • 尺寸依赖链是否单向?有没有 height: 100% 回指 Grid 行高?
  • aspect-ratio 是否只在一个层级声明?父子有没有重复?
  • height: 100%aspect-ratio 是否同时出现在同一元素?
  • 在目标平台真机上点击/滚动验证过了吗?

关于本文的”猜测”部分

“为什么会这样”一节里关于桌面引擎首次锚定、亚像素 ceil 不收敛的解释,是基于现象的推断,没有 webview 源码层面的验证。修复方案不依赖该解释正确性。如果未来我(或读者)拿到了更准确的机制,会以 update 段落追加在此处,原推断保留作为对照。

Profile Image of the Author
xt53
Hi
分类
标签
站点统计
文章
10
分类
2
标签
19
总字数
22,614
运行时长
0
最后活动
0 天前

目录