CSS Houdini API是 CSS 引擎暴露出来的一套 api,通过这套 API,开发者可以直接触及 CSSOM,告诉浏览器如何去解析 CSS,从而影响浏览器的渲染过程,实现很多炫酷的功能。
Properties and Values API 自定义属性
该 API 允许开发者自定义 CSS 属性,并告诉浏览器该如何解析。细想发现,这与Web Components有异曲同工之妙,只不过Web Components允许我们自定义 HTML 标签,而Properties and Values API允许我们自定义 CSS 属性。由此可以看出 Web 发展的一个重要趋势是,浏览器会越来越多地暴露底层能力给开发者。 Properties and Values API有两种书写形式,一种是 js 写法:
CSS.registerProperty({name: '--my-prop',syntax: '<color>',inherits: false,initialValue: '#c0ffee',});
另一种是 CSS 写法:
@property --my-prop {syntax: '<color>';inherits: false;initial-value: #c0ffee;}
这两种写法是等价的。它做了以下几件事:
name:定义了属性名(--my-prop);syntax:约定了属性的类型(<color>,所有支持的类型可以参考W3C 的标准),默认为*;inherits:规定是否可以继承父元素的属性,默认为true;initialValue:初始值、出错时兜底的值。
当我们将属性定义为<color>类型,就不能赋值给height属性,比如:
#app {width: 200px;height: var(--my-prop); /* 无效,高度为0 */}
但可以赋值给background-color
#app {width: 200px;height: 200px;--my-prop: red;background-color: var(--my-prop); /* 红色 */}
说了这么多,好像只说了Properties and Values API是什么,怎么用。但它如果没有好处,我为什么要用它呢? 不错。这里就举一个🌰吧。 我们知道,如果background是纯色的话,颜色切换的动画是很容易实现的,具体查看例子:CodePen。 但如果background是渐变色,然后用transition实现背景色切换,CSS 就无能为力了,CodePen上可以看到没有动画效果。不过,Properties and Values API可以轻松解决这个问题。
<head><title>cssPropertyValueApi</title><script>CSS.registerProperty({name: '--my-color',syntax: '<color>',inherits: false,initialValue: 'red',});</script><style>.box {width: 400px;height: 60px;--my-color: #c0ffee;background: linear-gradient(to right, #fff, var(--my-color));transition: --my-color 1s ease-in-out;}.box:hover {--my-color: #b4d455;}</style></head><body><div class="box"></div></body>
效果可以查看CodePen。 浏览器不知道如何处理渐变的转换,但知道如何处理颜色的转换。registerProperty方法告诉浏览器--my-color是<color>类型,所以transition能够处理--my-color的转换,从而实现渐变背景的动画效果。
Typed Object Model API
过去很长时间,我们用 js 操作CSSOM都是这么写:
// Element styles.el.style.opacity = 0.3;// 或者// Stylesheet rules.document.styleSheets[0].cssRules[0].style.opacity = 0.3;
好像很正常额,但是,如果我们打印一下opacity的类型:
el.style.opacity = 0.3;console.log(typeof el.style.opacity); // string
很多问号吧,类型竟然是string。 再来看看新的Typed Object Model API怎么写:
// Element styles.el.attributeStyleMap.set('opacity', 0.3);typeof el.attributeStyleMap.get('opacity').value === 'number' // true// Stylesheet rules.const stylesheet = document.styleSheets[0];stylesheet.cssRules[0].styleMap.set('background', 'blue');
直接赋值变成函数操作,更清晰了。除了set方法,还有has、delete、clear等方法。更详尽的 api 介绍可以到MDN网站上阅读。 元素上多了两个很重要的属性:attributeStyleMap和computedStyleMap,用来代替之前直接在style对象上的操作,后面会详细讲。 而且可以看到,这时opacity的类型是正确的。 再看一个例子:
el.attributeStyleMap.set('margin-top', CSS.px(10));el.attributeStyleMap.set('margin-top', '10px'); // string写法也没问题,向下兼容el.attributeStyleMap.get('margin-top').value // 10el.attributeStyleMap.get('margin-top').unit // 'px'el.attributeStyleMap.set('display', new CSSKeywordValue('initial'));el.attributeStyleMap.get('display').value // 'initial'el.attributeStyleMap.get('display').unit // undefined
Typed Object Model API增加了很多的类:
- CSSKeywordValue;
- CSSNumericValue;
- CSSTransformValue;
- …
还增加了很多有用的方法,如CSS.px、CSS.em等,效果跟使用CSSUnitValue类是一样的,就是更友好的一种形式而已。 属性值是一个对象,包含value和unit,当我们只想要数值而不想要单位时,可以减少解析这一步的处理。 总的来说,Typed Object Model API的设计让我们对样式的操作更明确了,也更像java了。
attributeStyleMap vs computedStyleMap
attributeStyleMap和computedStyleMap都是用来存放样式的对象,但两者有一些区别。 attributeStyleMap是一个对象,而computedStyleMap是一个函数。另外,computedStyleMap返回一个只读对象,只能执行get、has、entities、forEach等操作。 为什么要设计两个 map?因为我们设置的样式不一定完全符合约定,attributeStyleMap是原始输入的样式,而computedStyleMap经过浏览器转换最后实际应用的样式。
el.attributeStyleMap.set('opacity', 3);el.attributeStyleMap.get('opacity').value === 3 // 没有收紧el.computedStyleMap().get('opacity').value === 1 // 计算样式会收紧opacityel.attributeStyleMap.set('z-index', CSS.number(15.4));el.attributeStyleMap.get('z-index').value === 15.4 // 原始值el.computedStyleMap().get('z-index').value === 15 // 四舍五入
小结
Typed Object Model API带来了很多好处:
- 更少的心智负担和 bug:比如上面说的 opacity 的类型问题,可以避免
opacity + 0.5变成0.30.5。又比如,过去样式属性既可以驼峰写法也可以是横杆连接写法,现在只能是横杆连接写法(与 CSS 一致),我们再也不用在写法上纠结了。
el.style['background-color'] = 'red'; // ok// 等同于el.style['backgroundColor'] = 'red'; // okel.attributeStyleMap.set('background-color', 'red');
- 强大的数学操作和单位转换:我们可以将 px 单位的值转成 cm(厘米),这可能在某些场景下有用。
- 值的自动修正。
- 错误处理,可以用 try catch 语句捕获错误:
try {const css = CSSStyleValue.parse('transform', 'translate4d(bogus value)');// use css} catch (err) {console.error(err);}
- 性能更佳:js 对象转成 C++ 底层对象要比序列化、反序列化 string 再转 C++ 底层对象快。
Worklet
Houdini worlets 是一套类似于 web workers 的轻量级接口,允许用户使用浏览器渲染阶段的底层能力。使用方式有点类似 service worker,需要引入 js 文件,并注册模块。Houdini worlets 只能运行在 https 或者 localhost 上。 Houdini worlets 按功能分主要有 4 类:PaintWorklet、LayoutWorklet、AnimationWorklet和AudioWorklet,这里只会介绍前 3 类。 每种 worklet 对应着特定的 api 和特定的渲染阶段(cascade -> layout -> paint -> composite):
- Paint Worklet - Paint API - paint
- Layout Worklet - Layout API - layout
- AnimationWorklet - Animation API - composite
Paint API
Paint Api允许我们使用类似于 canvas 2D 的 api 定义如何绘制 image,主要用在一些可以设置 image 的 CSS 属性上,比如background-image、border-image、list-style-image等。主要步骤分为 3 步:
registerPaint定义如何绘制;CSS.paintWorklet.addModule注册模块;- 在 CSS 里调用全局的
paint方法绘制指定模块。
// path/to/worklet/file.jsregisterPaint('paint-color-example', class {static get inputProperties() {return ['--my-color'];}static get inputArguments() {return ['<color>'];}static get contextOptions() {return {alpha: true};}paint(ctx, size, properties, args) {ctx.fillStyle = properties.get('--my-color');ctx.beginPath();...});// html或者main jsCSS.paintWorklet.addModule('path/to/worklet/file.js');// 或者引用外部url,但需要https// CSS.paintWorklet.addModule("https://url/to/worklet/file.js");
registerPaint里的类有几个方法:
inputProperties,要使用哪些 CSS 属性;inputArguments,CSS 中使用 paint 函数除了模块名外的其他参数,指定其类型;contextOptions,由于使用的是 canvas 的 2D render context 绘制,所以可能会设置一些 canvas 上下文的选项;paint:最关键的方法,定义绘制行为。ctx的使用和 canvas 一致,size表示绘制的大小,包括 width、height 等信息,properties就是inputProperties静态方法里定义的属性,args就是paint的入参,跟inputArguments定义的对应。
CSS.paintWorklet.addModule注册模块,可以是本地路径,也可以是外部的 url。 最后,在 CSS 里使用
.example {background-image: paint(paint-color-example, blue);}
Houdini.how网站上有很多使用 Paint API 实现的炫酷效果,大家可以去看看。
Layout API
Layput API扩展了浏览器 layout 的能力,主要作用于 CSS 的display属性。 基本写法如下:
registerLayout('layout-api-example', class {static get inputProperties() { return ['--exampleVariable']; }static get childrenInputProperties() { return ['--exampleChildVariable']; }static get layoutOptions() {return {childDisplay: 'normal',sizing: 'block-like'};}intrinsicSizes(children, edges, styleMap) {/* ... */}layout(children, edges, constraints, styleMap, breakToken) {/* ... */}});
inputProperties,父布局元素使用的属性childrenInputProperties,子布局元素使用的属性layoutOptionschildDisplay,预定义子元素的display值,block或者normalsizing,值为block-like或者manual,告诉浏览器是否要预先计算大小
intrinsicSizes,定义盒子或者内容如何适配布局children,子元素edges,盒子边缘styleMap,盒子的 Typed Object Model
layout,布局实现的主要函数children,子元素edges,盒子边缘constraints,父布局的约束styleMap,盒子的 Typed Object ModelbreakToken,分页或者打印时使用的分割符
定义好之后使用,跟 Paint Api 类似
// 注册模块CSS.layoutWorklet.addModule('path/to/worklet/file.js');
.example {display: layout(layout-api-example); /* 作为一种自定义的dislay */}
目前 CSS 已经有很多种布局方式了,我们还需要Layout API吗?当然需要,做过移动端开发的同学应该知道,瀑布流布局(Masonry)是很常见的。如果我们根据业务定义好这 Masonry 布局,下次再遇到同样的需求,就可以直接复用了。网上已经有人实现了 Masonry 布局,大家可以参考一下。
Animation API
扩展浏览器动画的能力,能够监听 scroll、hover、click 等事件,提供流畅的动画效果。 基本用法:
// 定义动画registerAnimator("animation-api-example", class {constructor(options) {/* ... */}animate(currentTime, effects) {/* ... */}});
amimate:动画的主要实现逻辑
currentTime,时间线上当前的时间;effects,动效的集合。
// 注册,异步函数await CSS.animationWorklet.addModule("path/to/worklet/file.js");;// 动画要作用的元素const elementExample = document.getElementById("elementExample");// 定义关键帧动画const effectExample = new KeyframeEffect(elementExample,[ /* ... */ ], /* 关键帧 */{ /* ... */ }, /* duration, delay, iterations等选项 */);/* 创建WorkletAnimation实例并运行 */new WorkletAnimation("animation-api-example" // 前面定义的动画名effectExample, // 动画document.timeline, // 输入时间线{}, // constructor的参数).play();
动画的知识点非常多,不是本文所能涵盖的。 网上有人用 Animation API 实现了以下的动画效果,具体可以参看这里
可以用了吗
目前只是部分主流浏览器实现了部分 API,要谨慎使用,最好判断浏览器是否支持再使用,或者借助 polyfill。
总结
Houdini API是一套功能强大,暴露 CSS 引擎能力的方案;- 优势明显,比如:更友好的 API、轻松突破以往 CSS 有限的能力范围、性能提升;
- 浏览器实现程度不是很好,很多 API 还在草案当中,有些 API 的使用需要借助 polyfill。本文并没有提及
Parser API和Font Metrics API,这两个还在提案阶段,以后变数太大; Houdini API还是很值得期待的,大家可以持续关注下。
