vanilla-extract — 把 CSS 写成 TypeScript,浏览器看到的却是零字节运行时
是什么
vanilla-extract 是一套让你用 TypeScript 写 CSS、但浏览器最终只看到一份普通 .css 文件的方案。日常类比:像点外卖时厨房在后台炒菜,递到桌上的只是一盘菜——你看不到锅、油烟、调料瓶。
你写:
import { style } from '@vanilla-extract/css';export const primary = style({ background: 'royalblue', color: 'white' });构建工具读完 .css.ts 文件,把所有 style() 调用收集起来,生成一个静态 .css 文件 + 一个返回类名字符串的 .js 模块。生产环境跑的时候,浏览器拿到的是 <link rel="stylesheet"> 加 className="primary_a3b_0"——没有任何 vanilla-extract 自己的 JS 代码在跑。
为什么重要
不理解它,下面这些事都没法解释:
- 为什么 React Server Components 不能用 emotion,但能用 vanilla-extract——前者依赖 runtime Context,后者只剩静态字符串
- 为什么 Tailwind 党和类型派吵了三年,vanilla-extract 是少数让”类型安全 + 0 runtime”同时成立的方案
- 为什么 Astro / Next App Router 推荐它而不是 styled-components——后者每次组件挂载要算 hash 注入
<style> - 为什么 CSS 也能像 TypeScript 一样在 IDE 里 “Go to Definition”——因为类名就是个 const 字符串,IDE 当普通变量处理
核心要点
vanilla-extract 的整个机制可以拆成 三件事:
-
.css.ts是源文件:扩展名很关键——bundler 看到.css.ts就触发 vanilla-extract 插件,看到普通.ts就走正常 TS 编译。类比:邮局看信封颜色决定走哪条投递路线。 -
build 时单独跑一遍:插件用 esbuild 把
.css.ts单独打包成 CommonJS,丢进 Node 的vm沙箱里执行,通过 adapter 协议 收集所有style()调用产生的样式规则。类比:让一个机器人替你把每个 style 调用念一遍,把内容记到本子上。 -
emit 静态产物:把收集到的规则用 stylis 算法展开成扁平 CSS 文件,把原本
.css.ts模块的导出重写成”类名字符串常量”。运行时不再有任何 vanilla-extract 的代码。
三件事加起来叫 build-time evaluator 模式,panda-css / stylex / linaria 都是它的徒弟。
实践案例
案例 1:最简上手——5 分钟见到第一个按钮
import { style } from '@vanilla-extract/css';export const primary = style({ background: 'royalblue', color: 'white', padding: '8px 16px', ':hover': { background: 'mediumblue' },});import { primary } from './Button.css';export default () => <button className={primary}>Click</button>;打开 DevTools Elements 面板,看到 class="Button_primary__a3b0"——Button_primary__ 是开发模式的可读前缀,a3b0 是哈希。生产模式只有 a3b0。
案例 2:recipe variants——给按钮加颜色和尺寸
import { recipe } from '@vanilla-extract/recipes';
export const button = recipe({ base: { padding: 8, borderRadius: 4 }, variants: { color: { primary: { background: 'blue' }, danger: { background: 'red' } }, size: { sm: { fontSize: 12 }, lg: { fontSize: 18 } }, }, defaultVariants: { color: 'primary', size: 'sm' },});
// 用法button({ color: 'danger', size: 'lg' })// → "button_base_xxx button_color_danger_yyy button_size_lg_zzz"build 时枚举所有 variant 组合各生成一份 CSS,runtime 只是查表拼字符串——这是 vanilla-extract 唯一保留的 ~1KB JS。
案例 3:80 行手写一个 mini vanilla-extract
理解机制最快的方法是自己写一个小型版:
import vm from 'node:vm';import { transformSync } from 'esbuild';
const collector: Array<{ className: string; rule: object }> = [];let counter = 0;
function processFile(source: string, filePath: string) { const { code } = transformSync(source, { loader: 'ts', format: 'cjs' }); const ctx = { require: (name: string) => name === '@vanilla-extract/css' ? { style: (rule: object) => { const cn = `mini_${counter++}`; collector.push({ className: cn, rule }); return cn; } } : (() => { throw new Error('not supported'); })(), module: { exports: {} }, console, }; vm.createContext(ctx); vm.runInContext(code, ctx); return ctx.module.exports;}跑一下,你会看到 processFile 返回 { primary: 'mini_0', danger: 'mini_1' },collector 里是对应的 CSS 规则——核心机制就这么简单,剩下都是工程化打磨(nested selector、media query、theme variable)。
踩过的坑
-
.css.ts必须独立文件:你不能像 emotion 那样把样式写在组件里。每个组件至少Button.tsx + Button.css.ts两个文件,前两周写起来心流被打断很常见。 -
recipe / sprinkles 类型签名极复杂:基础
style()5 分钟会用,但 recipe 的 variants 出错时 TS 报错信息天书级。建议先用 100 个style()攒经验再上 recipe。 -
.css.ts里不能写副作用:vm 沙箱没注入fetch,写await fetch(...)build 会卡死且报错指向 csstype 的.d.ts——根因不明显。社区只是约定俗成不能写,没在编译期强制。 -
动态主题切换比 emotion 啰嗦:emotion 一个
<ThemeProvider value={...}>搞定的事,vanilla-extract 要createTheme出 vars 再用assignVars在某个 className 下覆写,多一层心智。
适用 vs 不适用场景
适用:
- React Server Components / Astro / Next App Router 这些 server-first 框架——emotion 在这里直接报错
- 中型组件库(10-200 组件)想要”类型安全 + 0 runtime”——典型甜区
- 团队 TypeScript 熟练,重构频繁——
.css.ts里类名是 const,rename 一处全文跟着改
不适用:
- demo / hackathon 小项目——setup 成本(vite 插件 +
.css.ts习惯)摊不平 - 大量 props 驱动的动态样式(10+ 个 boolean prop 决定颜色)——recipe 写不过来
- 组件库要让用户传 className 覆盖样式——hash 类名外部无法预测
历史小故事(可跳过)
- 2018-2020 年:emotion / styled-components 主流,写 CSS 像写 JS,但代价是每个组件挂载都要算 hash 注入
<style>,runtime 占 ~20KB - 2021 年 3 月:Mark Dalgleish(Seek 工程师团队)在 Twitter 发第一个 demo——
.css.ts文件 + bundler 插件 + 0 runtime - 2022 年:进入 0.x 稳定期,Astro / Remix / Next 13 RSC 时代起作为推荐 CSS 方案
- 2023 年:panda-css / stylex 出现,都参考了它的 build-time evaluator 思路
- 2024-2026 年:成为”想要类型安全的 CSS-in-JS”的事实标准,与 Tailwind 形成”类型派 vs 原子派”的双雄格局
学到什么
- build-time vs runtime 是个真权衡:把工作搬到 build 时,能换来生产 bundle 小、首屏快、SSR/RSC 友好——前提是设计良好的中间产物(这里是
.css.ts) - adapter 协议是侧通道注入的范本:module-level 单例 +
setAdapter/removeAdapter包裹 evaluate,让.css.ts文件无感地把 style 调用送到 build 期 collector - vm 沙箱不是安全边界,是隔离机制:Node
vm模块官方文档明确说不安全,vanilla-extract 用它只是为了多次跑.css.ts不污染主进程 - 80 行能复刻一个核心:esbuild + vm + 全局 collector,证明这套机制不是黑魔法——剩下 99% 是工程化打磨
延伸阅读
- 官方文档:vanilla-extract.style(含 sprinkles / recipe / theme 的完整指南)
- Mark Dalgleish 演讲:Statically extracted CSS in TS(讲了为啥要做这件事)
- 源码入口:
packages/css/src/style.ts(80 行入口) +packages/integration/src/processVanillaFile.ts(150 行 build 主流程) - emotion —— vanilla-extract 想替代的 runtime CSS-in-JS 代表
- stylex —— Meta 的同类方案,思路相近、落地策略不同
关联
- emotion —— runtime CSS-in-JS 的代表,与 vanilla-extract 是哲学对立面
- tailwind —— utility-first 路线,与 vanilla-extract 在”类名爆炸 vs 文件爆炸”上各有取舍
- stylex —— Meta 的 zero-runtime 方案,思路高度相近
- esbuild —— vanilla-extract 内部用它把
.css.ts单文件打包成 CJS - vite —— 最常见的宿主,
@vanilla-extract/vite-plugin是一等公民 - astro —— Astro 文档明确推荐 vanilla-extract 作为 CSS-in-TS 选项
- react —— 最大用户群;RSC 时代 vanilla-extract 是少数能继续跑的 CSS-in-JS
反向链接
- astro —— Astro — 内容站点优先的 Web 框架
- emotion —— Emotion — 在 JS 里写样式,让浏览器拿到一张唯一的 className
- esbuild —— esbuild — 用 Go 写的极速 JS bundler
- react —— React UI 组件库
- styled-components —— styled-components — React 生态最早的 CSS-in-JS 库
- stylex —— StyleX — 编译期把样式拍扁成原子 className 的 CSS-in-JS
- tailwind —— Tailwind CSS — 工具类优先样式框架
- vite —— Vite — 浏览器自己加载源码的构建工具