CSS Houdini 与 CSS-Typed-OM
为什么需要 Houdini
传统 CSS 的问题在于它是声明式的黑盒——你无法用 JavaScript 干预 CSS 引擎的内部工作。当 JavaScript 修改样式时,只能在 CSSOM 层面操作字符串,既无法获取类型安全的值,也无法在渲染阶段插入自定义逻辑。
CSS Houdini 是一组 Low-level API,提供了访问 CSS 引擎内部的能力,让开发者能够编写自定义的 Paint、Layout、Animation 等逻辑。这些 API 于 2016 年首次提出,经过多年的发展,正在逐步被浏览器实现。
理解 Houdini 的架构和 API,对于构建真正可扩展的 CSS 解决方案至关重要。
面试定位:Houdini 是 CSS 的高级特性,不是面试高频考点,但它代表了 CSS 的未来方向。面试官通过候选人对 Houdini 架构的理解、对各个 API 的掌握程度、以及对浏览器支持现状的认知,来评估其技术视野和工程判断能力。
Houdini 的核心 API 概览
Houdini 包含多个 API,每个 API 负责不同的渲染阶段:
| API | 功能 | 状态 |
|---|---|---|
| Paint API | 自定义绘制逻辑 | 较完善支持 |
| Layout API | 自定义布局算法 | 实验性 |
| Properties & Values API | 自定义属性和值类型 | 较完善支持 |
| Animation Worklet | 自定义动画逻辑 | 实验性 |
| Font Metrics API | 字体度量 | 提议中 |
Worklets 机制
Houdini 的核心概念是 Worklet。Worklet 是一种轻量级的脚本机制,允许开发者在渲染管线的特定阶段执行自定义代码。
Worklet vs Service Worker
Worklet 与 Service Worker 有相似之处,但关键区别在于:
- Service Worker:运行在独立线程,处理网络请求和缓存
- Worklet:运行在渲染管线特定阶段,处理绘制、布局等
// 注册 Paint Worklet
CSS.paintWorklet.addModule('paint-worklet.js');
Worklet 脚本必须遵循以下规则:
- 无状态:Worklet 中的代码应该无状态运行,每次调用都独立
- 单线程:Worklet 在主线程之外运行,但不意味着可以执行耗时操作
- 受限 API:Worklet 中只能访问有限的 API
Paint Worklet 示例
Paint Worklet 允许开发者自定义元素的背景、边框等绘制逻辑:
// paint-worklet.js
registerPaint('my-paint', class {
// 上下文选项
static get contextOptions() {
return { alpha: true };
}
// 输入属性
static get inputProperties() {
return ['--my-color', '--my-size'];
}
// 主绘制方法
paint(ctx, size, properties) {
// ctx: PaintRenderingContext2D
// size: PaintSize (元素尺寸)
// properties: StylePropertyMap
const color = properties.get('--my-color');
const mySize = properties.get('--my-size');
ctx.fillStyle = color.toString();
ctx.fillRect(0, 0, size.width * mySize.value, size.height * mySize.value);
}
});
.element {
background-image: paint(my-paint);
--my-color: blue;
--my-size: 0.5;
}
Paint API 详解
Paint API 是 Houdini 中最成熟的 API,允许开发者使用 JavaScript 绘制背景、边框等。
registerPaint 参数
registerPaint(name, class, options)
name:注册的名称,在 CSS 中使用paint(name)引用class:包含绘制逻辑的类options:可选配置
类方法
contextOptions
返回上下文选项,目前只支持 { alpha: true }:
static get contextOptions() {
return { alpha: true };
}
inputProperties
返回需要监听的自定义属性列表:
static get inputProperties() {
return [
'--my-color',
'--my-size',
'width', // 也可以监听原生属性
'height'
];
}
paint(ctx, size, properties)
主绘制方法,接收三个参数:
paint(ctx, size, properties) {
// ctx: PaintRenderingContext2D (类似 Canvas 2D Context)
// size: PaintSize { width, height }
// properties: StylePropertyMap
// 绘制逻辑
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, size.width, size.height);
}
Paint API 实用示例
示例一:动态条纹背景
// stripe-worklet.js
registerPaint('stripe', class {
static get contextOptions() {
return { alpha: true };
}
static get inputProperties() {
return ['--stripe-color', '--stripe-width', '--stripe-angle'];
}
paint(ctx, size, properties) {
const color = properties.get('--stripe-color').toString().trim();
const width = parseInt(properties.get('--stripe-width')) || 10;
const angle = parseInt(properties.get('--stripe-angle')) || 45;
ctx.fillStyle = color;
// 绘制条纹
const diagonal = Math.sqrt(size.width ** 2 + size.height ** 2);
ctx.save();
ctx.translate(size.width / 2, size.height / 2);
ctx.rotate((angle * Math.PI) / 180);
for (let i = -diagonal; i < diagonal; i += width * 2) {
ctx.fillRect(i, -diagonal, width, diagonal * 2);
}
ctx.restore();
}
});
.striped-bg {
background-image: paint(stripe);
--stripe-color: #667eea;
--stripe-width: 20;
--stripe-angle: 45;
}
示例二:渐变边框
registerPaint('gradient-border', class {
static get contextOptions() {
return { alpha: true };
}
static get inputProperties() {
return ['--border-width', '--gradient-colors'];
}
paint(ctx, size, properties) {
const borderWidth = parseInt(properties.get('--border-width')) || 4;
const colors = properties.get('--gradient-colors').toString().split(',');
const gradient = ctx.createLinearGradient(0, 0, size.width, size.height);
colors.forEach((color, index) => {
gradient.addColorStop(index / (colors.length - 1), color.trim());
});
ctx.strokeStyle = gradient;
ctx.lineWidth = borderWidth;
ctx.strokeRect(0, 0, size.width, size.height);
}
});
.gradient-border {
background-image: paint(gradient-border);
--border-width: 4;
--gradient-colors: #667eea, #764ba2, #f093fb;
}
Properties and Values API
Properties and Values API 允许开发者注册自定义属性,并为其定义类型、初始值和继承行为。
注册自定义属性
CSS.registerProperty({
name: '--my-color',
syntax: '<color>',
inherits: false,
initialValue: 'blue'
});
参数说明
| 参数 | 类型 | 描述 |
|---|---|---|
| name | string | 属性名(必须以 -- 开头) |
| syntax | string | 语法描述 |
| inherits | boolean | 是否继承 |
| initialValue | string | 初始值 |
syntax 语法
// 颜色
CSS.registerProperty({
name: '--my-color',
syntax: '<color>',
initialValue: 'blue'
});
// 数值(带单位)
CSS.registerProperty({
name: '--my-length',
syntax: '<length>',
initialValue: '10px'
});
// 百分比
CSS.registerProperty({
name: '--my-percentage',
syntax: '<percentage>',
initialValue: '50%'
});
// 多个可能值
CSS.registerProperty({
name: '--my-transform',
syntax: 'none | <transform-list>',
initialValue: 'none'
});
// 自定义值
CSS.registerProperty({
name: '--my-custom',
syntax: 'a | b | c',
initialValue: 'a'
});
与 CSS 变量的区别
自定义属性通过 Properties and Values API 注册后,具有类型检查能力:
// 注册了类型的属性
CSS.registerProperty({
name: '--my-number',
syntax: '<number>',
initialValue: '0'
});
// 在 CSS 中使用
.element {
--my-number: 10; /* ✓ 有效 */
--my-number: 20px; /* ✗ 无效(类型不匹配) */
}
这使得动画和过渡可以作用于自定义属性:
.animated {
--my-size: 0;
animation: grow 1s forwards;
}
@keyframes grow {
to { --my-size: 100; }
}
.animated:hover {
--my-size: 200; /* 支持过渡 */
transition: --my-size 0.3s;
}
CSS Typed OM
传统的 CSSOM 使用字符串操作样式值,既不直观也容易出错。CSS Typed OM 提供了类型化的对象来表示 CSS 值。
StylePropertyMap
element.attributeStyleMap 和 element.computedStyleMap() 返回 StylePropertyMap 对象。
// 设置值
element.attributeStyleMap.set('padding', CSS.px(10));
element.attributeStyleMap.set('opacity', CSS.number(0.5));
// 获取值
const padding = element.computedStyleMap().get('padding');
console.log(padding.value); // 10
console.log(padding.unit); // 'px'
const opacity = element.computedStyleMap().get('opacity');
console.log(opacity.value); // 0.5
console.log(opacity.type); // CSSUnitType.NUMBER
CSSUnitValue
表示带单位的数值:
const length = CSS.px(100);
console.log(length.value); // 100
console.log(length.unit); // 'px'
// 运算
const doubled = CSS.px(length.value * 2);
// 类型转换
const rem = length.to('rem');
CSSMathValue
表示计算值:
// 加法
const sum = CSS.px(10).add(CSS.px(20)); // 30px
// 乘法
const scaled = CSS.px(10).multiply(2); // 20px
支持的类型
| 类型 | 工厂方法 | 示例 |
|---|---|---|
| 长度 | CSS.px(), CSS.em(), CSS.rem() | CSS.px(10) |
| 百分比 | CSS.percent() | CSS.percent(50) |
| 数值 | CSS.number() | CSS.number(0.5) |
| 颜色 | CSS.rgb(), CSS.hsl() | CSS.rgb(255, 0, 0) |
| transform | CSS.transform() | CSS.transform([...]) |
| 计算 | CSS.calc() | CSS.calc(CSS.px(10).add(CSS.em(2))) |
Layout API
Layout API 允许开发者自定义元素的布局算法。这是一个实验性 API,目前支持有限。
registerLayout
registerLayout('my-layout', class {
async intrinsicSizes() {
// 返回固有尺寸
return { minSize: 0, maxSize: 0 };
}
async layout(children, edges, constraints, styleMap) {
// 布局逻辑
const childFragments = await Promise.all(
children.map(child => child.layoutNextFragment())
);
// 计算尺寸
const width = constraints.fixedInlineSize;
const height = 100; // 自定义高度
return {
autoBlockSize: height,
childFragments
};
}
});
.outer {
display: layout(my-layout);
}
Animation Worklet
Animation Worklet 允许开发者创建高性能的 JavaScript 动画。
registerAnimator('my-animator', class {
constructor(options) {
this.options = options;
}
animate(currentTime, effect) {
// effect.target: 动画目标元素
// effect.localTime: 本地时间
const progress = currentTime / 1000; // 转换为秒
effect.target.style.transform = `rotate(${progress * 360}deg)`;
}
});
// 注册 worklet
await CSS.animationWorklet.addModule('animator.js');
// 创建动画
const element = document.querySelector('.rotating');
const animation = new Animation(
new WorkletEffect({ target: element }),
new DocumentTimeline()
);
animation.play();
浏览器支持与注意事项
当前支持状态
Houdini 的各个 API 支持程度不同:
- Paint API:Chromium 系浏览器较完善支持
- Properties and Values API:Chromium 系浏览器较完善支持
- CSS Typed OM:Chromium 系浏览器较完善支持
- Layout API:实验性支持
- Animation Worklet:实验性支持
使用 Feature Query
@supports (background-image: paint(my-paint)) {
.element {
background-image: paint(my-paint);
}
}
渐进增强策略
Houdini 应该作为渐进增强的一部分使用:
/* 基础样式 */
.box {
background: blue;
}
/* Houdini 增强 */
@supports (background-image: paint(my-paint)) {
.box {
background-image: paint(my-paint);
--my-color: red;
}
}