上个月接手一个电商老项目,首页商品墙在 iPhone 上一列、iPad 上两列、27 寸显示器上——居然还是两列,右边空出老大一块白。同事挺委屈:”CSS 明明写的是响应式啊。”我打开一看,grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)) 一字不差。
这行代码确实没错,错的是它周围那一圈东西:视口标签、盒模型、图片尺寸、hover 动画,任何一处没管住,网格就会以各种姿势崩给你看。下面按我实际排查的顺序写,都是真踩过的坑。
viewport 没写,后面全白干
排查那个商品墙时我第一件事是看 <head>,果然没写 viewport。这是最容易被忽略的一条,因为它在电脑上看不出任何问题——你在 Chrome 里拖窗口,网格照样老老实实地变列数,一切正常,于是你以为代码是对的。
但手机浏览器不按你想的来。缺了这个标签,它会拿一个约 980px 的虚拟视口去渲染整页,再整体缩小塞进屏幕。结果就是:@media 的断点压根不触发,auto-fit 算出来的列数也是基于 980px 算的。你在真机上看到的”两列”,其实是 980px 宽下算出的两列被缩到了屏幕大小。
<!-- 必须硬编码在 head 里,别用 JS 动态插入 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
就这一行。它决定了后面所有响应式逻辑有没有机会执行。
别加 user-scalable=no。它禁掉了双指缩放,属于无障碍红线。有些安卓 WebView 对 initial-scale=1 解析不准,可以补 maximum-scale=1 兜底,但 pinch-zoom 一定要留着。
minmax() 的上限,只能是 1fr
回到那行 CSS。很多人抄的时候会”顺手优化”成这样:
/* 别这么写 */
.grid {
grid-template-columns: repeat(auto-fit, minmax(280px, 300px));
}
看起来很合理:每列最窄 280px、最宽 300px,多工整。但在 500px 宽的容器里就露馅了——两列要 560px,塞不下;退成一列,可这一列又被 300px 的上限锁死,右边剩下 200px 全是空白。上限写死像素,等于把弹性交还给了你自己,而你自己算不准。
1fr 的意思是”有剩的就均分”,它不只分配余量,也是小屏下自动退化成单列占满的关键信号。所以正确写法里,上限永远是 1fr:
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
240px 这个数不是拍脑袋来的。iPhone SE 视口宽 375px,减掉页面左右留白后,内容区通常在 340px 上下,单列时 240px 的下限不会溢出,也撑得住一句正常的卡片标题。内容比较密的卡片可以往上抬到 280px 或 320px,但别再低了——低于 220px 时,中文标题基本每行都在换行。

auto-fit 还是 auto-fill,选错了右侧就是一片白
这两个关键字的区别只有一句:auto-fit 会把空轨道合并掉,auto-fill 会把空轨道留着。
卡片流、画廊、商品墙这类”有多少内容显示多少”的场景,一律用 auto-fit。合并空轨道之后,剩下的三张卡片会拉伸填满整行——这才是”无缝”的来源。换成 auto-fill,就算只有一张图,右边也会虚位以待地留出七八个空格子,视觉上就是右边塌了一块。
auto-fill 的用武之地是槽位固定的东西:九宫格、仪表盘占位、日历。想验证自己用的是哪个,Chrome DevTools 里切到 Layout 面板,勾上 Show line numbers,轨道号直接画在页面上,被合并的一目了然。
更保险的写法:给下限也加个上限
还有一个只在极端窄屏出现的坑:容器比 240px 还窄(比如折叠屏外屏、或者卡片被塞进了窄侧边栏),这时下限 240px 会强行撑出横向滚动条。加一层 min() 就能堵住:
.grid {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
}
min(100%, 240px) 表示”下限取容器宽度和 240px 里更小的那个”,容器再窄也不会溢出。
gap 和 box-sizing:两个不出声的守门员
网格间距用 gap,别用子项 margin 去凑。原因不是”更优雅”,而是两者参与计算的时机不同:gap 是轨道之间的间距,发生在 Grid 算完列宽之后;margin 算在盒模型里,会先吃掉一部分宽度,minmax() 拿到的可用空间就少了,列数可能因此少一列。
用 margin 还有个经典后遗症:为了清掉每行最后一个的右边距,你得写 :nth-child(3n) 之类的选择器。可列数是浏览器动态决定的,3n 在两列布局下立刻错位,最后一行必然对不齐。
.grid { display: grid; gap: 16px; } /* 对:间距只写一次 */
.card { margin: 8px; } /* 错:参与盒模型,挤压轨道计算 */
另外 gap 别全站统一写一个值。手机上 12px 就够,桌面 24px 才显得透气,用 clamp() 一句话搞定,省掉一个断点:
.grid { gap: clamp(12px, 2vw, 24px); }
盒模型别忘了统一。没写 box-sizing: border-box 时,卡片的 padding 和 border 是往外长的,实际占宽会超过 Grid 分配的轨道宽度。列宽计算从第一步就偏了,后面怎么调都不对。全局重置最省事:*, *::before, *::after { box-sizing: border-box; }
Grid 只负责划线,内容撑破它不管
这是新手最容易困惑的地方:明明列宽定得好好的,一张图就把整行撑歪了。因为 Grid 分配的是轨道,轨道里的元素自己要多大,它默认不拦。
图片必须显式约束。max-width: 100% 只解决横向,纵向还得靠 height: auto 保住比例;如果卡片高度要统一,用 aspect-ratio 锁比例比写死 height 可靠得多,配 object-fit: cover 裁切,图片不会变形:
.card img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 4 / 3;
object-fit: cover;
}
文字这边主要防长串。URL、连续英文、没空格的订单号,都能把卡片顶宽。另外 Grid 子项有个”自动最小尺寸”:它的最小宽默认是内容宽,而不是 0。所以碰到长内容时,要显式允许它被压缩:
.card {
min-width: 0; /* 允许子项被压缩,而不是顶开轨道 */
overflow-wrap: anywhere; /* 长串强制断行 */
}
hover 时下方卡片”弹一下”,跟 transform 没关系
这个现象我见过太多次:鼠标移上去,下面一排卡片跟着抖一下。第一反应都是改 transform: scale(),改完没用——因为真正的原因是卡片高度变了,引发了整行重排。
最常见的触发方式,是 hover 时给卡片加了 border 或者把 box-shadow 从”无”变成”有”。尺寸一变,行高一变,后面的行全跟着动。
治法是对症下药:
-
给卡片一个
min-height锚定基线。它比写死height灵活,内容多时还能往下长。 -
边框别在 hover 时出现。想有描边效果,平时就写上透明边框
border: 1px solid transparent,hover 只改颜色。 -
动画只碰
transform和opacity。这两个走合成层,不触发重排;改width、margin、top就一定重排。 -
阴影用
filter: drop-shadow()或常驻的box-shadow改透明度,别凭空新增。 -
用了
grid-auto-rows时更要小心,隐式行高会跟着内容走,一个卡片变高,后续所有行都位移。必要时改用显式grid-template-rows。
will-change: transform 别全局加。它确实能让动画更顺,但每个元素都会提前占一个合成层,卡片一多内存就上去了。只给确定会频繁交互的那几个加。
IE11 和旧 Safari 的兜底
如果项目还要管 IE11(希望不用,但确实还有),策略是别硬扛,直接降级:默认给一套 flex 或单列流式布局,再用 @supports 给支持 Grid 的浏览器升级。这样老浏览器看到的是能用的单列,不是散架的三列。
/* 兜底:单列流式 */
.grid { display: block; }
.grid > * { margin-bottom: 16px; }
/* 支持 Grid 的浏览器走网格 */
@supports (display: grid) {
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.grid > * { margin-bottom: 0; }
}
还有两个只在旧版 Safari 上遇到的细节:
-
Safari 15.4 之前,
gap在inline-grid上不生效。容器老老实实写display: grid,别用缩写偷懒。 -
要兼容更早的版本,用老属性名
grid-column-gap/grid-row-gap并排写一份,新浏览器会以后面的gap为准。
一份能直接拿去用的代码
把上面几条合起来,就是一个我常用的卡片网格模板。复制进 HTML 文件就能跑,缩放窗口可以看到列数自己变。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
*, *::before, *::after { box-sizing: border-box; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
gap: clamp(12px, 2vw, 24px);
padding: 0; margin: 0; list-style: none;
}
.card {
min-width: 0;
min-height: 180px;
background: #fff;
border: 1px solid transparent; /* 先占位,hover 只改颜色 */
border-radius: 10px;
overflow: hidden;
transition: transform .18s ease, box-shadow .18s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0,0,0,.08);
border-color: #e5e8eb;
}
.card img {
display: block; width: 100%; height: auto;
aspect-ratio: 4 / 3; object-fit: cover;
}
.card p {
margin: 12px; overflow-wrap: anywhere;
}
</style>
</head>
<body>
<ul class="grid">
<li class="card"><img src="cover.jpg" alt=""><p>卡片标题</p></li>
<!-- 更多卡片 -->
</ul>
</body>
</html>
收工前自查这五条
-
<head>里有 viewport,且没写user-scalable=no -
minmax()的上限是1fr,不是任何像素值 -
间距只用
gap,子项身上没有任何 margin -
全局
box-sizing: border-box,图片有width: 100%和height: auto -
卡片有
min-height,hover 只改transform和opacity
说到底,响应式网格的”无缝”不是那一行 CSS 自动达成的,而是你把四周会漏的地方都堵上之后的结果。下次再遇到列数不对,别急着改 minmax() 里的数字,按上面这个顺序从头过一遍,通常第二关就找到了。

