Flex 布局踩坑记:flex-grow/shrink/basis 和 gap 兼容性怎么判断
中后台要加一个筛选工具栏:左边是数量不固定的筛选标签,标签多了要自动换行,右边固定挂着"新增""导出"两个按钮,不管左边换几行,右边这两个按钮都要贴在第一行的位置,不能被挤到换行里去。这种"左边不定宽度可换行,右边固定不换行"的组合,用以前那套 float 加 clear 的写法要拼好几层容器,换成 Flex 之后思路完全不一样,但也正是这个需求把 flex-grow、flex-shrink、flex-basis 这几个属性的实际计算规则和 align-items、align-content 的区别都逼了出来。
先分组,再决定谁来伸缩
工具栏的 HTML 结构很直接,一个外层容器包两个子块:
1<div class="filter-bar"> 2 <div class="filter-tags"></div> 3 <div class="filter-actions"> 4 <button>新增</button> 5 <button>导出</button> 6 </div> 7</div>
外层用 display: flex,但这里要先弄清楚一个容易忽略的点:Flex 只管直接子元素,.filter-bar 只管 .filter-tags 和 .filter-actions 这两层,标签内部具体怎么排、按钮内部怎么排,跟外层这次 Flex 上下文没关系,得各自单独再开一个 Flex 容器。写惯了嵌套布局的人很容易顺手把 flex-wrap: wrap 加在最外层,结果标签没换行,按钮组反倒被挤没了——因为换行这件事应该发生在 .filter-tags 内部,而不是外层两大块之间。
1.filter-bar { 2 display: flex; 3 align-items: flex-start; 4} 5 6.filter-tags { 7 display: flex; 8 flex-wrap: wrap; 9 flex: 1; 10 min-width: 0; 11} 12 13.filter-actions { 14 flex-shrink: 0; 15 display: flex; 16}
.filter-tags 拿到 flex: 1 意味着它会尽量占满除按钮组之外的剩余空间,.filter-actions 给 flex-shrink: 0 是明确告诉浏览器这块不参与收缩,容器变窄时宁可让标签换行,也不能把按钮压扁。
这两层容器套在一起之后,第一次联调就出了个小插曲:标签行数一多,.filter-bar 整体被撑高了,但 .filter-actions 的两个按钮却仍然贴在最顶上,看起来像是"浮"在标签堆的第一行旁边,没跟着容器变高。这其实是 align-items: flex-start 生效的正常结果——按钮组这个子项的高度只有一行按钮那么高,align-items 让它在交叉轴上贴着起始边对齐,不会被拉伸去匹配另一侧的高度。如果想让按钮组随着标签换行整体居中,把 .filter-bar 的 align-items 换成 center 就行,但产品那边明确要求按钮永远贴第一行,所以 flex-start 才是对的选择,不是 bug。
嵌套 Flex 容器的几个常见坑
这次工具栏是两层嵌套(外层管两大块,内层各自管排列),实际项目里嵌套层数往上叠很快,几个坑值得记一下:
第一个坑是百分比高度在嵌套 Flex 里经常不生效。如果 .filter-tags 内部某个标签想用 height: 100% 撑满父容器高度,前提是 .filter-tags 本身有明确高度,而 Flex 容器的高度默认是由内容撑起来的,链条里只要有一环没有显式高度,百分比就会失效退化成 auto。这次工具栏没有这类需求,但同期另一个卡片列表组件里就踩过一次,最后是把外层容器的高度用 min-height 显式写死才解决。
第二个坑是子项的 flex 简写值会一层层叠加干扰。.filter-actions 里的 button 元素本身又是 display: flex,如果不小心在按钮上也写了 flex: 1,因为 .filter-actions 是 flex-shrink: 0 不参与收缩,按钮内部的 flex: 1 反而会让两个按钮在 .filter-actions 内部抢占剩余空间,把"新增"和"导出"撑成等宽的两个大块,跟设计稿的"内容多宽就多宽"完全不符。这类问题排查起来容易走弯路,因为报错现场往往是最外层看着不对,其实是某个内层子项多写了一行没必要的 flex 属性。
第三个坑是overflow 在嵌套 Flex 里容易被忽略处理层级。.filter-tags 如果换行之后想要一个最大高度、超出就滚动,只在最外层 .filter-bar 上写 overflow: hidden 是没用的,因为真正内容溢出的是 .filter-tags 这一层,overflow 得加在真正装内容的那层容器上,这个判断跟 Flex 本身关系不大,但嵌套层数一多就很容易加错位置。
主轴交叉轴一变,对齐属性跟着换方向
.filter-tags 里的标签是横向排列还是纵向排列,取决于 flex-direction,默认值 row 让主轴是水平方向。justify-content 永远管主轴,align-items 永远管交叉轴,这个对应关系不会变;会变的是主轴到底对应水平还是垂直。刚开始接手这类需求时最容易踩的坑,就是把 justify-content 和 align-items 直接背成"水平对齐"和"垂直对齐",一旦某个子模块把 flex-direction 改成 column(比如把筛选栏改成侧边栏纵向排列),两个属性管的方向就整个对调,之前记的"水平垂直"就全反了。更稳的记法是先确认主轴方向,再决定 justify-content 具体管的是哪个视觉方向。
工具栏这里用不上 flex-direction: column,但按钮组内部有个居中需求:按钮里图标和文字要垂直居中对齐,用 Flex 写就是两行:
1.filter-actions button { 2 display: flex; 3 align-items: center; 4 gap: 4px; 5}
不用再算图标的行高怎么和文字对齐,这也是 Flex 最容易让人立刻喜欢上的地方。居中这步反倒不费劲,费脑子的是换行之后剩余空间怎么分。
justify-content 的几个取值实际效果差别挺大,值得摆在一起对比一遍。flex-start 是所有子项贴主轴起始边堆在一起,flex-end 反过来贴末尾;center 是整体居中,子项间距不变;space-between 是首尾两个子项贴边,中间按剩余空间均分间距,只有一个子项时这个值跟 flex-start看起来一样;space-around 是每个子项两侧都分到相等的间距,所以两个子项之间的间距会是子项与容器边缘间距的两倍,视觉上不如 space-between 直觉;space-evenly 是这几个值里最新的一个,效果是所有间距(包括两端)严格相等,Chrome 60 起才支持,工具栏这次筛选标签的排列用的是最朴素的 flex-start,没有用到分散对齐这几个值,但按钮组和标签之间要撑开的效果,其实就是靠 .filter-tags 的 flex: 1 把剩余空间吃掉实现的,没有直接用 justify-content: space-between——如果标签数量是 0(筛选条件清空的极端情况),space-between 在只剩一个子项时的行为跟预期对不上,用 flex: 1 撑开空间反而更稳。
flex-grow、flex-shrink、flex-basis 到底怎么算
.filter-tags 拿到的 flex: 1 其实是 flex-grow: 1 flex-shrink: 1 flex-basis: 0% 的简写。这三个值分别管三件不同的事:flex-basis 决定这个子项在分配剩余空间之前的"起始尺寸",flex-grow 决定有多余空间时它按什么比例去抢,flex-shrink 决定空间不够时它按什么比例被压缩。真正容易算错的是后两者都不是简单的等比例分配,而是要乘上各自的 flex-basis(对 flex-shrink 来说更准确地讲是乘以 basis 值本身)作为权重再参与比例计算,所以两个都写 flex-shrink: 1 的子项,basis 越大的那个在收缩时反而会被压缩得更多。
拿一个具体数字过一遍会更直观。假设容器宽度 900px,三个子项 flex-basis 分别是 200px、300px、500px,加起来 1000px,超出容器 100px,需要收缩。如果三个子项都是 flex-shrink: 1,每个子项分摊的收缩量并不是平均的 100/3,而是按"basis 乘以 shrink 值"算权重:200、300、500 的权重比是 2:3:5,100px 按这个比例拆成 20px、30px、50px 分别从三个子项身上扣掉,最终宽度是 180px、270px、450px。如果把 500px 那个子项的 flex-shrink 改成 0,它就完全不参与收缩,剩下的 100px 短缺全部由另外两个按 2:3 的比例分摊。这也是为什么调试 Flex 收缩效果时,光看 flex-shrink 的数值大小判断不出实际压缩比例,必须把 flex-basis 一起算进去。
工具栏这次用不上精细的比例分配,但按钮组内部两个按钮之间要留间距,早年间只能用 margin-left 加 :not(:first-child) 或者相邻选择器凑:
1.filter-actions button + button { 2 margin-left: 8px; 3}
Chrome 从 84 版本起给 Flex 容器加上了 gap 支持,正好是今年 7 月的事,写法上会干净不少:
1.filter-actions { 2 display: flex; 3 gap: 8px; 4}
Firefox 其实早就支持了,gap 用在 Flex 容器上这个能力早在两年前的 Firefox 63 就有了,反而是 Chrome 这边一直拖到今年才补上;Safari 还没支持,这个项目还要顾及一部分用 Safari 的运营同事,gap 在 Flex 容器上暂时不敢直接上生产,只在自己本地内部工具页面先试着用。等 Safari 也补齐了,再把 margin 换掉。gap 在 Grid 容器上其实一直没有这个兼容顾虑,Grid 用 gap 早就是稳的,只是 Flex 这次是新加的能力。
flex-wrap 配合 column 方向能拼出瀑布流
flex-wrap 默认是 nowrap,工具栏这里用的是 wrap,换行方向跟着主轴走:主轴是水平(row)时换行是往下增加新的一行;如果把 flex-direction 换成 column,flex-wrap: wrap 换行就变成往右增加新的一列。这个组合拿来做简单的多列瀑布流很顺手,比如后台有个标签管理页,需要把几十个标签按列堆叠、排满一列就另起一列:
1.tag-waterfall { 2 display: flex; 3 flex-direction: column; 4 flex-wrap: wrap; 5 height: 320px; 6} 7 8.tag-waterfall .tag-item { 9 width: 120px; 10 margin: 4px; 11}
这里必须显式给容器一个高度(或者 max-height),因为 column 方向的换行是靠"纵向排到容器高度用完就转下一列"来判断的,容器如果没有固定高度、又是靠内容撑起来的,浏览器就没法决定什么时候该换列,实际表现就是所有标签排成一条长长的竖线,不会换列。这跟 row 方向换行时依赖容器宽度换行是同一个道理,只是水平方向的宽度平时几乎都是确定的(跟着父容器走),纵向高度反而更容易被漏掉不写。
这套 column + wrap 的瀑布流有个局限:每一列的宽度得自己在子项上写死或者用百分比控制,Flex 本身不会像 Grid 的 grid-template-columns 那样自动帮你分配列宽,列数完全是"排得下几列就几列",容器变窄时列数会自动减少,这点跟真正意义上的瀑布流布局(每列宽度固定、根据内容高度决定放哪一列)也不完全一样,只是排列效果类似,凑合能用,严格意义上的瀑布流还是得手动算高度分配到各列。
order 属性改视觉顺序,不改 DOM 顺序
工具栏这次没用到 order,但同期另一个需求用到了:移动端适配的时候,设计稿要求筛选标签在小屏幕下排到按钮组下面,但 DOM 结构不想为了适配单独维护一套顺序,就用 order 做了视觉上的调整:
1@media (max-width: 600px) { 2 .filter-tags { 3 order: 2; 4 } 5 .filter-actions { 6 order: 1; 7 } 8}
order 默认值是 0,值越小排列越靠前,同值的子项按 DOM 原本的顺序排列。这个属性用起来很方便,但有个容易被忽略的副作用:它只改变视觉呈现顺序,不改变 DOM 顺序,也不改变 Tab 键的聚焦顺序。键盘用户按 Tab 键切换焦点,走的仍然是 DOM 里原本的顺序,跟屏幕上看到的顺序对不上,视觉上按钮组排在上面、Tab 键却先聚焦到下面的标签筛选项,对键盘操作或者屏幕阅读器用户来说体验是错乱的。这次移动端适配用 order 只是做了顺序对调,两块内容之间没有强的操作依赖关系,暂时问题不大,但如果是表单这类强调 Tab 顺序的场景,order 造成的视觉顺序和访问顺序不一致就是个实打实的无障碍问题,得靠调整实际 DOM 顺序而不是纯 CSS 来解决,不能图省事全指望 order。
align-items 和 align-content 不是一回事
.filter-tags 一旦标签多到需要换行,就会出现好几行标签,这时候容器如果比内容行加起来还高,align-items 和 align-content 这两个属性管的东西就分开了:align-items 管的是单独一行内,各个标签在这一行的交叉轴方向怎么对齐;align-content 管的是当有多行时,这些行作为一个整体在容器里怎么分布。只有一行内容的时候 align-content 根本不生效,这也是为什么很多人调了半天 align-content 没反应,回头一查发现容器压根没换行,或者容器高度和内容行高度刚好相等,没有多余空间可分。
工具栏这次给 .filter-tags 设置 align-content: flex-start,让换行后的多行标签都贴顶对齐,不要因为容器被撑高了就把标签行整体居中:
1.filter-tags { 2 display: flex; 3 flex-wrap: wrap; 4 flex: 1; 5 min-width: 0; 6 align-content: flex-start; 7 gap: 8px; 8}
align-content 支持的取值跟 justify-content 很像,也有 space-between、space-around 这类分散对齐的写法,还多一个 stretch(默认值),意思是如果每行子项本身没有设置固定高度,各行会被拉伸去填满容器剩余的交叉轴空间。这次标签本身有固定的行高(内边距加文字撑起来的),align-content 就算保留默认的 stretch,视觉上也看不出被拉伸的效果,因为没有可拉伸的空间——stretch 只在子项高度可变的情况下才有意义,工具栏里显式写 flex-start 主要是图个语义清楚,避免以后有人调整了标签的高度设置反而意外撑开行距。
flex-shrink 不设的话,按钮会被压扁
前面 .filter-actions 已经写了 flex-shrink: 0,这一步不能省。Flex 子项默认 flex-shrink: 1,意味着空间不够时所有子项都会被压缩,按钮这种有固定内边距和文字的元素一旦被压缩,视觉上就是文字被挤变形、图标被压扁。头像、图标、固定宽度的操作按钮这类元素,只要它们不该跟着变窄,就得显式写 flex-shrink: 0。
反过来,标签文字如果本身很长又不想撑爆一行,需要配合 min-width: 0 和文本省略一起用:
1.filter-tag-text { 2 min-width: 0; 3 overflow: hidden; 4 text-overflow: ellipsis; 5 white-space: nowrap; 6}
这里 min-width: 0 是因为 Flex 子项的 min-width 默认值是 auto,也就是内容的最小尺寸,一段不换行的长文本的最小尺寸就是它完整展开的宽度,不主动覆盖成 0,overflow: hidden 和 text-overflow: ellipsis 都不会生效,容器该撑破还是撑破。
这个 min-width: auto 的坑不止影响文本省略,图片和其他有固有尺寸的元素放进 Flex 子项里也会遇到类似情况。比如筛选标签前面带个小图标用 <img> 实现,如果图标本身的宽度比分配到的空间大,.filter-tag 这个子项因为 min-width: auto 的默认行为,最小宽度会被图片的固有宽度撑起来,导致整行标签溢出容器,外层看起来就是"明明设了 flex-wrap: wrap,怎么还是有一部分内容超出了容器右边"。同样的道理也适用于 min-height(在 flex-direction: column 的场景下),只要是可能存在"内容自身尺寸大于分配空间"的子项,稳妥做法都是显式覆盖 min-width: 0(或对应方向的 min-height: 0),把浏览器的默认最小尺寸让出来,交给 overflow 或子元素自身的 max-width 去控制。
配合图片自适应缩放,通常还要再加一条:
1.filter-tag img { 2 max-width: 100%; 3 height: auto; 4 flex-shrink: 0; 5}
max-width: 100% 保证图片不会超出它所在的 Flex 子项宽度,height: auto 保持宽高比不被拉伸变形;但如果这张图片本身是需要保持清晰度、不想被压缩变形的图标(比如按钮里的小图标),那反而要显式加回 flex-shrink: 0,两种诉求容易记混,区分标准就是这张图是"内容配图允许缩放"还是"功能性图标不允许变形"。
IE11 这块要单独处理
这套工具栏理论上还要兼容 IE11,目前后台系统的浏览器占比里 IE11 还有一小部分份额没清零。IE11 对 Flex 规范的支持是早期草案版本,几个已知的坑得单独应对:flex-shrink 在 IE11 里实际表现接近失效,写了也可能不收缩;min-height 在 Flex 容器上不生效,纵向布局的高度撑不满这件事得用别的方式兜底;flex-basis 配合百分比在部分场景下计算不准。真要兼容到 IE11,稳妥的做法是关键尺寸尽量用显式的 width/max-width 兜底,不完全依赖 flex-shrink 的自动收缩效果,同时避免在 IE11 分支里使用刚才提到的 gap——gap 在 IE11 里压根不存在这个属性,样式规则会被直接忽略,间距还是得退回 margin 方案。
除了这几个常被提到的坑,IE11 在 flex-wrap: wrap 配合 align-content 的组合下还有一个不太容易被文档提及的表现:多行内容换行之后,某些情况下行间距的计算会跟 Chrome、Firefox 有细微出入,特别是子项高度不一致(比如标签里混了带图标和不带图标两种高度)的时候,视觉上会看到行与行之间的间距比预期略大或略小。这个问题目前没找到一个通用的 CSS 修复方式,实际处理是把这批标签的高度统一成同一个值(不管有没有图标,外层容器高度都固定),从根源上避免子项高度不一致触发这个渲染差异,而不是去找一个 hack 补丁。
order 属性在 IE11 里的支持也不完整,早期版本的实现里 order 对无效值或者非整数的处理跟规范不完全一致,如果这次移动端适配的顺序调整要往下兼容到 IE11(虽然目前来看这套后台在移动端基本不太会跑 IE11),得实际验证一遍效果,不能想当然认为规范里写的行为在 IE11 上都成立。
align-self 单独覆盖某一个子项的对齐
align-items 是一次性给容器里所有子项定调的交叉轴对齐方式,但工具栏后来加了个小需求:标签组里有一个"清空筛选"的文字按钮,视觉上要求它比其他标签低半行、贴着底部对齐,其余标签保持顶部对齐不变。如果为了这一个子项去改 .filter-tags 整体的 align-items,会把其他标签也带偏,这种"大部分子项一个对齐方式,个别子项特殊处理"的场景就该用 align-self:
1.filter-tag--clear { 2 align-self: flex-end; 3}
align-self 的取值跟 align-items 完全一样(flex-start、flex-end、center、baseline、stretch),区别只是它写在子项上,只覆盖这一个子项的交叉轴对齐,不影响容器里的其他兄弟节点。容易搞混的一点是很多人以为 align-self 也能管主轴方向的对齐,其实主轴方向从来没有类似 justify-self 这样给单个 Flex 子项用的属性(justify-self 是 Grid 布局里的东西),Flex 子项如果想在主轴方向单独跳出对齐规则,只能通过给这个子项加 margin-left: auto 之类的自动外边距去实现,跟 align-self 不是一套机制。
margin: auto 在 Flex 里的特殊用法
顺着上面这一点,Flex 布局下 margin: auto 的表现跟普通文档流不太一样,值得单独说一下。普通块级元素里 margin: 0 auto 是常见的水平居中写法,但在 Flex 子项上,只要某个方向的 margin 是 auto,Flex 会先把这个方向能分配的剩余空间尽量塞给这个 auto 外边距,而不是按 flex-grow 的比例分配。工具栏里"清空筛选"这个按钮除了纵向要贴底,还要求横向上和左边的标签之间空出一大截、贴到 .filter-tags 这个子容器的最右边,比单纯设置 justify-content 更直接的写法就是给它加 margin-left: auto:
1.filter-tag--clear { 2 margin-left: auto; 3 align-self: flex-end; 4}
这样不管前面标签有多少个、占了多宽,"清空筛选"永远会被推到 .filter-tags 这一行的最右边,等价于把 justify-content: space-between 的效果限定在了这一个子项和它前面所有子项之间,不影响标签彼此之间的排列间距。这个写法比 justify-content 更灵活的地方在于它可以只作用在某一个特定子项上,而 justify-content 是整个容器统一起效的。
flex 简写的几种边界写法容易记混
flex 这个简写属性支持一到三个值,写法上有几种边界情况容易搞混。只写一个数字,比如 flex: 1,等价于 flex-grow: 1; flex-shrink: 1; flex-basis: 0%;只写一个长度值,比如 flex: 200px,等价于 flex-grow: 1; flex-shrink: 1; flex-basis: 200px——同样是一个值,写数字和写长度单位解析成的 flex-basis 完全不同,这个区别很容易在写代码时手滑写错。另外还有两个关键字简写:flex: auto 等价于 flex-grow: 1; flex-shrink: 1; flex-basis: auto,意味着子项会先按自身内容尺寸展示,然后再参与伸缩分配;flex: none 等价于 flex-grow: 0; flex-shrink: 0; flex-basis: auto,子项完全不参与伸缩,尺寸严格由自身内容或者显式设置的 width 决定,这也是工具栏里如果不想用 flex-shrink: 0 单独控制、想干脆让某个子项彻底"退出"伸缩游戏时更省事的写法。
flex-basis 和 width 同时出现在一个子项上时,只要主轴方向是水平(row),flex-basis 的优先级高于 width,width 会被忽略;但如果 flex-basis 是 auto,则会退回去参考 width 的值。这一点在调试的时候特别容易踩坑:明明改了 width 数值样式却没变化,回头查发现是同一个选择器上还留着一个数值型的 flex-basis 没清理干净,浏览器压根没走到 width 那条路径上。
flex-basis 和 box-sizing 的取值关系
flex-basis 计算的是内容盒子还是包含内边距、边框的盒子,取决于这个元素的 box-sizing。工具栏里所有标签统一用了 box-sizing: border-box,这也是这几年团队约定的全局重置规则,好处是 flex-basis 写多少,最终占用的实际宽度就是多少,不用再手动加上 padding 和 border 的宽度去凑数字。如果哪个子项漏掉了这条全局规则、退回默认的 content-box,同样的 flex-basis: 100px 加上内边距之后实际占用宽度就会超过 100px,几个子项排在一行里就会出现宽度算不齐、换行时机跟预期对不上的情况。排查这类问题时,box-sizing 是不是全局统一,往往比 Flex 属性本身更值得先确认一遍。
响应式场景下调整 flex-basis 而不是整个重写布局
工具栏在小屏幕下不需要大改结构,只是标签的初始宽度要缩小一点,好让一行能挤下更多标签、减少换行次数带来的高度变化。这种场景没必要写一套单独的移动端样式表,直接在媒体查询里改 flex-basis 就够:
1.filter-tag { 2 flex-basis: 88px; 3} 4 5@media (max-width: 600px) { 6 .filter-tag { 7 flex-basis: 72px; 8 } 9}
flex-grow 和 flex-shrink 保持不变,只是给伸缩计算换一个起始尺寸,容器的整体排列逻辑(换行、贴顶对齐、按钮不收缩)完全不用动。这比每个断点单独重写一套 display、flex-direction、justify-content 组合要轻量得多,也是判断"这个响应式调整算不算大改"的一个基准:如果只是尺寸参数变了、排列逻辑没变,调 flex-basis 或者 flex-grow 的数值就够了;如果排列逻辑本身要变(比如从横向排列变成纵向堆叠),那才需要在媒体查询里重写 flex-direction 这类结构性属性。
用 flex 属性做"更多"折叠交互时的顺序问题
标签数量如果特别多,产品后来又提了一个需求:超过一定数量就折叠成"更多 (12)"这样一个入口,点开再展示全部。这个交互本质上是运行时改变参与 Flex 布局的子项数量,实现思路是先按容器宽度算出一行大概能放几个标签,超出的部分统一塞进"更多"这个入口里,而不是让浏览器的自动换行来决定隐藏和折叠:
1function computeVisibleTags(tags, containerWidth, tagWidth) { 2 var maxCount = Math.floor(containerWidth / tagWidth); 3 if (tags.length <= maxCount) { 4 return { visible: tags, hiddenCount: 0 }; 5 } 6 // 留一个位置给"更多"入口 7 var visible = tags.slice(0, maxCount - 1); 8 return { visible: visible, hiddenCount: tags.length - visible.length }; 9}
这里特意没有直接依赖 flex-wrap: wrap 天然换行之后再用 JS 去数第二行有多少个标签,而是提前算好一行放得下几个,因为换行发生之后再去读取 DOM 尺寸,中间会有一次额外的重排,标签数量一多、用户操作快一点就能感觉到明显的抖动。折叠模式下 .filter-tags 干脆去掉 flex-wrap: wrap,改回默认的 nowrap 配合 overflow: hidden,让多出来的标签直接被裁掉,展示的内容完全由上面这段计算逻辑决定,不依赖浏览器的自动换行结果,两套逻辑(自动换行模式和折叠模式)该切换成互斥的,不能同时开着,否则容易出现"JS 算出来该显示 5 个,CSS 换行又只留了 3 个位置"这种双重裁剪导致的显示数量对不上的问题。
visibility 和 display 隐藏标签时对 Flex 排列的影响
折叠交互还牵扯一个细节:切换标签的显示隐藏,用 display: none 和用 visibility: hidden 效果完全不同。display: none 会让这个子项彻底退出 Flex 布局的计算,剩余空间由其他子项重新分配,这正是折叠功能想要的效果——隐藏起来的标签不占位置,让出空间给"更多"入口和其他可见标签;如果误用成 visibility: hidden,子项虽然看不见了,但依然占着原来的位置参与主轴空间分配,justify-content、flex-grow 该怎么算还是怎么算,只是这个位置上是一片空白,视觉上会看到标签中间莫名其妙空出一块,这是排查这类交互问题时最容易一开始想不到的方向,因为控制台看 DOM 结构完全正常,元素也确实"不可见"了,只有实际对照 Flex 的排列结果才能看出空间没有让出来。
align-items: baseline 对齐文字基线而不是盒子边缘
工具栏里标签内部有个不太起眼的细节:标签文字和后面跟着的一个小型计数徽标(比如"已选 (3)"里括号内的数字用了更小的字号)要按文字基线对齐,而不是按各自盒子的顶部或底部对齐,字号不一致的两段文字如果用 align-items: center 或 flex-start,视觉上会出现"数字比文字矮一截却又不是贴底对齐"的别扭效果。这种场景该用 align-items: baseline:
1.filter-tag-label { 2 display: flex; 3 align-items: baseline; 4} 5 6.filter-tag-count { 7 font-size: 12px; 8 color: #999; 9}
baseline 对齐的依据是每个子项内部第一行文字的基线,不是整个盒子的几何中心或者边缘,如果某个子项内部没有文字(比如纯图标),会退化成参考它的下边缘。这个属性平时用得少,一旦遇到大小字号混排、图标和文字混排这种需要"文字对文字"对齐的场景,比手动拿 margin 和 line-height 去凑效果稳定得多,改字号、改行高都不会影响对齐结果。
flex 子项的百分比宽度和 flex-basis 混用要小心
有的历史代码习惯给 Flex 子项直接写百分比 width 来控制占比,比如三栏布局写 width: 33.33%,这在没有 flex-grow、flex-shrink 参与伸缩计算的静态布局里没问题,但一旦这个子项同时又写了 flex: 1 这类会覆盖 flex-basis 的简写,width 的百分比值就会被忽略(前面提过,flex-basis 优先级更高,除非它是 auto)。工具栏排查过一次类似的问题:某个历史遗留的表单项目里,子项同时写了 width: 25% 和 flex: 1 1 auto,页面在会审查的时候被质疑"为什么这个字段没有按四分之一宽度显示",原因就是 flex-basis: auto 这里虽然会退回参考 width,但 flex-grow: 1 让它在有剩余空间时继续伸展,超出了 25% 这个初始值,width 只决定了起点,不是上限。想要百分比宽度被严格锁定,得配合 flex-grow: 0; flex-shrink: 0 一起写死,或者干脆用 flex: 0 0 25% 这种写法把百分比放进 flex-basis 里统一管理,不要让 width 和 flex 简写分别控制、职责混在一起。
用 Flex 做等高布局时容易漏掉的一步
工具栏之外,同一批中后台组件里还顺带处理了一个等高卡片布局的老问题:几张信息卡片并排展示,卡片内容长短不一,以前只能用 display: table-cell 或者 JS 量高度来做「几个盒子高度保持一致」的效果,现在用 Flex 默认就能拿到:
1.card-row { 2 display: flex; 3} 4 5.card { 6 flex: 1; 7 padding: 16px; 8}
align-items 默认值就是 stretch,所以只要没有显式覆盖它,同一行里的 Flex 子项会自动被拉伸到跟最高的兄弟节点一样高,这是很多人第一次用 Flex 时觉得"意外好用"的地方。但这里有个容易漏掉的前提:子项内部如果有绝对定位或者浮动的子元素,stretch 拉伸的是子项这个盒子本身的高度,内部靠绝对定位撑起来的内容不会跟着重新排布,卡片如果想让"底部按钮始终贴在卡片底边",还得在卡片内部再开一层 display: flex; flex-direction: column; height: 100%,让按钮通过 margin-top: auto 推到底部,单靠外层的等高拉伸并不会自动处理卡片内部的二次布局,这是两件各自独立、需要分别处理的事情。
reflow 视角下 Flex 计算比传统布局更重一些
团队里做过一次性能排查,页面里有个筛选面板动态渲染上百个 Flex 子项(批量导入的标签回显),发现每次批量增删标签时页面有轻微卡顿,用 Chrome 的 Performance 面板录制发现 Layout(也就是 reflow)耗时比预期高。这跟 Flex 布局的计算特点有关:Flex 每次重新排列都要综合考虑所有子项的 flex-grow、flex-shrink、flex-basis 做一次比例分配的计算,子项数量越多,这个计算量越不是线性增长那么简单,尤其是跟"自动换行 + 子项内容尺寸不固定"这种组合放在一起时,浏览器往往要多算几轮才能收敛出最终的换行结果和每行高度。工具栏这次子项数量最多也就一二十个,谈不上性能问题,但那次批量导入标签的场景确实因为子项数量上百,把原本"用 DOM 结构渲染全部标签、靠 CSS 处理换行"的方案改成了前面提到的"先计算可见数量再渲染,超出部分折叠进'更多'入口"的方式,本质上就是用减少参与 Flex 布局计算的子项数量,换取一次布局计算的耗时下降,这算是这次顺带摸索出来的一个经验:子项数量一旦上到大几十甚至上百,能不能提前把不需要展示的部分从 DOM 里摘出去,是优先于任何 CSS 微调的第一件事。
flex-flow 简写和几种方向组合的实际表现
flex-direction 和 flex-wrap 平时都是分开写的,但也有一个简写属性 flex-flow 能把两者合成一行,比如 flex-flow: row wrap 就等价于分开写 flex-direction: row; flex-wrap: wrap。工具栏里没用这个简写,两个属性分开写更方便单独调试,但排查 CSS 覆盖优先级问题时得留意一点:如果某个基础样式类里用了 flex-flow: row nowrap,业务组件又单独写了一行 flex-wrap: wrap 想覆盖掉换行方式,这条单独的声明确实能生效,因为 flex-wrap 的优先级判断是按普通的 CSS 选择器规则走的,跟它是不是通过简写属性设置的没关系,但如果两条规则写在同一个选择器权重的样式里、又赶巧后加载的样式表把 flex-flow 整个重新声明了一遍,那这条简写会把之前单独设置的 flex-wrap 一起覆盖掉,这类问题实际调试起来容易第一时间怀疑错了地方,得回头去确认样式表加载顺序和是否用了简写属性整体覆盖。
flex-direction 除了 row 和 column,还有 row-reverse 和 column-reverse 两个反向的取值,视觉上子项从末尾往起始方向排列,效果上跟 order 属性调换顺序有点像,但本质不同:row-reverse 改变的是整个容器的主轴方向定义,连带着 justify-content: flex-start 这类依赖主轴方向的属性所指的具体方向也会跟着反过来;而 order 只是单独调整某几个子项的排列位置,容器的主轴方向定义本身不变。这两者都不会改变 DOM 顺序和 Tab 键焦点顺序,跟前面提到 order 的无障碍问题是同一类风险,row-reverse 用在纯展示、不涉及键盘操作的场景里问题不大,用在表单或者有明确操作顺序要求的场景里,同样得留意视觉顺序和实际焦点顺序对不上的情况。
flex 容器本身作为子项时的默认 display 表现
有个细节容易被忽略:一个 display: flex 的容器,如果它自己又是另一个 Flex 容器的子项,这个容器本身在参与外层布局时的行为跟普通块级元素没有区别,Flex 相关的属性(flex-grow、flex-shrink、flex-basis)该怎么设、怎么参与外层的空间分配,跟它内部是不是 Flex 容器完全无关,display: flex 只对它的直接子元素生效,对它自己在父级布局里的身份没有任何特殊加成。工具栏这次 .filter-tags 和 .filter-actions 都是这样的双重身份——对上(在 .filter-bar 里)是普通的 Flex 子项,对下(管自己内部的标签或按钮)又是 Flex 容器,这两层身份互不干扰,各自按各自的规则计算,初学 Flex 时容易下意识以为"这个容器是 Flex 就自动获得了更聪明的伸缩行为",其实它作为子项该多"笨"还是多"笨",取决于父级容器怎么给它设置 flex 相关属性。
同理,display: inline-flex 和 display: flex 唯一的区别只在于这个容器本身在外部布局里的显示方式(表现得像 inline 还是 block),内部子元素的 Flex 排列规则完全一致,工具栏没有用到 inline-flex,但如果哪天要把这个筛选栏内嵌到一段文字描述中间、需要跟着文字一起换行流动,inline-flex 就是比额外套一层 <span> 再改 display 更直接的写法。
文字方向变化时 Flex 主轴跟着走,不是写死的水平垂直
justify-content、align-items 这些属性名字本身故意没有用"水平""垂直"这类词,用的是"主轴""交叉轴",这不是规范制定者故弄玄虚,而是因为 Flex 布局从设计上要兼容不同书写方向的语言。中文、英文这类从左到右横排的语言,flex-direction: row 的主轴就是从左到右;但如果页面通过 direction: rtl 声明成从右到左的书写方向(阿拉伯语、希伯来语这类语言的常见需求),同样是 flex-direction: row,主轴的起始方向会自动翻转成从右到左,justify-content: flex-start 这时候对齐的就是容器右侧而不是左侧。工具栏这次的中后台系统没有国际化到需要支持 RTL 语言的地步,这个特性目前用不上,但知道这一层设计意图,能解释清楚为什么 Flex 布局的属性命名要绕开"左右上下"这种看起来更直观的词——一旦真的要做国际化,flex-start、flex-end 这套相对方向的命名反而是省心的地方,不用因为文字方向变了就把所有布局属性重写一遍。
flex 子项内部文字换行与 white-space 的配合
标签内部文字默认情况下遇到容器变窄会自动换行,这也是为什么前面处理长文本省略时特意加了 white-space: nowrap——如果不加这一条,overflow: hidden 加 text-overflow: ellipsis 这套组合根本不会触发,因为浏览器优先选择把文字自动换成两行来适应容器宽度,而不是触发省略号,text-overflow: ellipsis 生效的前提之一就是这个盒子里的文字被要求"不能换行"。这三条声明(overflow: hidden、text-overflow: ellipsis、white-space: nowrap)在实际项目里几乎总是绑在一起出现,缺一条效果就出不来,Flex 子项这边额外要留意的只是前面提过的 min-width: 0,三件套加上这一条才是这次真正让文本省略在 Flex 布局里生效的完整配方,单独记这三件套、忘了 Flex 子项默认 min-width: auto 这层,还是会在 Flex 场景里失效,这也是这次调试时反复被问起的一个点。
用开发者工具直接看 Flex 排列结果,比肉眼猜更准
Chrome DevTools 这两年给 Flex 布局加了专门的调试面板,在 Elements 面板选中一个 display: flex 的元素之后,样式面板里 display: flex 这一行旁边会出现一个小图标,点开能直接在页面上叠加显示主轴方向、各条对齐参考线,鼠标悬停在某个子项上还能看到它实际占用的 flex-basis 和最终尺寸。这次调试标签换行和按钮不收缩的效果时,很大一部分时间省在了不用再靠肉眼估算容器宽度、手动拿计算器算 flex-shrink 的比例,直接在这个面板里就能看到浏览器实际按什么比例分配了空间。Firefox 的 Grid/Flex 调试工具起步更早、功能更细,能把每个子项的 flex-grow、flex-shrink、flex-basis 数值直接标注在页面上,两个浏览器切着用,遇到某个浏览器独有的排列差异(比如前面提到的 IE11 换行间距问题)也能第一时间对照确认是不是布局引擎本身的差异,而不是自己代码写错了。
排查 flex-basis 计算错误还有一个笨办法但很管用:给每个子项临时加一个鲜艳的背景色和 outline,肉眼就能看出每个子项实际占了多宽、有没有被压缩变形,这个办法在没有称手的调试面板、或者需要截图给别人看问题现场时依然是最快的手段,不需要依赖任何工具,写起来也就一行样式。
flex 子项加 transform 之后仍然遵循原来的布局位置计算
工具栏里标签有个交互效果:鼠标悬停时标签会有一个轻微的放大动画,用的是 transform: scale(1.05)。这里有一点需要确认清楚:transform 修改的只是元素的视觉呈现(渲染层面的位移、缩放),不会影响 Flex 布局阶段对这个子项尺寸和位置的计算,也就是说悬停放大不会把旁边的标签挤开,也不会触发重新排列,纯粹是视觉上的叠加效果,性能上也更友好,因为 transform 走的是合成层,不会引起 reflow。如果需求变成"悬停放大之后旁边的标签要跟着让开位置",那就不能用 transform 实现了,得改成真的修改这个子项的 flex-basis 或者 padding 才会触发重新排布,这类需求所在意的效果本质是两种性质完全不同的操作,混着记容易在写悬停动画的时候误以为改一下 width 或者 flex-basis 更"正确",实际上纯视觉反馈用 transform 才是对的选择,既不影响布局也不会有额外的重排开销。
表单场景下 Flex 和原生控件默认样式的冲突
同一批中后台组件里,筛选工具栏旁边紧接着就是一个横向排列的表单,输入框、下拉选择器、日期选择器要在一行里等分排列,这时候直接套用前面 flex: 1 的思路会撞上一个新问题:<input>、<select> 这类原生表单控件自带浏览器默认的最小宽度(不同浏览器不完全一致,Chrome 里大概是 min-width: auto 结合原生渲染引擎给的一个隐式最小值),跟前面标签文字溢出的坑本质是同一类问题——原生控件套进 Flex 子项之后,同样得显式加 min-width: 0,否则控件多了之后没办法正常压缩到统一宽度,会有一两个字段莫名其妙比其他字段宽出一截。
1.form-row { 2 display: flex; 3 gap: 12px; 4} 5 6.form-row .field { 7 flex: 1; 8 min-width: 0; 9} 10 11.form-row .field input, 12.form-row .field select { 13 width: 100%; 14 box-sizing: border-box; 15}
这里内层的 input、select 又额外加了 width: 100%,是因为原生表单控件的默认宽度是按内容或者平台默认值走的,不会自动撑满父级 Flex 子项让出来的空间,flex: 1 只保证了外层 .field 这个包装盒子能拿到等分的空间,内部的输入框还得再显式声明一次占满父容器,这两层容易被写成只处理了外层、忘了内层,调出来的效果是外层间距对了,输入框本身宽度却各自不一致。
小结之外:这次顺带记下的几条判断标准
这次工具栏改造前后牵扯到的几个判断标准,值得放在一起对照:子项该不该参与收缩,看它是不是"内容定长、不允许变形",是的话显式 flex-shrink: 0;容器该不该换行,看这批子项数量是不是天然不确定,数量确定用 nowrap 加省略号,数量不确定用 wrap 加多行对齐;要不要上 gap,看目标浏览器范围里 Safari 是不是还在服务范围内,暂时不在就能上、在就先退回 margin;要不要上 Grid,看这次布局是一维还是二维,只要出现"跨行跨列"这种需求,一维的 Flex 无论怎么嵌套都是绕远路。这几条判断标准比死记某个属性的默认值更好用,属性细节记混了随时能查文档,但"这个场景该往哪个方向想"这件事,还是得靠具体做过一遍才能形成判断。
打印样式下 Flex 布局的表现要单独确认
这套中后台系统里有个导出打印的功能,筛选结果列表要支持直接走浏览器打印预览,这时候发现 Flex 布局在打印样式(@media print)下有个容易漏掉的点:部分浏览器的打印引擎对 flex-wrap: wrap 换行之后跨分页的处理不够智能,一行标签如果刚好卡在两页交界的位置,可能会被从中间硬切开,视觉上一半在上一页、一半在下一页。这个问题不是 Flex 独有的(float 布局一样会有类似的分页断行问题),但因为工具栏本身用了大量 flex-wrap: wrap,打印联调这一步专门验证了一遍,最后采用的办法是给可能被切开的整行内容加上 break-inside: avoid(老一点的浏览器还得写 page-break-inside: avoid 兼容),告诉打印引擎这块内容尽量作为一个整体、不要从中间分页切断。这条经验和 Flex 布局本身的计算规则关系不大,但只要页面涉及打印场景,用了 flex-wrap 换行的区域都值得单独走一遍打印预览确认,不能默认屏幕上显示正常就万事大吉。
回到这次工具栏,几条规则合起来看
前面这些点分散在不同场景,回到最初这个筛选工具栏的具体实现上,真正用到的其实就是一个精简组合:外层 .filter-bar 一个 Flex 容器管两大块的位置关系,.filter-tags 内层再开一个 Flex 容器专门处理换行和多行对齐,.filter-actions 显式关闭收缩保证按钮不变形,标签文字配合 min-width: 0 处理超长省略。这几条规则单独看都不复杂,容易出岔子的地方始终是几条规则叠在一起时互相牵制的关系——flex-shrink 不设对不对,取决于这个子项是不是"允许变窄";min-width: 0 加不加,取决于子项内部有没有不愿意主动收缩的内容;gap 敢不敢用,取决于目标浏览器范围。这些判断没有一条能脱离具体场景死记硬背,这次改造真正花时间的也不是查文档背属性,而是把这几条判断在这一个具体工具栏的场景里过一遍,确认每一条都站得住。
什么时候不用 Flex,换成 Grid
这次的筛选工具栏是标准的一维布局:一条线上排几个块,谁伸谁缩。如果需求换成商品卡片墙、仪表盘这种要同时控制行和列、还要求"某几个卡片跨行跨列"的场景,用 Flex 硬套多半会变成好几层嵌套容器加一堆魔法数字,越改越难维护,这种场景直接用 Grid 定义行列模板更省事。这次工具栏结构简单,谁需要伸缩、谁需要换行、谁需要固定不动,三句话就能说清楚,用 Flex 是合适的,判断标准就是"要不要同时管两个维度"。
前面提到的 column 方向瀑布流其实就是一个介于两者之间的例子:勉强能用 Flex 拼出类似效果,但列宽分配、跨列跨行这些真正意义上的二维控制,Flex 是做不到的,遇到这类需求还是老实换 Grid,用 flex-wrap: wrap 配 column 拼瀑布流只适合"排列效果差不多就行、列宽可以手动写死"这种简单场景,不要为了不引入新概念硬把复杂的二维布局也塞进 Flex 里。
Grid 目前的浏览器支持其实已经不差,主流浏览器基本都跟上了,团队里也有人在小范围的仪表盘页面上试过,但这次筛选工具栏没有理由为了赶时髦而换掉本来就合适的 Flex——工具需求匹配才是第一位的,不是新工具天然比旧工具好,一维布局用 Flex 依然是更简单、更容易让同事看懂的选择,Grid 留给真正需要二维控制的场景更合适。
判断这两者该怎么选,比较实用的一条标准是先在纸上(或者脑子里)画一下布局的示意图:如果画出来是一条线上排几个块、顶多在这条线满了之后另起一行,这就是一维场景,交给 Flex;如果画出来天然带着"网格"感、需要同时标注行号列号才能描述清楚某个模块在哪,这就是二维场景,交给 Grid。这次工具栏怎么画都只是一条线,答案很明确。
两者也不是非此即彼的关系,实际项目里更常见的是外层用 Grid 划分大的区域(比如页面整体的侧边栏、头部、内容区),区域内部具体某一块再用 Flex 处理一维排列,这次的筛选工具栏本身就是嵌在页面内容区里的一个局部组件,往上一层看,整个页面骨架用的还是更传统的区域划分方式,没有整页套 Grid,这也是目前团队对新特性偏保守的落地节奏——局部尝试,不动整体骨架,等把握更足了再考虑更大范围的替换。
标签换行、按钮不换行、按钮不压缩、标签文字超长省略,这几条判断放在一起写完之后,整个工具栏在容器变窄、标签数量变多的各种情况下都能保持该有的样子,不需要再额外补媒体查询去兜底。
这套判断标准和踩过的坑记下来,下次遇到类似的自适应工具栏、卡片列表、表单排版需求,能省掉不少来回试错的时间,这也是这次特意把每条规则背后"为什么这么写"的判断过程一并记清楚的原因,而不是只留一份能跑起来的代码。