CSS 中的 Babel 来了

最近读了几篇关于 Houdini 的文章,大部分都是 Phil Walton 写的,深感该提案之于 CSS 的重要性:

Web 中的 polyfill

每个激动人心的 Web 新特性都需要等待浏览器支持,在全部常见浏览器支持之前,我们需要写 polyfill 保证兼容性。其中针对 JS 的 polyfill 十分常见,例如下面是常见的从提案到 polyfill,再到最终实现的流程: JS polyfill process

简单的特性例如 ES6 中为 Array 增加的新方法 includes,我们可以:

if (typeof Array.includes != 'function') {
    Array.includes = function() {
        // Implement polyfill here...
    };
}

而对于 async/await,可以通过 babel 插件进行转译。

相比之下,针对 CSS 的 polyfill 则困难的多,不像 JS 这种动态语言,可以使用 JS 编写 JS polyfill,使用 CSS 本身很难编写 CSS。虽然有 PostCSS 这样的可以在构建时转译一部分 CSS 特性,大部分依赖运行时 DOM 结构的特性仍然得完全依靠浏览器逐步实现。 CSS polyfill process

在浏览器解析渲染 HTML 的流程中,JS 能充分干预的只有 DOM。而对于 CSSOM,缺乏浏览器一致的规范和 API。

CSS polyfill 的现状

让我们来看一个例子,我们想新增一个关键字 random,类似 JS 中 Math.random(),取值范围为 0 ~ 1

.foo {
    color: hsl(calc(random * 360), 50%, 50%);
    opacity: random;
    width: calc(random * 100%);
}

浏览器提供的 CSSOM 访问方式

我们很自然地想到,可以使用 document.styleSheets 获取页面上全部 CSS 规则,遍历并替换掉所有 random 的值:

for (const stylesheet of document.styleSheets) {
    // Flatten nested rules (@media blocks, etc.) into a single array.
    const rules = [...stylesheet.rules].reduce((prev, next) => {
        return prev.concat(next.cssRules ? [...next.cssRules] : [next]);
    }, []);

    // Loop through each of the flattened rules and replace the
    // keyword `random` with a random number.
    for (const rule of rules) {
        for (const property of Object.keys(rule.style)) {
            const value = rule.style[property];

            if (value.includes('random')) {
                rule.style[property] = value.replace('random', Math.random());
            }
        }
    }
}

运行后会发现根本就找不到包含 random 的规则。原因是当浏览器遇到无法解析的 CSS 规则,会直接忽略。 这个特性保证了容错和向后兼容,但同时也意味着我们无法获取原始的 CSS 规则。

手动解析样式

既然浏览器提供的访问 CSSOM 的方法不管用,我们只能尝试手动获取原始的样式内容,包括全部 <style><link rel='stylesheet'>,但是外链样式表中引用的就获取不到了:

const getPageStyles = () => {
    // Query the document for any element that could have styles.
    var styleElements =
        [...document.querySelectorAll('style, link[rel="stylesheet"]')];

    // Fetch all styles and ensure the results are in document order.
    // Resolve with a single string of CSS text.
    return Promise.all(styleElements.map((el) => {
        if (el.href) {
            return fetch(el.href).then((response) => response.text());
        } else {
            return el.innerHTML;
        }
    })).then((stylesArray) => stylesArray.join('\n'));
}

获取了原始内容,下一步就是解析样式了,这一步可以依靠现有的例如 PostCSS:

  1. 原始内容字符串转换成 AST
  2. 遍历 AST,替换变量后再生成对应的字符串
  3. 包含结果字符串的 <style> 节点插入 DOM 中
const randomKeywordPlugin = postcss.plugin('random-keyword', () => {
    return (css) => {
        css.walkRules((rule) => {
            rule.walkDecls((decl, i) => {
                if (decl.value.includes('random')) {
                    decl.value = decl.value.replace('random', Math.random());
                }
            });
        });
    };
});

看起来一切正常对吗?

等等,还有问题

我们仅仅在预处理阶段完成了针对一条规则的替换,换句话说这条规则的随机值在实际应用前就已经确定了。 例如两个 DOM 节点应用同一条规则,他们的 random 值就是一样的:

.foo {
    color: hsl(calc(0.1 * 360), 50%, 50%);
    opacity: 0.2;
    width: calc(0.3 * 100%);
}

所以仅仅修改某一条 CSS 规则是不够的,需要对页面上每一个应用这条规则的元素进行处理。最终可运行的方法在这里,就不展开了。

CSS polyfill 的弊端

仅仅新增一个看似简单的 random 关键字就已经困难重重,如果是更复杂的例如 position: sticky 这样依赖布局,并且随时可能需要重绘(例如 resize 事件)的特性就更加无从下手了。 所以目前只能通过浏览器前缀的方式降级处理。

原本获取资源,解析创建 CSSOM,处理层叠规则这些工作都是浏览器做的,由于没有开放给开发者,都需要 polyfill 处理,这意味着开发者必须编写大量复杂代码。 而且由于需要使用 fetch 手动获取,也存在跨域限制。

另外,由于 JS 编写的 polyfill 在首次渲染中派不上用场,执行时必须额外执行一遍完整的渲染流程,性能低下:

我们需要 Houdini

记得以前看魔术节目,讲到说历史上有一个有名的表演水下逃脱术的大师,叫 Houdini。 而这个叫做 Houdini 的项目,给予开发者一系列访问浏览器 CSS 引擎的能力,可以尽情施展,有无限的想象空间。这里面包括了能够控制元素布局的 Layout API,绘制图案的 Paint API,注册自定义属性的 Properties & Values API 等等。

虽然目前这些 API 大多处于开发阶段,但可以想见,一旦这些全部实现之后,上述 CSS polyfill 的问题就能够得到完美解决。 houdini-support

Worklet

之前提到了,CSS 无法对 CSS 进行 polyfill,所以肯定还得依靠 JS。从名字上看类似 Web Worker,两者确实有一定相同之处,比如只能访问某些受限的 API。但是由于 Worklet 需要挂载到 CSS 引擎,介入每一帧的渲染流程,这样的场景下性能开销较大的 Web Worker 就不适合了。

Worklet 可以说是 Houdini 一系列 API 实现的基础手段。

下面我们来体验一下已经在 Chrome 65 中实装的 Paint API 和 Worklet 的编写。

CSS Paint API

通过这个 API,开发者可以绘制图案,应用在例如 background-image 这样的属性上。

例如我们想实现一个 “X” 形占位符效果:

占位符效果

占位符效果

最终希望通过如下规则使用:

.placeholder {
    background-image: paint(placeholder-box);
}

在 Worklet 中,可以使用 ES6 的 class 语法编写,完成后使用 Paint API 提供的 registerPaint 完成自定义特性的注册。 其中 paint 方法中,可以使用类似 Canvas API 的方法完成绘制,第二个参数是应用该规则的当前元素的尺寸对象。

class PlaceholderBoxPainter {
    paint(ctx, size) {
        ctx.lineWidth = 2;
        ctx.strokeStyle = '#666';

        // draw line from top left to bottom right
        ctx.beginPath();
        ctx.moveTo(0, 0);
        ctx.lineTo(size.width, size.height);
        ctx.stroke();

        // draw line from top right to bottom left
        ctx.beginPath();
        ctx.moveTo(size.width, 0);
        ctx.lineTo(0, size.height);
        ctx.stroke();
    }
}

registerPaint('placeholder-box', PlaceholderBoxPainter);

最后在页面中完成 Worklet 的注册:

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

目前我们的 paint 方法使用固定的线宽和线条颜色绘制,能不能根据每一个元素当前的边框颜色和宽度自适应呢?还记得之前仅仅 polyfill 一条样式规则的问题吗?

Typed OM

读取当前 DOM 元素的其他 CSS 属性,需要依靠 Typed OM。 这个 API 是为了解决一个常见的问题:当我们想给一个样式属性设置值时,需要使用字符串拼接数字和单位,在计算时更加麻烦,还需要先去掉单位再进行运算。

$('#someDiv').style.height = getRandomInt() + 'px';

而有了 Typed OM,获取属性值以及计算都变得规范了:

// 获取 width 属性
var w1 = $('#div1').styleMap.get('width');
var w2 = $('#div2').styleMap.get('width');
// 设置 background-size 属性
$('#div3').styleMap.set('background-size', [new SimpleLength(200, 'px'), w1.add(w2)]);

在之前的基础上,通过 paint 方法的第三个参数 props,我们就能获取当前元素的样式属性值:

class PlaceholderBoxPropsPainter {
    static get inputProperties() {
        return ['border-top-width', 'border-top-color'];
    }

    paint(ctx, size, props) {
        // default values
        ctx.lineWidth = 2;
        ctx.strokeStyle = '#666';

        // set line width to top border width (if exists)
        let borderTopWidthProp = props.get('border-top-width');
        if (borderTopWidthProp) {
            ctx.lineWidth = borderTopWidthProp.value;
        }

        // set stroke style to top border color (if exists)
        let borderTopColorProp = props.get('border-top-color');
        if (borderTopColorProp) {
            ctx.strokeStyle = borderTopColorProp.toString();
        }

        // same drawing code as before goes here...
    }
}

Properties & Values API

除了使用规范中的 CSS 属性值,我们还可以自定义 CSS 变量,在 paint 方法中使用。

自定义 CSS 属性并不陌生:

.placeholder {
    background-image: paint(placeholder-box);
    --line-width: 2px;
    --stroke-style: red;
}

通过 API 注册自定义属性后,在 paint 中就可以像其他已支持的 CSS 属性一样引用了:

CSS.registerProperty({
    name: '--line-width',
    syntax: '<length>',
    initialValue: '2px'
});

更多应用场景

占位符的例子似乎不具有说服力。Paint API 的应用场景远不止如此。

熟悉 Material Design 的开发者都知道常见的水波纹效果,通常实现都是需要额外的 DOM 节点的,例如 Vuetify 中:

const container = document.createElement('span')
const animation = document.createElement('span')

container.appendChild(animation)
container.className = 'ripple__container'

而如果使用 Paint API,则不需要增加任何额外 DOM 元素。Ripple Demo

除此之外,自定义属性结合动画,可以做出很多炫酷的效果:

See the Pen Hello Houdini: Animated Polka Dot Fade by xiaop (@xiaoiver) on CodePen.

总结

Houdini 的优势是显而易见的。首先开发者可以在浏览器实现之前自己实现新特性,并保证一致性效果。光是这一点,就足够秒杀现有的各种针对 CSS 的 polyfill 和 hack 了。其次开放出来的介入 CSS 引擎的能力可以创造出很多有趣的效果,例如布局,动画。最后,各个浏览器厂商也都有意支持这个项目。

但是另一方面,需要等待各个浏览器对于 Houdini 本身的完成度。而且浏览器加载 Worklet 也需要时间,多少会影响首屏时间。另外, Worklet 的开发体验还有待提升,由于会被浏览器缓存,所以在开发调试时需要禁用缓存查看更新效果,而且目前 Chrome 开发工具中还不支持添加断点。

不过它提供的能力已经足够令人期待了,有人类比它是 CSS 中的 Babel。

参考资料