深入css的flex排版原理

移动端伴随flex布局的出现,极大地满足了日常排版需求,已成为前端的标配之一。

前言

在经历过table布局、div布局之后,flex逐渐完善且成为标准。这时候前端排版迎来了一次解放,很多难以书写的布局写法被大大简化了。时至今日,flex早已成为一个基础,无论是功能还是技能方面。它涵盖了日常开发中绝大部分场景。

本文不再讨论flex语法、功能和缩写,直接讲解flex如何实现即算法原理部分。对于应用方面还不了解的请先自行查阅。

准备

和其它普通文档流(如block)不同的是,flex的子节点的顺序比较特殊,其按照css声明的`order`顺序排列,如果没有或相等则降级为节点序。因此第一步是把容器的文档流子节点先排序,结果记为`orderChildren`。

我们叫flex为弹性布局的根本原因是每个子节点都是弹性的,它可以伸缩(grow/shrink),这样势必有一个基准值(basis)作为参考,如此才能在一个基础上决定伸缩情况。那么这个基准值怎么决定呢?

根据规范,我们可得知(进行了一定程度简化,如不考虑min/max限制、竖排版文字、英文单词排版等):

  1. 如果节点定义了`flex-basis`,则使用它;
  2. 如果节点是具有固定宽高比的特殊节点(如img),且`flex-basis`最终是`content`,且交叉轴已知,那么根据宽高比计算后的主轴尺寸就是;
  3. `auto`如果定义了主轴尺寸则使用它,如果没有则降级为`content`;
  4. `content`为内容自适应后的尺寸。

上面自适应是最难的,因为内容是不固定的,且内容本身的布局方式也是变化的。因此自适应需要有个计算内容尺寸有多大的过程(其实即便不是自适应也需要计算下面说的max/min)。

在了解这个过程之前,需要先知道2个基本概念:

  1. 最大尺寸(记max):指节点在当前环境下理想状态的尺寸;
  2. 最小尺寸(记min):指节点在当前环境下被压缩到最小状态的尺寸;

文字描述有点抽象,看图示例:

左边即第1点max,它假设可用空间是无限的,因此文本(或inline节点等)可以在一行内全部排下,即便超过flex父节点(黑边)也无所谓;

右边即第2点min,它假设空间完全不够用,但为了保证内容渲染至少要有单个字符(或英文单词等)的宽度,也不考虑是否超过flex父节点。

回到计算自适应的过程,我将它分为2个大部分:

  1. flex直接子节点的尺寸计算;
  2. 其后续递归子节点的尺寸计算。

先看第1大部分。flex的子节点是个匿名块容器,可认为强制是块级(block/flex,忽略inline-flex)。

当子节点是block时,我们需要结合`flex-direction`(row/column)和递归子节点的类型(dom/text)来计算。它比较简单,在row情况下,先对block的子节点(此时关系层级为flex的孙子节点,下面都将简称孙子节点)进行上述第2大部分的计算,获取到max2/min2,然后看孙子节点的`display`,block则独占一行,inline和文字则尝试理想状态下同行,如此便能获取到max/min的最大值(所有max相比最大,所有min相比最大)。basis最终就是max的值。

在column的情况下差不多,所有block类型的孙子节点独占一行从上到下排列时max/min是累加的,而inline和文字则是取同行里高度最大的那个。

当子节点是flex时,情况和上面block也类似,不过更加简化,因为flex子节点强制块级,因此孙子节点中没有inline更加容易计算。

再看第2大部分。它和第1大部分的区别在于节点可以有inline类型,其余部分一样,整体计算方式类雷同。

至此,我们获取了每个flex子节点的max/min值。

分行

接下来根据每个子节点的情况求假设主尺寸,这步比较简单,根据basis/max/min这3者之间的关系即可,用公式表达为:

hypothetical = clamp(min_main_size, flex_base_size, max_main_size)

伪代码形式为:

hypothetical_main_size:
 if flex_base_size > max_main_size:
  return max_main_size
 if flex_base_size < min_main_size:
  return min_main_size
 return flex_base_size

根据`flex-wrap`的声明,我们要判断容器内布局是单行(nowrap)还是多行(wrap),反向多行(wrap-reverse)和多行相同,只是顺序颠倒。那么怎么判断呢?用上面求得的假设主尺寸将`orderChildren`依次排布,如果一行放不下则另起一行。当然如果声明是nowrap不需要换行(即只有一行),但无论哪种都需要统计每行的假设主尺寸之和。

布局

现在得到每一行的信息了,利用官方文档给定的如下算法:

  1. 确定使用的弹性因子。对所有项的外部假设主尺寸求和,如果和小于flex容器的主尺寸,则算法使用增长因子,否则使用收缩因子。即看用flex-grow还是flex-shrink。
  2. 非弹性尺寸项。冻结,设置它的目标主尺寸为其假设主尺寸。满足任意下列条件即为非弹性尺寸项:
    a. 弹性因子为0
    b. 如果使用增长因子,flex-basis计算值大于其假设主尺寸
    c. 如果使用收缩因子,flex-basis计算值小于其假设主尺寸
  3. 计算初始可用空间。对行上所有项的外部尺寸求和,再被减去flex容器的主尺寸。对于冻结项目来说,外部尺寸是指目标主尺寸;其它非冻结的则为其外部flex-basis计算值。
  4. 循环:
    a.检查每一项。如果所有的弹性项都被冻结了,则可用空间视为分配完毕,跳出循环。
    b.计算剩余可用空间并将它作为上条中提到的初始可用空间。如果未冻结的项的弹性因子之和小于1,则将初始可用空间乘以此和。如果这个值小于剩余可用空间,则设置它为新的剩余可用空间。
    c.根据弹性因子分配可用空间。
    如果剩余可用空间是0
    跳过。
    如果使用增长因子
    计算该项的增长因子占所有未冻结项的增长因子之和的比例。设置该项的目标主尺寸为flex-basis计算值加上比例乘以剩余可用空间。
    如果使用收缩因子
    对未冻结的每项,将其收缩因子和flex-basis计算值相乘,记为缩放收缩因子。求得所有缩放收缩因子的和,然后得出每项缩放收缩因子占和的比例。将项的目标主尺寸设置为flex-basis计算值减去比例乘以剩余可用空间的积。注意,这可能会导致主尺寸为负值,下一步将修正。
    否则
    跳过。
    d.修正最小/最大值违规问题。将每个非冻结项的目标主尺寸按其使用的最小/最大值固定(clamp函数的意思),限制其content-box为0。如果目标主尺寸小于最小值,则为最大违规。反之大于最大值,则为最小违规。
    e.冻结过渡弹性伸缩的项。总的违规数值为上一步中每项调整的综合 ∑(clamped size - unclamped size),即违规差值。如果这个数值是:
    0
    冻结所有项。
    正数
    冻结所有最小违规项。
    负数
    冻结所有最大违规项。
  5. 设置每项的主尺寸为其目标主尺寸。

另外值得注意的是,规范中没有详细提及`orderChildren`的mpb(margin/padding/border),以及递归孙子节点的mpb。直接item的无论单位如何都是考虑在内的;而孙子节点只考虑进min/max尺寸,且必须是固定值,其它如百分比则忽略。

反向

当反向多行时,需要多处理一步,将当前正向多行的内容进行倒序排列,注意单位是行,下面将以row举例。

记正向每行高度列表是`maxCrossList`,行首为相对起点即y=0。统计出递增的高度列表`crossSumList`,即前面所有行高度之和。

然后从末尾开始循环,设一个`count`变量,每次循环结束增加`maxCrossList`对应索引的值。记`source`为`crossSumList`对应索引的值,记`diff`为`count`减去`source`的值,如果不为0,则进行偏移。

示例

<div style={{display:'flex',width:100}}>
  <span style={{flex:'1 1 50',background:'#F00',padding:'0 5'}}>2</span>
  <span style={{flex:'1 1 40',background:'#00F'}}>3</span>
</div>
<div style={{display:'flex',width:100}}>
  <span style={{flex:'1 1 auto',background:'#F00'}}>
    <strong style={{display:'block',padding:'0 5'}}>2</strong>
  </span>
  <span style={{flex:'1 1 auto',background:'#00F'}}>
    <strong style={{display:'block'}}>3</strong>
  </span>
</div>
<div style={{display:'flex',width:100}}>
  <span style={{flex:'1 1 auto',background:'#F00'}}>
    <strong style={{display:'block',padding:'0 5%'}}>2</strong>
  </span>
  <span style={{flex:'1 1 auto',background:'#00F'}}>
    <strong style={{display:'block'}}>3</strong>
  </span>
</div>

我们用这3个简化的例子来看过程,最终效果如下:

3个flex节点按顺序称之为A、B、C,它们很相似,区别在于mpb以及子节点。

A节点的孩子们basis为50和40,但由于首个声明了padding,所以最终是60和40,max同,min是19和9左右(字符2和字符3的尺寸,首个要算padding)。假设主尺寸和basis保持一致。因为60+40正好=100,所以最终他们就是60和40的宽度。

B节点的孩子们basis为auto,且没有width声明,所以降级为content自适应。首个节点的递归子节点多了padding,所以最终max、min和basis是19和9左右。假设主尺寸和basis保持一致,空余100-19-9=72,均分给2个节点每个36,最终他们是55和45的宽度。

C节点的孩子们basis为auto,且没有width声明,所以降级为content自适应。首个节点的递归子节点多了padding但百分比无效,所以最终max、min和basis都是9左右。假设主尺寸和basis保持一致,空余100-9-9=82,均分给2个节点每个41,最终他们都是50的宽度。


最后附上源码:

编辑于 2022-03-09 17:01