九宫格这个需求,做前端的人基本躲不掉。商品列表、功能入口、抽奖转盘、相册预览、数据看板,随便拎一个出来都是三行三列,甚至更多行更多列。很多人第一次写 CSS 九宫格布局,都是拿百分比宽度加 float 一通怼,本地看着挺齐,上线换个字号或者多一个字就错位了。这篇文章就把我这些年攒下来的五种做法完整摊开讲:grid 布局、flex 布局、table 布局、float 浮动定位、inline-block 配合 letter-spacing,每种都给出可直接复制的代码、宽度计算过程、真实踩过的坑,以及什么场景该用哪一种。不管你是刚学 CSS 布局的新手,还是接手老项目要做兼容的熟手,看完都能直接抄作业。
1. 九宫格布局到底难在哪:从需求拆解到方案选型
九宫格看起来是个很简单的需求,无非就是九个方块排成三行三列。但真动手写就会发现,麻烦的地方从来不是"排成三行三列",而是那些附加要求:格子之间的间距要一致、每格宽度要相等、容器宽度变了要跟着变、内容多少不一还得对得齐。这四件事叠加起来,才让九宫格成了 CSS 布局的经典考题。
1.1 一个九宫格,拆开看只有三个变量
我把九宫格拆解成三个独立的变量来看,思路会清楚很多。
第一个变量是列数与行数。九宫格默认 3×3,但实际项目里经常变成 3 行 4 列、4 行 2 列,甚至用媒体查询在手机上变 2 列、平板上变 3 列、桌面上变 4 列。这意味着方案本身要能低成本地改列数,而不是改一次就要重算一遍宽度。
第二个变量是间距的处理方式。间距是九宫格最容易翻车的地方。用margin加百分比宽度的组合,稍不留神就是33.33% × 3 + 20px > 100%,第三列直接被挤到第二行去。而用gap或者表格的border-spacing,间距是从剩余空间里扣的,不会参与宽度分配,天然不会溢出。
第三个变量是单元格内部的对齐。格子里可能是一张图片加一行文字,也可能是纯文字可能只有两个字。文字长一点、短一点,方块高度就会不齐;默认的基线对齐还会让第二行的位置怪怪地往下掉。垂直居中这件事,不同方案的实现成本差异极大——表格一个vertical-align: middle就完事,float 则几乎做不到。
提示:先把这三个变量列清楚,再去看五种方案,你会发现每种方案的差异,本质上就是它解决这三个变量的成本不一样。
1.2 五种方案的取舍逻辑
为什么会有五种做法?因为它们诞生的年代不同,解决的问题侧重也不同。我按兼容性和实现难度拉了个表,先有个整体印象。
| 方案 | 核心属性 | 垂直居中 | 改列数成本 | 浏览器兼容下限 | 典型场景 |
|---|---|---|---|---|---|
| grid 布局 | grid-template-columns | 一行搞定 | 改一个数字 | 现代浏览器(IE11 部分支持,需前缀) | 新项目、后台看板、图库 |
| flex 布局 | flex-wrap+flex-basis | 一行搞定 | 改计算式 | IE10 起(IE11 有若干已知问题) | 移动端列表、卡片流 |
| table 布局 | table-layout: fixed | 天然支持 | 改单元格宽度 | 几乎全覆盖 | 老系统、需要严格对齐的报表 |
| float 浮动定位 | float+width | 做不到,需额外处理 | 改宽度和 nth-child | 全覆盖 | 维护十年以上的老代码 |
| inline-block + letter-spacing | display: inline-block+ 负字距 | 需vertical-align | 改宽度 | 全覆盖 | 需要支持极旧环境 |
选型的逻辑其实很直接:新项目一律先考虑 grid,其次是 flex,另外三种基本只在维护历史代码时才会碰到。反过来说,如果你接手的是一个还在跑 IE8 兼容模式的后台系统,那 float 和 table 就是你必须掌握的生存技能,而不是"过时的东西没必要学"。
2. Grid 布局:把九宫格写成一行的方案
Grid 是专门为二维布局设计的,九宫格正好是它的主场。用 grid 写九宫格,核心代码只有三行,而且几乎不需要关心宽度计算——这是它跟 float 最大的区别。
2.1 最小可用代码与 gap 的真实作用
先看最小可用的版本:
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }配上一个简单的 HTML 结构:
<div class="grid"> <div class="item">1</div> <div class="item">2</div> <div class="item">3</div> <div class="item">4</div> <div class="item">5</div> <div class="item">6</div> <div class="item">7</div> <div class="item">8</div> <div class="item">9</div> </div>repeat(3, 1fr)的意思是"重复三次,每次占一份剩余空间"。1fr里的fr是分数单位,它的分配逻辑是:容器宽度减去所有固定的列宽和所有gap之后,剩下的空间按份数分。所以三列永远等宽,而且总宽度永远不会超出容器——gap是从剩余空间里扣的,不占百分比额度。
这里有个很多人没注意的细节:gap和margin在计算上的地位完全不同。假设容器宽 360px,gap: 10px,一行三个格子只有两个间隙,也就是 20px 被扣掉,每格拿到(360 - 20) / 3 = 113.33px。而如果你写成width: 33.33%; margin-right: 10px,总占位就是33.33% × 3 + 30px,直接超过 100%,换行是必然结果。这也是为什么我一再强调,能用gap就别用margin。
注意:
gap在 flex 布局里的支持比 grid 晚,Safari 14.1 之前不认 flex 的gap。grid 的gap支持要早得多,所以做 grid 布局时用gap基本不用担心。
2.2 间距、留白与响应式的几种常见变体
实际项目里九宫格不会只有一种形态,下面这几种改法都用得很多。
行列间距分开控制,写row-gap和column-gap就行:
.grid { display: grid; grid-template-columns: repeat(3, 1fr); row-gap: 16px; column-gap: 8px; }格数跟着容器宽度自动变化,用auto-fit加minmax():
.grid-auto { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; }这行的意思可以翻译成大白话:每列最少 110px,最多占一份剩余空间,能塞几列就塞几列,塞不下就换行。容器 360px 时,(360 - 2×10) / 110 ≈ 3.09,所以是三列;容器缩小到 240px 时变成两列。这比写一堆媒体查询要省事得多。
让某个格子跨列或跨行,用grid-column和grid-row:
.item-large { grid-column: span 2; grid-row: span 2; }配上grid-auto-rows: 1fr或者固定行高,就能做出那种"第一个格子特别大"的不规则九宫格。这也是 grid 相比另外四种方案最不可替代的地方——float 和 inline-block 想做跨格,得靠 JS 算位置或者写死宽高。
实操心得:我在做后台数据看板时,习惯给容器加一句align-items: stretch(这本来就是默认值,写出来是为了提醒自己),再给格子加min-height,这样即使某一行只有文字很短的格子,整行高度也保持一致,视觉效果会整齐很多。
3. Flex 布局:兼容与灵活之间的平衡点
flex 是做一维排列的,本身不擅长二维网格,但配合flex-wrap: wrap之后,做九宫格也没问题。它的优势是兼容性比 grid 好,语法比 float 干净;劣势是宽度必须自己算,最后一行不满时还得额外处理。
3.1 三列等宽的宽度计算过程
先看容器:
.flex-wrap { display: flex; flex-wrap: wrap; gap: 10px; }关键在子元素的宽度。一行三格只有两个间隙,所以正确的算法是:
.flex-wrap .item { flex: 0 0 calc((100% - 20px) / 3); /* 也就是 (100% - 2 个 gap) / 3 */ }用具体数字验证一遍。容器宽 360px,gap10px:可用宽度是360 - 20 = 340px,每格340 / 3 = 113.33px,三格加两个间隙正好113.33 × 3 + 20 = 360px,严丝合缝。
如果换成四列呢?一行四个间隙变成三个,公式跟着变成calc((100% - 30px) / 4)。规律很简单:减掉的是(列数 - 1)个 gap。很多人在这里出错,是因为脑子里默认"减掉列数个 gap",结果算出来每格窄了一点,右边空出一条缝,视觉上还能看,但严格来讲已经不对齐了。
提示:
flex: 0 0 calc(...)里的三个值分别是flex-grow、flex-shrink、flex-basis。写成0 0是明确告诉浏览器"不许放大也不许缩小",宽度完全由flex-basis决定。如果用简写flex: 1,浏览器会把flex-basis设成0%,宽度分配逻辑就完全变了,九宫格会被撑得乱七八糟。
3.2 换行、间隙与最后一行对齐的补偿技巧
flex 做九宫格最常见的两个问题,一是最后一行不满时的对齐,二是老浏览器不支持gap。
最后一行不满:假设九个格子,容器一行只能放四个,那第三行只有一个格子,它会靠左,右边空出三格。多数设计稿期望的也是这样,但如果你希望它居中或者均分,就得用::after占位:
.flex-wrap::after { content: ""; flex: 0 0 calc((100% - 20px) / 3); }占位元素的数量要按"一行几个减掉最后一行的个数"来算。这种技巧能用,但我个人不太喜欢,因为它把结构搞复杂了,而且列数一变就得重新算。真遇到这种需求,我会直接换成 grid。
老浏览器没有gap:兜底方案是回到margin,但这时宽度公式必须跟着改。比如用margin-right: 10px,那三格的宽度就得是calc((100% - 20px) / 3),同时把第三列的margin-right去掉:
.no-gap .item { width: calc((100% - 20px) / 3); margin-right: 10px; margin-bottom: 10px; } .no-gap .item:nth-child(3n) { margin-right: 0; }注意nth-child(3n)这类选择器一旦列数从 3 变成 4,就得跟着改成4n,维护成本明显比gap高。这也是我建议新项目直接用 grid 的原因之一。
4. Table 布局:最老但对齐最稳的一条路
Table 布局在很多人印象里是"上个时代的东西",但在需要严格对齐的场景里,它依然好用到让人舍不得换。行高天然一致、垂直居中一行搞定、内容撑不破宽度,这三点加起来,在很多老后台系统里比 flex 还省心。
4.1 真 table 标签的写法与 table-layout: fixed 的意义
最直接的写法就是用真的表格标签:
<table class="tbl"> <tr> <td>1</td> <td>2</td> <td>3</td> </tr> <tr> <td>4</td> <td>5</td> <td>6</td> </tr> <tr> <td>7</td> <td>8</td> <td>9</td> </tr> </table>样式上有个属性必须加:
.tbl { width: 100%; table-layout: fixed; border-collapse: separate; border-spacing: 10px; } .tbl td { width: 33.33%; vertical-align: middle; text-align: center; }table-layout: fixed是这套方案能不能用的分水岭。不加它,浏览器会按"内容优先"的算法分配列宽——哪个格子里的文字长,哪一列就更宽,九宫格立刻变成"三列不等宽"。加上它之后,列宽完全由你指定的33.33%决定,内容再长也只会在格子内部换行或者溢出,不会影响其他列。
border-spacing是表格版的gap,它只在border-collapse: separate时生效。如果你习惯性写上border-collapse: collapse,border-spacing会直接失效,表现就是九个格子紧紧贴在一起,间距全没了。这是个非常经典的坑,我自己就在这儿浪费过半小时。
4.2 display: table 系列的 div 模拟与垂直居中红利
不想用真表格标签的话,可以拿 div 模拟:
.tbl-sim { display: table; width: 100%; table-layout: fixed; border-spacing: 10px; } .tbl-sim .row { display: table-row; } .tbl-sim .cell { display: table-cell; width: 33.33%; vertical-align: middle; text-align: center; }这种写法的最大红利是垂直居中。vertical-align: middle在table-cell上就是真正意义上的垂直居中,不需要line-height等于行高,也不需要transform: translateY(-50%),内容多高都能自动居中。对于格子内容高度不一致的九宫格(比如有的格子一行字,有的三行字),这一点太省事了。
需要提醒的是,display: table的盒子在宽度计算上会有自己的脾气:它默认是"收缩到内容宽度"的,所以width: 100%基本是必须写的。另外,用 CSS 生成的匿名表格盒子,在某些极端嵌套情况下会产生意料之外的换行,调试时用浏览器的元素检查器看一眼实际生成的盒子结构,会省很多功夫。
注意:table 系列方案的
min-width行为跟普通块级盒子不一样,它会受内容影响。如果某个格子放了特别长的英文串或者不换行的数字,列会被撑宽。加word-break: break-all或者overflow-wrap: anywhere到td上是常规做法。
5. Float 浮动定位:老项目里用得最多的续命方案
float 是最早被拿来排版的属性,本质上它是为"文字环绕图片"设计的,被硬生生用成了布局工具。用 float 做九宫格能跑,但必须自己处理宽度、间距和清除浮动这三件事,一步没做到位就会出问题。
5.1 宽度与外边距的配对计算
基础代码大概是这样:
.float-box { overflow: hidden; /* 先给它一个 BFC 兜着 */ } .float-box .item { float: left; width: calc((100% - 20px) / 3); margin-right: 10px; margin-bottom: 10px; box-sizing: border-box; } .float-box .item:nth-child(3n) { margin-right: 0; }宽度计算逻辑和 flex 版完全一致:减掉两个间隙再除以三。但 float 有一个额外的要求——每行最后一个必须把margin-right清掉,不然它的右外边距会残留,虽然视觉上看不出来(因为右边本来就没东西),但在某些情况下会影响换行判断,尤其在间距改大之后。
更稳的写法是给容器用负外边距法,也就是"容器margin-right: -10px,子元素统一margin-right: 10px",这样不用写nth-child,列数改了也不用动选择器。代价是容器的实际宽度超出了父级,如果父级有overflow: hidden,右边会被裁掉 10px,需要小心处理。
我个人的经验是:float 方案里,box-sizing: border-box一定要全局打开。因为九宫格里的卡片十有八九要加 padding 和内边框,如果不设成 border-box,实际占位宽就变成了113.33 + 20 + 2 = 135.33px,三格直接超宽换行,而且这种问题在只加 1px 边框的时候特别隐蔽。
5.2 清除浮动、高度塌陷与 BFC 的副作用
float 的子元素会脱离文档流,容器不会把它们的高度算进去,这就是"高度塌陷"。表现是容器背景色、边框、下方内容全部压上来,看起来像没写布局一样。
清理方式有三种,按年代排:
| 方式 | 写法 | 问题 |
|---|---|---|
| 额外标签 | 末尾加<div style="clear:both"></div> | 污染结构 |
| overflow | 父元素overflow: hidden | 会裁剪阴影、下拉菜单、绝对定位子元素 |
| 伪元素 | ::after { content:""; display:block; clear:both; } | 最推荐 |
伪元素法是我现在唯一会用的方式:
.clearfix::after { content: ""; display: block; clear: both; }overflow: hidden虽然代码短,但它的副作用在九宫格场景里特别容易爆发:卡片通常都有box-shadow和鼠标移入放大效果,overflow: hidden会把这些超出容器的部分统统剪掉,视觉上卡片像被"削掉一块"。另外,如果格子里有绝对定位的角标、标签,跑到容器外面也会被裁剪。这些都是我在老项目里翻过车的具体情形。
6. inline-block + letter-spacing:被误传成黑科技的经典技巧
这套组合经常被写成"CSS 小技巧"到处流传,看代码的时候会觉得很神奇,理解了原理之后其实一点也不神秘。它解决的只是行内块元素之间的空白间隙问题。
6.1 那个多出来的空隙到底是什么
先用最基本的写法看看现象:
.ib { font-size: 0; /* 一种常见的处理方式 */ } .ib .item { display: inline-block; width: 33.33%; vertical-align: top; }之所以要处理"空隙",是因为 HTML 源码里标签之间的换行符和空格在渲染时会被当成一个空白字符,行内块元素之间的这个空白字符会实打实地占宽度——大约是当前字体下空格字符的宽度,常见字体里差不多是 0.25em 左右。三个 33.33% 的格子之间夹两个空格,总宽就超过了 100%,第三个格子被挤到第二行。
处理办法有几种:把 HTML 里标签之间的换行全删掉(可读性差到没法维护)、父元素font-size: 0、或者用本文提到的letter-spacing方案。font-size: 0的问题是会被子元素继承,子元素里所有文字都得重新设字号,一旦漏设,文字直接消失,排查起来很懵。
6.2 负 letter-spacing 的原理、取值与三个容易翻车的点
letter-spacing控制的是字符间距。给父元素设一个负值,等于把每个字符之间的距离缩短,行内块之间的那个空白字符自然也跟着变窄。写成代码:
.ib-spacing { letter-spacing: -0.5em; font-size: 0; /* 保险起见 */ } .ib-spacing .item { display: inline-block; width: 33.33%; letter-spacing: normal; /* 关键:把文字的字距还原 */ vertical-align: top; box-sizing: border-box; }原理拆开就是两步:父元素的负字距把空白字符"压缩"掉,子元素再把letter-spacing重置回normal,避免自己的文字被挤成一团。子元素那句letter-spacing: normal绝对不能漏,因为letter-spacing是可继承属性,漏了之后格子里的中文会挤在一起,标题看着像出了 bug。
三个容易翻车的点:
第一,负值大小依赖字体。空格宽度在不同字体、不同字号下并不一致,-0.5em是个经验值。取值偏小,空格没吃干净,第三格会掉到下一行;取值偏大,格子之间会产生负间隙,表现为内容被裁掉一点或者布局轻微左偏。稳妥的做法是取一个略小的值,比如-0.4em,宁可留一丝缝也别吃掉内容。
第二,压缩过的 HTML 会让这套写法失效。如果你的构建流程会把 HTML 标签之间的换行去掉,那空格本来就不存在,负字距反而制造了多余的负空间,格子会整体往左偏。这类问题本地开发看不出、打包上线才出现,特别难查。
第三,垂直对齐默认是基线对齐。行内块的默认vertical-align: baseline,会导致下一行的第一个格子根据上一行的文字位置"跳"一下,看起来行距忽大忽小。统一写vertical-align: top就能把这排齐。
7. 五种方案横向对比:同一份九宫格,五份代码的实测差别
光看代码不够直观,我把同一份需求用五种方案各实现了一遍,在同一个页面里并排跑,记录了一些实际差异。
7.1 对比表与实测数据
测试环境是 360px 宽的容器,gap统一 10px,九格内容一致(每个格子一行两字标题加一行说明)。实测结果如下:
| 方案 | 单格宽度 | 改四列需要动几处 | 内容变长时的表现 | 是否等高 |
|---|---|---|---|---|
| grid | 113.33px | 1 处 | 行高自动撑开,其余格子跟着变高 | 是 |
| flex | 113.33px | 1 处(计算式) | 同上 | 是 |
| table | 113.33px | 1 处(width) | 单格内换行,列宽不变 | 是 |
| float | 113.33px | 2 处(宽度 +nth-child) | 单格变高,其他格子高度不变,行高参差 | 否 |
| inline-block | 113.33px | 1 处 | 同 float,行高参差 | 否 |
"是否等高"这一栏是九宫格最容易被忽略的差别。grid、flex、table 三种方案里,一行内所有格子默认高度一致,视觉上就是严格的网格;float 和 inline-block 里,格子各自按内容撑高,一行里可能一个高 60px、一个高 80px,下一行就参差不齐了。要修就得用 JS 算最大高度,或者给所有格子写死height,这两种做法都很难维护。
7.2 不同项目场景下的选型建议
结合上面这些差异,我总结了几条实际的选型经验。
新写的页面、不需要兼容旧浏览器:直接用 grid。代码最短,改列数只动一个数字,跨格布局也有现成属性。这一条适用于绝大多数 2020 年之后的新项目。
需要较好的兼容性又要写得舒服:用 flex。IE11 虽然有几个已知的flex-basis计算问题,但通过把flex-basis写成具体的calc()而不是百分比,基本能绕过去。移动端列表、卡片流用 flex 非常合适。
格子内容高度不可控,且要求整行严格对齐:table 布局最省事。很多后台系统的报表、参数展示页到现在还在用表格,不是守旧,是因为它确实好用。
维护老项目:大概率你会遇到 float。这时别急着重构,先按现有写法把nth-child和clearfix补齐,把它跑稳,再考虑是否分批替换。
极旧的环境,连calc()都不支持:只能上 inline-block 配负letter-spacing,或者老老实实写死像素宽度。这种情况下,固定宽度反而是最可靠的选择。
8. 常见问题与排查实录
下面这些是我在实际项目里真实撞见过的问题,按出现频率排了个序,附上排查思路。
8.1 高频问题速查表
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 第三格掉到第二行 | 宽度加边距超过 100% | 改用calc()扣掉间隙,或改用gap |
| 格子之间的间距不等 | 用nth-child清边距时列数对不上 | 确认选择器的倍数与列数一致 |
| 一行里格子高度参差 | float / inline-block 不等高 | 换 grid、flex 或 table,或统一min-height |
设置gap没效果 | flex 里老浏览器不支持,或父元素不是 flex/grid | 用margin兜底 |
| 表格间距消失 | border-collapse: collapse覆盖了border-spacing | 改成separate |
| 格子内容把列撑宽 | 表格未设table-layout: fixed | 加上它,并配word-break |
| 卡片阴影被切掉 | 父元素overflow: hidden | 改用clearfix伪元素清浮动 |
| 文字挤在一起 | 负letter-spacing被继承 | 子元素重置letter-spacing: normal |
| 打包上线后布局错位 | HTML 压缩去掉了标签间空白 | 不要依赖空白字符做布局 |
8.2 几个只有动手做过才知道的细节
第一,gap到底扣在哪一侧。用 grid 或 flex 的gap时,间距只出现在格子之间,容器的最左、最右、最上、最下都没有。这一点和"每个格子都有margin: 10px"的效果完全不同:后者会让容器左右各多出 10px 的空隙,导致内容整体比设计稿窄 20px。如果你按设计稿的容器宽度去核像素,永远差那么一点,问题往往就出在这里。
第二,格子内图片的处理。九宫格里放图片是最常见的情况,图片不处理的话会出现"图片把格子撑高"或者"图片比格子窄,右边留白"。我习惯给图片加两句:
.item img { display: block; width: 100%; height: 100%; object-fit: cover; }display: block是为了去掉行内图片底部的基线空隙,那个空隙大约 3 到 5 像素,肉眼可见但很难想到原因。object-fit: cover保证图片按比例填满格子、裁掉多余部分,避免被拉变形。
第三,鼠标移入放大时的层叠问题。九宫格卡片加载入放大的效果很常见,但一旦放大,卡片会盖住旁边的内容,或者反过来被后面的卡片盖住。解决办法是给.item加上position: relative,在:hover时把z-index提高:
.item { position: relative; transition: transform 0.2s ease; } .item:hover { transform: scale(1.05); z-index: 2; }顺序不能反——z-index只在定位元素上生效,所以position: relative必须写在基础样式里。
第四,用浏览器工具核对像素。布局对不齐的时候,别凭感觉调数值。打开开发者工具,选中容器,看它的实际内容宽度;再选中格子,看getBoundingClientRect给出的实际宽度和位置。你会发现很多时候差的不是宽度,而是某个看不见的margin或者padding在作怪。我排查九宫格问题基本都遵循同一个顺序:先看容器宽度,再看单个格子的实际占位宽,最后拿着"占位宽 × 列数 + 间隙总数"跟容器宽度对一遍,通常一眼就能看出问题在哪。
第五,列数一定要在代码里集中管理。不管你用哪种方案,只要列数写死在多个地方,后面改需求就一定会漏改。用 CSS 变量把列数抽出来是个成本很低的好习惯:
:root { --cols: 3; --gap: 10px; } .grid { display: grid; grid-template-columns: repeat(var(--cols), 1fr); gap: var(--gap); }改列数的时候只动--cols一个值,宽度计算、nth-child的倍数全部跟着走,维护成本直接降一大截。我在几个长期迭代的项目里都用这个方式,每次改版省下来的时间加起来相当可观。