Panda CSS Rust 引擎的 JS 绑定架构:NAPI 与 WASM 双通道设计深度解析

原创2026-10-09 10:54:40881 阅读
文章标签:前端构建工具开发工具

Panda CSS Rust 引擎的 JS 绑定架构:NAPI 与 WASM 双通道设计深度解析

本文以 design-notes/bindings.md 为核心,结合 @pandacss/compiler 与 @pandacss/compiler-wasm 两个包的源码与配置,讲解 Panda 如何以“薄镜像层 + 核心逻辑下沉”的方式把 Rust 编译管线暴露给 JavaScript。读完你可以掌握:两个 cdylib 绑定 crate 的职责划分与构建产物、Compiler / Extractor / WasmExtractor 的完整使用姿势、JS 侧配置回调(utility.transform、pattern.transform 等)的注册与缓存机制,以及浏览器加载、WebContainer 回退与包体积预算这些工程细节。

一、两个绑定 crate:一套哲学,两种通道

Panda 的 Rust 管线通过两个 cdylib crate 对外输出,它们共享同一条设计哲学:核心逻辑留在 pandacss_* 核心 crate 中,绑定 crate 只做“薄镜像类型 + 跨 JS 边界搬运”。绑定 crate 是 pandacss_* 命名规则的刻意例外,因为 cdylib 的输出文件名对 JS 侧是 load-bearing 的(例如 compiler.node)。

Crate Target Output JS 包
packages/compiler/crate(compiler_napi) Native(NAPI) compiler.node @pandacss/compiler
packages/compiler-wasm/crate(compiler_wasm) wasm32-unknown-unknown(wasm-bindgen) pkg-node/compiler_wasm_bg.wasm + pkg-web/compiler_wasm_bg.wasm @pandacss/compiler-wasm

两个 crate 都依赖 pandacss_fs,但切换的 feature 不同:NAPI 用 os,wasm 用 memory;其余核心 crate 一律 default-features = false,目的是让 wasm 产物保持精简。这一点在 compiler-wasm/crate/Cargo.toml 中可以确认:pandacss_fs 与 pandacss_extractor 均声明为 features = ["memory"]。

需要特别区分的是:@pandacss/compiler-wasm 与 @pandacss/compiler-wasm32-wasi 是两个刻意分开的包:

  • @pandacss/compiler-wasm 是 wasm-bindgen 的浏览器/playground 宿主,自带加载 API(loadWasm、createCompiler)。
  • @pandacss/compiler-wasm32-wasi 则由 NAPI crate 生产,面向 WebContainer 这类环境:@pandacss/compiler 必须保留生成的 binding.cjs 表面,但原生 .node 文件无法加载,于是回退到 WASI 产物。

绑定 crate 里到底放了什么

绑定 crate 刻意保持“镜像层”的克制:任何两个绑定都要实现的逻辑,都下沉到 pandacss_compiler 核心 crate;每个绑定只负责宿主调用或 IO 原语——配置 → System 构建(load_system)、transform 回调运行时与其缓存(apply_*_transform、TransformCache)、源码 glob 选项、design-system 导入扫描、输出写入、稳定 atom 顺序,以及工具类视图(inspect_file_source、token 建议、HookFilter)。

NAPI 侧的文件布局(packages/compiler/crate/src):

  • lib.rs——共享跨模块类型(Span、SourceLocation、Diagnostic、ExtractedArg)
  • matcher.rs——Matchers / Matcher / MatchCategory 镜像 + match_imports
  • imports.rs——ImportRecord 镜像 + scan_imports
  • calls.rs——ExtractedCall 镜像 + extract_calls
  • jsx.rs——ExtractedJsx 镜像 + extract_jsx
  • extract.rs——ExtractResult / ExtractDebugResult + extract / extract_debug
  • compile.rs——遗留的 CompileInput / Output 镜像
  • project.rs——Compiler 类,基于配置构建(经 pandacss_compiler::load_system)
  • project/——按领域拆分的 Compiler 方法:files、css、codegen、recipes、build_info、design_system、introspect、transform_source、transforms(JS 回调桥)、interop、config
  • session.rs——Extractor 类(推荐的批量入口)
  • convert.rs——pandacss_extractor::X ↔ X 转换辅助

WASM 侧的文件布局(packages/compiler-wasm/crate/src):

  • lib.rs——re-exports + installPanicHook
  • fs.rs——WasmFileSystem(MemoryFileSystem 之上的句柄)
  • matcher.rs——MatchersInput 形状 + to_core_matchers / to_core_token_dictionary
  • extract.rs——WasmExtractor(parseFile)
  • project.rs——WasmCompiler,基于配置构建(经 pandacss_compiler::load_system)
  • project/——按领域拆分的 WasmCompiler 方法(与 NAPI 相同)+ serde_types 定义 JS option 形状

两种边界技术选型的权衡

  • NAPI 使用 napi-rs 的宏模式:#[napi(object)] 表示纯数据,#[napi] 表示可构造类,每个函数对应一个镜像模块,类型在边界上被显式声明。
  • WASM 使用 #[wasm_bindgen] 加 serde-wasm-bindgen,多数类型以 serde 序列化的 JS 对象跨边界,而不是声明的镜像类型——需要维护的代码更少,代价是每次调用的序列化开销略高。

wasm 侧是 playground 路径,不是生产热路径,因此这个取舍是值得的(源码注释见 compiler-wasm/crate/src/lib.rs 中“Mirrors the napi binding's shape … for browser playgrounds”的定位)。

二、ExtractedArg:为无歧义的 null 打上标签(NAPI)

旧的参数形状是 Array<unknown | null>,它把两种不同的情况折叠在一起:

  • 源码里真实的 null 字面量:css(null) → 折叠值为 Literal::Null;
  • 不可折叠的参数:css(getConfig()) → 没有值可记录。

JS 调用方无法区分两者。带标签的形状解决了这个问题(见 lib.rs 的实现):

#[napi(string_enum = "camelCase")]
pub enum ExtractedArgKind {
    Value,
    Missing,
}

#[napi(object)]
pub struct ExtractedArg {
    pub kind: ExtractedArgKind,
    pub value: Option<serde_json::Value>,
}

跨边界后 JS 侧看到的是:

  • { kind: "value", value: null } —— 真实的 null 字面量;
  • { kind: "missing", value: undefined } —— 不可折叠参数。

同时,ExtractedCall::data.length 始终等于源码的参数个数,位置性的 None 槽位被保留,这样消费方可以知道到底是哪一个参数不是字面量。

wasm 绑定则不需要等效的形状:它通过 serde-wasm-bindgen 直接使用原始 JSON,位置性的 null 与 undefined 在编码层本身就是无歧义的。

三、Compiler(NAPI):生产入口 fromConfig

Compiler.fromConfig(config) 是推荐的生产入口。JS 侧把“已解析、已序列化的 Panda 配置快照”以 JSON 字符串 传入,这比传一个对象让 NAPI 逐属性遍历更省开销。绑定侧解析 JSON 后交给 pandacss_compiler::load_system(validate → deserialize → 构建 tokens → 解析 utility.values 回调 → 编译出 System),再把结果包装进 Project::new。

这条路径是可失败的:配置编译错误(例如非法的序列化 JSX 正则)会被映射为 napi::Error 抛出。对应实现见 project.rs。

值得注意的工程细节:基于 matcher 的项目构造器被刻意移除。原始 matcher 流程属于 extractor session(new Extractor(matchers))与分阶段提取辅助;有状态的项目只允许由配置派生,这从架构上保证了 Compiler 的状态边界清晰。

配置回调(utility.values)在构造期注入

fromConfig 的第四个参数接收 utility_values_callbacks。构造时,Rust 侧会为每个携带 utility_values 回调 id 的 utility 调用 JS 函数,并传入一个 Rust 侧的 theme(category) 函数作为回调参数;回调的返回值决定该 utility 的可用值列表(返回 null 时该 utility 的 values 被清空)。完整逻辑在 transforms.rs。

四、Extractor session 类:为批量提取而生

自由的 extract() / extract_debug() 函数按值接收 Matchers,并且每次调用都重新转换内部的 token dictionary。在 Vite 风格的批量场景下(多个文件对同一套 design tokens 提取),这种重建成本是 O(tokens × files) 的纯浪费。

Extractor 翻转了这个权衡(session.rs):

const extractor = new Extractor(matchers)
for (const file of files) {
  const result = extractor.extract(file.source, file.path)
}

类在构造时一次性持有预构建的 pandacss_extractor::ExtractorConfig(matchers + 物化后的 token dictionary),每个文件的调用都跳过 dictionary 重建。这是生产 / 批量提取的推荐路径;自由函数则留给一次性 CLI 使用和测试。

Extractor 还提供 match_imports(scan),可在不重新解析的情况下对已有的 ImportScanResult 运行 matched-imports 过滤(见 session.rs)。

五、WasmExtractor:浏览器 playground 的共享 FS 模型

wasm 绑定选择了不同的形状,因为源码发现逻辑在 JS 侧(playground 里就是 React 状态)。一个 WasmFileSystem 句柄与 matchers 一起传入,WasmExtractor 在内部自动把 cross-file resolver 接到同一个 FS 上。

import { createExtractor } from '@pandacss/compiler-wasm'

const { fs, extractor } = await createExtractor(matchers)
fs.addFile('/proj/tokens.ts', "export const brand = '#ef4444';")
const result = extractor.parseFile('/proj/main.tsx', source)

为什么共享一个 FS 句柄:

  • 多实例共享同一项目状态:多个 WasmExtractor 实例可以通过克隆 FS 句柄指向同一项目状态,克隆开销极低——内部是 Arc<RwLock>。
  • 跨文件解析即时生效:import { x } from './tokens' 的解析走共享 FS,playground 的编辑把文件推进 FS,resolver 立刻能看到。
  • 浏览器宿主拥有权威状态:宿主不需要把源码反复往返穿过绑定层。

在 Rust 侧,WasmExtractor::new 接收 fs: &WasmFileSystem 与 matchers: JsValue,反序列化为 MatchersInput 后构建 ExtractorConfig,并把 CrossFileResolver::with_fs(fs.inner.clone()) 挂到配置上(extract.rs)。WasmFileSystem 提供 addFile / removeFile / exists / readFile / fileCount 等方法,内部包着 MemoryFileSystem(fs.rs)。

六、NAPI 边界的 Rust 约定

NAPI 边界有几个 Rust 侧的硬约束(源码中用 clippy allow 注释明确标注):

  • #[napi] 函数不能接收 &str。必须用 owned String,并加 #[allow(clippy::needless_pass_by_value, reason = "NAPI requires owned arguments")]。
  • Option<T> 接受 undefined / 缺省,但不接受 null。TS 调用方应省略字段,而不是传 null。
  • #[napi(string_enum = "camelCase")] 用于判别联合(discriminated union)。
  • #[napi(object)] 用于纯数据;#[napi] 用于可构造类。

七、WASM 边界的工程取舍

  • serde-wasm-bindgen 默认把 map 序列化成 Map 而非普通 {} 对象。因此绑定配置了 serialize_maps_as_objects(true),让跨边界的结果符合 JS 调用方预期的 JSON 形状(见 extract.rs 中的序列化配置)。
  • wasm-opt 必须加 --all-features。因为 Rust 会为依赖(oxc_*、fast-glob 等)产出 post-MVP 的 wasm 指令(bulk-memory、mutable-globals、sign-ext、simd 等),wasm-pack 自带的 wasm-opt 默认会拒绝它们。配置写在 Cargo.toml 的 [package.metadata.wasm-pack.profile.release] 中:wasm-opt = ["-Oz", "--all-features"]。
  • 两个 wasm-pack target:nodejs(CommonJS,供 Vitest + SSR)与 web(ESM + fetch 初始化,供浏览器)。两者都随 npm 包发布,分别在 pkg-node/* 与 pkg-web/* 下(见 compiler-wasm/package.json 的 exports 声明)。
  • 纯同步,无 async:wasm crate 是 sync-only。引入 async 会拉进 wasm-bindgen-futures 并把包搞复杂。
  • Panic hook:install_panic_hook()(导出为 installPanicHook)把 Rust panic 路由到 console.error,输出可读的堆栈。TS 包装层的 loadWasm() 会自动调用它(index.ts)。

八、包体积预算

当前 wasm 产物为 pkg-web/compiler_wasm_bg.wasm 约 1.3 MB 原始、约 490 KB gzip,低于 playground 的 500 KB gzip 目标。构成:

  • 体积大头是 oxc_parser + oxc_semantic(AST + 作用域解析);
  • fast-glob、pandacss_fs、pandacss_extractor 合计占几个百分点;
  • wasm-opt -Oz --all-features 比未优化输出再砍掉约 30%。

未来的预算纪律:在 500 KB gzip 以内,不轻易引入新的重大依赖;任何会让体积翻倍的依赖都必须先有基准数据支撑。

九、原生编译路径与性能红线

Project.compile() 是生产环境原生 CSS 路径。在回调应用之后,它全程停留在 Rust 侧:

  1. 从 pandacss_project::Project 借用项目级动态 atom 集合;
  2. 仅当注册了 JS utility transform 时,物化回调展开后的 atom;
  3. 快照动态 recipes;
  4. 从编译后的 project/config 计算静态 recipe 快照;
  5. 调用共享的 pandacss_compiler 输出函数——它负责样式表装配与诊断。

绑定在 Project 构造时缓存解析后的 UserConfig,这样每次 compile 都不必重新克隆、反序列化整个 JSON 配置。当前输出形状为 { css, sourceMap, manifest, diagnostics };其中 sourceMap 与 manifest 哈希仍是占位符。

pandacss_stylesheet 负责 emit 并做 writer 级压缩,但不运行 CSS 优化器。旧的 optimize 开关已从原生 API 中移除,让边界更明确。

性能红线:热路径禁止 to_json()

每次 Literal::to_json() 都会物化一个 serde_json::Value 并穿过 NAPI 边界。对工具类 API(extract*() 为 JS 消费返回 JSON)这是不可避免的——JS 调用方要的就是 JSON。

生产热路径 compile() 永远不能触达这次转换。真实管线落地后,引擎会把 Literal → encoder → stylesheet emitter 完全留在 Rust 内,返回紧凑 CSS 加 manifest。不要在 compile() 里调用 to_json()——convert.rs 的 to_call 处留有 PERF(port) 标记专门提醒这一点。

wasm 绑定用 serde-wasm-bindgen 直接序列化绕开了 JSON 中间层,但代价类似——把 Literal 序列化成 JsValue 遍历的是同一棵树。同样的约束适用:尽量把重数据留在 Rust 侧。

十、加载器与回退(NAPI)

TS 包装层 packages/compiler/src/index.ts 定义公开 API,并为不支持的平台提供 no-op 回退。src/load-binary.ts 加载生成的 binding.cjs:该加载器优先使用本地/平台原生包,原生绑定不可用时回退到 @pandacss/compiler-wasm32-wasi。

一个重要细节:自 @napi-rs/cli 3.9 起,wasi 包不再是 @pandacss/compiler 的 optionalDependency——它的 manifest 不再携带 cpu gate,如果把它列进去,每个消费者都会下载 wasm。若想重新声明它,需在 packages/compiler/package.json 中设置 napi.wasm.optionalDependency: true;scripts/verify-release-artifacts.mjs 会跟随该标记。当前 package.json 的 napi.targets 列表(packages/compiler/package.json)覆盖 darwin / linux-gnu / linux-musl / windows-msvc 各架构,外加 wasm32-wasip1-threads。

WebContainer 下的回退流程(load-binary.ts):

  1. 检测到 process.versions.webcontainer 且原生加载失败;
  2. 在 /tmp/pandacss-compiler-<version> 安装 @pandacss/compiler-wasm32-wasi@<compiler version>;
  3. 从那里 require('compiler.wasi.cjs')——与 Rolldown / Oxc 的回退形状一致。

WASI 构建以 reactor 方式链接,导出 _initialize,让 emnapi 在使用前运行模块构造函数。原生产物、生成的 WASI 文件与自动生成的 native.d.ts 都在 gitignore 中。

本地验证命令:

pnpm --filter @pandacss/compiler verify:webcontainer

通过 -- --compiler-tgz <path> --wasi-tgz <path> 可在发布前用本地包 tarball 测试;不带 tarball 时它会从 npm 安装当前版本(脚本见 verify-webcontainer-api.mjs)。

十一、JS 宿主配置回调:JSON 之外的那部分配置

已解析的配置快照可以携带无法用纯 JSON 表达的配置项的回调引用。序列化配置里存一个稳定 id,活着的 JS 函数放在 sidecar 回调 map 中:

{
  config: {
    utilities: {
      size: {
        transform: { kind: "js-callback", id: "utilities.size.transform" },
      },
    },
  },
  callbacks: {
    "utility.transform": {
      "utilities.size.transform": (value, args) => ({ width: value, height: value }),
    },
  },
}

四种回调的语义差异

  • utility.transform:把一个 utility atom 映射为直接的 CSS 声明对象。展开后保留原始 atom 的条件链;返回的键(如 sm、tablet、_hover)不被解释为条件。
  • pattern.transform:在原子编码之前运行,返回一个完整 system style 对象。pattern 输出可以包含嵌套的配置派生条件与断点,因为它会流回常规 style encoder 处理。
  • pattern.defaultValues:在 JS 宿主中于 pattern.transform 之前运行;显式 props 覆盖默认值。
  • utility.values:影响 utility 元数据与类型/值的可用性。优先把解析后的数据序列化;只有 JS 配置模型确实需要时才保持可执行状态。

当前的实现支持

  • NAPI:当二进制暴露 registerUtilityTransform 时,utility.transform 回调注册到原生 Project 上。注册的函数是 TS 宿主包装器,所以 Rust 只传原始值;包装器再把真正的 TransformArgs(token、token.raw、utils.colorMix)传给用户回调。pattern.transform 同理走 registerPatternTransform,pattern 助手与默认值留在 JS 侧而非在 Rust 里 stub(实现见 transforms.rs)。
  • 执行时机:utility transform 在 parseFile() / refreshFile() 期间执行,作用于文件派生的 atoms 与编码后的 recipe 条目;atoms() 与 encodedRecipes() 是纯读取,从不回调 JS。
  • 缓存策略:utility transform 结果按(回调 id、property、原始值)缓存;缓存结果是无条件的,原始 atom 或 recipe 条目的条件在展开后应用。失败的回调执行上报为 parseFile() 诊断且不缓存,下一次 parse 可以重试回调。
  • WASM:utility.transform 与 pattern.transform 都通过 TS 宿主包装器注册;pattern 与 utility 回调在 parseFile() 之前安装,保证 Rust project 能在原子编码前回调 JS。WASM 的 pattern transform 结果按(回调 id、pattern 名、序列化 props)缓存;抛出的回调变成 parseFile() 诊断,失败调用不缓存。
  • createCompilerFromWasmModule(mod, config, options):供直接 import pkg-web/compiler_wasm.js 并自行调用 wasm-bindgen init() 的浏览器调用方使用,与 Node 侧 createCompiler() 走同一条回调注册路径(web.ts)。

剩余工作(文档原文标注):一是决定 utility.values 保持回调式还是变成序列化配置中的解析数据;二是决定 config/static CSS utility transform 是继续走共享的 parse-time 回调桥,还是迁移到独立的 compile-time 诊断面。

十二、WASM 加载器与 ./web 浏览器子路径

packages/compiler-wasm/src/index.ts 导出 loadWasm()(懒加载、带缓存)与 createCompiler() 供 Node 消费者使用;浏览器消费者可以直接从 @pandacss/compiler-wasm/pkg-web/* 导入,自行调用 wasm-bindgen 的 init() 以控制 wasm 的 fetch,再把初始化后的模块传给 createCompilerFromWasmModule(),保证配置回调在解析前注册完成。

@pandacss/compiler-wasm 还发布 ./web 子路径导出(web.ts,构建产物 dist/web.mjs),它只携带 wasm 模块 facade——createCompilerFromWasmModule 与共享类型——模块图中没有 import('../pkg-node/...')。

因为 src/web.ts 从不引用 pkg-node,tsup 不会向 dist/web.mjs 注入 Node 的 fileURLToPath shim;webpack / Vite 这类会 stub 或移除 node:url 的浏览器打包器在模块求值阶段就不会抛错:

import initWasm, * as wasmMod from '@pandacss/compiler-wasm/pkg-web/compiler_wasm.js'
import { createCompilerFromWasmModule } from '@pandacss/compiler-wasm/web'

await initWasm(new URL('/compiler_wasm_bg.wasm', window.location.origin))
const compiler = createCompilerFromWasmModule(wasmMod, config, options)

主入口 "." 仍然从 ./web re-export 所有内容,并额外导出 loadWasm / createCompiler / createCompilerFromSnapshot,因此 Node 消费者与既有测试不受影响(见 compiler-wasm/package.json 的 exports 映射)。

十三、相关设计文档

登录后查看全文
panda