用 Flex 重做圣杯布局、Sticky Footer 和 RTL 适配:几种经典结构的取舍

圣杯布局和双飞翼布局是很老的两个名字了,最早是为了解决同一个问题:三栏结构里中间内容区要最先渲染、最先加载,左右两栏宽度固定,中间自适应,还要求源码顺序和视觉顺序不一致。这套问题在 float 时代有一套很成熟但相当繁琐的解法,团队里维护的老页面还有一部分在用。这个月把其中一个中后台的三栏详情页从 float 版本换成 Flex 实现,顺手把两种历史方案和新方案摆在一起比较了一遍,发现真正值得记录的不是"Flex 能不能实现",而是换了实现方式之后,原来那套解决问题的思路本身就显得多余了。

圣杯布局和双飞翼布局解决的到底是什么问题

先回顾一下 float 版本要解决的核心矛盾:三栏中,中间栏要放在 HTML 源码最前面(这样浏览器解析到它就能优先请求资源、优先渲染),但视觉上又要求它显示在中间,左右两栏分别贴在两侧。float 布局没法凭空让一个"排在前面的元素"跑到视觉中间,所以圣杯布局用的办法是父容器加左右内边距空出两栏的位置,中间栏 float: left 撑满宽度,左右两栏分别用负 margin-left 拉回到父容器的内边距区域里:

1.container {
2  padding: 0 200px;
3}
4
5.main {
6  float: left;
7  width: 100%;
8}
9
10.left {
11  float: left;
12  width: 200px;
13  margin-left: -100%;
14  position: relative;
15  left: -200px;
16}
17
18.right {
19  float: left;
20  width: 200px;
21  margin-left: -200px;
22  position: relative;
23  right: -200px;
24}

双飞翼布局是同一个目标,换了个更省心(但多一层 DOM)的做法:不依赖父容器的内边距和子元素的相对定位配合,而是给中间栏内部再包一层容器,用这层容器的 margin 直接让出左右空间,少了圣杯布局里那两个容易算错符号的 position: relative 微调。这两套方案我早年都手写过,说实话每次都要对着图重新推一遍这几个负 margin 的正负号和百分比,稍微改一下栏宽就要把这几个数字全部重新算一遍,属于典型的"写得出来但没有人能一眼看懂"的方案。

换成 Flex 之后,源码顺序和视觉顺序这两件事直接解耦了,不再需要负 margin 这种障眼法:

1.container {
2  display: flex;
3}
4
5.main {
6  order: 2;
7  flex: 1;
8  min-width: 0;
9}
10
11.left {
12  order: 1;
13  flex: 0 0 200px;
14}
15
16.right {
17  order: 3;
18  flex: 0 0 200px;
19}

HTML 里 .main 依然可以放在最前面,视觉顺序完全交给 order 决定,不用再操心哪个方向该拉多少距离。这里能看出来一个本质区别:float 方案是"用位置偏移伪造顺序",Flex 方案是"顺序本身就是一个独立可声明的属性",圣杯布局和双飞翼布局当年费那么大力气解决的问题,在 Flex 里根本不构成问题——order 天生就是用来做这件事的。唯一要留意的是这个月之前几篇笔记里提过的老坑:order 只改变视觉顺序,不改变 Tab 键的聚焦顺序,中间栏如果放在源码最前面、又用 order 挪到视觉中间,键盘用户依然会先聚焦到中间栏,再聚焦左栏、右栏,这个顺序错位在纯展示页面问题不大,但这次的详情页左栏是一组筛选操作,右栏是辅助信息,中间是正文,键盘操作走"正文、筛选、辅助信息"这个顺序其实也说得通,倒是顺带把一个可能的无障碍问题变成了无心插柳的合理顺序,不是每次都这么走运,改造前还是得单独过一遍键盘操作。

老方案还有个隐藏的历史包袱这次一起清理掉了:float 版本的三栏为了让左右两栏"看起来"和中间栏一样高(三栏背景色要贴到页面同一条底线),当年只能靠给三栏都设一个巨大的 padding-bottom 加上等值的负 margin-bottom,让内容溢出的部分被父容器的 overflow: hidden 裁掉,从而制造出"三栏等高"的假象。这是那个年代等高布局最常见的一个技巧,效果说得过去,但本质是拿裁剪伪造出的视觉效果,容器一旦意外裁不干净(比如某栏内容里有 position: absolute 的元素跳出了裁剪范围),三栏错位的 bug 排查起来很难联想到问题出在那个巨大的 padding-bottom 上。Flex 布局下 align-items 默认值就是 stretch,三栏会自动等高,这个技巧连同它带来的排查成本一起被淘汰了,这次改造顺手把相关的注释和那段"魔法数字" padding-bottom 一起从代码里删掉,某种程度上比布局本身的收益还让人满意。

圣杯布局原本还有一个变体需求这次也一并验证过:如果要求左右两栏在小屏幕下收起、只保留中间栏独占一行,float 版本得单独写一套媒体查询把 floatmargin-leftposition 全部清空重置,因为这几条属性互相纠缠,任何一条漏清都会导致小屏幕下栏位错位。Flex 版本只需要在媒体查询里把 .containerflex-directionrow 改成 columnorder 依然可以保留但纵向排列时通常不需要再调整顺序,三栏各自的 flex 简写也不用清空,直接从"三栏并排、各自固定或自适应宽度"切换成"三块纵向堆叠、各自撑满宽度",改动量小了一个量级。

圣杯布局在弹窗场景下的一个变体:三段式对话框

圣杯布局的思路不止用在整页三栏结构上,这次顺手处理了一个看起来完全不相关、但骨架高度相似的问题:一个内容可能很长的确认对话框,头部标题栏和底部按钮栏要求永远固定可见,中间内容区超出高度时自己滚动,不能把头部和底部一起顶出可视区域。这本质上是圣杯布局的纵向版本——"首尾两块固定,中间一块自适应并且允许内部滚动",只是主轴从水平换成了垂直:

1.dialog {
2  display: flex;
3  flex-direction: column;
4  max-height: 80vh;
5}
6
7.dialog-header,
8.dialog-footer {
9  flex: 0 0 auto;
10}
11
12.dialog-body {
13  flex: 1;
14  min-height: 0;
15  overflow-y: auto;
16}

.dialog-header.dialog-footer 显式写 flex: 0 0 auto,表示这两块严格按内容自身高度决定尺寸,既不参与增长也不参与收缩;.dialog-body 拿到 flex: 1 去吃掉剩余空间,同时补上前面反复出现过的 min-height: 0,这个坑在纵向 Flex 容器里几乎是必考题——不补这一句,中间内容区一旦内容超长,会尝试把自己撑到内容的完整高度,而不是老老实实停在剩余空间那么高、把超出部分交给 overflow-y: auto 处理。

这个对话框结构做完之后,团队里另一个同事很自然地问了一句:这跟圣杯布局是不是一回事?细想确实是同一个骨架在不同方向上的两次应用——圣杯布局是"首尾两栏宽度固定,中间栏自适应宽度",对话框是"首尾两块高度固定,中间块自适应高度",两者用的都是"两端 flex: 0 0 auto、中间 flex: 1 吃剩余空间"这套分配逻辑,只是换了个主轴方向。这也是这次觉得比较有意思的一点:过去圣杯布局、双飞翼布局这些名字总被当成专门针对"页面三栏"这一种场景的专有解法,换到 Flex 之后才看清楚,它们其实只是"固定两端、弹性中间"这套更通用的分配思路的一个特例,一旦理解了这套通用思路,遇到对话框、遇到带筛选栏的表格页(表头固定、表体滚动、底部分页栏固定)都是同一个骨架换个方向搬过去用,不需要针对每种场景单独重新想一套方案。

表格页那个场景这次也顺手验证了一遍,结构和对话框几乎一样,只是把 .dialog-body 换成表格容器:

1.table-page {
2  display: flex;
3  flex-direction: column;
4  height: 100vh;
5}
6
7.table-toolbar {
8  flex: 0 0 auto;
9}
10
11.table-scroll {
12  flex: 1;
13  min-height: 0;
14  overflow: auto;
15}
16
17.table-pagination {
18  flex: 0 0 auto;
19}

工具栏和分页栏固定,中间表格区域独立滚动,这个结构在中后台系统里几乎每个列表页都要用到一次,以前团队里没有形成统一写法,有的页面是给表格容器算了一个基于 calc(100vh - 工具栏高度 - 分页栏高度) 的固定高度,工具栏或者分页栏的样式一旦有细微调整(比如筛选栏多了一行换行),这个写死的 calc 数值就得跟着手动改,还经常忘改。这次把这套"两端固定、中间 flex: 1"的写法整理成组件库里的一个基础布局组件,往后这类页面不用再手动算减法,是这批改造里对团队协作实际帮助最大的一处。

Sticky footer:几种 Flex 实现方式和各自的坑

footer 永远贴底是另一个经典问题:页面内容少的时候,footer 要贴在视口底部,不能悬在内容下面留出一片空白;内容多的时候,footer 要跟着内容被推下去,不能盖住正文。这次順手把公司官网落地页的 footer 从老式的负 margin 方案换掉,负 margin 那套写法是给 footer 一个固定高度,主体内容区域 min-height: 100vh,再用 margin-bottom: -footerHeight 加上一个同等高度的占位元素去抵消,footer 高度一旦改动,两处数字都要同步改,很容易漏改一处导致内容被 footer 盖住一小截。

Flex 版本最直接的写法是把 body 或者最外层容器变成纵向 Flex 容器,让内容区自己撑满剩余空间:

1html, body {
2  height: 100%;
3}
4
5.page {
6  display: flex;
7  flex-direction: column;
8  min-height: 100vh;
9}
10
11.content {
12  flex: 1;
13}

.content 拿到 flex: 1 之后会自动吃掉视口减去 header、footer 之后剩下的空间,内容少的时候它会被撑到足够高,把 footer 挤到视口底部;内容一旦超出视口高度,.pagemin-height: 100vh 允许它继续增长,footer 照样跟在内容后面,不会被内容盖住。这个写法不需要预先知道 footer 的高度,是它相对旧方案最大的优势。

但这次踩到一个新坑:.content 内部如果又是一个可以横向滚动的表格容器,需要设置 overflow: auto,光加 flex: 1 没用,表格会把 .content 撑爆而不是自己滚动。这其实还是老熟人 min-height: 0 的坑——Flex 子项默认 min-height: auto,纵向 Flex 容器下这个默认值会让 .content 的最小高度至少是内容的固有高度,必须显式补一句 min-height: 0(或者干脆写 overflow: hidden),才能把"允许收缩到比内容还矮、超出部分交给内部滚动条处理"这件事真正打开:

1.content {
2  flex: 1;
3  min-height: 0;
4  overflow: auto;
5}

另一种更简单粗暴的 sticky footer 写法是不用 flex: 1 撑内容区,而是直接给 .pagejustify-content: space-between,配合 .content 不设伸缩属性:

1.page {
2  display: flex;
3  flex-direction: column;
4  min-height: 100vh;
5  justify-content: space-between;
6}

这个写法在只有 header、content、footer 三块、且互相之间不需要额外控制间距的场景下更省事,但一旦 .content 本身高度超出视口,space-between 只保证首尾两块贴边,中间这块该多高还是多高,效果和 flex: 1 版本几乎一样;真正的区别出现在 .content 高度恰好小于视口、但又不是唯一需要伸缩的场景——如果这个页面以后要在 .content 内部再插入一个也想跟着伸缩的区块,space-between 这个写法完全没有"谁该伸展"的语义,改起来只能推倒重写,而 flex: 1 的版本只要在新区块上加一行 flex 就行。这也是我现在更倾向优先用 flex: 1 而不是 justify-content: space-between 做 sticky footer 的原因,不是效果不一样,而是往后维护的扩展性不一样。

官网这个 footer 本身内部还分了三列(关于我们、产品链接、联系方式),这三列在桌面端要横向平铺、等宽对齐,移动端要纵向堆叠。这一层内部结构又是一次嵌套 Flex,.footer 自己是外层纵向 Flex 容器(.page)里贴底的一个子项,内部又开一个横向 Flex 容器管三列平铺,两层身份互不干扰,管好各自的排列规则就行——这一点在前面详情页的三栏结构里也验证过,这次不算新知识点,只是又一次确认了"容器本身作为子项时,跟它自己内部是不是 Flex 容器完全无关"这个判断在实际项目里到处都在被用到。

这次顺带把 sticky footer 一个容易被忽视的边界情况也测了一遍:如果页面整体设置了 overflow-x: hidden 防止横向滚动条,同时 .pagemin-height: 100vh 撑满视口,在移动端浏览器地址栏收起展开导致视口高度变化时(这是移动 Safari 一个很典型的行为,滚动过程中地址栏收起,100vh 对应的实际像素值会跟着变),footer 贴底的位置会跟着轻微跳动。这不是 Flex 布局计算错了,而是 100vh 这个单位本身在移动端的定义就跟着视口变化在变,用 Flex 实现的 sticky footer 没办法规避这个问题,规避的办法只能是换成动态视口单位或者用 JS 监听 resize 校正,跟 Flex 布局的实现方式关系不大,这次只是记一笔,避免以后遇到"footer 在手机上晃"这类反馈时第一反应去怀疑 Flex 布局本身出了问题。

gap 属性今年终于能放心用在 Flex 上了

前面这几处间距,如果放在半年前的项目里大概率还是得靠 margin 手动处理最后一项的例外情况,这次官网 footer 的三列间距、详情页表单里字段和字段的间距,都直接用了 gap

1.footer-columns {
2  display: flex;
3  gap: 24px;
4}

Safari 14.1 是今年 4 月跟进的,加上 Chrome、Firefox 早两年就支持了 Flex 容器上的 gap,这个月起主流场景基本可以放心铺开,不用再像去年那样为了兼容旧版 Safari 特意退回 margin 方案。这次顺手把项目里几处历史遗留的 margin-right:last-child 清零的写法都换成了 gap,代码量没少多少,但少了"最后一项要不要清零"这种每次新增子项都要重新确认一遍的心智负担。唯一还留了个心眼的地方是给用户量大的落地页做了一层特性检测兜底:

1.footer-columns {
2  display: flex;
3}
4
5@supports (gap: 1px) {
6  .footer-columns {
7    gap: 24px;
8  }
9}
10
11@supports not (gap: 1px) {
12  .footer-columns > * + * {
13    margin-left: 24px;
14  }
15}

@supports 这里检测的是 gap 这个属性本身能不能被解析,不是专门检测"Flex 容器上的 gap"和"Grid 容器上的 gap"的区别(这两者在检测层面是一回事,浏览器不区分容器类型去判断这个属性认不认识),但 Grid 早两年就支持 gap 了,所以真正需要这层兜底的场景仅限于极少数还在使用老版本 Safari 的访客,公司中后台系统内部用户环境统一,这层兜底就没必要加,只在面向外部访客的官网页面上留着,这也是这次顺带定下来的一条规则:内部工具类页面浏览器环境可控,新特性能用就直接用;面向不可控外部访客的页面,新特性用之前多一层特性检测的成本值得付。

嵌套 Flex 容器层级太深,会拖累一次布局计算

这次三栏详情页做完之后,中间的 .main 内部还嵌了好几层 Flex——卡片头部一层横向 Flex 排标题和操作按钮,卡片内容里表单每一行又是一层 Flex,表单里某些字段本身还带着图标和文字的第三层 Flex。团队里另一个同事负责的活动页,把这种嵌套层级叠到了六七层,滚动的时候能感觉到轻微的卡顿,用 Chrome 的 Performance 面板录制之后发现 Layout(也就是 reflow)耗时确实比预期高不少。

这背后的原因是 Flex 布局算法本身不是简单的自顶向下一遍过:浏览器计算一个 Flex 容器的最终尺寸,很多情况下需要先知道它内部子项在各种可能的收缩状态下的最小内容尺寸,而这个最小内容尺寸如果本身又是一个 Flex 容器,就得先把这一层的子项也过一遍类似的计算,层层嵌套下去,理论上一次布局的计算复杂度会随嵌套深度叠加。规范层面这个问题是有名字的,浏览器实现里对深层嵌套 Flex 容器的尺寸计算确实存在性能上的已知短板,不是某个浏览器实现得差,是这套按比例动态分配空间的算法天然要比传统盒模型更"重"一些。

排查那次卡顿用的办法是把最深两层原本用 Flex 实现的简单横排(图标加文字这种,压根不需要伸缩或者换行)换成了普通的 inline-block 或者干脆用 display: block 配合 line-height 对齐,因为那两层子项本来就没有用到 Flex 真正的能力——没有伸缩比例、没有换行、没有主轴交叉轴对齐的复杂需求,纯粹是"图标挨着文字摆一排",属于普通文档流也能做到的效果,套一层 Flex 反而是拿高成本的通用方案去做低成本的事。把这两层换掉之后嵌套深度从六七层降到四层,卡顿基本消失,这也是这次得到的一个实际判断标准:不是所有排成一行的元素都值得用 Flex,越往嵌套的深层走,越应该反问一句"这一层是不是真的需要主轴交叉轴的动态计算,还是单纯图个写法统一",图个写法统一在浅层没什么代价,嵌套到第五第六层再图省事,性能账是会累加的。

顺带把这次测出来的量级记一下:那个活动页在改造前,一次典型的窗口 resize 触发的 Layout 耗时在 Performance 面板里读到接近 30ms,改完之后降到 10ms 出头。数字本身没有绝对意义(跟设备、子项数量都有关),但"嵌套层级和布局耗时正相关"这个方向性的判断,这次算是真正拿数据核实过一遍,而不是凭感觉猜的。

排查这类嵌套过深的问题,Performance 面板的火焰图上能看到一连串标记成 Layout 的紫色色块反复出现,如果一次 resize 或者一次内容更新触发了不止一次 Layout(术语上叫强制同步布局,或者布局抖动),往往意味着 JS 里有读取几何属性(offsetWidthgetBoundingClientRect 这类)又紧接着修改样式的代码交替出现,浏览器被迫在两次操作之间插入一次同步的重新计算。这次活动页的卡顿其实还叠加了一个这样的次生问题:某个基于 Flex 子项宽度做的轮播组件,在处理窗口缩放时先读了一次 .filter-tags 那层嵌套 Flex 容器子项的 offsetWidth,又立刻改了另一个子项的行内样式,两者顺序对调之后(改完样式统一放到最后,读取操作集中在最前面)能明显减少布局抖动的次数,这个优化跟嵌套层级本身没关系,但和嵌套层级的问题混在同一次卡顿里出现,值得一起记录,排查这类问题时既要看嵌套深度,也要看 JS 读写几何属性的顺序有没有相互穿插。

另外这次也做了个对照实验,验证嵌套层级本身和"嵌套层级里有没有用到需要动态计算的 Flex 特性"其实是两件独立的事:把嵌套最深的那层从 Flex 换成普通块级元素固然有效,但如果保留 Flex、只是把里面的 flex-growflex-shrink 全部显式设成 flex: none(不参与任何伸缩计算,纯粹靠子项自身尺寸决定宽度),Layout 耗时同样有明显下降,只是没有完全换掉 Flex 那么彻底。这说明这套开销的真正来源是"需要动态求解的伸缩比例计算",而不是"用没用 display: flex 这个声明"本身,如果一个场景既想保留 Flex 的对齐能力(比如 align-items: center 这种),又不需要伸缩计算,把子项显式设成 flex: none 是一个比整体换回块级元素更折中、改动更小的优化手段。

排查嵌套层级时,先画一张"谁是谁的 Flex 子项"的关系图

嵌套 Flex 层级一深,光靠在浏览器里点开 Elements 面板一层层展开去数嵌套深度,效率很低,尤其是组件库里样式是拆在好几个文件里、通过类名组合拼出来的,肉眼很难一眼看出某个元素到底嵌了几层。这次处理那个活动页的卡顿问题,实际操作是先把整个组件树的 DOM 结构导出来,手动标出每一层容器是不是 display: flex,画成一张简单的缩进列表,类似这样:

.page-shell        flex
  .layout-main      flex
    .card           flex
      .card-header  flex
        .icon-text  flex   ← 第 4 层,无伸缩需求
      .card-body    flex
        .form-row   flex
          .field    flex
            .icon-text flex ← 第 5 层,无伸缩需求

这张表画出来之后,问题一下子就清楚了:真正需要 Flex 动态计算能力(伸缩比例、换行对齐)的容器只在前三层,第四层、第五层的 .icon-text 单纯是"图标挨着文字",套 Flex 纯粹是团队里写惯了 display: flex; align-items: center 这个组合当默认对齐方案,没有人真的用到它的伸缩能力。这种"团队默认习惯"造成的嵌套层级虚高,比业务本身真正需要的布局复杂度更容易被忽略,画一遍关系表比单纯在 DevTools 里目测更容易暴露出这类"图省事" 带来的层级堆叠。

Chrome DevTools 这两年也在往这个方向补工具,Elements 面板选中一个 Flex 容器时旁边会出现一个可以点开的图标,能在页面上叠加显示这个容器的主轴、对齐参考线,但这个功能是针对单个容器展示的,没有一个视图能直接告诉你"当前这条 DOM 路径上一共嵌套了几层 Flex 容器",这也是这次选择手动画表格而不是完全依赖工具的原因——工具解决"这一层具体怎么排列"的问题很在行,解决"这一路嵌套了多少层、哪几层是必要的"这类结构性问题,目前还是得靠人工梳理。

row-reversecolumn-reverse 配合 RTL 语言时要小心的地方

这次详情页项目还有一个背景:产品这边在评估要不要给中东地区的客户单独做一版界面,牵扯到 RTL(从右到左书写)语言的适配问题,虽然这次没有正式排期,但我借着这个由头把 Flex 在 RTL 场景下的几个反直觉行为过了一遍,免得真排上的时候手忙脚乱。

前面圣杯布局那节提过,Flex 的 justify-contentalign-items 这些属性命名故意避开"水平""垂直",用的是"主轴""交叉轴",就是为了兼容不同书写方向。页面一旦通过 direction: rtl 声明成从右到左,flex-direction: row 的主轴起始方向会自动翻转成从右到左,这本身是好事——大部分横向排列的组件不需要为了 RTL 专门写一套镜像样式,flex-startflex-end 会自动跟着语言方向走。

真正容易出问题的是那些原本就写了 row-reverse 的地方。团队里有个消息通知的下拉列表,图标在右、文字在左,当时图省事没有细想直接写的是 flex-direction: row-reverse,在从左到右的默认书写方向下这个效果没问题。但这次测试给页面套上 direction: rtl 之后发现这个下拉列表的图标和文字顺序变成了图标在左、文字在右——因为 row-reverse 本身就是"反转主轴方向",而 RTL 又把主轴方向反转了一次,两次反转叠加,视觉效果直接翻回了正向排列,跟同一个页面里其他没写 row-reverse 的横排组件(它们只经历了 RTL 这一次反转)顺序完全对不上。

排查这个问题花了点时间,因为乍一看每个组件单独测都"正确"——不带 row-reverse 的组件在 RTL 下自动镜像,符合预期;带 row-reverse 的组件在 LTR 下符合预期。只有两种组件放在同一个 RTL 页面里对比着看,才会发现"反转了两次等于没反转"这个逻辑陷阱。这个坑背后的教训是:row-reverse 这类写法在设计初期就该问一句"这个顺序是视觉上天然应该反的(比如时间轴倒序展示),还是单纯为了凑一个视觉效果顺手写反的(比如图标该放右边就该正经调整 DOM 顺序或者用 order)"。凡是后一种情况,更稳妥的做法是老老实实按正常语义方向排列 DOM,需要图标在右就把图标写在 DOM 后面或者用 order 调整,而不是靠 row-reverse 走捷径,因为 row-reverse 携带的"反转"语义在国际化场景下是会被继续反转的,order 则始终是绝对的视觉位置声明,不会受书写方向影响而二次颠倒。

column-reverse 理论上不受 direction: rtl 影响(书写方向翻转的是水平轴,不动垂直轴),这次也验证了一遍确实如此,纵向的 column-reverse 组件套上 RTL 之后表现跟 LTR 下完全一致,这也印证了前面的判断——direction 影响的只是主轴对应的具体水平方向,跟垂直方向的排列逻辑无关。

这次测试还顺带发现一个容易被忽略的连带影响:margin-leftmargin-right 这类物理方向的外边距在 RTL 下不会自动镜像,跟 flex-startflex-end 这类逻辑方向属性的行为完全不同。详情页里有个卡片内边距用的是 margin-left: 8px 给图标和文字留间距,套上 RTL 之后这个间距依然长在左边,而这时候视觉上文字和图标的相对位置已经因为主轴反转跑到了右边,间距留错了方向,卡片看起来贴得很挤。这个问题不是 Flex 布局本身的锅,是 margin-left 这种物理属性和 Flex 的逻辑主轴天生不是一套坐标系——真要做严肃的 RTL 适配,这类间距应该换成逻辑属性 margin-inline-startmargin-inline-end,它们才会跟着 direction 一起镜像,这一点这次只是记录下来,逻辑属性这批新特性当前浏览器支持还不算特别齐整,团队目前的做法是遇到明确要支持 RTL 的组件才单独处理,不做全局替换。

RTL 这一圈测下来,gap 属性的表现反而是最省心的一个:gap 描述的是"子项之间留多少空隙",这个语义天然不区分方向,无论主轴朝哪边,gap 留出来的间距永远等距地夹在相邻子项之间,不需要像 margin-leftmargin-right 那样操心镜像问题,这也是这次决定尽量把间距都迁移到 gap 上的另一层动机——不仅仅是少写一个 :last-child 清零规则,还顺带避开了一整类国际化场景下的方向坑。

这次为了验证这几个反直觉行为,用的办法很朴素:不依赖真的接入阿拉伯语翻译文案,直接在浏览器地址栏对着现有页面开一个本地调试用的样式表,在 html 标签上手动切换 dir="rtl",配合 Chrome DevTools 的 Rendering 面板里也能强制模拟 prefers-color-scheme 之外的一些渲染特性,肉眼对照切换前后的排列差异。这套方法只能验证"方向反转本身有没有把布局搞乱",验证不了阿拉伯语文案本身的排版问题(字符本身从右往左连写、数字混排方向这些是排版引擎处理的事,跟 Flex 布局没关系),这次的范围也只限定在"Flex 布局层面的方向适配",真要做完整的 RTL 支持,字体排版、数字格式化这些还得单独找母语人士或者专门的国际化测试走一遍,这次只是先把布局这一层的地基摸清楚。

横向滚动的标签栏:另一个经典结构,Flex 和传统方案的差异更细微

这次一起改造的还有一个中后台常见结构:一排页签(Tab),数量不确定,页签多了不换行,而是整行横向滚动,同时要求当前选中的页签如果不在可视区域内,切换时自动滚动到可见位置。这个结构以前团队里的实现是页签容器用 white-space: nowrap 配合 display: inline-block 的子项,横向滚动交给 overflow-x: auto 处理,这套方案本身没什么问题,但页签内部如果需要图标和文字对齐、或者页签本身要显示一个关闭按钮,inline-block 处理内部对齐就比较别扭,得靠 vertical-align 去凑。

换成 Flex 之后,页签容器和页签内部对齐两件事可以用同一套机制处理:

1.tab-bar {
2  display: flex;
3  overflow-x: auto;
4  flex-wrap: nowrap;
5}
6
7.tab-item {
8  display: flex;
9  align-items: center;
10  gap: 4px;
11  flex: 0 0 auto;
12  white-space: nowrap;
13}

.tab-bar 保持 flex-wrap: nowrap(这也是默认值,写出来是为了不被后面维护的人手滑改成 wrap),子项 .tab-item 显式 flex: 0 0 auto,表示每个页签严格按自身内容决定宽度,既不参与增长也不参与收缩,多出来的部分交给外层的 overflow-x: auto 横向滚动掉,而不是被压缩变形。这里如果漏写 flex: 0 0 auto,页签数量少的时候看不出问题,一旦数量多到超出容器宽度,子项默认的 flex-shrink: 1 会让每个页签被压得越来越窄,文字被挤得变形,而不是触发滚动条——这是这次真正踩到的一个坑,排查的时候一开始怀疑是 overflow-x 没生效,其实是压根没走到"溢出触发滚动"这一步,子项自己先被压扁了。

自动滚动到选中页签这部分逻辑本身是 JS 计算,跟 Flex 布局关系不大,但有一个衔接点值得记录:计算某个页签需要滚动多少距离,依赖读取这个页签的 offsetLeft,而 offsetLeft 是相对于最近的 positionstatic 祖先元素,.tab-bar 这个 Flex 容器本身如果没有显式设置 positionoffsetLeft 算出来的基准点可能不是预期中的容器本身,而是再往上找到的某个定位祖先,这类计算偏差在页面结构简单时不容易暴露,这次是套进了一个本身有 position: relative 的弹窗容器里才发现算出来的滚动距离不对,最后是显式给 .tab-bar 也设了 position: relative,让 offsetLeft 的参照系固定下来,不再依赖外层容器有没有意外设置定位。这个坑跟 Flex 本身的规则无关,是 Flex 容器恰好被卷入了一个更早期、跟盒模型和定位相关的老问题,但因为这次是在改造页签这个具体场景里冒出来的,一并记在这里。

Flex 和 Grid 分工:这次三栏页面为什么最终还是选了 Flex

Grid 到今年已经是很成熟的方案了,主流浏览器早就跟上,团队里问起"这次三栏详情页要不要干脆用 Grid 定义三列"也是很自然的问题。这次坚持用 Flex 而不是 Grid,判断标准落在一个具体问题上:中间栏和左右两栏之间是不是存在跨行、跨列这种真正意义上的二维关系。答案是没有——整个页面结构从头到尾都是"一条线上排三块,谁固定谁自适应",不涉及任何一个模块需要同时用行号和列号去描述它的位置,这就是标准的一维分布问题,Grid 定义 grid-template-columns: 200px 1fr 200px 当然也能做到同样效果,但引入 Grid 之后,order 这种简单直接的顺序调整就得换成 Grid 的 grid-column 顺序声明,多绕一层概念,对这次单纯的三栏对齐没有额外收益。

反过来这次公司官网落地页倒是新增了一个用 Grid 的场景:首页有一个案例展示区,要求某几个案例卡片跨两列、跨两行展示成大图,其余卡片正常一格一个,这种"部分元素需要显式声明跨行跨列"的需求,Flex 无论怎么嵌套都做不到,flex-basis 只能控制一个子项占多宽,没办法让它同时跨到下一行去和另一列对齐,这次直接用了 Grid 的 grid-template-areas 定义好每块区域的形状,比强行用 Flex 拼凑要清楚得多。这两个案例摆在一起正好构成一组对照:三栏详情页是纯一维分布,交给 Flex;案例展示区有明确的跨行跨列需求,交给 Grid,两者的分工标准不是"哪个更新更好",而是"这次布局本身天然是几维的"。

这次也顺带确认了一件事:Flex 和 Grid 不是互斥选择,公司官网这个页面本身外层用 Grid 划出头部、案例区、页脚这几个大区域,案例区内部具体到某个卡片,卡片自己内部(图片、标题、描述文字的排列)还是用 Flex 处理一维排列,两者按各自擅长的维度分层使用,比试图用其中一个覆盖所有场景更顺手。

还有一类场景这次也拿出来单独讨论过:内容顺序不固定、但要求"每行元素数量根据容器宽度自动调整"的卡片列表,比如商品列表页每行放几个商品完全由屏幕宽度决定,不需要指定某个商品跨行跨列。这种场景乍一看跟前面案例展示区一样是"卡片墙",但因为不涉及任何一个元素需要显式声明占几行几列,用 Grid 的 repeat(auto-fill, minmax(200px, 1fr)) 或者用 Flex 的 flex-wrap: wrapflex: 1 1 200px 都能做出效果接近的自适应网格,这两种写法的细微差别在于最后一行数量不满时的表现:Grid 的 auto-fill 会让最后一行的卡片保持和前面几行同样的宽度(各自撑满自己的列宽),空位就空着;Flex 的 flex-wrapflex: 1 则会让最后一行剩下的几个卡片自动把空出来的剩余空间吃掉、被拉得比前面几行的卡片更宽。这次商品列表页产品明确要求"最后一行数量不满时,卡片保持和其他行一样的宽度,不要被拉伸变形",这个细节直接决定了选 Grid 而不是 Flex,倒不是因为这个场景本身有跨行跨列的二维需求,而是两种方案在"末尾行剩余空间怎么处理"这件事上语义不同,得按实际产品要求对着选,不能想当然认为"自适应换行"这个大类下 Flex 和 Grid 可以随便换着用。

这次几处布局改造下来,越来越觉得"一维还是二维"这个判断标准虽然好记,但真正落地到具体页面时,还得多问一句"末尾不满一行时该怎么表现""是否需要显式声明某个元素跨行跨列""间距和顺序的语义是不是需要跟着书写方向走",这几个更细的追问比单纯的一维二维分类更能决定最终选哪个方案,也是这次几个页面改造下来沉淀得比较扎实的一组具体判断依据。

flex-basis 配合 min-contentmax-content 处理"内容说了算"的场景

这批改造里还有一个之前没细想过的角落:flex-basis 除了写具体像素、百分比、auto 这几种熟悉的取值,还能直接写 min-content 或者 max-content 这两个内在尺寸关键字,这次在页签栏和详情页表单里都用上了,值得单独记一下区别。

页签栏那个场景,某几个页签的文字长度差异很大("全部订单"和"待发货"这种长短不一),产品的要求是页签宽度刚好包住文字,不多留白,也不允许被压缩到文字换行。这种"宽度完全由内容撑起来,别的什么都不参考"的诉求,写 flex-basis: max-content 比写 auto 更精确:

1.tab-item {
2  flex: 0 0 max-content;
3}

auto 在有剩余空间要分配、或者空间不够要收缩的场景下,还是会参与 flex-growflex-shrink 的比例计算,最终尺寸不一定严格等于内容本身的宽度;max-content 则是直接把这个子项的基准尺寸锁定成"内容完全不换行时的自然宽度",配合 flex-shrink: 0 一起写死,就是真正意义上的"内容多宽就多宽,谁都别想改",比之前只会用 flex: none 或者手动量出一个像素值写死更贴近意图,也不用在文案改了之后重新量一遍宽度。

表单那边用到的是另一个方向:一组多选按钮组,要求每个选项的宽度不能小于按钮组里最长文字那一项的宽度(视觉上要对齐成统一宽度),但选项文字长度都不固定,没法写死一个数字。这种"取所有兄弟节点里最宽的那个作为统一基准"的需求,CSS 层面其实没有一个原生机制能直接跨兄弟节点做这种比较,max-content 只能算某一个子项自己内容的最大尺寸,管不到兄弟节点,这次退回的办法还是老实用 JS 量出最长选项的实际宽度,统一设成所有选项的 min-widthmin-contentmax-content 解决不了"参照别的兄弟节点"这类问题,这个适用范围这次算是确认清楚了,不要指望这两个关键字能顶替掉所有需要 JS 测量的场景。

min-content 用得更少一些,这次唯一用上的地方是一个自适应表格里的操作列,要求这一列的宽度不能再压缩到比里面按钮文字还窄(避免按钮文字被挤成两行),flex-shrink 配合 min-width: min-content 能达到"允许收缩,但收缩下限是内容本身决定的最窄宽度",比手动估一个像素值当 min-width 更准确,尤其是这批按钮文案后续还可能因为多语言而变长变短,写死的像素值遇到文案变化就得跟着改,min-content 是让浏览器根据实际内容自己算这个下限,减少了一处需要手动维护的数字。

权限控制导致的字段隐藏,是这批改造里最容易被低估的布局问题

前面表格页那节提过工具栏和分页栏固定、中间滚动这套骨架,这次真正落地到详情页表单的时候,暴露出一个比"要不要滚动"更麻烦的问题:这批表单和表格里有大量字段挂着权限控制,不同角色能看到的字段数量不一样,普通客服看不到成本价、只有主管能看到审批意见、财务角色才能看到发票信息这类字段,都是同一个表单组件根据角色动态渲染出不同的字段子集。原来的做法很朴素,后端下发的字段权限位直接决定这个字段渲染不渲染,不渲染就是整个 DOM 节点都不生成,图省事,出问题之前没人觉得这有什么讲究。

这次踩到的第一个坑是一行三个字段横排的表单行,字段用的是 flex: 1 等比例分配宽度:

1.form-row {
2  display: flex;
3  gap: 16px;
4}
5
6.form-field {
7  flex: 1;
8  min-width: 0;
9}

普通角色只能看到两个字段(比如"客户名称"和"订单状态",第三个"审批意见"因为权限被整个不渲染),这两个字段会自动平分掉原本三个字段的宽度,单看这一行没什么违和感。问题出在页面上下一共有六七个这样的表单行,权限位不完全一致——有的行三个字段全渲染,有的行因为权限缺一个字段,六七行字段宽度对不整齐,同一份表单在主管视角和客服视角下,纵向看每一列字段的左右边界完全对不上,视觉上像是没有对齐的两套表单拼在一起。这个问题在开发环境用管理员账号自测时完全看不出来,因为管理员权限位全开、字段永远齐整,直到测试用低权限账号跑了一遍才发现每行宽度参差不齐,属于典型的"用最高权限自测掩盖了字段数量不定场景下的布局问题"。

排查下来,根源还是回到前面反复出现的判断:flex: 1 这种比例分配的宽度,本质上是"用兄弟节点数量反推自己该多宽",只要兄弟节点数量会变(不管是响应式换行、还是权限决定的动态渲染),这个宽度就跟着变,跟"权限"这个业务动作本身没关系,是布局层面"参与比例计算的子项数量不是常量"这个更通用的问题在这次的具体业务场景里现了形。解决办法不是在权限判断的地方加特殊处理,而是回到布局本身:给表单字段一个基于总列数算出来的固定 flex-basis,而不是让它们互相比例分配:

1.form-row {
2  display: flex;
3  flex-wrap: wrap;
4  gap: 16px;
5}
6
7.form-field {
8  flex: 0 0 calc((100% - 32px) / 3);
9  min-width: 0;
10}

这里 32px 是两条 gap 的总宽(三个字段两条间隙),calc 算出来的固定宽度对应"一行始终按三等分设计"这个产品原意,权限隐藏掉某个字段时,剩下的字段各自还是占三分之一宽度,只是这一行多出一块空白,而不是把剩下字段拉宽去填补空白。这个空白在这次的场景里反而是产品想要的效果——某个字段因为权限缺失,视觉上留白提示"这里本来有内容",比强行把两个字段拉伸得比正常宽度更宽、看起来暴增了信息密度要合理。

这里还有个容易被忽略的细节:flex-basiscalc 写死之后,flex-growflex-shrink 要不要保留为非零值,取决于这一行是否允许在窗口变窄时继续收缩。这次表单大多数场景是收缩优先于换行(屏幕变窄时字段本身变窄,而不是提前换行),保留了默认的 flex-shrink: 1;但审批意见那种文本域字段设了 flex-shrink: 0,避免窗口变窄时文本域被压得太窄导致里面的文字换行频率暴涨,影响阅读。

第二个衍生问题出在表格列而不是表单行。表格的表头和表体各自是一套横向 Flex(早年 float 时代表格列宽靠 <table> 自身的列宽算法,这批表格改造成 Flex 实现之后列宽完全靠 CSS 手动控制),某一列(比如"操作"列的"审批"按钮)因为权限只对部分角色显示,如果这一列直接 display: none,表头和表体各自独立计算宽度分配,两边隐藏的时机只要有一点点不同步(比如表头是一次性渲染、表体是分页异步渲染,权限判断逻辑分别写了两份,其中一处判断条件手滑漏了一种角色),就会出现表头和表体错列,某一列的数据实际显示在了另一列的表头下面。这类问题一旦出现相当隐蔽,因为大多数角色测试时权限判断两处都是一致的,只有极少数特殊角色的权限组合会暴露出两处判断不同步。

这次的修复思路是把"要不要显示这一列"这个判断收敛成一个共享的派生状态,表头和表体的列渲染逻辑都从这个共享状态读取,不再各自维护一份权限判断;同时把列宽也统一用固定的 flex-basis 而不是各自现算,从源头避免了"表头表体各自平分宽度、平分基数还可能不一致"这个问题:

1// 共享的列可见性计算,表头和表体都从这里取,不再各自判断权限
2function getVisibleColumns(allColumns, permissions) {
3  return allColumns.filter(function (col) {
4    return !col.permissionKey || permissions.includes(col.permissionKey);
5  });
6}
1.table-header,
2.table-row {
3  display: flex;
4}
5
6.table-cell {
7  flex: 0 0 var(--col-width, 120px);
8  min-width: 0;
9}

每一列的 --col-width 由这份共享的可见列表统一计算好再下发成 CSS 变量,表头和表体拿到的列宽基数完全一致,不会出现"表头算出来是四列平分、表体因为权限判断晚了一拍还是按五列平分"这种错位。这次的教训是:权限控制字段隐藏这件事,产品和后端通常只关心"这个人能不能看到这个字段",但前端要多想一层——隐藏一个参与弹性布局的子项,会不会连带影响其他子项重新计算宽度、会不会导致依赖同一份权限判断的多处渲染逻辑不同步,这两层影响在纯说明文档里基本不会被提到,都是真正上手改造才会暴露出来。

visibility: collapse 处理"暂时不显示但不想影响兄弟宽度"的场景

顺着上面权限隐藏的问题继续往下看,还有一个更小范围的例外:多选按钮组里如果某个选项因为权限被 display: none 隐藏,剩下的选项同样会重新平分空间,宽度跟着变化,而产品这边希望即使某个选项因为权限被隐藏,其余选项的宽度也保持跟"完整显示时"一样,不要因为少了一项而重新拉伸。

Flex 布局里 visibility: hidden 保留占位但不隐藏交互(键盘依然能聚焦到隐藏元素,这本身也是个需要注意的点),不是理想选择;真正贴合这次需求的是给这个子项设置一个固定的 flex-basis 或者 width 而不是用比例分配的 flex: 1,这样任何一项显示或隐藏都不会影响其他兄弟节点重新计算宽度比例。这次没有用上 CSS 表格布局里的 visibility: collapse(这个值原本是设计给 <table> 的行列使用的,在 Flex 容器的子项上各浏览器实现不一致,效果基本等同于 visibility: hidden,不建议在 Flex 场景依赖它),最终采用的方案还是回到"让每个选项一开始就用固定宽度而不是弹性比例"这个更朴素的思路,反而更好维护,也再次印证了前面反复出现的判断:越是需要精确控制"谁的宽度不受别人影响"的场景,用固定的 flex-basis 或者干脆 flex: none 往往比依赖比例分配的 flex: 1 更省心,弹性分配是有代价的,代价就是任何一个子项的增减都会牵动其他子项重新计算。

权限隐藏叠加 order:视觉顺序和权限判断顺序对不上的坑

前面圣杯布局那节用 order 解决了源码顺序和视觉顺序解耦的问题,这次表单里恰好又用到一次 order,但和权限隐藏撞在一起,暴露出另一个新坑。表单里有个字段区块用 order 手动调整过视觉顺序(把"备注"字段视觉上提前,源码顺序不变,方便这个字段将来要做无障碍朗读优先级调整时不用改 DOM),而"备注"这个字段本身又受权限控制,某些角色看不到。权限隐藏的判断逻辑当时是按源码顺序遍历字段列表决定要不要渲染,order 只影响视觉,不影响这份权限遍历列表的顺序,本身两者互不相干,不该出问题。

真正出问题的是可访问性测试环节:QA 用屏幕阅读器验收时发现,对于能看到"备注"字段的角色,朗读顺序和视觉顺序一致(因为 order 只是运气好地被安排成了和源码顺序一致的语义顺序,前面圣杯布局那节也提过这类巧合),但对于看不到"备注"字段的角色,因为这个字段整个从 DOM 里被摘掉,其余字段的 order 值之间出现了空当,剩下字段的视觉顺序会不会重新排列,取决于其余字段的 order 值是不是连续的。这次因为其余字段的 order 是按 1、2、3、4 连续编号,"备注"字段是 3,权限缺失时直接变成 1、2、4,视觉顺序不受影响,只是数字不连续,没有引发实际问题,但这次专门去验证了一下如果最初编号不连续(比如留了预留位用 10、20、30 这种跳号方式),字段隐藏后 order 之间的相对大小关系不变,视觉顺序依然稳定,order 影响的是子项之间的相对排序,不是绝对位置编号,缺一个子项不会导致其余子项"错位填补",这次算是把这个容易被口头猜测出错的细节坐实了一遍:只要用到 order 又叠加权限动态渲染,测试的时候得覆盖"最少字段""最多字段"两种极端权限组合各看一遍视觉顺序,不能只测最高权限那一种。

按权限角色矩阵回归 Flex 布局,而不是只用管理员账号自测

前面这几处权限相关的坑(表单行宽度错位、表格表头表体错列、order 空当)拼在一起看,其实指向同一个更根本的测试盲区:这批表单和表格组件的自动化测试和日常联调,默认用的都是权限最全的管理员账号,管理员能看到的字段数量是所有角色的并集,天然不会触发"某个子项缺失导致兄弟节点重新计算"这类问题,这类问题只有在字段数量比"完整态"更少的角色下才会显现。以前团队里对权限功能的验收标准停留在"这个角色能不能看到该看的字段、看不到不该看的字段"这个业务正确性层面,没有人把"字段增减是否影响布局"当作权限测试的一部分,因为这两件事分属产品逻辑和样式表现,平时是两拨人在关注。

这次改造完,顺手把布局回归加进了权限测试的检查单里:测试账号不再只挑"管理员"和"普通用户"两个极端,而是从后台权限配置表里挑出实际存在的几种字段数量差异最大的角色组合,分别截图对比同一个表单区块在不同角色下的字段宽度、表格列宽是否跟设计稿保持一致的比例关系,而不仅仅是核对字段本身的显示与隐藏是否正确。这套检查单本身谈不上多复杂,实际操作就是把权限配置表里"字段可见性"这一列按角色分组,取字段数量最多和最少的两组角色,各截一次图,人工过一遍对齐关系,暂时没有做成自动化的像素级比对,工作量不大,但过去确实从来没人把这一步纳入常规验收流程。

这次顺带也理清楚一个判断标准:凡是布局里用了比例分配(flex: 1flex-grow 非零、justify-content: space-between 这类依赖兄弟节点数量或尺寸的属性),只要子项数量存在因为权限、开关配置、异步加载失败等原因动态变化的可能,就该在测试环节至少覆盖"最多子项"和"最少子项"两种边界情况,而不能假设子项数量是从设计稿量出来的那个固定值。这个判断标准不只适用于权限字段隐藏,前面 sticky footer 一节提到的"往内容区插入新的可伸缩区块"、表单那节提到的"多选按钮组因为某个选项隐藏重新拉伸",本质上都是同一类问题在不同业务场景下的变体,回过头看这批改造里反复出现的"某个子项一多一少,布局就跟着变",说到底就是同一条比例分配的规则,换了个业务外壳而已,排查思路不用每次重新想一遍。