CSS 布局
摘要:CSS 布局本质是一个约束求解系统,CSS 声明是约束方程组的 DSL。晦涩之处来自 DSL 的心智负担、依赖的双向流动和各种非线性约束组合。
注意
尝试模型(GLM-5.2)辅助技术写作。该说不说,模型的文风可能很僵需要大改,但 demo 画的是真好看。
CSS 布局的“正论”
从一个不对称现象说起
同样是百分比,width: 50% 多数时候符合预期,height: 50% 却经常形同虚设:
要解释这种不对称,得先想明白一件事:元素的尺寸被"知道",只有两条路:一条是推导——从已经确定的约束里算出来,不需要看内容;另一条是测量——必须去看、去问元素本身才能得到。这两条路对布局方向的选择是决定性的:推导只需要父级的信息,所以自上而下就能完成;测量必须递归下降到最深处的子树找到一个位于末端的确定量,然后再自下而上地收集数据。
把这个区分套到块级元素的默认行为上:默认情况下,页面自身可以看作一个宽度固定、高度无限的容器。一个 display: block 的元素,width: auto 时它的内容宽是这样定的:
块的内容宽 = 父的内容宽 − 左右 margin − 左右 border − 左右 padding这是一个纯算术的约束方程,只用到父宽和它自己声明的盒模型常量,根本不看块里装了什么。即"块占满父宽"不是量出来的,是算出来的。换成数字更直观:一个 800px 容器里的块,无论里面装 1 个字、1 万个字,还是一张 5000px 宽的图,这个块的内容宽永远是 800px。
高度则相反。块的 height: auto 没有这样的算术公式,它等于子内容堆叠起来的总高,而这件事必须递归深入子树才能知道:因为我们只有先算出子树的宽度,才能够去算每一行文字换几行、每张图多高、每个子块多高,一路问到底再汇总回来。
于是宽度走推导,自上而下、一步到位;高度走测量,自下而上、必须等子树算完才累积。回到开头那个现象——width: 50% 锚定的是父宽(已经定),所以生效;height: 50% 锚定的是父高(往往还没定),所以落空。
display
不妨看看三种常见 display 在两个方向上各靠什么确定?
| display | 宽度怎么定 | 高度怎么定 |
|---|---|---|
block(默认 div) | 推导(填满父,算术,不看内容) | 测量(子内容堆叠) |
inline | 测量(随内容) | 由 line-height 决定,落在行盒里 |
inline-block | 测量(shrink-to-fit,见后文) | 测量(内部是块流) |
早期我对 display 的理解不过是一串并列的取值:block、inline、inline-block、flex、grid……记住一个是一个。但如今我理解了,它们其实是两个独立维度的组合:一方是外层(outer),回答"我如何参与父流"——是像块一样独占一行(block),还是像文字一样混在行里(inline);另一方是内层(inner),回答"我的内部怎么布局"——是普通的文档流(flow)、弹性(flex)、网格(grid)、表格(table),还是自带 BFC 的流(flow-root)。这两层彼此正交,可以自由组合,常见的 display 值不过是它们的某个简化写法:
| 写法 | outer | inner |
|---|---|---|
block | block | flow |
inline | inline | flow |
inline-block | inline | flow-root |
flex | block | flex |
inline-flex | inline | flex |
grid | block | grid |
TIP
现代 CSS 已经把这层正交性写进了语法,允许双值写法,比如 display: block flex、display: inline flex。
outer 没什么好说的,重点看inner。inner 维度具体决定了什么?——它决定了元素内部建立什么样的规则环境。flow 建立普通的文档流,flex 建立弹性布局,grid 建立网格。而这个最基础的 flow,又细分为块级(BFC)和内联(IFC)两条流。这个"规则环境"有个正式名字:格式化上下文。
格式化上下文:BFC 与 IFC
格式化上下文(formatting context)——可以理解成一套局部生效的布局规则。关键词是"上下文":它是一种局部的、自洽的规则环境,同一套 CSS 规则在不同上下文里是否生效可以完全不同。
BFC
BFC(Block Formatting Context,块格式化上下文) 管的是"块级盒子怎么纵向堆叠"。一个建立了 BFC 的容器,块级子元素自上而下挨个摆放,相邻块的纵向 margin 会折叠,浮动会参与它的高度计算——这些都是 BFC 内部的规则。
任何块容器,只要不被内部的东西穿透,内部默认就在一个 BFC 里。但有些情况会新建一个独立的 BFC,常见的有 overflow: hidden、display: flow-root(以及 flex、grid)、浮动、绝对定位、contain: layout。BFC 最值得记的性质是"隔离",它带来三个常被当成面试题的效果:
- 包含内部浮动——独立 BFC 会把内部浮动的高度算进自身,父元素不再"塌陷";
- 阻止 margin 折叠穿透——子元素的 margin 不再越过 BFC 边界泄露到外面;
- 不与外部浮动重叠——独立 BFC 会避开兄弟浮动,给它让出空间。
这三件事不是三个互不相干的魔法,而是同一条原理的三个切面:BFC 是一个封闭的布局单元,内外互不干扰。所以遇到需要清浮动、或防 margin 穿透的场景,给容器加一句 display: flow-root(专为"无副作用地建 BFC"设计的值)通常就够了。
IFC
IFC(Inline Formatting Context,内联格式化上下文) 管的是"内联内容怎么横向流动"。一个只装着文字、<span>、<button> 这类内联内容的块容器,内部就是一个 IFC。
它的核心是一个叫行盒(line box)的结构:IFC 把内联内容从左到右排进一条水平的行盒,排到边缘放不下下一个单元就换行、开一条新行盒,所有行盒再自上而下堆叠,构成这个 IFC 的总高度。行盒里装的是一个个原子单元(atomic inline-level box)——一个英文词、一段 <span> 文本、一个 <button>、一个 inline-block、一张图,在内联流里地位完全相同,都当作一个不可拆的整体。换行算法只认原子单元和它们之间的软断点(词间的空格、CJK 字符、原子盒的边界),在哪能断、在哪断不了,全由软断点决定。没有软断点的连续串(一个长英文词、一个长 URL)就断不开,只能溢出。
把 BFC 与 IFC 两点合起来,整个页面的布局结构就是它们的交替嵌套:一个块容器(BFC)里装着内联内容(IFC),IFC 里的某个 inline-block 内部又是 BFC,那个 BFC 里又可能装着 IFC……如此递归,直到叶子。
为什么 CSS 看起来这么晦涩
有了心智模型:约束怎么流动、尺寸靠推导还是测量、格式化上下文在哪执行。现在尝试用它去解释 CSS 里那些让人困惑的行为。
横向超宽
前面说宽度走推导、自上而下分配,那横向滚动条从哪来?通常是因为分配有一个隐含的下限:min-content。
一个元素再怎么被父容器往窄里挤,也压不过它的 min-content——"最小不可压缩宽度",由内容里最长的那个不可断单元决定(最长的英文词、最宽的图片、一个 nowrap 的片段)。分配时,一旦内容的下限大于从父容器那里继承来的宽度上限,元素就溢出父容器。
溢出 ≠ 滚动条
默认 overflow: visible 下,溢出的内容看得见(会伸出盒子之外),但并不会产生滚动条;只有把 overflow 设成 auto / scroll,或者溢出一路传到视口、再无去处时,才会冒出滚动条。
所以横向溢出问题只需要看两个方面:究竟是内容把它撑开的,还是它自己声明要那么宽的?
内容撑开
内容里某个 IFC 的“原子单元”,它的 min-content 超过了容器:
- 长 URL、长英文词,默认是
overflow-wrap: normal。修复:overflow-wrap: break-word,允许长词内部截断。 white-space: nowrap/pre,内容被强制成一行,整段变成一个不可断单元。- flex 子项,默认
min-width: auto,意即它至少要占 min-content 宽且不会被flex-shrink压缩到更低,所以一个长内容的 flex 子项能把整行撑爆。修复:min-width: 0。 - grid 轨道的
1fr实为minmax(auto, 1fr),和 flex 子项同理,那个auto下限正是 min-content。修复:minmax(0, 1fr)。
元素声明
这类更直白,是显式值或单位本身算超了:
box-sizing: content-box(默认)下,width: 100%只管内容区,加上 padding、border 后实际就超过 100%。这是"width: 100%怎么还会溢出"的头号原因。修复:全局box-sizing: border-box。width: 100vw含滚动条宽度——vw连滚动条一起算,而100%不含,所以100vw总比可视区宽一截。修:用100%,或新的100dvw。- 显式
min-width: 1200px在小屏上直接顶爆(min-width优先级高于width和分配宽度)。 - 此外还有负 margin 把元素拉出去、绝对 / 固定定位逃逸出容器等。
总结一下,横向的宽度同时受三股力量约束:内容给的下限(min-content)、父容器给的上限(分配宽度)、自己声明的 width。溢出只会从两端发生——要么下限顶上去(内容撑开),要么声明超过了上限(自己要太宽)。修复方案通常就是降低下限、提高上限、允许换行。
纵向不定高
横向的痛是"塞不下",纵向正好反过来——高度无限,永远塞得下,所以纵向很少溢出(溢出是常态,往下滚就是了)。它常见的问题是“测不准”:百分比高度不生效、两个元素的间距和算的对不上、想撑满视口的容器死活撑不满。根源在于,高度走测量、自下而上收集,而"自下而上"意味着很多量在你需要它的时候还没定下来。
百分比高度失效
开头就提到的“bug”,衍生出一个经典问题:想让一个容器撑满视口,子要 height: 100%,父也得是确定高度,爷爷也得是……一路到 html,任何一环是 auto,链就断了。现代解法想要绕开这条链——用 min-height: 100dvh 直接锚定视口、用 flex 的 flex: 1 或 grid 的 1fr 吃掉剩余空间、或用 position: absolute; inset: 0 让绝对定位撑满包含块。
TIP
其实横向也可以形成这种锚定失效,比如表格的列宽,取决于其单元格内容最小宽度,单元格可以设置一个百分比宽度来构造循环。浏览器也会先将百分比看作auto切断依赖链再收敛求值:
<td>
<div style="width: 50%">Some text</div>
</td>margin 折叠
另一类常见问题,刚刚在BFC章节也提过了:两个上下相邻的块级元素,各自的纵向 margin 不会累加,而是重叠取较大的那个;父子之间还会"穿透"。
更诡异的是这是纵轴独有的现象,横向 margin 不会发生折叠。要防止纵向产生折叠,得让当父子之间、或相邻块之间存在"阻隔":除了 BFC,还可添加 border 或 padding。现代 CSS 修复首选 display: flow-root,可以干净地建一个 BFC。
视口单位的坑
100vh 在移动端会偏高:vh 基于理想视口,把浏览器地址栏那块区域也算了进去,底部内容被遮。修复也是锚定视口:100dvh(dynamic viewport height),它会跟着地址栏的伸缩变化。
根因:双向依赖和析取规则
把横纵向各自的现象列在一起,有一个体悟:CSS 布局的很多规则之晦涩,来自两个叠加的困难——约束的双向流动(height 依赖成环)和布局规则本身的各种非线性(内容宽度 min/max 析取)。前者让布局不能一遍算完,后者让每遍的结果都可能跳到不同分支,需要多遍 reflow 才能收敛到最终值:
依赖环(拓扑):方程互相引用,双向流动会变成自指方程(A = f(B)、B = g(A),代入后 A = f(g(A))),不能一遍代入消解,得找到一个突破口(“终结符”),引入新的方程以类似消元的方式逐步迭代到不动点。
析取(代数):CSS 布局的核心操作(min-content、max-content、margin 折叠的 max()、shrink-to-fit 的 min(max())、flex 的 min-width: auto)全是 min / max,数学上叫分段线性——每段内线性,但走哪段取决于值本身,违反叠加性,是非线性的。
两者本身就够困难的了,叠加时更为棘手:自指方程里嵌着析取,每轮迭代 min/max 可能跳到不同分支,求解器的收敛性无法保证。
理想情形是可预测的单向流:父把约束(宽度)下发,子在约束内算出自己的尺寸返回,父再据此摆放,子不回头去改父的约束,一遍就够。高度也无非是一个”冒泡-捕获”过程就完成。由此我们可以产生一个认识:布局时应主动切断反向流,强制依赖单向流动,避免弹性协商等需要多轮 reflow 的属性,防止问题归约的过程太长,或者超出开发者能推演的复杂度。
方法论:怎样写出可预测的布局
把布局依赖改成单向
总策略只有一句话:不主动引入需要协商的约束(指那些会成环或触发多轮 min/max 的声明)。CSS 默认允许双向,但我们完全可以把自己限制在它的单向子集里——让宽度始终自上而下给定,让高度要么显式、要么可预知,让子元素绝不回头去改父的约束。具体表现为三条规则:
- 容器宽度永远来自外部。用固定 px、百分比、flex basis 或 grid 轨道,让宽度是个已知量;别用 shrink-to-fit(
float/inline-block/table/fit-content)做主宽度; - 高度用显式来源。用显式值或设法在渲染前预计算。特别的是混杂着多行文本、emoji、超链接和内联按钮的复杂 IFC 的高度计算,以前很难做到,现在可以借助 pretext,马上我们就会介绍它;
- 在需要预计算的地方避开弹性协商。
flex-grow、fr这些是多轮联立求解,结果依赖兄弟元素、不到最后定不下来。需要可预知尺寸时,改用能从已知量推导出来的来源——固定值、锚定父宽的百分比、子内容累积等;
把 reflow 限制在局部
封口的思路是让一棵子树对外声明"我自洽,内部的变化不外泄"。这样一来,子树内部无论怎么 reflow,都不会越过边界去搅动外部布局;引擎也能据此跳过这棵子树的重算,把失效范围截断在封口边界内。CSS 把这个能力做到了标准里,新出的contain属性值得了解。
contain:声明子树自洽
contain 告诉浏览器"这个元素的某些方面独立于外部",让浏览器可以据此做隔离优化。常用几个值:
contain: layout——元素内部的布局变化不影响外部,外部布局变化也不波及它内部。给一个独立组件加上,它的 reflow 就被关在了自己盒子里。contain: paint——元素的绘制不溢出自身边界(类似裁剪),常和 layout 一起用。contain: style——隔离 counters、quotes 等样式影响。contain: content——layout + style + paint的组合,是最常用的"安全隔离"档位,副作用小,日常推荐。contain: strict——在content基础上再加size,隔离最彻底;但size意味着元素尺寸不再依赖内容,必须自己给一个确定尺寸,否则可能塌成 0。所以strict要慎用。
理论与实践:pretext 的妙用
Talk is cheap,刚刚我们提到了前不久很火的库 pretext,看看这个库结合这套心智模型能带来怎样的化学反应。
案例一:浮动图片
先看一个文字环绕图片的例子:一段长文字中间浮着一张图片,用户能拖动它,文字要实时绕着图片重排。传统 CSS 借助float和shape-outside可以实现贴边环绕,也能跟随不规则形状,但要去实现图片在文字中间,还要任意拖拽浮动就很麻烦了。其核心难点是什么?显然是文字宽度的不确定性,我们可以轻松拿到图片的位置和宽高,如果还能较为精确地拿到一个“最小文字单元”所占的宽高,就可以不受float的桎梏,将原本一段文字的布局规约为若干行原子单元基于容器宽度和图片位置、宽高的简单计算(未尝不是一种约束求解)。
pretext 是什么,给它确定的文本、字体、一个宽度,就能算出换了几行、每行放哪些字、总高多少。从原理上说,它先用 Intl.Segmenter 把文本切成一个个不可断的原子单元(词、CJK 字、空格、标点……),再用 Canvas 的 measureText 量出每个单元的宽度并缓存——这一步只在预处理时做一次,而且 measureText 只测量,不需要字画出来。之后任意调整容器宽度,换行都只是拿这些缓存宽度做纯算术得到行数,高度则直接用"行数 × lineHeight"。
这意味着什么—— 文字是流动的,在宽度自上而下,高度自下而上的理念下,知道文本各原子单元所占宽度,意味着我们在高度底层找到了一个“终结符”,意味着可以把宽高联系起来,从确定的、分得的容器宽度计算出文本要多少行,进而算出文本总高度,更进一步在中间均没有层级打破三大原则的情况下,可以向上累积出任意子树所占高度,各种布局都可以规约为若干约束求解问题。当然,measureText 相比起渲染为真实 DOM 再测量有一些误差,但实测精度已经足够高,而且库也帮我们处理了不少边界情况。
其实最早为了 pretext 这点醋写的这篇文章
回到这个场景,某一行文字的可用宽就是"容器宽 − 图片在这一行占的宽",能放下哪些文字借助 pretext 计算。图片怎么挪动都行,每一行照算不误,文字自然从两侧绕开;拖动时无非是在新位置重跑一遍逐行 layout,纯算术运算,非常流畅。
案例二:动态字号
也是一个 CSS 常规解法有点麻烦的例子:一段固定文字标题,容器宽度可收缩,要求收缩时字号跟着缩小,直到临界值(12px)才允许换行。
常规解法,一种是用clamp() ,让字号随宽度收缩,但它只是线性映射,很难判断文字什么时候该溢出——可能字号还没到下限就提前折了,也可能缩过头已经溢出还在缩。另一种是容器查询,也不精确,因为断点往往是人为定下的一个非常技巧性的值。
有了 pretext, 问题退化成了简单的约束求解。先量出文字在「每个字号 × 每个宽度」下的真实宽度,逆向找当前宽度下"单行还装得下的最大字号"。这个字号 ≥ 下限,就单行显示;≤ 下限,就换行。
案例三:约束求解布局——把"想要的样子"列成方程
前两个案例只是热身,主要展示一下 pretext 的功能。这个例子我们来把这些理念真正运用到布局上去。布局要处理的很多,不只是文本——元素之间的间距、对齐、优先级冲突,这些关系谁来管?CSS 的 flex/grid 是一种答案,但它们是"隐式约束求解":我们调用属性(gap/justify/flex),规则隐藏在浏览器底层,一些复杂的约束,需要我们先对齐到 CSS 所提供的属性,再由引擎求解和布局,这个对齐的过程很痛苦——要记忆各种属性的特征和行为,属性的组合还不能太 hack,否则很可能在未来某一天成为击溃样式的元凶。
pretext 填补上关键的一块砖之后,我们现在可以换个思路:把布局要满足的条件显式列成方程,交给约束求解器求几个基本属性值(x/y/w/h等)。 这里我们用上了一个叫做 cassowary.js 的库,这是一个增量单纯形求解器,适合处理线性约束系统,也就是所有约束都可以用线性等式或不等式表示,UI布局中绝大多数约束(边距、对齐、宽高比、居中等)都符合这一特点,最重要的是它支持约束区分权重,当约束冲突时,求解器不是报错,而是主动违反低权重约束,找到总体违反量最小的最优解。这非常适合表达诸如“文本必须全部展示、最好不换行和溢出”、“弹窗不超出屏幕边界,最好贴在鼠标位置”等场景,据说苹果 iOS 和 macOS 上 Auto Layout 系统的核心算法也是它。
假定我们在设计一个自适应卡片(标题 + 多行正文 + 两按钮),容器宽度可拖。每个元素的 x、y、宽、高都是一个变量,它们之间的关系是一组线性方程:title.y = padding、body.y = title.y + title.h + gap、btn2.x = cw − padding − btn2.w……加一个约束就是加一些等式或不等式,让求解器统一解出所有变量的值。
为了避免混乱,我们把常见约束归为五类,按顺序梳理:
- 尺寸(宽高);
- 容器关系(padding、百分比、不超出);
- 兄弟关系(堆叠顺序、间距);
- 对齐(居中、等宽、对齐线);
- 优先级(required 必须满足、strong 尽量满足、medium 优先级中等、weak 只是建议)。
作为演示,我们要求文字底部有所留白。容器宽度收缩导致文字换行增多时,留白跟着被挤压;但留白有个最小值,压到这一程度后,文字开始溢出省略。
把这套要求列成约束,绝大部分都是线性的——标题钉顶、正文跟在标题下、按钮贴底、右按钮靠右,尺寸 / 容器关系 / 兄弟 / 对齐全是等式或不等式。唯一卡住的是正文部分:它占多高取决于换了几行,而行数是宽度的阶梯函数(宽度连着缩,行数却只在几个阈值跳变),这种分段非线性求解器表达不了。因此需要 pretext 先把当前宽度下的行数量出来、折合成常量 totalTextH 喂进去,求解器只在"显示多高文字 shownTextH"这一个连续变量上做取舍:shownTextH ≤ 正文区 − 最小留白(不溢出,required)、shownTextH = totalTextH(不截断,medium)。空间够时 medium 守住全文;放不下时 medium 让步、文字开始截断。底部留白不另设约束,它是 shownTextH 取整成实际行数后、"正文区 − 已画出的整行高度"反推出来的余量——连续解与整行之间那一脚取整的差,全归到留白里。
读者可以拖拽滑块、勾选按钮,观察约束方程组怎么随之变化:
小结:依赖环与析取规则
案例三恪守了"宽度外部给定"的纪律——容器宽由滑块确定、卡片高度固定,其他根据规则推导,同时也充分展现了另一个难点:行数是随宽度跳变的阶梯函数,需要靠 pretext 量成常量喂给约束系统。cassowary 的舒适区是无环 + 线性——求解器在这种约束上保证找到最优解。但它:
- 对析取:cassowary 的 strength 机制(required > strong > medium > weak)是线性松弛——把"A OR B"的析取近似成"prefer A (strong)、允许 B (weak)"的优化。从而能优雅处理"按钮宽尽量 70、容器窄时自动缩"。
- 对环:cassowary 完全处理不了——约束图有循环时,求解过程会退化或报不可满足。这是我们恪守单向依赖规则的理由。
由此得到一个最终的落地图景:
| 难题 | 谁解决 |
|---|---|
| 依赖环 | 方法论:宽度外部给定、封口隔离、不引入弹性协商——整个布局过程遵守约束的单向流动规则,减小问题规模和心智负担 |
| 析取规则 | cassowary 的 strength:线性松弛,把"A OR B"近似成"prefer A、允许 B"的优化 |
| 须测量的量(文本尺寸:cassowary 难以处理的阶梯函数,有时也是依赖环的突破口) | pretext:测出来当常量喂给求解器 |
总的来看,我们做了什么?我们没有再把想要的布局转译为某几个特定的 CSS 属性,而是回到了有点像是纯px计算布局的模式,但约束更为灵活,求解完全自动化。CSS 的布局引擎本质上也是一个约束求解器,各种 CSS 声明其实是约束语言的 DSL,引擎接收我们的 CSS 声明,转译为约束,求解出每个元素的尺寸和位置。只不过这个求解器是隐式的、黑箱的,我们只能通过调属性间接操控它,遇到复杂要求时还得在脑海中完成从 DSL 到约束不等式的转换。DSL 的所有缺点都可以在 CSS 属性身上找到影子。案例三做的事情,无非是把这个黑箱打开看了下:哪些约束是正向的、哪些是反向的;哪些尺寸可以推导、哪些必须测量。然后用 pretext 和 cassowary 试着把一部分求解过程拿到台面上,自己列约束计算。这个例子稍显刻意,我们肯定也不会在简单布局上用到求解器,但它启发了我们,可以在一些复杂的约束场景,抛开杂乱无章的 CSS 属性,直接站在约束方程组的角度去理解布局问题。