- 前端
- 构建工具
【免费下载链接】css-blocks
High performance, maintainable stylesheets.
导读
本文围绕 packages/@css-blocks/runtime/README.md 展开,深入讲解 CSS Blocks 项目中负责动态类名(dynamic classnames)运行时求值的核心模块:@css-blocks/runtime。你将从零理解c$$([3, 2, 0, leSigh, ...])这类紧凑数组表达式是如何被逐段解析的——包括三元条件、样式依赖、字符串 switch 与输出布尔表达式——并进一步掌握ExpressionContainer的二进制编码原理与 JSX 转换器生成c$$调用的完整链路。读完本文,你将能够读懂任意一段c$$序列化表达式,并理解 CSS Blocks 为何用这种"数据堆栈 + 微型解析器"的方式换取极致的运行时性能。
一、为什么需要运行时:从objstr到c$$
CSS Blocks 的理念是:样式在编译期被静态分析、由 OptiCSS 优化,得到精简后的 CSS 类名映射。但模板中总有一部分样式依赖运行时才能确定的值(例如用户输入、异步数据),这些"动态样式"无法在编译期固化为静态类名字符串,必须借助一个轻量运行时来计算最终应挂载到元素上的类名。
@css-blocks/runtime正是为此而生。它在 package.json 中的自我定位是 "Browser runtime for computing dynamic classnames with css-blocks",对外暴露的默认导出函数在 JSX 转换结果中被命名为c$$(定义见 classNameGenerator.ts 的HELPER_FN_NAME)。
它的设计目标是高效且极简(efficient and terse):编译器把动态表达式"压扁"成一个扁平的参数数组(flat argument stack),运行时只需顺序消费这个数组即可算出最终类名,不涉及任何 AST、闭包或对象分配开销。
以 JSX 中最常见的objstr写法为例,下面的作者代码:
let style = objstr({ [bar.pretty]: leSigh, [bar.pretty.bool()]: true, [bar.pretty.color(dynamic)]: true });经 CSS Blocks 的 Babel 转换器重写后会变成:
c$$([ 3, 2, 0, leSigh, 1, 0, 0, 1, 1, 0, 1, 1, 5, 1, 0, 1, 0, dynamic, "yellow", 1, 2, "c", -2, 2, 0, 1, "d", 2])这一转换的完整证据链可以在 transformer-test.ts 中找到:测试先断言转换后的 JSX 代码包含上述c$$调用,再直接执行c$$([...])断言其返回"c d"。
二、c$$的两种调用形态与执行框架
运行时入口函数定义在 src/index.ts:
export default function c(staticClasses: string | unknown[], stack?: unknown[]): string它支持两种调用形态:
- 单参数形态:
c$$([3, 2, ...])—— 直接把整个序列化堆栈作为唯一参数,此时不携带静态类名; - 双参数形态:
c$$("a", [1, 1, 2, ...])—— 第一个参数是编译期确定的静态类名(以空格分隔的字符串),第二个参数才是序列化堆栈。静态类名会被无条件追加到输出最前面。
从源码可以看到执行框架非常简单清晰(src/index.ts):
- 读取堆栈头部的两个数字:
nSources(源表达式数量)和nOutputs(输出表达式数量); - 用一个布尔数组
sources记录"源样式"(source style)是否被置为 on; - 循环处理
nSources个源表达式(sourceExpr),每处理完一个就重置canSetSource; - 循环处理
nOutputs个输出表达式(boolExpr),每个输出表达式关联一个类名,求值为真就把该类名拼进结果。
注意源码注释中的一句重要说明:"the api it provides is not optimized for human consumption, but rather, for speed of evaluation"——这个 API 是给编译器(rewriter)生成的,不是给人手写的。因此理解它的唯一正确方式是把它当作一种序列化协议来阅读。
三、序列化堆栈逐段拆解:源表达式部分
下面严格依照 README 的拆解逻辑,逐段解读c$$([3, 2, 0, leSigh, ...])这个完整例子。
1. 堆栈头部:两个计数
- 第一个数字
3:输入动态源样式的数量(有多少个条件源表达式待处理); - 第二个数字
2:输出动态样式(类)的数量(最终要计算多少个输出类名)。
2. 源表达式的类型标识
每个源表达式都以一个类型数字开头,后续参数根据类型而不同。完整的类型枚举定义在 src/index.ts:
| 类型值 | 含义 |
|---|---|
0 | ternary —— 布尔三元表达式 |
1 | dependency —— 纯依赖(无自身条件) |
2 | boolean —— 布尔条件 |
3 | booleanWithDep —— 布尔条件 + 依赖 |
4 | switch —— 字符串 switch |
5 | switchWithDep —— 字符串 switch + 依赖 |
依赖位(1)可以被"或"到任何其他条件类型上(如3 = 2 | 1、5 = 4 | 1),也可以独立使用,这正是示例中1与5的由来。
3. 三元表达式:0, leSigh, 1, 0, 0
0:类型为三元表达式;leSigh:作为布尔条件被求值;1:条件为真时设置的源样式个数;0:为真时置 on 的源样式索引(style index 0);0:条件为假时设置的源样式个数(此处为 0 个,即假时不设置任何样式)。
三元表达式的参数布局在 src/index.ts 有完整注释:1: 真时样式数 t / 2: 假时样式数 f / 3: 条件表达式 / 4..4+t-1: 真时样式索引 / 4+t..4+t+f-1: 假时样式索引。
4. 依赖表达式:1, 1, 0, 1, 1
1:类型为纯依赖(dependency);1:依赖的样式个数;0:所依赖的样式索引(style index 0);1:该依赖条件成立时置 on 的样式个数;1:置 on 的样式索引(style index 1)。
依赖(dependency)是一个位标记:它表达"只有当所依赖的源样式全部为 on 时,本条件才允许把后续样式置为 on"。它既可以附加在其他条件类型上(如示例中的5),也可以像这里一样独立使用。由于纯依赖自身没有额外的布尔条件,只要前置样式满足,后面跟着的样式索引就会被打开。
底层实现见 src/index.ts:运行时用abort()机制——一旦发现某个依赖样式未置 on,就把canSetSource置为false,从而阻止该表达式继续设置任何源样式。
5. 带依赖的 switch 表达式:5, 1, 0, 1, 0, dynamic, "yellow", 1, 2
这是整个示例中最复杂的片段,5 = 4 | 1,即 switch 类型叠加了依赖位:
5:switchWithDep 类型标识;- 依赖部分:
1, 0—— 依赖1个先前已设置的源样式(style index 0),整个 switch 只有在它置 on 时才生效; 1:switch 中可匹配的字符串个数(只有"yellow"一个);0:falsy 行为——当被求值的字符串表达式结果为 falsy 时应抛错(error)。这一字段的完整取值定义见 src/index.ts:
| falsy 行为值 | 含义 |
|---|---|
0 | error —— 抛错 "string expected" |
1 | unset —— falsy 时禁用样式(不设置任何样式) |
2 | default —— 提供默认字符串,falsy 时使用该默认值 |
dynamic:要作为字符串求值的表达式;"yellow":匹配目标字符串;1:匹配成功后置 on 的源样式个数;2:置 on 的源样式索引(style index 2)。
switch 的完整参数布局同样记录在 src/index.ts。运行时对字符串 switch 的求值逻辑在 src/index.ts:先取字符串值,若为 falsy 则按ifFalsy字段分别走抛错 / 禁用 / 取默认值三条分支;随后把值依次与每个候选字符串比较,命中则将对应的源样式索引置 on。
四、序列化堆栈逐段拆解:输出表达式部分
处理完全部源表达式后,运行时转向输出部分。每个输出表达式都是"类名 + 一个布尔表达式":
"c", -2, 2, 0, 1:设置类c,后面跟着一个or(-2)布尔表达式。布尔表达式的开始由一个负数信号标识,紧接着是一个数量,表示用该布尔运算符组合多少个表达式或源样式。-2, 2, 0, 1表示对 2 个源样式(索引0和1)做 or——只要二者任一为 on,该布尔表达式即为真。README 特别提示了原因:这两个源样式都设置了color: red,因此输出类c的出现条件是两个来源的"或"关系。"d", 2:不带布尔运算符的简单表达式,后跟一个非负整数即源样式索引。当 style index 2 为 on 时该类被输出(README 原文此处笔误写成类c,根据上下文与测试断言"c d"的输出可知实际是类d)。
输出布尔表达式的运算符枚举见 src/index.ts:
| 类型值 | 含义 |
|---|---|
-1 | not —— 对其后一个表达式取反 |
-2 | or —— 对后续<o>个表达式做逻辑或 |
-3 | and —— 对后续<a>个表达式做逻辑与 |
| 非负数字 | 直接引用一个源样式索引:该样式为 on 则为真 |
boolExpr的递归求值实现位于 src/index.ts:not递归取反,and/or按计数循环折叠,默认情况则直接查isSourceSet(type)。
五、源码级纵深:ExpressionContainer与二进制编码
README 提到"More documentation can be found in the code"。除了上述"人读版本"的堆栈协议外,runtime 包还内置了一套二进制编码机制,位于 src/ExpressionContainer.ts。
1. 表达式构建 API
ExpressionContainer提供了一个面向编译器的声明式 API:
let el = new ExpressionContainer(); el.class("fubar", expr(0, EQ, expr(1, OR, 2))); // fubar 在 (arg0 === (arg1 || arg2)) 时出现expr(left, op, right)构造表达式树节点,左/右操作数可以是值索引、NotValue或其他表达式;NOT(val)构造取反操作(src/ExpressionContainer.ts);- 运算码
OP_CODE:OR=0, AND=1, EQUAL=2, SEP=3(src/runtime.ts),其中SEP是分隔符,标志源表达式处理完毕、开始输出类名; getBinaryString()生成完整二进制逻辑串,getBinaryEncoding()将其按 32 位切分并做 base36 编码,得到最终传给runtime()的 shape 数组;exec(...args)是便捷求值入口,内部直接调用二进制运行时。
表达式会被扁平化并去重:flattenExpressions先收集所有被引用的参数(getArgs),再深度优先遍历表达式树,通过genUID生成的唯一 ID 复用等价子表达式,根表达式(类表达式)保证排在末尾(src/ExpressionContainer.ts)。
2. 二进制运行时的位级求值
真正的"速度担当"是 src/runtime.ts 中的runtime(shape, classes, args)函数。它接收三个参数:base36 编码的二进制 shape 数组、输出类名数组、运行时参数数组。核心机制:
- 把每个 base36 字符串
parseInt(shape[i], 36)还原成整数,然后按位消费(integer % 2取最低位,integer >>>= 1右移),每个 token 保证在 32 位整数内处理(源码注释说明:JS 位运算会把操作数钳制为 32 位整数,因此解析器保证输出 32 位整数); - 解析器是一个三态状态机
STATE.OP / STATE.LEFT / STATE.RIGHT(src/runtime.ts),依次发现运算码、左操作数、右操作数; - NOT 位编码:每个 VAR token 的最后一 bit 是取反位,
val = !!(state && (+!!exprs[working >>> 1] ^ (working % 2)))即用异或实现取反; - 变量 token 宽度动态计算:
size依据当前可引用值数量exprCount决定(Math.ceil(Math.log2(exprCount - 1))的位运算等价写法),使索引可以用尽可能少的 bit 表达; - 遇到
SEP后进入输出阶段:每求值完一个输出布尔表达式,就把对应类名拼进out,直到classIdx到达classes.length提前跳出循环。
值得注意的是,runtime.ts顶部有/* I'm special. (The rules don't apply when you're coding for a minifier) */的注释并关闭了部分 tslint 规则——整个函数就是为压缩与极致执行速度而优化的。
3. 测试对二进制机制的验证
test/expression-test.ts 给出了可直接验证的断言。例如 "Binary string and encoding generation properly" 用例:
let el = new ExpressionContainer(); el.class("fubar", expr(0, EQ, expr(1, OR, 2))); assert.equal(el.getBinaryString(), "000101001110000110"); assert.deepEqual(el.getBinaryEncoding(), [parseInt(el.getBinaryString().split("").reverse().join(""), 2).toString(36)]);其他用例还验证了 NOT 取反(expr(0, EQ, NOT(1)))、多类输出("fubar bizbaz")、带括号的嵌套表达式、深层堆栈表达式(arg0 && (arg1 || !(arg0 && arg2)))等场景的exec求值结果,可作为理解二进制编码行为的权威参考。
六、端到端调用链:JSX 转换器如何生成c$$
理解运行时后,再看它在上游是怎么被生成的,能让整条链路闭合。
1. 参数构造:classNameGenerator
classNameGenerator.ts 的classnamesHelper()负责把一次 JSX 元素的样式分析结果翻译成c$$调用:
constructArgs首先推入element.dynamicClasses.length + element.dynamicAttributes.length(即源表达式数)和rewrite.dynamicClasses.length(即输出类数)——与运行时读取的两个头部数字一一对应;constructSourceArgs遍历动态类与动态属性:动态类生成 ternary 表达式,动态属性按isSwitch/hasDependency/isConditional组合类型位并依次生成依赖、条件、switch 参数;constructOutputArgs为每个动态输出类推入类名字符串,并递归生成布尔表达式(-1not /-2or /-3and,见 classNameGenerator.ts)。
可见,c$$参数数组的每一个数字都不是随意拼凑的,而是严格对应 src/index.ts 中注释定义好的协议格式。
2. 导入注入与重写:babelPlugin
babel.ts 的 Babel 插件在post阶段完成两件关键工作:
- 把原本的 block 样式导入(如
import bar from 'bar.block.css')替换为import c$$ from "@css-blocks/runtime"的默认导入; - 对含有动态样式的 JSX 元素,把
className/class属性替换为className={c$$(...)}表达式(generateClassName(classMapping, elementAnalysis, HELPER_FN_NAME, true),其中HELPER_FN_NAME = "c$$")。
若整个文件未发现动态样式(dynamicStylesFound为 false),则不会注入c$$导入——这是为了把运行时开销严格限制在真正需要它的模板上。
3. 测试闭环:transformer-test.ts
transformer-test.ts 的 "States with dynamic sub-states" 用例完整展示了从objstr到c$$再到最终类名的闭环:源码块bar.block.css定义了.pretty、.pretty[bool]、.pretty[color=yellow],作者代码用objstr组合三个动态键;断言转换后的代码包含c$$([3, 2, 0, leSigh, ...]);最后直接执行该表达式并断言assert.deepEqual(c$$([...]), "c d")。测试还覆盖了leSigh && dynamic布尔表达式(生成c$$("b", [1,2,4,2,1,leSigh&&dynamic,...]))与模板字符串动态值(` ${dynamic}Color`)等更多形态。
七、集成与运行方式
@css-blocks/runtime是一个独立的 npm 包(当前仓库版本1.2.0,见 package.json),支持 Node10.* || >= 12.*,主入口为dist/src/index.js,类型声明为dist/src/index.d.ts。运行时包本身不依赖其他运行时库,devDependencies仅包含代码风格与编译工具链,非常适合作为浏览器端轻量依赖打入最终 bundle。
日常使用中你无需也不应手写c$$调用——它由 CSS Blocks 的 JSX 转换器(babelPlugin,或 jsx 包 对应的构建集成)在编译期自动生成并注入导入。运行测试的方式:
cd packages/@css-blocks/runtime yarn test # 内部依次执行 compile -> mocha 测试 -> tslinttest:runner脚本使用mocha dist/test --opts test/mocha.opts运行编译后的测试;若需本地验证,可参照 expression-test.ts 中ExpressionContainer+exec()的写法,在不依赖 JSX 转换的情况下直接观察二进制编码与求值结果。
结语
c$$看似是一串难读的数字堆栈,实则是 CSS Blocks 在"编译期静态分析"与"运行时动态求值"之间精心设计的一道桥梁:编译器(classNameGenerator+babelPlugin)把丰富的动态样式语义折叠为扁平的序列化协议,@css-blocks/runtime则用最少的运行时开销(纯数组消费 + 位运算)把它还原成最终类名。理解这套协议,既能帮你读懂转换后的产物代码、定位动态样式问题,也能为阅读 runtime.ts 与 ExpressionContainer.ts 的位级实现打下基础——它本身就是一份关于"如何为编译器输出设计极简运行时"的优秀范本。
- 前端
- 构建工具
【免费下载链接】css-blocks
High performance, maintainable stylesheets.
相关推荐
Slang 表达式求值类别详解:翻译期常量、运行时值与求值时机
Slang 表达式求值类别详解:翻译期常量、运行时值与求值时机 导读 :在 Slang 着色器语言中,表达式的结果(value)究竟在何时被求值,取决于它的求值
编译器图形学编程语言JSON for Modern C++ 二进制值(Binary Values)完全指南:subtype、序列化与五种二进制格式的字节级解析
JSON for Modern C++ 二进制值(Binary Values)完全指南:subtype、序列化与五种二进制格式的字节级解析 二进制值是 JSON
序列化Hutool表达式解析:动态表达式求值引擎
Hutool表达式解析:动态表达式求值引擎 还在为Java项目中复杂的动态表达式计算而烦恼?还在手动编写繁琐的解析逻辑?Hutool表达式解析模块为你提供了一套
后端开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考