Things I Learned (CSS)

Digit Display🔗

• CSS

在计时展示的时候,经常会遇到这样的问题:因为不同数字实际的“宽度”是不同的,因此在数字跳动的时候,整体的宽度会时常变化,无法对齐。

一个常见的方案,是将数字的字体设置成等宽字体(monospace):

.digits {
  font-family: monospace;
}

但是这样的方案会导致数字部分的字体和其他地方的字体出现出入,视觉上可能出现不统一的情况。

CSS 提供了一些属性来微调字体展示,从而让数字展示的时候不出现宽度变化:

  1. 第一个方案是使用 font-variant-numeric 属性,并设置值为 tabular-nums。根据 MDN 介绍,tabular-nums 会将字体设置成“等宽”的样子(占用的空间是等宽的,但是每个字本身并不一定是等宽的)。对应到 OpenType 中就是 tnum,相关的介绍可以参考这里;
  2. 第二个方案是使用 font-feature-settings 属性,并设置值为 tnum。根据 MDN 介绍,这是一个更底层的属性。效果其实和 font-variant-numeric 是一样的,且因为本身底层,并不建议直接使用。

需要注意的一点是,这一属性仅针对数字生效,对于小数点或是英文数字,并不生效。下面是一些实际的效果:

  • 3.14 <= 正常数字的展示效果
  • 3.14 <= tabular-nums 展示效果
  • 2.72 <= 同上,数字部分可以保持对齐
  • 1234 <= 对小数点无效,并没有对齐
  • wave <= 对英文字母无效,并没有对齐

prefers-reduced-motion🔗

• CSS

前庭系统(Vestibular System)位于人的内耳,对于人的运动和平衡能力起关键性的作用(来源)。一般常见的晕动病(Motion Sickness)就与前庭系统有关:当人眼所见到的运动与前庭系统感觉到的运动不相符时,就会有昏厥、恶心、食欲减退等症状出现(来源)。这其中,就包括了看网页上的各种动画引起的身理上的不适。需要注意的是,除了前庭系统受损外,随着年龄的增长,器官功能本身也在衰退,这些都有可能造成晕动病的症状。根据 vestibular.org 给出的数据,在美国,年龄四十及以上的成年人中,至少有 35% 的人受前庭系统疾病的困扰。显然,这不是一个小众的问题。

在各类操作系统中,都有类似的配置来减少动画,以减轻使用者的负担。比如:

  1. Windows 10 可以在 Settings > Ease of Access > Display > Show animations 中配置;
  2. MacOS 可以在 System Preferences > Accessibility > Display > Reduce motion 中配置;
  3. iOS 可以在 Settings > General > Accessibility > Reduce Motion 中配置;
  4. Android 9+ 可以在 Settings > Accessibility > Remove animations 中配置。

(完整的设置列表可以参考 MDN 列出的数据)

然而,这些是系统层面的设置,对应的是系统的一些行为。在 Web 中,可以通过 prefers-reduced-motion 这个媒体选择器来获取当前系统配置的信息。这个选择器可能的值分别是:no-preference 和 reduce,其中后者表示用户进行了减少动画的配置。

一个简单的使用例子:

@media (prefers-reduced-motion: reduce) {
  .something {
    animation: none;
  }
}

如此,在一般的浏览器中,.something 元素可以有一些动画效果;但是当用户配置了减少动画之后,就不再显示任何动画效果。和 Dark Mode 的配置类似(对应的笔记见这里),除了 CSS 之外,也可以从 JavaScript 和 HTML 的层面响应这一媒体选择器:

const reduceAnimation =
  window.matchMedia('(prefers-reduced-motion: reduce)').matches;
<picture>
  <source srcset="static-image.jpg" media="(prefers-reduced-motion: reduce)">
  <img src="eye-catching-animation.gif">
</picture>

浏览器的兼容性可以查看 Caniuse。

注:可以把这个媒体选择器看作一种渐进增强的功能,浏览器的适配情况不必太过在意。


CSS Typed Object Model🔗

• CSS

在 Houdini 实现的过程中(什么是 Houdini?),Chrome 已经在 66 中已经实现了一部分 CSS 样式的 Typed Object Modal 支持(支持的列表可以参考这里)。

实现之后,在 JavaScript 中就可以通过 window.CSS 对象上的各类属性 API,生成指定类型的 CSS 属性值。看一个简单的例子:

在以前的实现中,往往需要这么写代码:

const fontSize = +(element.style.fontSize.replace('px', ''));
element.style.fontSize = `${fontSize * 2}px`;
element.style.opacity = 0.1;

这样写,会存在几个问题:

  1. 读取和设置带单位数值的时候,需要在字符串和数字之间进行转化;
  2. CSS 的属性名称是用 - 连接的,但是在 CSSStyleDeclaration 中却需要写成小驼峰的形式(font-size 变成 fontSize);
  3. 如果设置违法的值,代码会默默失败,没有任何错误提示;
element.style.opacity = 0.1;
// no error! not success!
element.style.opacity = '?';
// output: 0.1
console.log(element.style.opacity);
  1. 即使设置的属性值是数字,但是实际拿到的时候,值又变成了字符串

如,上例中的 element.style.opacity,虽然设置的值是 1,但如果运行 typeof element.style.opacity 结果却是 string

element.style.opacity = 0.1;
// output: string
console.log(typeof element.style.opacity);

如果试图直接进行运算,则可能得不到预料中的结果。比如,下面的输出依然是 0.1 而不是 0.6,因为 element.style.opacity += 0.5 的结果是 0.10.5(字符串拼接),作为一个非法值,直接被浏览器抛弃了(见第三点)

element.style.opacity = 0.1;
element.style.opacity += 0.5;
// output: 0.1
console.log(element.style.opacity);

有了 CSS Typed Object Model 之后,代码可以改写成这样:

const fontSize = element.attributeStyleMap.get('font-size').value;
element.attributeStyleMap.set('font-size', CSS.px(fontSize * 2));
element.attributeStyleMap.set('opacity', 1);

不难看出,这样的写法,基本解决了上面提到的几个问题:

  1. 读取和设置带单位数值的时候,不再需要手动进行字符串和数值的转化。CSS.px 这个函数可以将数值转化成一个带单位的对象,用于给 attributeStyleMap 赋值。另外,由于这个值 toString 之后就是类似 16px 的字符串,因此也可以直接给 element.style.fontSize 进行赋值。同时,从 attributeStyleMap 中拿到的数据,也是带单位的对象,对象中的 value 就是数值,unit 是字符串,表示单位,不再需要手动解析;
  2. attributeStyleMap 的属性名称和 CSS 的属性名称是一致的,不需要像以前一样在 JavaScript 中手动改成小驼峰的写法;
  3. 如果设置了违法的值,代码会报错:
try {
  element.attributeStyleMap.set('opacity', '?');
} catch (e) {
  console.log(e);
}

以上代码会输出报错:TypeError: Failed to execute 'set' on 'StylePropertyMap': Invalid type for property。

  1. 应该是数值的结果,拿到的时候也是数值,而不是字符串(因此数值计算也不会出错):
element.attributeStyleMap.set('opacity', 1);
// output: number
console.log(typeof element.attributeStyleMap.get('opacity').value);

当然,这里如果这么些,结果依然是数字:

element.attributeStyleMap.set('opacity', '1');
// output: number!
console.log(typeof element.attributeStyleMap.get('opacity').value);

另外,使用 CSS Typed OM 还有一些其他额外的好处,比如,浏览器不需要序列化和反序列化结果,因此性能更好(一个简单的性能检测可以查看这里,大概有 30% 左右的提升)。

更多更详细关于 CSS Typed OM 的介绍,可以参考 Google 的这篇 Blog。

P.S. 目前,其他的浏览器支持情况依然不理想,可以参考 Is Houdini ready yet? 网站上最新的支持情况了解详情。就实际情况来看,可以在 Electron 3 (基于 Chrome 66,见这里)或以上版本使用,但暂时不建议在 Web 项目中引入。


shape-rendering🔗

• CSS

在浏览器渲染 SVG 的时候,可以通过 shape-rendering 这一属性,来控制浏览器对 SVG 抗锯齿效果的展示。shape-rendering 支持从三个纬度来权衡 SVG 的渲染效果,这三个纬度分别是:速度、曲线精细度以及曲线的锐利程度。

  • auto,这个是默认值,表示由浏览器来决定改如何显示
  • optimizeSpeed,顾名思义,这个要求浏览器以渲染的速度优先,抗锯齿可能会被浏览器关闭
  • crispEdges,这个选项要求浏览器以曲线的锐利程度为第一优先级。这种情况下,速度和精细度的优先级会被降低。浏览器可能会关闭抗锯齿,或者只针对接近垂直和水平的线才开启抗锯齿的功能。同时,浏览器可能会微调线的位置和宽度,以适应显示器的物理像素点
  • geometricPrecision,这个选项要求浏览器以更好的精度来渲染图像,为此可能会牺牲渲染的性能(速度)和边界的清晰度

下图从左到右分别展示了 geometricPrecision,crispEdges 和 optimizeSpeed 三种情况下,同一个圆的显示效果。

不难看出,geometricPrecision 的效果是最平滑的,但是边缘清晰度不足;crispEdges 边缘很锐利,但是有一些毛边(越是低分辨率的屏幕,效果越明显);optimizeSpeed 的显示效果也明显有毛边,不过效果和 crispEdges 略微不同,可以看得出底层使用的算法是不太一样的。

上图的 HTML 代码如下:

<svg viewBox="0 0 640 200" xmlns="http://www.w3.org/2000/svg" width="740">
  <circle
    cx="100"
    cy="100"
    r="100"
    shape-rendering="geometricPrecision"
    fill="#ff8787"
  />
  <circle
    cx="100"
    cy="100"
    r="100"
    shape-rendering="crispEdges"
    fill="#da77f2"
  />
  <circle
    cx="100"
    cy="100"
    r="100"
    shape-rendering="optimizeSpeed"
    fill="#748ffc"
  />
</svg>

另外,除了在 SVG 中直接写属性之外,也可以通过 CSS 来给 SVG 加上相关的 shape-rendering 值:

svg {
  shape-rendering: geometricPrecision;
}

MDN 的相关介绍见这里。


iconfont to svg🔗

• CSS

图标的使用,之前的技术方案,一般都是使用特殊的字体文件进行的。而现在随着浏览器支持的变化,越来越多的技术方案开始迁移到直接使用 SVG 图标了。

当然,为了迁移的平滑进行,最好是可以尽可能的避免改动。在 CSS 层面上,一般针对图标有两个需要设置的部分,一个是颜色,一个是大小。

对于颜色,字体文件使用 color 属性进行着色。SVG 中可以用 fill 着色,用 stroke 描边。不过,由于 SVG 图标一般都是一个或多个 path 组成的,实际一般使用 fill 属性就可以了。这里,可以通过 CSS 中的 currentColor 来完成从 color 到 fill 的映射关系:

.icon {
  fill: currentColor;
}

其中,currentColor 的支持浏览器可以参考 caniuse。总体上来说,IE 9+ 都是支持的,是一个不需要有太多顾虑就可以使用的功能。

对于大小,字体文件使用 font-size 属性控制大小。SVG 中则使用 width 和 height 进行控制。这里可以取巧的对所有 SVG 图标统一设置一个如下的 CSS 样式,一步将大小的设置迁移过来:

.icon {
  width: 1em;
  height: 1em;
}

上面的方案可以解决大部分的大小问题,但是要警惕部分字体图标也设置了 width 和 height 的情况。这种时候,图标占的空间由 width 和 height 确定,但是实际图标的大小由 font-size 确定。相当于 SVG 图标外面加上了一圈 pending。实际在迁移的时候,也可以用这个方案,将 width 和 height 改成和原先 font-size 一样的值,其中变化的差值部分用 pending 补上。


CSS Attribute Selector🔗

• CSS

CSS 中有一些属性选择器,不常见,但是偶尔有一些小众的需求,实现起来会很方便。特别是在进行 Cypress 开发的时候,直接使用 JavaScript 查找元素比较困难,但是有了这些属性选择器,就可以很方便的通过 jQuery 的 API 进行元素的定位了。

属性选择器和一些可能的应用场景,列举如下:

attr

表示带有以 attr 命名的属性元素。这个选择器不关心属性具体的值,只要有,就会被选中。一些常见的应用场景包括:

  • 选择一些没有值的属性,比如 <input disabled /> 可以通过 input[disabled] 进行选择;
  • 选择一些带有属性的元素,属性具体的值并不关心。这种情况中,带有某种属性往往表示这类元素同属于一个类型组件,如一组列表中的每个元素,都会有一个子节点上带有 title 属性以显示 tooltip,此时就可以通过类似 ul li [title] 的方式,将这些文字都选出来,或是进行进一步的选择。

attr=value

表示带有以 attr 命名的属性,并且该属性的值是 value。这个的应用场景比较常见,一般的属性选择都会使用这个方案。值得注意的一点是,由于 CSS 选择器权重的关系,以下两个 CSS 定义是有不同优先级的:

#id {
  color: red;
}
[id=id] {
  color: blue;
}

最终的元素 <p id="id">Hello World</p> 显示颜色是红色,而不是蓝色。因为属性选择器的优先级比 id 选择器要低,即使两者表达的意思是一样的。

attr^=prefix

这个选择器可以将所有以 prefix 开头的 attr 属性所在的元素都选出来。^ 表示开头,这一点和正则表达式中的表述语义是类似的。可以设想这样一个应用场景:

在某个页面上,可能要根据一组数据显示对应的表单数据。因为每个表单中的输入项都需要一个 label + input 的组合,因而每个 input 可能需要给一个独一无二的 ID(方便 label 上加上 for 以绑定两者)。这时候,一个简单的做法,是给每一个表单中固定的输入项,取一个固定的前缀,再加上这个数据本身的 id 值,最终生成一个独一无二的 ID,防止重复。比如,数据 { id: 1, name: 'John' } 生成的名字 input 可能为:<input id="user-name-1" />。

针对这种情况,如果希望一次性选出所有这些 input,就可以使用属性选择器:[id^=user-name]。

attr|=prefix

上面的这个例子,也可以用这个属性选择器来进行改写:[id|=user-name]。两者都可以定义属性的前缀用于查找元素,但是区别在于,|= 的选择器规定的前缀之后一定跟着一个 - 字符。因此,[id|=user-name] 可以选出 <input id="user-name-1" /> 但是不能选出 <input id="user-name_1" />。这一点是和上面这个选择器最大的不同。当然,这个选择器最大的应用场景其实还是在选择 lang 上,比如将当前页面中所有英文的部分选择出来:[lang|=en],此时,无论是 <p lang="en-US">Color</p> 还是 <p lang="en-GB">Colour</p> 都可以被正确的选择出来。

attr$=suffix

这个选择器可以将所有以 suffix 结尾的 attr 属性所在的元素都选出来。$ 表示结尾,这一点和正则表达式中的表述语义是类似的。一个可能的例子是:在 Ant Design 中,Icon 组件会根据当前网页的语言,显示 aria-label="icon: right" 或 aria-label="图标: right"。如果要根据当前选择的语言去分别创建选择器,会有一些麻烦,这时候可以考虑直接使用 [aria-label$=right] 来进行选择。

attr*=keyword

这个选择器可以将所有 attr 中带有 keyword 字段的元素都选出来。暂时没有遇到什么实际的应用场景,但是可以考虑用作属性的文案检查器。比如,原先的产品名字叫 AAA,但是后期业务调整,名字改成了 BBB,那么下面的 CSS 就可以将所有还没有改过来的元素都标注出来:

[class*=AAA], [aria-label*=AAA] {
  color: red;
}

参考文档

MDN


Overflow & InlineBlock🔗

• CSS

当 display: inline-block 和 overflow: hidden 一起使用的时候,会发现文字的显示比一般正常的情况要“高”一些。举个例子来说:

good

上面的四个文字中,第一个 o 被设置为 display:inline-block 以及 overflow:hidden。最终的显示效果,第一个 o 的底部明显高于两边的 g 和 o。

通过给第一个 o 和整行文字画上边框,不难发现,这个文字是整体被抬高了。

good

在上面的例子中,inline-block 的高度是由 line-height 决定的,因而看上去会比 inline 情况时候的要高(inline 情况下 border 画出来的高度是固定的,由 font-family 和 font-size 决定);同时,overflow:hidden 会让内容的底部和父元素的文字基线(baseline)持平,从而会让整体的显示结果更高(这一点从上面的显示中不难发现,其中 g 的部分有少量是低于基线显示的,可以看到也低于第一个 o 的底部区域)。

因此,在这种情况下,line-height 越大,会看到这种情况下的文字越是高,高出来的空白区域主要是 line-height 本身比文字大的部分,以及对齐方式不同造成的差异距离。

上述这种情况,想要正确的对齐,只需要修改垂直对齐的方式就可以了。设置 vertical-align:bottom 后的结果:

good

符合预期。


object-fit🔗

• CSS

object-fit 这个 CSS 样式,是针对可替换元素(replaced element)设计的。一般来说,常见的可替换元素包含图片(img)或是视频(video)。这些可替换元素的大小是事先不确定的,在实际展示的时候,需要一定的规则来决定元素实际如何被放置到元素框中去。

以下是几种 object-fit 的值及对应的显示效果(每种类型显示两个图片,第一张图片的原始尺寸大于元素显示的尺寸,第二张图片的原始尺寸小于元素显示的尺寸):

fill

big image

small image

  • 宽高比例:不保持
  • 显示范围:占满元素
  • 可能影响:显示结果宽高比失真;显示结果比原始尺寸大

contain

big image

small image

  • 宽高比例:保持
  • 显示范围:至少一轴占满,整体(另一轴)不超过元素
  • 可能影响:出现黑边(letterboxed);显示结果比原始尺寸大

cover

big image

small image

  • 宽高比例:保持
  • 显示范围:至少一轴占满,整体(另一轴)可以超过元素
  • 可能影响:超出显示范围;显示结果比原始尺寸大

none

big image

small image

  • 宽高比例:保持
  • 显示范围:原始尺寸
  • 可能影响:超出显示范围

scale-down

big image

small image

使用 none 或者 contain 的规则进行显示。具体选择哪个规则,要看两个规则生成的最终效果,哪一个更小。换句话说,如果元素的原始尺寸两轴都小于元素的显示范围,就使用 none 进行显示,显示结果是原始元素的原始尺寸;否则就是用 contain 的方式进行显示,用黑边的方式将元素压缩到显示范围内完整显示。


Order of font-family🔗

• CSS

由于系统的差异,不同的电脑上存在的字体是不一样的。为了网站的效果可以兼顾各个设备,一般在写 CSS 的时候,font-family 总是很长的一串。通过字体 fallback 的功能,让浏览器自行选择最先能匹配到的字体文件,从而保证显示的效果大体上接近于视觉效果图。在实际书写中,有一个值得注意的细节:英文字体应该在中文字体的前面。

中文字体文件往往包含英文字符,但是这些英文字符的样式很可能并不是设计师希望看到的。如果中文字体展示在英文字体的前面,英文字体就没法被使用到,导致最终的效果略有偏差。下面展示了中文字体 PingFang SC 和苹果默认英文系统字体(SF NS Display)针对英文字母的渲染效果(需要在 MacOS 下查看):

ffi

ffi

所以,应该写:

.example {
  font-family:
    -apple-system, BlinkMacSystemFont,
    'Segoe UI', 'Helvetica Neue', Helvetica, Arial,
    'PingFang SC', 'Microsoft YaHei',
    sans-serif;
}

而不是:

.example {
  font-family:
    'PingFang SC', 'Microsoft YaHei',
    -apple-system, BlinkMacSystemFont,
    'Segoe UI', 'Helvetica Neue', Helvetica, Arial,
    sans-serif;
}

这里,-apple-system, BlinkMacSystemFont 针对苹果下的 Safari 和 Chrome 内核调用系统自带字体,对应到的英文字体是 SF (SF NS Display),中文字体是 PingFang (PingFang SC)。


CSS for Dark Mode🔗

• CSS

prefers-color-scheme 这个 Media Query 可以用于检测当前的操作系统是否选择了 Dark Mode。这是一个依然处于初始草案阶段的功能(见 Draft),不过 Safari (12.1) / Chrome (76) / Firefox (67) 的最新版本都已经做了支持。

示例代码如下:

@media (prefers-color-scheme: dark) {
  body {
    background-color: #333;
    color: #fff;
  }
  :not(pre) > code[class*="language-"] {
    background-color: rgba(255,229,100,0.8);
  }
}

下面是一个可编辑的 CSS 代码,可以直接试一试:

注:上面这段代码是可改的,修改后的 CSS 会直接生效。但是由于 contenteditable 的限制,所有代码需要在一行内完成。

除了 dark 之外,prefers-color-scheme 可以接受的属性还有 light 和 no-preference 两种。其中,light 表示用户选择的是 Light 模式,no-preference 表示用户并没有做选择。

在 JavaScript 中,也可以通过下面的代码来判断当前是否是 Dark Mode:

const ifDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;

注:从当前的实验结果来看,matchMedia 的结果用 .addListener 注册回调事件并不能生效(Safari 12 测试)。

注:经测试,Safari 13 可以支持 matchMedia 结果 .addListener 的回调。因而,通过 JavaScript 来感知 Dark Mode 的修改,可以通过类似如下的代码完成:

function listenToDarkMode(callback: (isDarkMode: boolean) => void) {
  const matchQueryList = window.matchMedia('(prefers-color-scheme: dark)');
  matchQueryList.addListener(function (event: MediaQueryListEvent) {
    callback(event.matches);
  });
  callback(matchQueryList.matches);
}

如果图片的展示也需要区分,mediaQuery 也可以帮上忙:

<picture>
  <source srcset="mojave-night.jpg" media="(prefers-color-scheme: dark)">
  <img src="mojave-day.jpg">
</picture>

css backdrop filter🔗

• CSS

传统的 CSS filter,可以对当前的元素应用指定的滤镜。以模糊(blur)滤镜为例,常常会被拿来实现毛玻璃的效果。然而,因为滤镜只能应用于元素自身,所以毛玻璃的效果也是局限性很大的。一个常见的做法是,背景图片在当前元素中用 background-image 的方式再赋值一次,然后通过定位对齐,再加上 blur 的效果。这样看上去,中间一块的图片就好像有了模糊的效果。

一个例子:

HTML 结构是:

<div class="container">
  <div class="filter"></div>
</div>

CSS 是:

.container {
  width: 620px;
  height: 414px;
  background-image: url("../../baseline-jpeg-demo.jpeg");
  background-size: 620px 414px;
  position: relative;
}
.filter {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 310px;
  height: 207px;

  background-image: url("../../baseline-jpeg-demo.jpeg");
  background-size: 620px 414px;
  background-position: -155px -103.5px;
  filter: sepia() hue-rotate(120deg);
}
.filter:hover {
  filter: blur(10px);
}

然而,显然这样的局限性是很大的。css backdrop filter 就是为了打破这种局限性。有了 css backdrop filter,当前元素的滤镜会加到当前元素下面的所有元素上,而不仅仅是自身的元素。

上面例子的改写(注意:当前浏览器不支持 backdrop-filter 功能):

HTML 保持不变,CSS 改动为:

.filter {
  -webkit-backdrop-filter: sepia() hue-rotate(120deg);
}
.filter:hover {
  -webkit-backdrop-filter: blur(10px);
}

可以看到,需要加 filter 的部分,没有做额外特殊的处理(比如背景图片的配适),就可以直接使用。简洁明了。

另外,鼠标悬停之后可以看到模糊效果的展示。使用 filter 和使用 backdrop-filter 的展示效果也是略有不同的。主要是,用 filter 这种方案,背后还是有图片的,所以当前景图片模糊之后,边缘部分,后面背景的图片会显示出来,效果有折扣。(如果需要处理,简单的做法是,加大 blur 元素的宽高,然后用 overflow:hidden 把整体显示出来的大小限定回原来需要的大小,这样边缘部分相当于被裁剪了)

当然,css backdrop filter 目前的支持还非常有限。除了 Safari 和 Edge,基本没有浏览器支持。具体可以看 Caniuse。

在 Electron 中,可以通过下面的方法让打开 backdrop-filter 的支持:

new BrowserWindow({
  // ...
  webPreferences: {
    enableBlinkFeatures: 'CSSBackdropFilter',
  },
  // ...
});

Chrome 对 backdrop filter 的支持进展可以看这个 Issue