查看原文
其他

【第2667期】CSS Houdini:用浏览器引擎实现高级CSS效果

Wei Xing 前端早读课 2022-07-11

前言

Houdini,有具体实践的吗?今日前端早读课文章由 vivo 互联网前端团队 @Wei Xing 分享,公号:vivo 互联网技术授权。

正文从这开始~~

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 与浏览器的渲染流程相关。

1、Paint API

提供了一组与绘制(Paint)过程相关的 API,我们可以通过它自定义的渲染规则,例如调整颜色(color)、边框(border)、背景(background)、形状等绘制规则。

2、Animation API

提供了一组与合成(composite)渲染相关的 API,我们可以通过它调整绘制层级和自定义动画。

3、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 Model 与 TypeScript 的命名类似,都增加了 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 的过程简述如下:

  • 使用 registerPaint() 方法创建一个 PaintWorklet。

  • 将它添加到 Worklet 模块中,CSS.paintWorklet.addModule()

  • 在 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 的创建和使用流程,分为三个步骤,简述如下:

  • 使用 registerAnimator() 方法创建一个 AnimationWorklet。

  • 将它添加到 Worklet 模块中,CSS.animationWorklet.addModule ()。

  • 使用 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) 方法有两个参数,就是它们让开发者能够干预动画执行过程。

1、currentTime:

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

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

2、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) 使用它。

/* myLayoutWorklet.js */

registerLayout('myLayoutWorklet', class {
static get inputProperties() { return ['--my-prop']; }

static get childrenInputProperties() { return ['--my-child-prop']; }

static get layoutOptions() {
return {
childDisplay: 'normal',
sizing: 'block-like'
};
}

intrinsicSizes(children, edges, styleMap) {
/* ... */
}

layout(children, edges, constraints, styleMap, breakToken) {
/* ... */
}
});
CSS.layoutWorklet.addModule('path/to/myLayoutWorklet.js');
.my-layout {
display: layout(myLayoutWorklet);
}

一个 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 一定会慢慢走进开发者的世界,大家可以期待并做好准备迎接它。

参考文献:

  • W3C Houdini Specification Drafts

  • State of Houdini (Chrome Dev Summit 2018)

  • Houdini’s Animation Worklet - Google Developers

  • Interactive Introduction to CSS Houdini

  • CSS Houdini Experiments

  • Interactive Introduction to CSS Houdini

  • Houdini Samples by Google Chrome Labs

关于本文
作者:@Wei Xing
原文:https://mp.weixin.qq.com/s/4owfsfX6bMzvgwvVxaCmzQ

关于【Houdini】相关推荐,欢迎读者自荐投稿,前端早读课等你

【第1254期】用CSS Houdini画一片星空

【第542期】Houdini:也许是你从未听过的在CSS领域最令人兴奋的发展

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存