Houdini, in essence, gives developers lower level access to CSS itself.

  • Worklets, TypedOM, Custom Properties
  • Paint API, Animation API, Layout API

CSS Houdini API

TL;DR

Houdini API主要包含一下六个部分:

类别 简称 Spec全称 特点
多线程 Worklets Worklets Level 1 使用多线程执行渲染引擎的指定类型任务,提高整体性能
CSS对象模型 TypedOM CSS Typed OM Level 1 使用JS操作CSSOM属性
自定义属性 Custom Properties CSS Properties and Values API Level 1 在JS主进程中定义与类型化CSS属性
绘制 Paint API CSS Painting API Level 1 使用类canvas上下文在元素背景与mask中自由绘制
动画 Animation API CSS Animation Worklet API 使用JS与worklet操作元素帧动画的效果与时间
布局 Layout API CSS Layout API Level 1 通过类逻辑属性与worklet自由地布局元素

注意: 目前在chrome使用这些API可能需要手动开启web实验性功能选项。

开启方法: 进入chrome://flags/地址,将Experimental Web Platform features启用即可。

Worklet

渲染引擎的扩展模块,类似web worker,也是开启新的线程去执行指定任务。

具有以下两个核心概念:

  1. 并行设计 - 每个Worklet必须拥有2个及以上的实例,每个实例都会在被调用时实际运行
  2. 域的限制 - 只能访问一些全局域下的方法或对象,比如setTimeout(这一点类似worker)

如何使用

Worklet属于JS模块,通过调用worklet对象的addModule函数来添加,该函数会返回一个Promise。

// 在浏览器上下文中加载worklet
await demoWorklet.addModule('path/to/script.js');

// 也可以一次性加载多个worklet
Promise.all([
  demoWorklet1.addModule('script1.js'),
  demoWorklet2.addModule('script2.js'),
]).then(results => {
  // 待worklet都加载完成,可以基于此做一些其他工作
});

worklet文件的内容结构大同小异,均有一个在全局作用域的注册函数,该函数接收一个注册的名称和一个针对指定类型worklet设计的类。

// 一个worklet大概长这个样子
registerDemoWorklet('name', class {
  // 每种worklet中都需要定义渲染引擎所需的函数
  process(arg) {
    // 具体功能取决于worklet的种类
    // 有时可能会有返回值,有时也许会直接在参数上进行操作
    return !arg;
  }
});

生命周期

  1. 从渲染引擎开始,启动一个主线程
  2. 接着会启动多个worklet所运行的worklet进程。为了不阻塞主线程,这些进程理论上是与主线程分离的。
  3. 在主线程中加载浏览器的JS脚本
  4. JS中调用worklet.addModule异步加载一个worklet
  5. 一旦捕获到空闲的worklet进程,则worlet会加载到两个或更多的worklet进程中
  6. 当需要时,渲染引擎会从已加载的worklet中调用合适的进程来执行任务,这个调用可以是任何并行的worklet实例

TypedOM

Typed OM是对于已存在的CSSOM的扩展,它使用类型化JS对象暴露了CSS值,而不是像现在的一个简单的字符串,将字符串转换成有意义的类型会消耗很多性能,因此采用类型化JS对象的方式可以更有效率的处理CSS属性值。

Typed OM中,CSS Value现在是一个新基类CSSStyleValue的成员变量,CSSStyleValue中包含大量用来描述具体CSS属性的子类:

  • CSSKeywordValue - CSS关键字或其他标识符(比如inhertgrid)
  • CSSPositionValue - 坐标属性(x, y)值
  • CSSImageValue - 表示图像属性的对象
  • CSSUnitValue - 数值型值。可以由一个数值与单位表示(比如50px),也可以由无单位的数值或百分比表示
  • CSSMathValue - 复杂的数值型值。比如使用了calc,min,max等函数得到结果,包含很多子类:CSSMathSum, CSSMathProduct, CSSMathMin, CSSMathMax, CSSMathNegate, CSSMathInvert
  • CSSTransformValue - 由CSSTransformComponents组成的一个CSS变换列表,包含CSSTranslate, CSSRotate, CSSScale, CSSSkew, CSSSkewX, CSSSkewY, CSSPerspective 或 CSSMatrixComponent

如何使用

与Typed OM相关的两个重要方法:attributeStyleMapcomputedStyleMap

  • attributeStyleMap - 用来get与set类型化样式
  • computedStyleMap - 用来get一个元素完整的Typed OM样式

attributeStyleMap

在设置属性值时,有多个数值函数可以用来处理它们(与关键字值不同)

myElement.attributeStyleMap.set('font-size', CSS.em(2));
myElement.attributeStyleMap.get('font-size'); // CSSUnitValue { value: 2, unit: 'em' }

myElement.attributeStyleMap.set('opacity', CSS.number(.5));
myElement.attributeStyleMap.get('opacity'); // CSSUnitValue { value: 0.5, unit: 'number' };

computedStyleMap

一个css片段

.foo {
  background-position: center bottom 10px;
  transform: translateX(1em) rotate(50deg) skewX(10deg);
  vertical-align: baseline;
  width: calc(100% - 3em);
}

使用computedStyleMap获取对应关键字的完整属性值,对应的js操作如下

const cs = $('.foo').computedStyleMap();

cs.get('vertical-align');
// CSSKeywordValue {
//  value: 'baseline',
// }

cs.get('background-position').x;
// CSSUnitValue {
//   value: 50,
//   unit: 'percent',
// }

cs.get('background-position').y;
// CSSMathSum {
//   operator: 'sum',
//   values: CSSNumericArray {
//     0: CSSUnitValue { value: -10, unit: 'px' },
//     1: CSSUnitValue { value: 100, unit: 'percent' },
//   },
// }

cs.get('width');
// CSSMathSum {
//   operator: 'sum',
//   length: 2,
//   values: CSSNumericArray {
//     0: CSSUnitValue { value: -90, unit: 'px' },
//     1: CSSUnitValue { value: 100, unit: 'percent' },
//   },
// }

cs.get('transform');
// CSSTransformValue {
//   is2d: true,
//   length: 3,
//   0: CSSTranslate {
//     is2d: true,
//     x: CSSUnitValue { value: 20, unit: 'px' },
//     y: CSSUnitValue { value: 0, unit: 'px' },
//     z: CSSUnitValue { value: 0, unit: 'px' },
//   },
//   1: CSSRotate {
//     is2d: true,
//     angle: CSSUnitValue { value: 50, unit: 'deg' },
//     x: CSSUnitValue { value: 0, unit: 'number' },
//     y: CSSUnitValue { value: 0, unit: 'number' },
//     z: CSSUnitValue { value: 1, unit: 'number' },
//   },
//   2: CSSSkewX {
//     is2d: true,
//     ax: CSSUnitValue { value: 10, unit: 'deg' },
//   },
// }

Custom Properties

目前的CSS Variables特性提供了一种用户定义CSS属性值的方式,但限制了var()的外部引用(仅在CSS中使用),并且不能被严格的定义(类型等)。

针对这些问题产生了CSS Properties & Values API Level 1,即自定义属性,扩展了CSS Variables,允许我们注册属性并定义它们的类型、初始值和继承属性,极大提高了CSS属性变量的操作能力和灵活性。

对比

目前的CSS Variables

.thing {
  --my-color: green;
  --my-color: url('not-a-color'); // 它只是一个变量,什么都不懂
  color: var(--my-color); // 这就尴尬了
}

Houdini自定义属性

window.CSS.registerProperty({
  name: '--my-color',
  syntax: '<color>', // 现在它定义了一个color,url这种值就会被跳过
});

示例

CSS.registerProperty({
  name: '--foo', // String,自定义属性的名称
  syntax: '<color>', // String, 解析该属性的方式,默认为*
  inherits: false, // Boolean, 若为true则会继承自DOM树
  initialValue: 'black', // String, 该属性的初始值
});

在注册一个自定义属性时,有很多支持的syntax值可供使用:<length>, <number>, <percentage>, <length-percentage>, <color, <image>, <url>, <integer>等等

可以在使用空格分隔的列表中使用+表示包含多个syntax值(比如length),使用|来分隔不同的可选synatx值。

Paint API

CSS Painting API Level 1, 也被称为Houdini Paint API,它在CSS中使用图像的地方(背景、遮罩等)为我们提供了一种新的实现绘制自定义图样的方式。

使用全新的paint()函数与Paint API worklet,我们可以:

  • 使用类似2D Canvas的绘制上下文
  • 基于元素尺寸缩放绘制的图像(重绘亦是)
  • 通过自定义属性设置绘制样式

支持情况

Paint API的polyfill:css-paint-polyfill

Can I Use css-paint-api? Data on support for the css-paint-api feature across the major browsers from caniuse.com.

如何使用

画一个圆

比如现在我们想使用Paint API在某个元素的背景中画一个圆,主要分为如下三部分

  1. CSS部分

    section {
      background-image: paint(awesomePattern);
    };
    

    在css的属性值中使用paint()函数加载指定worklet模块,参数为之后起的worklet模块名称。

  2. JS部分

    CSS.paintWorklet.addModule('patternWorklet.js');
    

    使用CSS.paintWorkletaddModule()载入worklet。

  3. worklet部分

    // patternWorklet.js
    class Shape {
      paint(ctx, geom, properties) {
        let x = geom.width / 2;
        let y = geom.height / 2;
    
        ctx.strokeStyle = 'white';
        ctx.lineWidth = 4;
        ctx.beginPath();
        ctx.arc(x, y, 50, 0, 2 * Math.PI);
        ctx.stroke();
        ctx.closePath();
      }
    }
    // Register our class under a specific name
    registerPaint('awesomePattern', Shape);
    

    主要是paint()registerPaint()这两个函数。

    • paint()中会依次传入三个参数: ctx, geom, properties
      • ctx (PaintRenderingContext2D) - 绘制上下文,与canvas类似,不同的是无法像在canvas中那样获取图像中的像素数据
      • geom (PaintSize) - 表示绘制区域的尺寸,包含heightwidth属性
      • properties (StylePropertyMapReadOnly) - 一个包含自定义属性与样式属性的只读Map,属性在inputProperties中导入
    • registerPaint()是Paint worklet的注册函数

使用自定义属性

在paint worklet中使用自定义属性仅需两步:

  1. CSS部分

    在CSS中定义属性

    section {
      --circle-scale: 2;  // 定义缩放比例
      --circle-color: red;// 定义颜色
      ...
    };
    
  2. worklet部分

    在worklet中取得属性值并绘制出来

    class Shape {
      static get inputProperties() { return ['--circle-scale','--circle-color']; }
      paint(ctx, geom, properties) {
        ...
        const scale = parseInt(properties.get('--circle-scale').toString()) || 1;
        const color = properties.get('--circle-color').toString() || 'white';
        ...
        ctx.strokeStyle = color;
        ctx.arc(x, y, 50 * scale, 0, 2 * Math.PI);
        ...
      }
    }
    ...
    

Animation API

CSS Animation Worklet API,即Animation API(注意,与Web Animations不同), 这个API提供了一种能力,使我们可以通过一种标准的、非阻塞的方式,根据用户的输入(比如滚动等)来驱动关键帧动画。

支持情况

Can I Use web-animation? Data on support for the web-animation feature across the major browsers from caniuse.com.

如何使用

要使用Animation API,需要创建一个WorkletAnimation实例来控制动画

  1. JS部分

    // 1. 载入animation worklet
    await CSS.animationWorklet.addModule('sample-animation-worklet.js');
    
    // 2. 获取滚动的元素和生成与之绑定的timeline对象
    const scrollSource = document.scrollingElement;
    // 分解的动画步数
    const timeRange = 1000;
    // 监听scrollSource元素的滚动事件,将行为分解成timeRange所设置的步数,可以自定义滚动的起始`startScrollOffset`和终止`endScrollOffset`偏移量
    const scrollTimeline = new ScrollTimeline({
      scrollSource,
      timeRange,
    });
    
    // 3. 想要执行动画的元素及对应的关键帧动画效果
    const elem = document.querySelector('#my-elem');
    const effectKeyframes = new KeyframeEffect(
      elem,
      // 帧动画效果
      [
        {transform: 'scale(1)'},
        {transform: 'scale(.25)'},
        {transform: 'scale(1)'}
      ],
      {
        // 效果的持续时间
        // 取值 timeRange 则变化与元素滚动行为的速度保持一致
        // 取值 0-timeRange 则变化化相对滚动更快
        // 取值 >timeRange 则变化相对滚动更慢
        // 取值 0 组不会执行该动画
        duration: timeRange,
      },
    );
    
    // 创建一个WorkletAnimation实例
    new WorkletAnimation(
      // 使用的Animation Worklet名称
      'sample-animator',
      // 使用的帧动画效果,也可以是一个KeyframeEffect序列
      effectKeyframes,
      // 使用的执行动画的timeline
      scrollTimeline,
      // 传入Worklet构造器的其他参数
      {},
    ).play(); // 创建完后执行动画
    
  2. worklet部分

    registerAnimator('sample-animator', class {
      constructor(options) {
        // 在这里配置每个animator所使用的参数等
      }
      animate(currentTime, effect) {
        // currentTime - timeline中定义的current time
        // effect - 正在进行的效果组
    
        // 在这里执行帧动画的效果逻辑
        // 通常会在这里设置动画效果的执行时间
        effect.localTime = currentTime;
      }
    });
    

WorkletAnimation

WorkletAnimation中包含一个Effect和一个Timeline对象,它所具有的current time不会直接作为动画效果发生的local time,而与其关联的Animator实例可以控制动画实际发生的local time

下面介绍一下与WorkletAnimation相关的四个参数

  • Animator

    实际上是一个Animation Worklet,对动画实际发生时间进行处理,类似requestAnimationFrame()setTimeout()

  • AnimationEffect

    用来设置关键帧动画效果,是一个KeyframeEffect实例,可以理解为CSS中@keyframe内容的JS对象体现。

    const avatarEffect = new KeyframeEffect(
      document.querySelector('.avatar'),
      [
        {transform: `translateY(0) scale(1)`},
        {transform: `translateY(0px) scale(${0})`, offset: 0},
        {transform: `translateY(${0}px) scale(${0})`},
      ],
      {
        duration: 1000,
        fill: 'both',
      }
    );
    

    构建KeyframeEffect实例需要DOM元素、变换序列及计算的时间属性。

  • AnimationTimeline

    提供current time的对象,在WorkletAnimation中有一个特殊的timeline概念:ScrollTimeline,它可以根据当前在容器中滚动的位置来决定时间值。

  • options

    可选参数,其他需要在worklet内部使用的数据。

线程与时间模型

线程模型

  • 主线程执行帧动画
  • 动画线程(worklet)执行animator,计算local time
  • 并行的worklet执行上下文与主线程JS上下文之间动画效果值的同步,必须要在执行动画关键帧回调之前进行

时间模型

worklet animation的时间模型与web animation略有不同,具有如下特点:

  • worklet animation在ready状态的判断中会比其他的animation多一个条件:user agent是否完成了创建worklet animation对应的animator实例所需的步骤。
  • worklet animation不会将current time作为动画效果的local time,而是交由animator实例直接处理

Layout API

CSS Layout API Level 1,简称Layout API,使我们可以像玩俄罗斯方块一样对页面上的元素进行布局,释放无限可能的创造力。 使用它,我们可以:

  • 真正的去操作元素的显示相关属性
  • 写一个布局spec的polyfill,或者创造一个
  • 创建一个不会影响性能的瀑布流布局,人见人爱

基本概念

  • Current Layout

    表示在当前盒模型中执行的布局算法

  • Parent Layout

    作为执行布局算法的父级元素,会应用形如display: layout(custom)的计算属性,加载layout worklet中的布局算法。执行了布局算法后的父级元素布局即为Parent Layout

  • Layout Edges

    Parent Layout中包含的边框、内边距与滚动条等属性,都被收集在了Layout Edges

  • Layout Constraint

    Parent Layout中移除掉Layout Edges,剩下用来布局子元素的区域叫做Layout Constraint

  • Child Layout

    Child Layout表示Current LayoutLayout Child的布局算法

  • LayoutChild

    LayoutChild是一个用来内部计算的容器,本身不具有布局信息,只能通过确定条件生成,可以被用来生成实际执行布局的Fragment

  • Fragment

    用来执行具体布局算法的元素

如何使用

  1. CSS部分

    .some-element {
      display: layout(custom);
    }
    

    在目标元素的display属性上使用layout()计算属性加载自定义的layout模块

  2. JS部分

    CSS.layoutWorklet.addModule('customLayout.js');
    
  3. worklet部分

    // customLayout.js
    registerLayout('custom', class {
      // 传入样式属性与配置选项
      static inputProperties = ['--foo'];
      static childrenInputProperties = ['--bar'];
      static layoutOptions = {
        childDisplay: 'normal',
        sizing: 'block-like'
      };
    
      async intrinsicSizes(children, edges, styleMap) {
        // 在这里计算固有尺寸
        const childIntrinsicSize = await children[0].intrinsicSizes();
      }
    
      async layout(children, edges, constraints, styleMap, breakToken) {
        // 在这里执行布局代码&算法
    
        // 在intrinsicSizes与layout函数中均会传入children列表
        const child = children[0];
    
        // 获取childInputProperties中列举的属性
        const fooValue = child.styleMap.get('--foo');
    
        // layoutNextFragment函数用来生成frament,执行布局
        const fragment = await child.layoutNextFragment({});
      }
    });
    

Layout Worklet

简要介绍Layout Worklet中的主要配置选项与函数

  • inputPropertieschildrenInputProperties

    每个Layout API容器均有一个styleMap对象,在inputProperties中可以设置从styleMap中可读取的属性,该属性可以是css属性也可以是自定义属性,如margin-top, padding, --foo等。子元素的styleMap可访问属性需要在childrenInputProperties中定义

  • layoutOptions

    设置布局选项,包含childDisplaysizing属性。其中sizing的取值有两种:block-like(默认)manual,前者表示应用盒容器的尺寸特性,后者表示尺寸由开发者指定

  • layout()

    该函数包含五个参数: children, edges, constraints, styleMapbreakToken

    • children ([LayoutChild]) - 表示Parent中的LayoutChild列表
    • edges (LayoutEdges) - 包含盒模型的所有边界距内容的距离值,如inlineStart, blockEnd等,这里的inline和block指的是根据writing-mode值所确定的排列方向。
    • constraints (LayoutConstraints) - 包含父级元素布局区域的各种尺寸,如fixedInlineSize, availableBlockSize
    • styleMap (StylePropertyMapReadOnly) - 一个包含自定义属性与样式属性的只读Map,属性在inputProperties中导入
    • breakToken - Spec中的说明(实际测试中未发现该参数,也许是笔者姿势不对..)
  • intrinsicSizes()

    该函数中会传入三个参数: children, edges, styleMap。可以通过children中LayoutChild的intrinsicSizes()方法获取所有子元素的固有尺寸,在此基础上可以结合其他两种参数计算并返回该current layout中的固有尺寸。intrinsicSizes对象包含minContentSizemaxContentSize两种属性

其他

Demo

GoogleChromeLabs的Houdini API相关示例:

https://googlechromelabs.github.io/houdini-samples/

一些Paint API示例:

https://github.com/gagyibenedek/paint-api-examples

支持情况检测

Paint API为例

JS中检测

if ('paintWorklet' in CSS) {
  CSS.paintWorklet.addModule('paintWorklet.js');
}

CSS中检测

@supports (background: paint(id)) {
  /* ... */
}

总结

介绍完了这些CSS Houdini API后,相信已经对它们有了一个比较系统的了解。期待它们在主流浏览器上的实现,并看看它带来的这些可能性怎么供广大开发者施展拳脚,拭目以待~

参考