点一次就变大一点?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>关键条件:
- CSS Grid 布局,行高由内容撑开
- 子元素用
height: 100%拉伸到 Grid 行高 - 内部有
aspect-ratio根据宽度计算高度 - 父子元素重复声明同一
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 往上看哪里决定了这个元素的高度:
- 元素自身有
aspect-ratio: 0.77 - 父元素也有
aspect-ratio: 0.77 - 再往上的 grid item 用
height: 100%拉到 grid 行高 - 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 WKWebView 和 Android 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-ratio、min-height、max-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 段落追加在此处,原推断保留作为对照。