从字符串到对象的转变:一个动画场景的困惑
假设你在实现一个拖拽缩放的商品卡片,需要在每一帧读取当前宽度并加上增量。传统写法是 el.style.width,读出来的是 "320px" 这样的字符串。要计算新值,你得先 parseFloat 去掉单位,做加法,再拼回 "px" 后缀写回去。这个过程在每一帧重复,而且 parseFloat 对格式敏感,"320.5px" 和 "320px " 的结果可能不同。更隐蔽的问题是,el.style.width += 0.1 这类代码会得到 "0.30.1" 这样的字符串,因为 += 做的是字符串拼接而不是数值加法。
CSS Typed OM(CSS 类型对象模型)把样式值变成类型化对象:el.attributeStyleMap.get('width') 返回一个 CSSUnitValue,它有 .value 和 .unit 两个属性,分别是数字 320 和字符串 "px"。写入时直接 set('width', CSS.px(320.5)),不需要拼接字符串。这个 API 属于 Houdini 项目的一部分,目标是让 CSS 值在 JavaScript 中可以被直接操作,而不是通过字符串中转。
传统 CSSOM 的字符串解析开销
在 CSS Typed OM 出现之前,浏览器内部把 CSS 值存储为“内部表示”,这是引擎自定义的数据结构,JavaScript 无法直接访问。规范规定,作者只能通过字符串与这些内部表示交互:写入时,浏览器解析字符串;读取时,浏览器把内部值序列化成字符串。这个解析和序列化过程是有成本的,尤其是在高频读写时。
以 transform 为例,它的值是一个变换函数列表,比如 translate(10px, 20px) scale(1.5)。用传统方式读取 el.style.transform,浏览器需要把内部的变换矩阵或函数列表序列化成字符串。写入时,浏览器要解析字符串,拆出函数名和参数,再转成内部结构。如果动画中每帧都做一次读和写,字符串的解析和序列化就会重复发生。
更麻烦的是单位换算。CSS 中有多种长度单位,px、em、rem、vw 等。传统方式下,如果你想在 px 和 cm 之间换算,需要自己写转换逻辑,而且不同属性的参考值不同(比如 em 相对于元素字体大小)。CSS Typed OM 提供了 to() 方法,可以直接把 CSSUnitValue 从一种单位换算成另一种,例如 CSS.px(96).to('cm') 返回 CSS.cm(2.54)(假设 96dpi)。
CSS Typed OM 的核心对象:CSSStyleValue 与 StylePropertyMap
CSS Typed OM 的基类是 CSSStyleValue,所有类型化值都继承自它。CSSStyleValue 有一个 parse() 静态方法,可以把字符串解析成类型化对象;parseAll() 则返回所有匹配的值。这些方法在需要处理外部字符串时很有用,比如从 data 属性读取样式。
数值类型是重点。CSSUnitValue 表示“数值 + 单位”,例如 CSS.px(10) 返回一个 CSSUnitValue,其 .value 为 10,.unit 为 "px"。CSSNumericValue 是所有数值的基类,定义了 add()、sub()、mul()、div() 等方法,这些方法会返回新的 CSSMathValue 对象,表示复杂的计算表达式,比如 calc(10px + 5%)。CSSMathValue 的子类包括 CSSMathSum、CSSMathProduct 等,对应 calc() 中的运算。
StylePropertyMap 是声明块的类型化表示,替代 CSSStyleDeclaration。元素上的 attributeStyleMap 属性返回一个 StylePropertyMap,它像 Map 一样有 get()、set()、has()、delete()、clear() 方法,并且是可迭代的。样式表规则中的 styleMap 属性也返回 StylePropertyMap。
单位换算与数学运算:从手写逻辑到内置方法
传统方式下,单位换算需要手动处理。例如,要把宽度从 px 转成 em,需要知道父元素的字体大小。CSS Typed OM 的 to() 方法在支持的环境中可以自动完成换算,因为浏览器知道参考值。但要注意,to() 只能在不同绝对单位或相对单位之间换算,如果目标单位与源单位不兼容(比如 px 转 deg),会抛出错误。
数学运算方面,CSSNumericValue 的 add() 等方法可以直接操作数值,而不需要先转成字符串。例如:
const width = el.attributeStyleMap.get('width'); // CSSUnitValue {value: 320, unit: 'px'}
const newWidth = width.add(CSS.px(10)); // CSSMathSum,表示 320px + 10px
el.attributeStyleMap.set('width', newWidth);
这里 newWidth 是一个 CSSMathSum 对象,序列化时会变成 calc(320px + 10px)。浏览器在应用样式时会计算这个值,而不是先简化成 330px。这可能会影响性能,因为每次布局都需要重新计算。如果希望得到简化后的值,可以使用 to('px') 或 value 属性,但 CSSMathSum 的 value 属性是 null,因为它不是一个单一值。
动画中的性能对比:为什么 Typed OM 更快
在动画场景中,性能差异主要来自字符串解析的避免。规范明确指出,将 CSSOM 值字符串转换成类型化 JavaScript 表示以及反向转换会带来显著性能开销。Typed OM 允许直接操作内部表示,然后廉价地转换回去,无需构建和解析 CSS 字符串。
以一个简单的宽度动画为例,假设每帧需要增加 1px。传统方式:
el.style.width = (parseFloat(el.style.width) + 1) + 'px';
这里每次读取都触发序列化,每次写入都触发解析。Typed OM 方式:
const width = el.attributeStyleMap.get('width');
width.value += 1; // 直接修改数值
el.attributeStyleMap.set('width', width);
注意,get() 返回的 CSSUnitValue 是引用,修改 value 会影响原对象,但 set() 仍然需要调用,因为 StylePropertyMap 不会自动感知对象变化。不过,这里的读取和写入都避免了字符串操作。
对于 transform,差异更明显。传统方式需要拼接字符串:
el.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
每次拼接都要生成新字符串,浏览器解析它。Typed OM 方式:
const transform = new CSSTransformValue([
new CSSTranslate(CSS.px(x), CSS.px(y)),
new CSSScale(CSS.number(scale), CSS.number(scale))
]);
// 更新时直接修改对象的属性
el.attributeStyleMap.set('transform', transform);
但注意,CSSTransformValue 中的组件对象也是引用,修改 x 属性后,需要重新 set() 才能生效。不过,这仍然避免了字符串解析。
实际性能提升取决于浏览器实现。规范说“在许多情况下更高效”,但并没有给出具体数字。在 Chrome 中,Typed OM 的读写通常比字符串快,因为省去了解析和序列化。但要注意,如果动画本身受布局限制,瓶颈可能不在样式读写上,而在布局计算上。
浏览器支持与 Houdini 的关系
CSS Typed OM 是 Houdini 项目的一部分,Houdini 是一组旨在开放 CSS 内部能力的 API。Typed OM 为其他 Houdini API 提供了基础,例如 CSS Paint API 和 CSS Layout API,它们需要以类型化方式访问样式值。
浏览器支持方面,CSS Typed OM 目前不是 Baseline 特性,意味着在主流浏览器中并未全部支持。根据 MDN 的标注,该特性“有限可用”,因为它尚未在主流浏览器中得到支持。Chrome 从 66 版本开始支持部分属性,但 Firefox 和 Safari 的支持情况需要查阅最新的兼容性表。因此,在生产环境中使用前,必须进行特性检测,并提供降级方案。
特性检测可以使用 'attributeStyleMap' in Element.prototype 或 'CSS' in window 等。如果浏览器不支持,可以回退到传统的 style 字符串操作。
常见失败模式与诊断
使用 Typed OM 时,有几个常见的坑。
单位不匹配:add() 等方法要求操作数单位兼容,否则抛出 TypeError。例如,CSS.px(1).add(CSS.deg(90)) 会报错。
CSSMathValue 的序列化:CSSMathSum 序列化为 calc() 表达式,可能比预期更长,影响可读性。
引用问题:get() 返回的对象是引用,但修改后必须重新 set()。如果忘记 set(),样式不会更新。
自定义属性:对于自定义属性(CSS 变量),Typed OM 返回 CSSUnparsedValue,它包含字符串片段和变量引用,不能直接进行数学运算。需要先解析。
性能假象:如果动画中每帧都创建新的 CSSUnitValue 对象,可能产生垃圾回收压力。更好的做法是复用对象,只修改 value。
诊断时,可以使用 DevTools 的 Performance 面板查看脚本和样式计算的时间。如果样式读写占比较高,考虑使用 Typed OM。
与其他方案的权衡
除了 Typed OM,还有其他方式操作样式:
- 直接操作
style属性:简单,但字符串解析开销大,易出错。 - CSS 变量 +
setProperty:可以避免部分字符串问题,但读取时仍需解析。 - Web Animations API:用于动画,可以避免手动操作样式,但只适用于动画,不适用于一般样式更新。
下面是一个对比表格:
| 方案 | 性能 | 可维护性 | 浏览器支持 | 适用场景 |
|---|---|---|---|---|
el.style | 低(字符串解析) | 低(易拼接错误) | 所有浏览器 | 简单、低频修改 |
setProperty | 中(仍解析字符串) | 中 | 所有浏览器 | 需要设置自定义属性 |
| CSS Typed OM | 高(避免解析) | 高(类型化、单位感知) | 有限(Chrome 部分支持) | 高频读写、动画、复杂计算 |
| Web Animations API | 高(引擎优化) | 高(声明式) | 广泛(Baseline) | 动画场景 |
选择时,需要考虑浏览器支持。如果目标用户主要在 Chrome,Typed OM 可以提升性能;如果需要跨浏览器,可能需要 polyfill 或回退。
贯穿场景:商品卡片的拖拽缩放
回到开头的商品卡片。假设卡片支持拖拽缩放,每帧需要更新 transform 的 scale 和 translate。使用 Typed OM,可以这样实现:
// 初始化
const transform = new CSSTransformValue([
new CSSTranslate(CSS.px(0), CSS.px(0)),
new CSSScale(CSS.number(1), CSS.number(1))
]);
card.attributeStyleMap.set('transform', transform);
// 拖拽过程中
function onDrag(dx, dy) {
const t = card.attributeStyleMap.get('transform');
t[0].x.value = dx;
t[0].y.value = dy;
card.attributeStyleMap.set('transform', t);
}
// 缩放
function onZoom(scale) {
const t = card.attributeStyleMap.get('transform');
t[1].x.value = scale;
t[1].y.value = scale;
card.attributeStyleMap.set('transform', t);
}
这里 t[0] 是 CSSTranslate,t[1] 是 CSSScale。修改属性后必须重新 set()。
如果使用传统方式,每次都要拼接字符串:
card.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
在低端设备上,字符串拼接和解析的差异可能影响帧率。但要注意,如果卡片还触发布局(比如宽度变化),瓶颈可能在布局,而不是样式读写。
数据流与流程图
下面用流程图展示传统方式和 Typed OM 的样式写入路径:
flowchart TD
A[开始] --> B{使用哪种方式?}
B -->|传统 style| C[拼接字符串]
C --> D[浏览器解析字符串]
D --> E[转换为内部表示]
E --> F[应用样式]
B -->|Typed OM| G[创建/修改类型化对象]
G --> H[直接映射到内部表示]
H --> F
F --> I[布局/绘制]
传统方式需要经过字符串解析,而 Typed OM 直接操作内部表示,减少了中间步骤。
适用边界与未来
CSS Typed OM 并非万能。它目前不支持所有 CSS 属性,规范仍在制定中。对于不支持的属性,get() 可能返回 CSSUnparsedValue 或抛出错误。此外,它不能替代 CSS 动画或过渡,那些由浏览器优化,通常性能更好。
未来,随着 Houdini 其他 API 的普及,Typed OM 可能成为标准。但在那之前,开发者需要权衡性能和兼容性。如果项目必须支持 Safari 或 Firefox,可能需要等待或使用 polyfill(但 polyfill 无法提供原生性能)。
总之,CSS Typed OM 通过类型化对象消除了字符串解析,为高频样式操作提供了性能优势,但它的采用受限于浏览器支持。在 Chrome 环境中,对于动画和复杂计算,它值得尝试。