前端开发者学堂 - fedev.cn

给CSS加把锁

发布于 大漠

看到该标题,我想你可能会感到非常的意外和好奇,“CSS怎么能加把锁呢”?其实这里所说的给CSS加把锁是指业内所说的CSS Locks(或者CSS calc Lock)。该概念是@Tim Brown在2012年提出的一个概念,该技术其主要致力于解决文本排版可读性相关的技术难度,特别是在实现一些精准的流式布局中的文本排版。在《Web技术10》中也提到过,@Mike Riethmuller、@Tim Brown和@Geoff Graham都在致力于这方面的研究,而且找到了使用CSS锁技术方案来锁定流式排版。让我们实现一个精准的流式排版不再是难题。在今天这篇文章,我们来一起探讨一下CSS锁的故事。

什么是CSS锁

这里所指的CSS锁并非说给CSS加把锁,让程序无法写入CSS代码,所指的是CSS Locks。那么具体什么是CSS锁呢?如果要搞清这个概念,我们要先回顾一点Web相关的知识。

响应式设计

早在2010年@Ethan Marcotte就提出了Web响应式设计(Responsive Web Design)的概念。“响应式设计”简单地说是可以让你的网站从宽屏显示器到手持移动终端(比如手机)都能良好的显式。

这是一种Web设计和开发的方法,它可以让你基于同一套源码为不同的设备终端提供最佳的展示。

熟悉响应式设计的同学都知道:

响应式设计是通过CSS的媒体查询来实现的

开发者借助CSS的媒体查询特性有条件地使用CSS规则,比如媒体查询根据断点(屏幕分辨率节点):

它们会告诉浏览器应该根据用户的设备忽略或应用某些规则:

最终用户在不同的终端会看到不一样的效果:

事实上,现在社区中实现响应式设计采用的大都是该方案,很多优秀的CSS框架都会有一些预定义的断点。不过基于断点来做媒体查询条件判断,特别是在一些CSS框架中预定一些断点,难免有点武断。这好比有点像猜测和妥协一样。我们越想让它工作得更好,需要设计(处理)的东西就越多。这也造就响应式设计基于媒体查询来实现总是令人感到不太优雅,效率也不高。

另外响应式设计在断点之间的切换时,流畅度是有一定的折扣(会闪那么一下),而且在响应式排版中也有着令开发者感到头痛的事情,尤其是文本的排版以及元素间距间的控制。也正因如此,才会有CSS锁的出现。

CSS锁简介

简单地说,CSS锁是一种响应式的Web设计技术,他主要致力于解决响应式设计的文本排版,它允许你根据当前的视窗大小大两个值之间平稳地转换,而不是直接从一个值跳到另一个值。

CSS锁是@Mike Riethmuller在2015年的《Precise control over responsive typography》中首次展示的,但早在2012年的时候@Tim Brown在《Flexible typography with CSS locks》中就提出了该概念,并称之为CSS Locks

@Tim Brown用了实际生活中的一个示例来描述CSS锁。在运河和河流中都会备有船闸用来控制河中水位,也可以在不同水位的水域之间升降船只

用到CSS锁上来的话,就是CSS锁允许你设置最小和最大的字体大小。最小字体大小将应用于最小视窗宽度以下,最大字体大小将应用于最大视窗宽度之上,在最小宽度和最大宽度之间,字体大小将按比例从最小字体到最大字体间按一定的比例缩放:

如果用CSS来描述的话会像下面这样:

/**
 * 1. minf: 最小font-size
 * 2. maxf: 最大font-size
 * 3. minw: 视窗最小宽度
 * 4. maxw: 视窗最大宽度
*/

font-size: calc([minf]px + ([maxf] - [minf]) * ( (100vw - [minw]px) / ([maxw] - [minw]) ));
@media only screen and (max-width: [minw]px) { font-size: [minf]px; };
@media only screen and (min-width: [maxw]px) { font-size: [maxf]px; };

现在我们大致知道CSS锁是什么?那么接下来,我们再花点时间来了解一下,其设计原理。

CSS锁设计原理

接下来通过一些数学计算来向大家阐述CSS锁的设计过程。

CSS中的数学计算和其他程序语言有所不同,在CSS中需要借助calc()函数来做数学计算,有关于该方面的介绍可以阅读《Keep Math in the CSS》和《CSS3的calc()使用》。

视窗单位

先由视窗单位来说起。可能很多同学会和我一样,在做响应式布局时对于文本的排版,比如font-size会采用视窗单位:

h1 {
    font-size: 4vw;
}

期望的是标题在小屏幕有小的字体,大屏幕有大的字体。如果直接使用视窗单位可能会存在两个缺陷:

  • 文本在小屏幕上非常小,比如在320px屏幕下font-size12.8px;在大屏幕时非常大,比如1500px屏幕下font-size60px
  • 它没办法根据用户的喜好来设置文本字号大小

而CSS锁技术旨在解决第一个问题,当然该技术也在尝试着解决第二个问题:遵循用户的喜好来设置字号大小。

CSS锁的计算

前面的内容告诉我们,“CSS锁是一种特定类型的CSS值计算”。比如font-sizeline-height。比如计算font-size的代码:

font-size: calc([minf]px + ([maxf] - [minf]) * ( (100vw - [minw]px) / ([maxw] - [minw]) ));

上面的calc()计算涵盖了:

  • minf:最小字号
  • maxf:最大字号
  • minw:视窗最小宽度(断点1
  • maxw:视窗最大宽度(断点2
  • 在这两个断点之间的实际值是从最小值到最大值之间变化的,该变化是一个线性的变化

比如,我们在低于320px(minw)使用的font-size20px(minf),而高于960pxmaxw)使用的font-size40px(maxf);在断点320px ~ 960px之间采用的font-size20px ~ 40px(minf~maxf),用水位图来描述的话像下面这样:

如果用图表来描述的话,大致像下面这样:

用CSS可以像下面这样描述:

h1 { 
    font-size: 20px;
}

@media (min-width: 320px) {
    h1 { 
        font-size: /* 从20px到40px之间 */; 
    }
}

@media (min-width: 960px) {
    h1 { font-size: 40px; }
}

我们要接受的第一个挑战是真正的实现这个魔幻的值(自动变化的值)。在这里首先引入一个新的值,该值被称为视窗相对值,并且通过calc()来做值的计算:

h1 {
    font-size: calc(20px + '视窗相对值')
}

视窗相对值可以是一个单一的值,比如4vw,也可以是一个更复杂的计算值(也是基于vw或另一个视窗单位计算得来的值)。有关于视窗单位的更多介绍可以阅读《CSS 的值和单位》一文。

由于计算是基于视窗单位的,所以CSS锁有一定的限制。它们只对数字值有效,可以使用calc(),并且可以接像像素值。为什么是像素值呢?那是因为,浏览器客户端最终计算出来的值都是px值。也就是说,视窗单位vwvhvminvmax总是会解析成px。例如,如果视窗宽度是750px,那么1vw就相当于7.5px

在移动端上的一些适配方案,也就是基于这样的原理来做的。比如《再聊移动端页面的适配》中所介绍的技术方案。

从理论上来说,CSS锁对于所有应用数值的属性都应该是有效的,但事实上,它的有用性还是有限的,其中我们不能有效的查询元素,并且将每个元素的动态绑定到媒体查询上是件复杂的事情。为了让事情变得简单化,接下来我们主要聊的相关技术和特性可以运用于font-sizeline-height(或许有一天可以运用于其他的属性,比如widthheight等)以及它们是如何基于px或基于em的断点来构建CSS锁。

特别声明,下面中用到的数学公式来自于《The math of CSS Locks》一文

基于像素断点的CSS锁

先来看基于像素为单位的断点的CSS锁计算。

对于CSS锁而言,我们期望的font-size(或line-height)值在两个断点之间是按比例增长的。而且这种增长是一种线性增长。用图表来表示的话,像下面这样:

上图中红色线其实就是一个简单的线性函数。如果用公式来表达的话,可以是y = mx + b,其中:

  • y表示是font-size(纵轴)
  • x表示的是视窗宽度,单位为像素(横轴)
  • m是函数的斜率(“视窗宽度每增加1px,对应的font-size应该增加多少像素?”)
  • b是在添加任何基于视窗值之前的font-size大小

其中mby = mx + b中是一个不可变的部分。接下来我们要做的是算出mb各自的值。先来看m的值如何计算。

在计算m我们需要两个点的坐标值,从图表上可以看到xy在红线上都会有一个交点,其中每个点的坐标就是(x, y)。比如视窗宽度为320px时,x的值为320,对应y的值为20(即font-size20px);同样的,视窗宽度960pxfont-size40px也有一个对应的坐标,即(960, 40)。加上m其实表示的就是图表中红线的斜率,即可“字体大小增长值与视窗宽度增长值与的比例”。这样一来,我们就可以得到:

m = 字体大小增长值 / 视窗宽度增长值
m = (y2 - y1) / (x2 - x1)
m = (40px - 20px) / (960px - 320px) = 20 / 640 = 0.03125

还可以像下面这样简单的来表述:

font-size增加了20px,即在y轴上的增长值是20(40-20);视窗宽度增加了640px,即在x轴上增长值是640960 - 320)。如果视窗宽度只增加1px,那么对应的font-size增加就是20 / 640 = 0.03125px。也就是m的值为0.03125px

计算出了m的值,那么再要计算出b的值就不是难事了。我们可以根据y = mx + b公式即可转换出b = y - mx,即**b = y - 0.03125x**。

这样我们就得到了mb的值,其实我们也可以使用断点的坐标值来验证,比如断点1的坐标(x1, y1),即(320, 20),套用到公式中:

b = y1 - 0.03125 × x1 = 20 - 0.03125 × 320 = 10

或者用断点2坐标(x2, y2)也可以验证:

b = y2 - 0.03125 x x2 = 40 - 0.03125 x 960 = 10

这样一来,将mb套到公式y = mx + b中的话,线性函数就变成了y = 0.03125x + 10。公式中的y对应的是font-size,那么在CSS中的话,我们就可以通过calc()像下面这样表达出来:

font-size: calc( 0.03125 * x + 10px)

如果就像上面那样使用的话,将是一行无效的CSS代码,因为x不是有效的CSS语法。但在公式中x对应的视窗的宽度,在CSS中可以用100vw来表示,线性函数在CSS中可以像下面这样有效的表达出来:

font-size: calc( 0.03125 × 100vw + 10px)

根据calc()的语法规则,上面的公式可以变得更短一些:

font-size: calc(3.125vw + 10px)

而这个值对于我们来说是期望在两个断点之间的font-size的值,那么使用CSS媒体查询就可以很好的表述出来:

h1 { font-size: 20px; }

@media (min-width: 320px) {
    h1 { 
        font-size: calc( 3.125vw + 10px ); 
    }
}

@media (min-width: 960px) {
    h1 { 
        font-size: 40px; 
    }
}

如果用图表来描述的话就会像下图这样:

不过很多同学可能不太喜欢给font-size使用固定单位(比如px),而是更喜欢采用一些相对单位(比如remem%等)。接下来,我们将上面公式中的px单位换成rem单位。

注意,em%单位的使用和rem类似

要在公式中使用rem这样的单位值,首先要确保的是根字体大小没有被固定值覆盖,即root(也就是html)的font-size不是固定值。我们可以像下面这样设置htmlfont-size值:

html {
    font-size: 62.5%;
}

在用户未显式设置客户端字体大小的话,那么浏览器默认的font-size16px,这样一来:

html {
    font-size: 62.5%; /* 该值相当于.625 × 16px = 10px */
}

如果换成rem的话,62.5% 相当于 1rem,即62.5% ▶ 1rem = 10px,.1rem = 1px。如果将htmlfont-size设置为125%的话(即16 × 1.25 = 20px),即125% ▶ 1rem = 20px,.05rem = 1px

也就是说,将htmlfont-size大小保持不变,即始终为16px。这样的话:

  • 10px ▶ .625rem
  • 20px ▶ 1.25rem
  • 40px ▶ 2.5rem

将上面的值换到CSS锁中:

h1 { font-size: 1.25rem; }

@media (min-width: 320px) {
    h1 { 
        font-size: calc( 3.125vw + .625rem ); 
    }
}

@media (min-width: 960px) {
    h1 { 
        font-size: 2.5rem; 
    }
}

如果我们使用浏览器默认的字体大小,即16px,上面这段代码和前面基于px代码起到的效果是相同的。而我们的目标是能让用户根据自己的喜欢去设置,也就是说,如果用户将浏览器的默认的font-size改成自己所需要的字号,比如说24px(在16px的基础上增加了50%),这个时候上面的CSS锁运行之后,效果可能会如下图这样所示:

上图中蓝色点色表示浏览器默认字号是16px,未连接在一起红色表示浏览器默认字号是24px

从图中可以看出来,在断点1320px)处,font-size大小实际上变小了(从30px跳到了25px),而在断点2960px)处,font-size大小反而变大了(从45px跳到了60px)。整个红线是断层的,不像蓝色点线那样能连接在一起。这样和CSS锁的初衷是不同的。

要解决这个问题,我们可以对所有三种大小使用相同的用户可配置基线。例如,我们选择1.25rem作为用户配置的基线值,这样一来,上面的CSS锁就变成:

h1 { font-size: 1.25rem; }

@media (min-width: 320px) {
    h1 { 
        font-size: calc( 1.25rem + 3.125vw - 10px ); 
    }
}

@media (min-width: 960px) {
    h1 { font-size: calc( 1.25rem + 20px ); }
}

看到代码中的3.125vw - 10px?其实就是前面所说的线性函数(mx + b)相似,只不过这里的b的值有所不同,我们暂且将它称为**b′**,因为我们的基线值等于20px,我们可以像下面这样来计算b′的值:

b′ = b - 用户可配置基线值
b′ = 10px - 20px
b′ = -10px

另一种策略就是一开始选择好基线值,然后再寻找描述font-size增加的线性函数,这里为了和前面的线性函数区分出来,暂且命名为y'(也可以用来区别完整的字体大小y)。

// x轴,表示视窗宽度,x1和x2分别是断点1和断点2视窗的宽度
x1 = 320
x2 = 960

// y轴,表示font-size大小
y'1 = 0
y'2 = 20

// 斜率
m = (y'2 - y'1) / (x2 - x1)
m = (20 - 0) / (960 - 320) = 20 / 640 = 0.03125

b' = y' - mx
b' = y'1 - 0.03125 × x1 = 0 - 0.03125 × 320 = -10 

这样一来,我们得到了y' = 0.03125x - 10(前面的线性函数是y = 0.03125x + 10),如果用图表来描述的话:

这样我们基本值是rem或者是vw还是px都可以得到完全工作CSS锁(在font-size上的CSS锁)。当用户改变他们的基本字体大小时,整个东西就会上升或下降,而不再会中断。如下图所示:

上面我们看到的是CSS锁在font-size上的运用,接下来,我们来看看CSS锁在line-height上的运用。

font-size类似,假设我们在断点1(视窗宽度为320px)处设置的line-height1.4,而在断点2(视窗宽度为960px)处设置的line-height1.8

在《编写CSS时要考虑可访问性》一文中我们可以获知,line-height设置的好坏,直接会影响可读性,设置恰当的line-height在视觉上也更能吸引人。

CSS中的line-height在排版中的运用相对来说是非常复杂的,比如说,不同的单位计算,结果也有相应的差异,如下图所示:

从上图中可以看出,设置line-height的最佳实践是 不带任何单位设置行高的值。有关于这方面的知识,这里不作过多的阐述,感兴趣的可以阅读:

接下来,我们回到我们要聊的CSS锁中。通过前面的学习,我们知道,在CSS锁中会使用一个基础值加上一个以像素表示的动态值,那么在line-height设置中,我们首先要知道是 1.41.8的比率涉及多少像素。也意味着我们需要知道段落(文本)的font-size。假设我们的段落文本使用默认的font-size,即16px,那么line-height的值相应为:

// 断点1,视窗宽度为320px处的line-height
16 × 1.4 = 22.4px

// 断点2,视窗宽度为960px处的line-height
16 × 1.8 = 28.8px

假设我们使用1.4em = 22.4px 作为基线,那么6.4px28.8px - 22.4px)就是增量。这样一来,对应的线性函数:

x1 = 320
x2 = 960

y′1 = 0
y′2 = 6.4

m = (y′2 - y′1) / (x2 - x1)
m = (6.4 - 0) / (960 - 320)
m = 6.4 / 640
m = 0.01

b′ = y′ - mx
b′ = y′1 - 0.01 × x1
b′ = 0 - 0.01 × 320
b′ = 3.2

y′ = 0.01x - 3.2

用CSS来描述的话是:

line-height: calc( 1.4em + 1vw - 3.2px)

特别声明,这里基值使用1.4em主要是为了处理各浏览器下calc()的兼容性,因为使用不带单位的1.4,在所有浏览器中的calc()都不支持,如果使用140%,虽然在Chrome和Firefox中得到支持,但在Safari中不起作用。

那么相应的line-height对应的CSS锁就如下所示:

p { line-height: 1.4em; }

@media (min-width: 320px) {
    p { 
        line-height: calc( 1.4em + 1vw - 3.2px ); 
    }
}

@media (min-width: 960px) {
    p { 
        line-height: calc( 1.4em + 6.4px ); 
    }
}

另外,对于大的值,我们不能只使用1.8em,因为我们需要添加到基础上的部分是用px表示的。也就是说,要是我们直接使用1.8em的话,对于16px的基本字号来说,结果还是不错的,但用户更改了字号的基本值时,结果就有可有不一样了。

我们可以用函数图像来检查它是否适用于不同的基本字号大小:

也就是说,由于line-height计算公式取决于元素本身font-size的大小,如果我们更改font-size大小,就必须更改相应的公式。例如下面这个示例font-size1.66em,不再是16px,那么对应的line-height计算就是:

// 元素的line-height
.big {
    font-size: 1.66em
}

// 断点1,视窗宽度为320px处的line-height
16 × 1.66 × 1.4 = 37.184px

// 断点2,视窗宽度为960px处的line-height
16 × 1.66 × 1.8 = 47.808px

根据前面的运算过程,我们可以得到最终的线性函数公式为y' = 0.0166x - 5.312。这样就可以得到最终的CSS锁:

p { 
    line-height: 1.4em; 
}
.big { 
    font-size: 1.66em; 
}

@media (min-width: 320px) {
    p   { 
        line-height: calc( 1.4em + 1vw - 3.2px ); 
    }
    .big { 
        line-height: calc( 1.4em + 1.66vw - 5.312px ); 
    }
}

@media (min-width: 960px) {
    p { 
        line-height: calc( 1.4em + 6.4px );
     }
    .big { 
        line-height: calc( 1.4em + 10.624px ); 
    }
}

另一个选项是让CSS进行计算。因为在标准段落使用相同断点和相对line-height,所以我们只需要添加一个1.66因子:

p { 
    line-height: 1.4em; 
}
.big { 
    font-size: 1.66em; 
}

@media (min-width: 320px) {
    p { 
        line-height: calc( 1.4em + 1vw - 3.2px ); 
    }
    .big { 
        line-height: calc( 1.4em + (1vw - 3.2px) * 1.66 ); 
    }
}

@media (min-width: 960px) {
    p { 
        line-height: calc( 1.4em + 6.4px ); 
    }
    .big { 
        line-height: calc( 1.4em + 6.4px * 1.66 ); 
    }
}

上面看到的分别是font-sizeline-heihgt在以px为单位断点下的CSS锁。其实我们还可以将他们结合在一起。假设我们的排版中有标题h1和段落p,他们对应的font-sizeline-height分别如下表所示:

元素 属性 断点1(320px) 断点2(960px)
h1 font-size 24px 40px
h1 line-height 1.33em 1.2em
p font-size 15px 18px
p line-height 1.5em 1.66em

按排版本原则来说:“文本变大时(font-size更大),行高(line-height)要更紧凑一点;当宽度变宽时,行高(line-height)要更松散一点”。这样一来,在我们这个场景中,两个原则都使用的话就会相互矛盾。所以我们只会考虑其中一方面:

  • 对于标题h1来说,字体大小(font-size)的增加将比宽度增加更显著
  • 对于段落p来说,宽度的增加比字体大小(font-size)只增加一点,效果会更显著

现在,我们继续基于320px960px两个断点,先来完善font-size的CSS锁:

h1 {
    font-size: 1.5rem; /* 24px / 16px 浏览器默认font-size为16px */
}

/* 0.9375rem = 15px */
p {
    font-size: .9375rem; /* 15px / 16px*/
}

@media (min-width: 320px) {
    h1 {
        font-size: calc( 1.5rem + 2.5vw - 8px)
    }

    /* .46875vw - 1.5px的结果是0 ~ 3px*/
    p {
        font-size: calc(.9375rem + .46875vw - 1.5px)
    }
}

@media (min-width: 960px) {
    h1 {
        font-size: calc(1.5rem + 16px)
    }
    p {
        font-size: calc(.9375rem + 3px)
    }
}

接下来再来完成line-height的CSS锁,相对来说要比font-size的CSS锁更复杂一点。

先从h1开始。我们想给line-height设置一个相对基线的值,所以最低的值1.2em(上面表格所列的最小值),加上元素的字体大小是可变的,所以1.2em将描述一个动态的线性值,该线性值具备下面两个特征:

  • 24 × 1.2 = 28.8px 在断点1(320px)处的值
  • 40 × 1.2 = 48px 在断点2960px)处的值

从表格中我们也知道,在断点1处,期望的line-height的值是1.33em,因此我们可以将该值四舍五入为32px。另外我们希望找到一个线性函数可以来描述“我们添加到1.2em基线的内容”。如果我们从数据点中删除这个1.2em基线值,就会重新得到两数据:

  • 24 × (1.3333 - 1.2) = 3.2px 在断点1(320px)处的值
  • 40 × (1.2 - 1.2) = 0px 在断点2960px)处的值

放到我们前面提到的公式中来计算:

m = (y′2 - y′1) / (x2 - x1)
m = (0 - 3.2) / (960 - 320)
m = -3.2 / 640
m = -0.005

b′ = y′ - mx
b′ = y′1 - (-0.005 × x1)
b′ = 3.2 + 0.005 × 320
b′ = 4.8

y′ = -0.005x + 4.8

最终结果用CSS来描述的话,像下面这样:

h1 {
    line-height: calc( 1.2em - .5vw + 4.8px)
}

同样可以用图表来描述行高和字体大小函数之间的关系:

对于段落p,将使用1.5em作为基线。我们要增加的行高是:(1.75 - 1.5) × 18 = 4.5px

虽然上面的计算都是人肉的在计算,但效果确实是存在的。为了减少人肉计算过程造成的错误,我们可以选择整个过程通过CSS的计算来完成。比如下面这个示例:

@media (min-width: 320px) and (max-width: 959px) {
    h1 {
        font-size: calc(
            /* y1 */
            1.5rem
            /* + m × x */
            + ((40 - 24) / (960 - 320)) * 100vw
            /* - m × x1 */ 
            - ((40 - 24) / (960 - 320)) * 320px
        );
    }
}

可以将上面的代码进一步简化:

@media (min-width: 320px) and (max-width: 959px) {
    h1 {
        font-size: calc( 1.5rem + 16 * (100vw - 320px) / (960 - 320) );
    }
}

如果把行高的锁一起结合进来像下面这样:

@media (min-width: 320px) and (max-width: 959px) {
    h1 {
        font-size: calc( 1.5rem + 16 * (100vw - 320px) / (960 - 320) );
        /* 对于负的斜率,需要对断点求倒数 */
        line-height: calc( 1.2em + 3.2 * (100vw - 960px) / (320 - 960) );
    }
}

基于em断点的CSS锁

首先要说的是在CSS媒体查询中使用em做为断点时,CSS锁的计算会存在很多陷阱。比如说,我们前面的示例中使用m × 100vw(在CSS中类似calc( base + 2.5vw))就会有问题存在。主要因为在媒体查询的上下文中,emrem单位都是一种相对单位,其最终大小都会取决于别人。比如rem就是基于用户代理的基本字体大小,虽然默认情况下是16px,但它可有可能会更小或更大,这主要取决于两个因素:

  • 浏览器或操作系统选项
  • 用户的偏好

这也意味着,如果我们的断点是20em60em,它可能匹配的实际宽度是:

  • 基于font-size16px的场景,20em60em所匹配的是320px960px
  • 基于font-size24px的场景,20em60em所匹配的是480px1440px
  • 如果浏览器默认字体大小更换,那么对应的值也会再次更改

比如我们前面示例中的:

font-size: calc( 3.125vw + .625rem );

如果断点换成em来计算,假设媒体查询中的1em16px,那么CSS锁对应媒体查询中的px换成了em,代码像下面这样:

h1 { 
    font-size: 1.25rem; 
}

@media (min-width: 20em) {
    h1 { 
        font-size: calc( 1.25rem + 3.125vw - 10px ); 
    }
}

@media (min-width: 60em) {
    h1 { 
        font-size: calc( 1.25rem + 20px ); 
    }
}

就上面的代码而言,如果客户端浏览器的默认字体大小为16px的话,上面的CSS锁运行不会有任何缺陷,但是用户将客户端浏览器默认字号更换了,那么就会造成相应的混乱。如果用图表来表示的话,正常的是连续性的,不正常的就会断连:

造成这样的现象主要是我们更改了基本字体大小。因为基于em的断点将移动到更大的像素值。在公式中的3.125vw - 10px值仅适用于特定的像素点(基于像素的媒体查询)。如果更换了场景,那么结果就会差强人意:

  • 320px时,3.125vw - 10px的结果是0px,这是我们计划中也是我们想要的结果
  • 480px时,3.125vw - 10px的结果是5px
  • 960px时,3.125vw - 10px的结果是20px
  • 1440px时,3.125vw - 10px的结果是35px

如果拿320px480px两个断点来比较的话,3.125vw - 10px在两个断点的差值是5px,还能让人接受。但在960px1440px两个断点间,3.125vw - 10px的差值为15px,该差值过于偏大。

基于上述原因,我们如果想在媒体查询中使用em作为断点的话,就需要对CSS锁进行改造,换句话说需要一种不同的技术来实现CSS锁。

插句题外话,在媒体查询中使用pxem还是rem单位,哪个更适合,在社区中也有很多相关的讨论,如果你对这方面的话题感兴趣的话,可以阅读下面这些文章:

我们回到以em为单位的媒体查询的CSS锁计算中来。在以em为断点的媒体查询计算CSS锁,我们需要依赖CSS做大量的计算,在计算中会用到两个变量:

  • 视窗宽度为100vw
  • 在低的断点下,采用rem为单位

将采用的计算公式为:

y = m × (x - x1) / (x2 - x1)

该公式是如何得来的呢?我们简单地来看看。在前面的内容中,我们知道字体大小或行高可以用一个线性函数来描述,比如y = mx + b。在CSS中,虽然可以使用100vw来替代公式中的x,但无法将mb用精确的pxvw值来描述,其中原因是我们采用的是以px为单位的固定值。如果更改基本字体大小,它们将与基于em的断点会不相匹配。如此一来,就需要考虑用别的方式来替代mb,也就是两个数据点(x1, y1)(x2,y2)。在前面的内容中,我们知道如何从线性函数中得到b

b = y - mx
b = y1 - m × x1

将它们放在一起的话,可以得到:

y = mx + b
y = mx + y1 - m × x1

最后的结果,我们可以看到,在整个函数中已经没有b的身影了。同样,将font-sizeline-height的基线值添加进来,从前面的知识中我们可以知道,该值是一个动态值,我们把这个动态部分用``表示,那么整个公式可以是:

y  = y1 + y′
y′ = y - y1

上面的方式式可以继续演进成下面这样的:

y′ = mx + y1 - m × x1 - y1
y′ = mx + y1 - m × x1 - y1

可以进一步优化:

y′ = m × x - m × x1
y′ = m × (x - x1)

根据上面的公式,我们就可以轻意得到m的值:

m = (y2 - y1) / (x2 - x1)

也就是:

y′ = (y2 - y1) / (x2 - x1) × (x - x1)

上面的公式还可以换成:

y′ = 最大值的增长 × (x - x1) / (x2 - x1)

接下来将上面的公式用CSS代码来描述,我们接着回到20px ~ 40px的示例中:

@media (min-width: 20em) and (max-width: 60em) {
    h1 {
        /* 警告: 这还不行! */
        font-size: calc(
            1.25rem /* 基线值 */
            + 20px /* 最大值与基线值的差值 */
            * (100vw - 20rem) /* x - x1 */
            / (60rem - 20rem) /* x2 - x1 */
        );
    }
}

注意,上在代码中的calc()计算中使用相同或不同的值进行行乘法和除法时有许多限制,因此上面的代码在运行时可能会有问题

先回到100vw - 20rem的计算,该计算可以正常,并将返回一个像素值。为什么呢?我们来看一个小示例。如果基本字体的大小是16px,那么视窗的宽度就是600px,结果将是280px600 - 20 × 16 = 280);如果基础字体大小是24px,视窗宽度 为600px,则结果为120px600 - 20 × 24 = 120)。

可能你会问,既然em存在问题,我们为何不考虑采用rem呢?因为rem毕竟在计算的过程是基于根元素html:root来做计算的。这个原理是对的,但我们需要一个CSS单位来引用浏览器的基本字体大小,但这个单位并不存在。在CSS中最接近的只能是在html元素或:root中来做,如下面的代码:

/* 不好的 */
html { font-size: 10px; }

:root { font-size: 16px; }

/* 但在媒体查询的断点我们需要这样来进行管理,比如20rem / 1.25 或 40em / 1.25这样 */
:root { font-size: 125%; }

这样一来,把事情变得更为复杂化。为了让计算变得更简单,接下来在计算中引入无单位的因子计算。先来看理想情况之下的现象。

在理想的情况之下,我们期望60rem ~ 20rem部分解析为像素宽度。这意味着整个(x - x1) / (x2 - x1)结果会分解为0 ~ 1之间的值,在这里,将该值暂且称为 n

那么在基本字体大小为16px,视窗宽度为600px的情况下,我们会得到:

n = (x - x1) / (x2 - x1)
n = (600 - 320) / (960 - 320)
n = 280 / 640
n = 0.475

遗憾的是,上面的公式如果要用CSS的calc()来转换的话,有可能是不能工作的。主要是因为在calc()的除法计算中除数不能带有任何单位。不过我们可以尝试着将除数中单位去除,比如calc( (100vw - 20rem) / (60 - 20) )的结果:

视窗宽度(断点) calc()计算 结果
20em(320px) calc((320px - 16px * 20) / (60 - 20)) 0px
40em(640px) calc((640px - 16px * 20) / (60 - 20)) 8px
60em(960px) calc((960px - 16px * 20) / (60 - 20)) 16px

上面表格的计算是基于基本字体大小为16px(客户端浏览器默认字体大小为16px

视窗宽度(断点) calc()计算 结果
20em(480px) calc((480px - 24px * 20) / (60 - 20)) 0px
40em(960px) calc((960px - 24px * 20) / (60 - 20)) 12px
60em(1440px) calc((1440px - 24px * 20) / (60 - 20)) 24px

上面表格的计算是基于基本字体大小为24px(客户端浏览器默认字体大小为24px

你可能也注意到了,断点20em ~ 60em之间对应得到的线性变化的值是0 ~ 1em之间。该特性我们可以用起来。接下来在CSS的calc()中计算时采用20作为因子数:

font-size: calc( 1.25rem + 20px * n );

上面公式中的n是一个0 ~ 1之间的任意值,但在CSS的calc()中,并无法得到0 ~ 1之间对应的结果。为此,将该值暂时用r来描述。

这里需要注意,r是一个带单位的数值,但在calc()的乘法计算中,其中一个数是不能带任何单位的。也就是说,和r相乘的另一个数是需要不带单位的。基于这个原因,在我们的示例中,我们希望在较大的断点处增加20px20px对应的是1.25rem),所以 示例中采用的系数是1.25

font-size: calc( 1.25rem + 1.25 * r );

上面的代码在CSS中是可以工作了,但要注意r是根据基本字体大小而变化:

  • 基本字体大小是16px,那么1.25 * r的值在0 ~ 20px之间
  • 基本字体大小是24px,那么1.25 * r的值在0 ~ 30px之间

这样一来,我们的CSS锁则变成像下面这样:

h1 {
    font-size: 1.25rem;
}

@media (min-width: 20em) {
    /* (100vw - 20rem) / (60 - 20) 计算出断点 (20em ~ 60em)之间 0 ~ 1rem 对应的值 */
    h1 {
        font-size: calc( 1.25rem + 1.25 * (100vw - 20rem) / (60 - 20) );
    }
}

@media (min-width: 60em) {
    h1 {
        font-size: calc( 1.25rem + 1.25 * 1rem );
    }
}

这与基于pxfont-size锁不同,这一次,当用户将基本字体大小增加50%时,所有内容都会增加50%(基线值,可变部分和断点)。我们得到了一个30px ~ 60px的范围,而不是默认的20px ~ 40px的范围。

按同样的原理,我们可以得到line-height的CSS锁:

h1 {
    line-height: calc( 1.2em + 0.2 * 1rem );
}

@media (min-width: 20em) {
    h1 {
        line-height: calc( 1.2em + 0.2 * (100vw - 60rem) / (20 - 60) );
    }
}

@media (min-width: 60em) {
    h1 {
        line-height: 1.2em;
    }
}

有关于line-height的CSS锁更多的介绍还可以阅读《Molten leading (or, fluid line-height)》一文。

font-sizeline-height结合在一起,CSS锁对应的代码如下:

@media (min-width: 320px) and (max-width: 959px) {
    h1 {
        font-size: calc( 1.5rem + 16 * (100vw - 320px) / (960 - 320) );
        /* For a negative slope, we have to invert the breakpoints */
        line-height: calc( 120% + 3.2 * (100vw - 960px) / (320 - 960) );
    }
}

重构CSS 锁

@trysmudford 对传统的CSS锁做了一些重构,在重构的CSS锁中引入了CSS自定义属性,让整个CSS锁变得更灵活。有关于整个重构的过程可以阅读《Refactoring CSS Locks》一文。最终重构出来的代码如下:

:root {
    --fluid-min-screen: 20;
    --fluid-max-screen: 80;
    --fluid-viewport: 100vw;
    --fluid-bp: (
        (var(--fluid-viewport) - calc(var(--fluid-min-screen) * 1em)) / (var(
                --fluid-max-screen
            ) - var(--fluid-min-screen))
    );
}

h2 {
    font-size: calc(2em + (4 - 2) * var(--fluid-bp));
}

h3 {
    font-size: calc(1.5em + (3 - 1.5) * var(--fluid-bp));
}

h4 {
    margin-top: calc(0.5em + (2 - 0.5) * var(--fluid-bp));
}

hr {
    border: calc(0.1em + (4 - 0.1) * var(--fluid-bp)) solid teal;
}

@media screen and (min-width: 80em) {
    :root {
        --fluid-viewport: calc(var(--fluid-max-screen) * 1em);
    }
}

如果你不想人肉的去处理CSS锁的话,我们可以在工程中使用**postcss-scale**,可以让事情变得更简单:

@media screen and (min-width: 21em) {
    p {
        line-height: scale(21em, 35em, 1.3em, 1.5em, 100vw);
    }
}

编译出来:

@media screen and (min-width: 21em) {
    p {
        line-height: calc(((1.5 - 1.3) * ((100vw) - 21em) / (35 - 21)) + 1.3em);
    }
}

RFS

RFS是Responsive Font Size的缩写,该技术也是基于CSS锁上面做的。比如说:

html {
    font-size: 16px;
}

@media screen and (min-width: 320px) {
    html {
        font-size: calc(16px + 6 * ((100vw - 320px) / 680));
    }
}

@media screen and (min-width: 1200px) {
    html {
        font-size: 22px;
    }
}

在Sass中我们可以像下面这样使用RFS:

.title {
    @include font-size(4rem);
}

编译出来的结果:

.title {
    font-size: 4rem;
}

@media (max-width: 1200px) {
    .title {
        font-size: calc(1.525rem + 3.3vw);
    }
}

下面这张图表可以帮助我们更好的理解RFS是如何调整字体大小的:

有关于RFS更多的介绍可以阅读:

参考资料

小结

自响应式Web设计的出现,对于响应式中的文本排版(后期也被称为流式文本排版或精准排版)都面临着很大的困难。也正因为如此,社区中有很多大神,比如@Mike Riethmuller、@Tim Brown和@Geoff Graham都在致力于这方面的研究,而且找到了使用CSS锁技术方案来锁定流式排版。在该文中我们将CSS锁相关的知识结合在一起,介绍了什么是CSS锁,以及其设计原理。最后希望这篇文章对于你在响应式或流式排版本中有所帮助。如果你在这方面有更好的经验或建议,欢迎在下面的评论中与我们一起共享。