z-data 原子化 CSS 引擎的实现原理

作者: 一了 <[email protected]>
日期: 2026-08-21

z-data 4.0 发布: 内置原子化 CSS 引擎
https://github.com/Funlang/z-data

4.0 那篇讲的是原子化 CSS 怎么用. 这篇把实现拆开看: 规则
  怎么定义, 怎么编译, 运行时又是怎么把一个 HTML 属性变成
  一条 CSS 声明、注入页面的.

整套实现就三个文件, 都在仓库 css/ 目录:

  css/atomic-css-rules.txt   规则定义 (DSL)
  css/rule-compiler.fun      规则编译器
  css/z-css.js               运行时

核心思路一句话:
  把规则写成数据, 用编译器把数据编译成正则 + 模板,
  运行时用"属性即选择器"的方式即时翻译并注入.

什么是原子化 CSS?
  传统写法给元素一个 class, 再在样式表里写一条规则.
  原子化把每条样式拆成一个"原子": 一个类名只对应一个
  属性值, 比如 p1 = padding, d-flex = display: flex.
  在 HTML 里拼原子类名来组装样式, 不必给每个组件写一份
  专属 CSS. 主流代表是 Tailwind.

  Tailwind 通常要构建工具扫描源码, 提前生成 CSS 文件.
  z-data 相反: 不生成、不构建, 在浏览器里用一个正则引擎,
  把 HTML 属性上的原子标记"即时翻译"成 CSS 规则, 注入到
  <style id="z-data-ss">. 开箱即用.

总架构分三段:

  规则 DSL --> 编译器 --> 运行时 --> <style id='z-data-ss'>
              (rule-compiler.fun)  (z-css.js)

  1. 规则 DSL: 一份人类可读的文本, 描述"原子标记长什么样、
     翻译成什么 CSS".
  2. 编译器: 用 funlang 写的小工具, 读 DSL, 展开成两个产物
     -- 一个带编号捕获组的大正则 pattern, 一组模板字符串
     targets, 写回运行时文件.
  3. 运行时: 浏览器里的 JS, 拿到元素属性值, 用正则匹配,
     用模板算 CSS 声明, 合并序列化后写进 <style>.

  规则当数据、代码自动生成, 新增一条规则只需改 DSL, 不用动
  运行时逻辑.

规则 DSL
  文件开头定义一组"缩写表"(env) 和若干条规则:

  s       = \w+                       # 小写字母(正则)
  n       = -?(\d*\.)?\d+|--\w+       # 数字或 CSS 变量
  PM      = {p: 'padding', m: 'margin'}
  HW      = {h: 'height', w: 'width'}
  LTRB    = {l: 'left', t: 'top', r: 'right', b: 'bottom'}
  NUMS    = (num, unit) => /^--/.test(num) ? `var(${num})`
             : `${unit ? num : num / 4}${unit || 'rem'}`

  每条规则分左右两部分, 左边是 BNF 风格的模式, 右边是翻译
  模板. 比如第一条:

  (pm PM) [side LTRB] (num N) [unit U] = {pm}[-side]:{NUMS(num,unit)}

    (pm PM)       命名捕获组 pm, 取值枚举来自 PM 缩写表
    [side LTRB]   可选捕获组, 方向
    (num N)       数字
    [unit U]      可选单位
    {pm}          模板变量
    {NUMS(...)}   调用函数
    #             注释

  这条规则把"padding/margin + 可选方向 + 数字 + 可选单位"
  全盖住了: p1, pl2, mt3px, px4% 都命中, 不必为每种写法写
  一条规则. 这就是"规则作为数据"的价值.

编译器
  rule-compiler.fun 把 DSL 机械地翻译成运行时能用的代码,
  分两步:

  左侧, 把 BNF 组展开成正则捕获组. (pm PM) 查缩写表得到
  p|m, 包成命名捕获组; 可选组 [...] 加 ?. 第一条展开成:

  (?<pm>p|m)(?<side>l|t|r|b)?(?<num>-?(\d*\.)?\d+|--\w+)(?<unit>\w+|%)?

  右侧, 把 {...} / [...] 翻译成 JS 模板表达式. 普通变量
  {pm} 变 ${pm}; 可选组 [...] 调 IF(name,set,'-',''), 有值
  输出 -side, 无值空. 第一条的模板是:

  ${PM[pm]}${IF(side,LTRB,'-','')}:${NUMS(num,unit)}

  它和正则一一对应, 运行时用 with 求值就算出
  padding-left: 3px 之类的结果.

  编译器把多条规则拼成一个大正则, 收集命名捕获组的名字顺序
  存进 patterns 数组, 把每条模板存进 targets 对象, 写回
  z-css.js. 大正则里全是普通编号捕获组, 名字抽到数组, 这样
  replace 回调里按位置参数 (args[i+1]) 就能还原出每个命名组
  的值.

  有个小坑被巧妙处理了: monaco 正则把 \S 换成 [^\s>], 是
  为了让编辑器语法高亮不把选择器里的 > 组合器误判成属性
  内容.

运行时
  入口是 ZData.ss(s, v, args), 按 v 是否传值分三种:

    ZData.ss('pos')        返回缩写映射 position
    ZData.ss('p1', '')     空属性原子类 (样式=值)
    ZData.ss('hover','p2') 属性=值 (伪类/媒体/分组)

  空属性分支. HTML 里写 <div p1>, 属性 p1 的值是空串 "":

    1. 把 p1 用大正则 replace 匹配;
    2. 命中 rule16, 从 targets 取模板 ${PM[pm]}...;
    3. 收集命名捕获组的值, 和 env 合并成 vars;
    4. eval("with(vars)`"+target+"`") 算出 padding: 0.25rem;
    5. 构造选择器 [p1=""], 组装成 { "[p1=\"\"]": { padding: "0.25rem" } };
    6. 带 media 前缀再包一层 @media, 交给 ZDataStyle.add() 注入.

  属性=值分支. <div hover=p2> 时 v="p2" 非空, 先用 media
  正则解析修饰符, 拿到 after=":hover" 后递归调
  ZData.ss("p2","",{after, attr:'[hover="p2"]'}), 复用空属性
  分支把 p2 翻译成 padding:0.5rem, 最后拼出 [hover="p2"]:hover.

  所以两种语法是同一根管线: 属性=值负责解析上下文修饰,
  空属性负责翻译原子本体.

选择器即属性
  整套实现最妙的地方: 原子类不用 class, 用空属性选择器.

  [p1=""] { padding: 0.25rem }
  [hover="p2"]:hover { padding: 0.5rem }

  HTML 里写 p1 就是 p1="" 这个属性, 选择器 [p1=""] 精确命中.
  好处:

    - 属性本身是携带信息的容器, 值能承载状态;
    - 空属性写法简洁, 和 class 无关, 能和其他框架并存;
    - 同一属性名靠"属性=值"组合出无穷选择器, 互不冲突.

单位换算 NUMS
  数值语义遵循 Tailwind 的 4px 基础刻度:

  NUMS = (num, unit) =>
      /^--/.test(num) ? `var(${num})`
        : `${unit ? num : num / 4}${unit || "rem"}`

    p2      padding: 0.5rem (2 x 0.25rem, 即 8px)
    mt3px   margin-top: 3px (带单位原样)
    w100%   width: 100%

  用整数刻度就能得到成比例的尺寸, 不用每次手算.

组合规则 x / y
  px、py 这种"左右/上下一起"的写法, 靠组合规则实现, 模板
  开头用 > 标记:

  (pm [pm]) x (any) => {pm}l{any} {pm}r{any}    # px
  (pm [pm]) y (any) => {pm}t{any} {pm}b{any}    # py

  px4% 翻译成 "> pl4% pr4%". 运行时看到开头是 > 就拆成
  多个标记递归翻译:

  if (css[0] == '>') css = ZData.ss(css.replace(/^> */, ''), v, { ret: [] });
  // ret.join(';') => "padding-left:4%;padding-right:4%"

  最后合成一条带两条声明的规则, 用一个属性表达多个方向.
  bradt、bradb 等也同理.

CSS 变量支持
  数字处写 --名字, 就翻译成 var(--名字):

    m--varname   margin: var(--varname)
    py--sm       padding: var(--sm)   (上下各一条)
    c--color     color: var(--color)

  来自 NUMS 里对 /^--/ 的判断, 原子样式能直接对接设计系统
  的 CSS 变量.

媒体查询
  用 前缀: 表达, 例如 s:p0:

  MINW = { sm:640, s:640, md:768, m:768, lg:1024, l:1024,
           xl:1280, '2xl':1536, xxl:1536 }
  // s -> @media (min-width: 640px)

  前缀在正则里是可选捕获组, 运行时命中 media 组就按 MINW 表
  映射成 @media (min-width: ...), 把整块 CSS 包进去.

状态伪类与组合器
  状态用前/后修饰符表达, 解析逻辑在 media 正则里:

  state = ((?<before>(?!not)\w+)(?<to>[-+~\^]))?(?<not>not-)?
          (?<after>hover|focus(-\w+)?|active|after|before|
          (first|last)(-\w+)*)

    后修饰      hover=p2  -> [hover="p2"]:hover    (元素自身伪类)
    前修饰+组合 用 before 标记祖先/兄弟, to 决定组合符:
      a-hover=p3   [a=""]:hover [hover="p3"]     后代   (- -> 空格)
      a^hover=p4   [a=""]:hover > [hover="p4"]  子代   (^ -> >)
      a+hover=p5   [a=""]:hover + [hover="p5"]   相邻兄弟
      a~hover=p6   [a=""]:hover ~ [hover="p6"]   通用兄弟
    取反        not-hover=m0 -> :not(:hover)

  这样就能描述"悬浮在某个祖先上时, 改变某个后代/兄弟的样式",
  覆盖大多数交互态.

分组规则 group
  bd、bg、flex、grid 这类聚合属性, 用 属性=值 一次传多条
  声明:

  <div bd="style: bold; width: 1px; color: red"></div>

  运行时检测到 group:

  group = { [`[${s}="${v}"]`]: { [ZData.ss(s)]: css2json(v) } };

  用内置 css2json 把 "style:bold; width:1px; color:red" 解析成
  嵌套 JSON {style:'bold', width:'1px', color:'red'}, 再序列化
  成:

  [bd="style: bold; width: 1px; color: red"] {
      border-style:bold; border-width:1px; border-color:red }

  这就是 z-css.js 里 qs2json / json2css 存在的意义: CSS 文本
  和 JSON 双向转换.

序列化与注入
  所有原子规则汇入 ZDataStyle, 通过 add(css) 注入:

  add(css) {
      function update(me) {
          let styles = json2css(me.csss, 1);
          if (styles != me.old) {
              me.old = styles;
              // 找到或创建 <style id="z-data-ss">, 写入整段 CSS
          }
      }
      add2(css, this.csss);   // 深合并进 this.csss
      (this.updateLater || (this.updateLater = ZData.deb(update)))(this);
  }

  几个要点:

    - 单一 <style> 标签, 避免海量 <style> 节点;
    - 防抖刷新, 高频合并延迟为一次写 DOM;
    - 按 [s, v, after, before, attr, media] 缓存, 重复不再重算;
    - 全程 json2css / css2json 作数据交换, 既能合并又能序列化,
      还天然支持嵌套 (如 @media).

与 Tailwind 的对比
               Tailwind                  z-data 原子化引擎
  构建          需要 PostCSS/CLI 生成     无需构建, 运行时即时翻译
  样式来源      编译期产出 .css 文件     运行时注入单个 <style>
  规则扩展      改配置/插件             改 DSL, 编译器自动生成
  状态伪类      hover: focus: 前缀      属性=值语法, 天然带上下文
  体积          按需裁剪(Purge)         极小运行时 + 规则表

  z-data 更"内生": 规则做成数据, 编译器把数据翻译成可执行
  代码, 运行时只负责"匹配 + 求值 + 注入". 这是元编程思路,
  规则本身能动态增删, 不用改一行引擎代码.

  代价也明显: 用了 eval("with(vars)`...`") 动态求值; 大量
  正则挤在一个大 pattern 上, 可读性和调试成本高. 追求极致
  体积、无需构建的场景很合适; 大型工程要类型安全和静态
  分析, Tailwind 的构建期方案更有优势.

小结
  一句话:

    把规则写成数据, 编译器编译成正则 + 模板, 运行时用
    "属性即选择器"即时翻译并注入.

  三个最值得看的设计:

    1. 规则即数据: 一条 BNF 规则 + 一张缩写表, 盖住一大类
       写法, 扩展成本极低.
    2. 属性即选择器: [p1=""] 让原子样式和 class 解耦, 天然
       携带状态上下文.
    3. 一条管线复用两种语法: 空属性翻译本体, 属性=值附加
       伪类/媒体/分组, 共享同一套正则和模板.

  原子化 CSS 不一定是"构建期生成一堆类", 也可以是一套运行时
  的微型规则引擎. 在零依赖、零构建、轻量嵌入的场景, 这是
  另一种优雅的答案.

公众号二维码