网络日志

CSS Houdini:用浏览器引擎实现高级CSS效果

vivo 互联网前端团队-Wei Xing

Houdini被称之为Magic of styling and layout on the web,看起来十分神秘,但实际上,Houdini并非什么神秘组织或者神奇魔法,它是一系列与CSS引擎相关的浏览器API的总称。

一、Houdini 是什么

在了解之前,先来看一些Houdini能实现的效果吧:

反向的圆角效果(Border-radius):

动态的球形背景(Backgrond):

彩色边框(Border):

神奇吧,要实现这些效果使用常规的CSS可没那么容易,但对CSS Houdini来说,却很easy,这些效果只是冰山一角,CSS Houdini能做的有更多。(这些案例均来自Google Chrome Labs,更多案例可以通过 Houdini Samples 查看)。

看完效果,再来说说Houdini到底是什么。

首先,Houdini 的出现最直接的目的是为了解决浏览器对新的CSS特性支持较差以及Cross-Browser的问题。我们知道有很多新的CSS特性虽然很棒,但它们由于不被主流浏览器广泛支持而很少有人去使用。

随着CSS规范在不断地更新迭代,越来越多有益的特性被纳入进来,但是一个新的CSS特性从被提出到成为一个稳定的CSS特性,需要经过漫长地等待,直到被大部分浏览器支持时,才能被开发者广泛地使用。

而 Houdini 的出现正是洞察和解决了这一痛点,它将一系列CSS引擎API开放出来,让开发者可以通过JavasScript创造或者扩展现有的CSS特性,甚至创造自己的CSS渲染规则,给开发者更高的CSS开发自由度,实现更多复杂的效果。

二、JS Polyfill vs Houdini

有人会问,实际上很多新的CSS特性在被浏览器支持之前,也有可替代的JavaScript Polyfill可以使用,为什么我们仍然需要Houdini呢?这些Polyfill不是同样可以解决我们的问题吗?

要回答这个问题也很简单,JavaScript Polyfill相对于Houdini有三个明显的缺陷:

1.不一定能实现或实现困难。

CSSOM开放给JavaScript的API很少,这意味着开发者能做的很有限,只能简单地操纵DOM并对样式做动态计算和调整,光是去实现一些复杂的CSS新特性的Polyfill就已经很难了,对于更深层次的Layout、Paint、Composite等渲染规则更是无能为力。所以当一个新的CSS特性被推出时,通过JavaScript Polyfill不一定能够完整地实现它。

2.实现效果差或有使用限制。

JavaScript Polyfill是通过JavaScript来模拟CSS特性的,而不是直接通过CSS引擎进行渲染,通常它们都会有一定的限制和缺陷。例如,大家熟知的css-scroll-snap-polyfill就是针对新的CSS特性Scroll Snap产生的Polyfill,但它在使用时就存在使用限制或者原生CSS表现不一致的问题。

3.性能较差。

JavaScript Polyfill可能造成一定程度的性能损耗。JavaScript Polyfill的执行时机是在DOM和CSSOM都构建完成并且完成渲染后,通常JavaScript Polyfill是通过给DOM元素设置内联样式来模拟CSS特性,这会导致页面的重新渲染或回流。尤其是当这些Polyfill和滚动事件绑定时,会造成更加明显的性能损耗。

Houdini的诞生让CSS新特性不再依赖于浏览器,开发者通过直接操作CSS引擎,具有更高的自由度和性能优势,并且它的浏览器支持度在不断提升,越来越多的API被支持,未来Houdini必然会加速走进web开发者的世界,所以现在对它做一些了解也是必要的。

在本文,我们会介绍Houdini的APIs以及它们的使用方法,看看这些API当前的支持情况,并给出一些在生产环境中使用它们的建议。

Houdini的名称与一位著名美国逃脱魔术师Harry Houdini的名称一样,也许正是取逃脱之意,让CSS新特性逃离浏览器的掌控。

三、Houdini APIs

上文提到CSS Houdini提供了很多CSS引擎相关的API,根据Houdini提供的规范说明文件,API共分为两种类型:high-level APIs 和 low-level APIs 。

high-level APIs:顾名思义是高层次的API,这些API与浏览器的渲染流程相关。

  • Paint API
提供了一组与绘制(Paint)过程相关的API,我们可以通过它自定义的渲染规则,例如调整颜色(color)、边框(border)、背景(background)、形状等绘制规则。
  • Animation API
提供了一组与合成(composite)渲染相关的API,我们可以通过它调整绘制层级和自定义动画。
  • Layout API
提供了一组与布局(Layout)过程相关的API,我们可以通过它自定义的布局规则,类似于实现诸如flex、grid等布局,自定义元素或子元素的对齐(alignment)、位置(position)等布局规则。

low-level APIs:低层次的API,这些API是high-level APIs的实现基础。

  • Typed Object Model API
  • CSS Properties & Values API
  • Worklets
  • Font Metrics API
  • CSS Parser API

这些APIs的支持情况在不断更新中,可以看到当前最新的一次更新时间是在2021年5月份,还是比较活跃的。(注:图片来源于Is Houdini ready yet? 

对比下图2018年底的情况,Houdini目前得到了更广泛的支持,我们也期待图里更多绿色的板块被逐渐点亮。

大家可以访问 Is Houdini ready yet? 看到Houdini的最新支持情况。

下文中,我们会着重介绍Typed Object Model API、CSS Properties & Values API、Worklets和Paint API、Animation API,因为它们目前具有比其他API更好的支持度,且它们的特性已经趋于稳定,在未来不会有很大的变更,大家也能在了解它们之后直接将它们使用在项目中。

四、 Typed Object Model API

在Houdini出现以前,我们通过JavaScript操作CSS Style的方式很简单,先看看一段大家熟悉的代码。

// Before Houdini
 
const size = 30
target.style.fontSize = size + 'px' // "20px"

const imgUrl = 'https://www.exampe.com/sample.png'
target.style.background = 'url(' + imgUrl + ')' // "url(https://www.exampe.com/sample.png)"

target.style.cssText = 'font-size:' + size + 'px; background: url('+ imgUrl +')'  
// "font-size:30px; background: url(https://www.exampe.com/sample.png)"

我们可以看到CSS样式在被访问时被解析为字符串返回,设置CSS样式时也必须以字符串的形式传入。开发者需要手动拼接数值、单位、格式等信息,这种方式非常原始和落后,很多开发者为了节省性能损耗,会选择将一长串的CSS Style字符串传入cssText,可读性很差,而且很容易产生隐蔽的语法错误。

Typed Object ModelTypeScript的命名类似,都增加了Type这个前缀,如果你使用过TypeScript就会了解到,TypeScript增强了类型检查,让代码更稳定也更易维护,Typed Object Model也是如此。

相比于上面晦涩的传统方法,Typed Object Model将CSS属性值包装为Typed JavaScript Object,让每个属性值都有自己的类型,简化了CSS属性的操作,并且带来了性能上的提升。通过JavaScript对象来描述CSS值比字符串具有更好的可读性和可维护性,通常也更快,因为可以直接操作值,然后廉价地将其转换回底层值,而无需构建和解析 CSS 字符串。

Typed Object Model中CSSStyleValue是所有CSS属性值的基类,在它之下的子类用于描述各种CSS属性值,例如:

  • CSSUnitValue
  • CSSImageValue
  • CSSKeywordValue
  • CSSMathValue
  • CSSNumericValue
  • CSSPositionValue
  • CSSTransformValue
  • CSSUnparsedValue
  • 其它

通过它们的命名就可以看出这些不同的子类分别用于表示哪种类型的CSS属性值,以CSSUnitValue为例,它可以用于表示带有单位的CSS属性值,例如font-size、width、height,它的结构很简单,由value和unit组成。

{
  value: 30,
  unit: "px"
}

可以看到,通过对象来描述CSS属性值确实比传统的字符串更易读了。

要访问和操作CSSStyleValue还需要借助两个工具,分别是attributeStyleMap和computedStyleMap(),前者用于处理内联样式,可以进行读写操作,后者用于处理非内联样式(stylesheet),只有读操作。

// 获取stylesheet样式
target.computedStyleMap().get("font-size"); // { value: 30, unit: "px"}

// 设置内联样式
target.attributeStyleMap.set("font-size", CSS.em(5));

// stylesheet样式仍然返回20px
target.computedStyleMap().get("font-size"); // { value: 30, unit: "px"}

// 内联样式已经被改变
target.attributeStyleMap.get("font-size"); // { value: 5, unit: "em"}

当然attributeStyleMap和computedStyleMap()还有更多可用的方法,例如clear、has、delete、append等,这些方法都为开发者提供了更便捷和清晰的CSS操作方式。

五、CSS Properties & Values API

根据MDN的定义,CSS Properties & Values API也是Houdini开放的一部分API,它的作用是让开发者显式地声明自定义属性(css custom properties),并且定义这些属性的类型、默认值、初始值和继承方法。

--my-color: red;
--my-margin-left: 100px;
--my-box-shadow: 3px 6px rgb(20, 32, 54);

在被声明之后,这些自定义属性可以通过var()来引用,例如:

// 在:root下可声明全局自定义属性
:root {
  --my-color: red;
}
 
#container {
  background-color: var(--my-color)
}

了解了自定义属性的基本概念和使用方式后,我们来考虑一个问题,我们能否通过自定义属性来帮助我们完成一些过渡效果呢?

例如,我们希望为一个div容器设置背景色的transition动画,我们知道CSS是无法直接对background-color做transition过渡动画的,那我们考虑将transition设置在我们自定义的属性--my-color上,通过自定义属性的渐变来间接完成背景的渐变效果,是否能做到呢?根据刚才的自定义属性简介,也许你会尝试这么做:

// DOM
<div id="container">container</div>
 
// Style
:root {
  --my-color: red;
}
 
#container {
  transition: --my-color 1s;
  background-color: var(--my-color)
}
 
#container:hover {
  --my-color: blue;
}

这看起来是个符合逻辑的写法,但实际上由于浏览器不知道该如何去解析--my-color这个变量(因为它并没有明确的类型,只是被当做字符串处理),所以也无法对它采用transition的效果,因此我们并不能得到一个渐变的背景色动画。

但是,通过CSS Properties & Values API提供的CSS.registerProperty()方法就可以做到,就像这样:

// DOM
<div id="container">container</div>
 
// JavaScript
CSS.registerProperty({
  name: '--my-color',
  syntax: '<color>',
  inherits: false,
  initialValue: '#c0ffee',
});
 
// Style
#container {
  transition: --my-color 1s;
  background-color: var(--my-color)
}
 
#container:hover {
  --my-color: blue;
}

与上面的不同之处在于,CSS.registerProperty()显式定义了--my-color的类型syntax,这个syntax告诉浏览器把--my-color当做color去解析,因此当我们设置transition: --my-color 1s时,浏览器由于提前被告知了该属性的类型和解析方式,因此能够正确地为其添加过渡效果,得到的效果如下图所示。

CSS.registerProperty()接受一个参数对象,参数中包含下面几个选项:

  • name: 变量的名字,不允许重复声明或者覆盖相同名称的变量,否则浏览器会给出相应的报错。
  • syntax: 告诉浏览器如何解析这个变量。它的可选项包含了一些预定义的值等。
  • inherits: 告诉浏览器这个变量是否继承它的父元素。
  • initialValue: 设置该变量的初始值,并且将该初始值作为fallback。

在未来,开发者不仅可以在JavaScript中显式声明CSS变量,也可以直接在CSS中直接声明:

@property --my-color{
  syntax: '<color>',
  inherits: false,
  initialValue: '#c0ffee',
}

六、Font Metrics API

目前 Font Metrics API 还处于早期的草案阶段,它的规范在未来可能会有较大的变更。在当前的specification文件中,说明了 Font Metrics API 将会提供一系列API,允许开发者干预文字的渲染过程,创建文字或者动态修改文字的渲染效果等。期待它能在未来被采纳和支持,为开发者提供更多的可能。

七、CSS Parser API

目前 Font Metrics API 也处于早期的草案阶段,当前的specification文件中说明了它将会提供更多CSS解析器相关的API,用于解析任意形式的CSS描述。

八、Worklets

Worklets是轻量级的 Web Workers,它提供了让开发者接触底层渲染机制的API,Worklets的工作线程独立于主线程之外,适用于做一些高性能的图形渲染工作。并且它只能被使用在HTTPS协议中(生产环境)或通过localhost来启用(开发调试)。

Worklets不像Web Workers,我们不能将任何计算操作都放在Worklets中执行,Worklets开放了特定的属性和方法,让我们能处理图形渲染相关的操作。我们能使用的Worklet类型暂时有如下几种:

  • PaintWorklet - Paint API
  • LayoutWorklet - Animation API
  • AnimationWorklet - Layout API
  • AudioWorklet - Audio API(处于草案阶段,暂不介绍)

Worklets提供了唯一的方法Worklet.addModule(),这个方法用于向Worklet添加执行模块,具体的使用方法,我们在后续的Paint API、Layout API、Animation API中介绍。

九、Paint API

Paint API允许开发者通过Canvas 2d的方法来绘制元素的背景、边框、内容等图形,这在原始的CSS规则中是无法做到的。

Paint API需要结合上述提到的PaintWorklet一起使用,简单来说就是开发者构建一个PaintWorklet,再将它传入Paint API就可以绘制相应的Canvas图形。如果你熟悉Canvas,那Paint API对你来说也不会陌生。

使用Paint API的过程简述如下:

  1. 使用registerPaint()方法创建一个PaintWorklet。
  2. 将它添加到Worklet模块中,CSS.paintWorklet.addModule()。
  3. 在CSS中通过paint()方法使用它。

其中registerPaint()方法用于创建一个PaintWorklet,在这个方法中开发者可以利用Canvas 2d自定义图形绘制。

可以通过Google Chrome Labs给出的一个paint API案例checkboardWorklet来直观看看它的具体使用方法,案例中利用Paint API为textarea绘制彩色的网格背景,它的代码组成很简单:

/* checkboardWorklet.js */
 
class CheckerboardPainter {
  paint(ctx, geom, properties) {
    const colors = ['red', 'green', 'blue'];
    const size = 32;
    for(let y = 0; y < geom.height/size; y++) {
      for(let x = 0; x < geom.width/size; x++) {
        const color = colors[(x + y) % colors.length];
        ctx.beginPath();
        ctx.fillStyle = color;
        ctx.rect(x * size, y * size, size, size);
        ctx.fill();
      }
    }
  }
}
 
// 注册checkerboard
registerPaint('checkerboard', CheckerboardPainter);
/* index.html */
<script>
    CSS.paintWorklet.addModule('path/to/checkboardWorklet.js')  // 添加checkboardWorklet到paintWorklet
</script>
/* index.html */
<!doctype html>
<textarea></textarea>
<style>
  textarea {
    background-image: paint(checkerboard);  // 使用paint()方法调用checkboard绘制背景
  }
</style>

通过上述三个步骤,最终生成的textarea背景效果如图所示:

感兴趣的同学可以访问 houdini-samples查看更多官方样例。

十、Animation API

在过去,当我们想要对DOM元素执行动画时,通常只有两个选择:CSS Transitions和CSS Animations。这两者在使用上虽然简单,也能满足大部分的动画需求,但是它们有两个共同的缺点

  • 仅仅依赖时间来执行动画(time-driven):动画的执行仅和时间有关。
  • 无状态(stateless):开发者无法干预动画的执行过程,获取不到动画执行的中间状态。

但是在一些场景下,我们想要开发一个非时间驱动的动画或者想要控制动画的执行状态,就很难做到。比如视差滚动(Parallax Scrolling),它是根据滚动的情况来执行动画的,并且每个元素根据滚动情况作出不一致的动画效果,下面是个简单的视差滚动效果示例,在通常情况下要实现更加复杂的视差滚动效果(例如beckett页面的效果)是比较困难的。

Animation API却可以帮助我们轻松做到。

在功能方面,它是CSS Transitions和CSS Animations的扩展,它允许用户干预动画执行的过程,例如结合用户的scroll、hover、click事件来控制动画执行,像是为动画增加了进度条,通过进度条控制动画进程,从而实现一些更加复杂的动画场景。

在性能方面,它依赖于AnimationWorklet,运行在单独的Worklet线程,因此具有更高的动画帧率和流畅度,这在低端机型中尤为明显(当然,通常低端机型中的浏览器内核还不支持该特性,这里只是说明Animation API对动画的视觉体验优化是很友好的)。

Animation API的使用和Paint API一样,也同样遵循Worklet的创建和使用流程,分为三个步骤,简述如下:

  1. 使用registerAnimator()方法创建一个AnimationWorklet。
  2. 将它添加到Worklet模块中,CSS.animationWorklet.addModule()。
  3. 使用new WorkletAnimation(name, KeyframeEffect)创建和执行动画。

/* myAnimationWorklet.js */
registerAnimator("myAnimationWorklet", class {
  constructor(options) {
    /* 构造函数,动画示例被创建时调用,可用于做一些初始化 */
  }
   
  //
  animate(currentTime, effect) {
    /* 干预动画的执行 */
  }
});
/* index.html */
await CSS.animationWorklet.addModule("path/to/myAnimationWorklet.js");;
/* index.html */
 
/* 传入myAnimationWorklet,创建WorkletAnimation */
new WorkletAnimation(
  'myAnimationWorklet', // 动画名称
  new KeyframeEffect(   // 动画timeline(对应于步骤一中animate(currentTime, effect)中的effect参数)
    document.querySelector('#target'), 
    [
      {
        transform: 'translateX(0)'
      },
      {
        transform: 'translateX(200px)'
      }
    ],
    {
      duration: 2000, // 动画执行时长
      iterations: Number.POSITIVE_INFINITY  // 动画执行次数
    }
  ),
  document.timeline // 控制动画执行进程的数值(对应于步骤一中animate(currentTime, effect)中的currentTime参数)
).play();

可以看到步骤一的animate(currentTime, effect)方法有两个参数,就是它们让开发者能够干预动画执行过程。

  • currentTime:

用于控制动画执行的数值,对应于步骤3例子中传入的document.timeline参数,通常根据它的数值来动态修改另一个参数effect,从而影响动画执行。例如我们可以传入document.timeline或者传入element.scrollTop作为这个动态数值,传入前者表明我们只是想用时间变化来控制动画的执行,传入后者表明我们想通过滚动距离来控制动画执行。

document.timeline是每个页面被打开后从0开始递增的时间数值,可以简单理解为页面被打开的时长,初始时document.timeline === 0,随着时间不断递增。
  • effect:

对应于步骤3中传入的new KeyframeEffect(),可通过修改它来影响动画执行。一个很常见的做法是,通过修改effect.localTime控制动画的执行,effect.localTime的作用相当于控制动画播放的进度条,修改它的数值就相当于拖动动画播放的进度。

如果不修改effect.localTime或者设置effect.localTime = currentTime,那么动画会随着document.timeline正常匀速执行,线性动画。但是如果将effect.localTime设置为某个固定值,例如effect.localTime = 1000ms,那么动画将会定格在1000ms时对应的帧,不会继续执行。

为了更好理解effect.localTime,可以来看看effect.localTime和动画执行之间的关系,假设我们创建了一个2000ms时长的动画,并且动画没有设置delay时间。

通过上面的描述,大家应该get到如何做一个简单的滚动驱动(scroll-driven)的动画了,实际上有个专门用于生成滚动动画的类:ScrollTimeline,它的用法也很简单:

/* myWorkletAnimation.js */
 
new WorkletAnimation(
  'myWorkletAnimation',
  new KeyframeEffect(
    document.querySelector('#target'),
    [
      {
        transform: 'translateX(0)'
      },
      {
        transform: 'translateX(500px)'
      }
    ],
    {
      duration: 2000,
      fill: 'both'
    }
  ),
  new ScrollTimeline({
    scrollSource: document.querySelector('.scroll-area'), // 监听的滚动元素
    orientation: "vertical", // 监听的滚动方向"horizontal"或"vertical"
    timeRange: 2000 // 根据scroll的高度,传入0 - timeRage之间的数值,当滚动到顶端时,传入0,当滚动到底端时,传入2000
  })
).play();

这样一来,通过简单的几行代码,一个简单的滚动驱动的动画就做好了,它比任何CSS Animations或CSS Transitions都要顺畅。

接下来再看看最后一个同样有潜力的API:**Layout API **。

十一、Layout API

Layout API允许用户自定义新的布局规则,创造类似flex、grid之外的布局。

但创建一个完备的布局规则并不简单,官方的flex、grid布局是充分考虑了各种边界情况,才能确保使用时不会出错。同时Layout API使用起来也比其它API更为复杂,受限于篇幅,本文仅简单展示相关的API和使用方式,具体细节可参考官方描述。

Layout API和其它两个API相似,使用步骤同样分为三个步骤,简述如下:

  • 通过registerLayout()创建一个LayoutWorklet。
  • 将它添加到Worklet模块中,CSS.layoutWorklet.addModule()。
  • 通过display: layout(exampleLayout)使用它。

Google Chrome Labs案例如下所示,通过Layout API实现了一个瀑布流布局。

虽然通过Layout API自定义布局较为困难,但是我们依然可以引入别人的优秀开源Worklet,帮助自己实现复杂的布局。

十二、新特性检测

鉴于当前Houdini APIs的浏览器支持度仍然不是很完美,在使用这些API时需要先做特性检测,再考虑使用它们。

/* 特性检测 */
 
if (CSS.paintWorklet) {
  /* ... */
}
 
if (CSS.animationWorklet) {
  /* ... */
}
 
if (CSS.layoutWorklet) {
  /* ... */
}

想要在chrome中调试,可以在地址栏输入chrome://flags/#enable-experimental-web-platform-features,并勾选启用Experimental Web Platform features。

十三、总结

Houdini APIs让开发者有办法接触到CSS渲染引擎,通过各种API实现更高性能和更复杂的CSS渲染效果。虽然它还没有完全准备好,很多API甚至还处于草案阶段,但它给我们带来了更多可能性,并且诸如paint API、Typed OM、Properties & Values API这些新特性也都被广泛支持了,可以直接用于增强我们的页面效果。未来Houdini APIs一定会慢慢走进开发者的世界,大家可以期待并做好准备迎接它。

参考文献:

  1. W3C Houdini Specification Drafts
  2. State of Houdini (Chrome Dev Summit 2018)
  3. Houdini’s Animation Worklet - Google Developers
  4. Interactive Introduction to CSS Houdini
  5. CSS Houdini Experiments
  6. Interactive Introduction to CSS Houdini
  7. Houdini Samples by Google Chrome Labs