- 前端
- 开发工具
【免费下载链接】vanilla-extract
Zero-runtime Stylesheets-in-TypeScript
导读
vanilla-extract 是一款「Zero-runtime Stylesheets-in-TypeScript」方案,即所有样式在构建期被编译为静态 CSS 文件,浏览器中不携带任何运行时样式生成代码。但在真实应用中,主题切换、用户偏好、组件传参等场景仍然需要在运行时动态修改 CSS 变量。@vanilla-extract/dynamic正是为此设计的极简运行时(包体积 < 1kB 压缩后),它提供assignInlineVars与setElementVars两个 API,让你能够以类型安全的方式为createVar、createTheme、createThemeContract等 API 创建的变量动态赋值。读完本文,你将掌握两个 API 的两种调用形态(普通对象模式与 theme contract 模式)、null/undefined值过滤规则、字符串模板场景下的toString技巧,以及它们背后的源码实现原理。
一、包定位与安装
@vanilla-extract/dynamic是 vanilla-extract 官方仓库中的独立子包,位于 packages/dynamic,其包描述与主项目一致,均为 "Zero-runtime Stylesheets-in-TypeScript"。从 package.json 可以看到它只依赖@vanilla-extract/private(提供walkObject、get、getVarName等底层工具),并在 devDependencies 中依赖@vanilla-extract/css用于类型与测试。
npm install @vanilla-extract/dynamic包入口(src/index.ts)只导出两个函数:
export { assignInlineVars } from './assignInlineVars'; export { setElementVars } from './setElementVars';版本说明:当前仓库中该包最新版本为 2.1.2,历史版本变更完整记录于 CHANGELOG.md,下文会在对应小节中给出版本演进脉络。
二、核心 API 一:assignInlineVars(声明式)
assignInlineVars将 CSS 变量以内联样式对象的形式返回,可直接放进 React/Vue 等框架的style属性中。它解决的问题是:createVar等 API 生成的是带var()包裹的变量引用(例如var(--brandColor__8uideo0)),直接塞进style对象会得到非法值,需要先剥掉var()外壳,assignInlineVars在内部替你完成了这一步。
2.1 普通对象模式(单变量赋值)
最基础的用法是传入「变量引用 → 值」的映射对象,变量来自createVar、createTheme等 API:
// app.tsx import { assignInlineVars } from '@vanilla-extract/dynamic'; import { container, brandColor, textColor } from './styles.css.ts'; const MyComponent = ({ tone }: { tone?: 'critical' }) => ( <section className={container} style={assignInlineVars({ [brandColor]: 'pink', [textColor]: tone === 'critical' ? 'red' : null, })} > ... </section> ); // styles.css.ts import { createVar, style } from '@vanilla-extract/css'; export const brandColor = createVar(); export const textColor = createVar(); export const container = style({ background: brandColor, color: textColor, });关键行为:值为null或undefined的变量会被从结果对象中省略。因此当tone为undefined时,上面的内联样式实际变成{ '--brandColor__8uideo0': 'pink' },textColor不会被赋值,从而回退到 CSS 中定义的默认值。这一行为在 2.1.0 版本引入(见 CHANGELOG.md),并由 assignInlineVars.test.ts 的「basic assignment」用例验证——测试传入undefined与null的全局变量后,期望快照中只有--global-var-1: "3"与--global-var-2: "4"两项:
const style = assignInlineVars({ [vars.foo.bar]: '1', [vars.baz.qux]: '2', '--global-var-1': '3', '--global-var-2': '4', '--global-var-3': undefined, '--global-var-4': null, }); // 结果:{ '--baz-qux__1byvgzh1': '2', '--foo-bar__1byvgzh0': '1', '--global-var-1': '3', '--global-var-2': '4' }⚠️ 注意:
null/undefined值只有在不传 theme contract 时才会被接受。如果传入 theme contract,类型系统要求所有变量必须完整赋值,不允许空缺。
2.2 普通对象模式下的字符串模板用法
assignInlineVars返回的对象实现了自定义的toString方法,输出的是合法的style属性值字符串(以分号连接),因此可以直接用在字符串模板中:
// app.ts import { assignInlineVars } from '@vanilla-extract/dynamic'; import { container, brandColor } from './styles.css.ts'; // 输出为 "--brandColor__8uideo0: pink;" document.write(` <section class="${container}" style="${assignInlineVars({ [brandColor]: 'pink' })}" > ... </section> `);从源码(assignInlineVars.ts)可以看到,toString通过Object.defineProperty定义且不可写(writable: false),实现为遍历自身键值拼接成key:value并以;连接的字符串;测试用例验证了style.toString()的结果为"--foo-bar__1byvgzh0:1;--baz-qux__1byvgzh1:2;--global-var-1:3;--global-var-2:4"这种可直接写入style属性的格式。
2.3 Theme contract 模式(整组变量动态主题)
将theme contract 作为第一个参数传入,即可一次性为整组变量赋值。contract 由createThemeContract创建,其嵌套结构会被保留,最终展开为扁平化的 CSS 变量赋值。类型上要求所有叶子变量必须全部赋值,否则编译报错:
// app.tsx import { assignInlineVars } from '@vanilla-extract/dynamic'; import { container, themeVars } from './theme.css.ts'; interface ContainerProps { brandColor: string; fontFamily: string; } const Container = ({ brandColor, fontFamily }: ContainerProps) => ( <section className={container} style={assignInlineVars(themeVars, { color: { brand: brandColor }, font: { body: fontFamily }, })} > ... </section> ); const App = () => ( <Container brandColor="pink" fontFamily="Arial"> ... </Container> ); // theme.css.ts import { createThemeContract, style } from '@vanilla-extract/css'; export const themeVars = createThemeContract({ color: { brand: null }, font: { body: null }, }); export const container = style({ background: themeVars.color.brand, fontFamily: themeVars.font.body, });这种写法让「动态主题」的实现变得极其简单——React 组件通过 props 接收颜色、字体等主题值,运行时直接注入到元素的内联样式中,无需在构建期预先声明每一种主题组合。测试用例(assignInlineVars.test.ts 的「contract assignment」)传入varscontract 与{ foo: { bar: '1' }, baz: { qux: '2' } },期望结果为{ '--baz-qux__1byvgzh1': '2', '--foo-bar__1byvgzh0': '1' },证明嵌套对象被正确扁平化为--foo-bar__、--baz-qux__形式的变量名。
三、核心 API 二:setElementVars(命令式)
setElementVars是命令式(imperative)API,直接在一个DOM 元素上设置 CSS 变量,底层通过element.style.setProperty写入。适合事件回调、定时器、直接操作 DOM 的场景。
3.1 普通对象模式
// app.ts import { setElementVars } from '@vanilla-extract/dynamic'; import { brandColor, textColor } from './styles.css.ts'; const el = document.getElementById('myElement'); setElementVars(el, { [brandColor]: 'pink', [textColor]: null, // 值为 null/undefined 时不会被设置 }); // styles.css.ts import { createVar, style } from '@vanilla-extract/css'; export const brandColor = createVar(); export const textColor = createVar();与assignInlineVars相同,null/undefined值会被跳过(仅在未传 contract 时允许)。测试 setElementVars.test.ts 在 jsdom 环境中验证了该行为:传入包含undefined、null的映射后,元素的style属性字符串为"--foo-bar__1byvgzh0: 1; --baz-qux__1byvgzh1: 2; --global-var-1: 3; --global-var-2: 4;",空值变量确实未出现。
3.2 Theme contract 模式
将theme contract 作为第二个参数传入,即可一次性为元素设置整组变量,同样要求所有变量完整赋值:
// app.ts import { setElementVars } from '@vanilla-extract/dynamic'; import { themeVars } from './theme.css.ts'; const el = document.getElementById('myElement'); setElementVars(el, themeVars, { color: { brand: 'pink' }, font: { body: 'Arial' }, }); // theme.css.ts import { createThemeContract } from '@vanilla-extract/css'; export const themeVars = createThemeContract({ color: { brand: null }, font: { body: null }, });四、源码级原理剖析
两个 API 的实现高度对称,读懂一个即读懂另一个。源码见 assignInlineVars.ts 与 setElementVars.ts,二者均定义了两个重载签名 + 一个联合实现:
// 重载 1:普通对象模式 export function assignInlineVars( vars: Record<string, string | undefined | null>, ): Styles; // 重载 2:theme contract 模式 export function assignInlineVars<ThemeContract extends Contract>( contract: ThemeContract, tokens: MapLeafNodes<ThemeContract, string>, ): Styles; // 联合实现:通过 typeof tokens === 'object' 分流 export function assignInlineVars(varsOrContract: any, tokens?: any) { const styles: Styles = {}; if (typeof tokens === 'object') { // contract 模式:walkObject 扁平化 + get 取变量名 } else { // 普通模式:for...in 遍历 } // 定义 toString return styles; }4.1 分流逻辑:typeof tokens === 'object'
两种模式通过第二个参数是否为对象来区分。有意思的是,即便在普通模式下只传一个参数,tokens为undefined,也会走else分支执行for...in遍历,逻辑正确。setElementVars的分流方式完全相同。
4.2 底层工具:@vanilla-extract/private 三件套
两个函数都依赖@vanilla-extract/private提供的三个工具(源码见 packages/private/src):
walkObject(walkObject.ts):递归遍历嵌套对象,只对叶子值(string、number、null、undefined)调用回调,并携带完整路径数组;遇到数组等非法类型会console.warn提示。contract 模式正是用它把{ color: { brand: 'pink' } }展开为['color', 'brand']这样的路径。get(get.ts):按路径从 contract 对象中取出对应的变量引用;若路径不存在会抛出Path ... does not exist in object错误,这为「必须全部赋值」提供了运行时兜底。getVarName(getVarName.ts):核心函数,用正则/^var\((.*)\)$/匹配并剥掉变量引用外层的var()外壳,只保留--brandColor__8uideo0这样的原始变量名;若是普通字符串(如直接传入的'--global-var-1')则原样返回。这也是本文开头所说的「剥掉var()外壳」的具体实现。
4.3 值处理与类型系统
- 普通模式下,
value == null(同时覆盖null与undefined)时continue跳过;否则写入styles[getVarName(varName)]。 - contract 模式下,
walkObject回调中同样先判空再赋值;setElementVars还会用String(value)强制转字符串后通过setProperty设置(setVar内部实现见 setElementVars.ts)。 - 类型层面,
MapLeafNodes<ThemeContract, string>保证 contract 模式的 tokens 结构与 contract 完全一致、且叶子值为 string,从编译期杜绝遗漏赋值。
五、版本演进与迁移指南
@vanilla-extract/dynamic的完整变更历史记录在 CHANGELOG.md,其中两个大版本对 API 形态影响深远:
5.1 2.0.0:API 大重构
2.0.0 是 Major Changes(PR #276),新增assignInlineVars与setElementVars,并取代了旧 APIcreateInlineTheme、setElementTheme和setElementVar(Breaking Change)。迁移方式如下:
// createInlineTheme → assignInlineVars(参数位置完全一致) -createInlineTheme(vars, { brandColor: 'red' }); +assignInlineVars(vars, { brandColor: 'red' }); // setElementTheme → setElementVars -setElementTheme(el, vars, { brandColor: 'red' }); +setElementVars(el, vars, { brandColor: 'red' }); // setElementVar(单变量)→ setElementVars 的动态键写法 -setElementVar(el, vars.brandColor, 'red'); +setElementVars(el, { + [vars.brandColor]: 'red', +});其中第三项迁移还带来了一个能力提升:新写法天然支持一次设置多个变量。
5.2 2.1.0:null/undefined 支持
2.1.0(PR #1175)为两个函数都增加了null/undefined值过滤能力(仅限非 contract 模式),即本文 2.1 与 3.1 节展示的行为。在此之前的版本中,空值会直接以字符串形式写入变量,破坏样式回退逻辑;升级后条件渲染时只需传null即可「不覆盖默认值」,配合可选 props 非常顺手。
5.3 其他补丁版本
其余版本均为工程化改进,不影响 API 用法,但值得了解:
- 2.1.1:为
package.json增加types字段。 - 2.1.2:升级
@vanilla-extract/private至 1.0.6。 - 2.0.3:构建产物内联 TypeScript 声明文件(
.d.ts)。 - 2.0.2:代码按 Babel
esmodules目标转译,默认符合现代浏览器策略;如需支持 IE11 等 esmodules 之前的老浏览器,需要在项目中自行配置转译。 - 2.0.1:增加
exports字段,支持 Node.js ESM 环境下导入嵌套包路径。
当前 package.json 中sideEffects: false的声明也让打包工具可以放心 tree-shake 未使用的 API。
六、典型实战组合
将上述知识组合起来,可以构建出「声明式样式 + 运行时动态主题」的完整方案:
- 构建期:用
createThemeContract定义主题契约(颜色、字体等),用style声明组件样式并引用这些变量,vanilla-extract 编译为静态 CSS; - 运行时:React 组件接收主题值作为 props,通过
assignInlineVars(themeVars, {...})注入内联样式,实现按用户偏好、A/B 实验或组件实例差异化渲染; - 命令式场景:在拖拽、动画帧、事件监听等无法使用 JSX 内联样式的场景,用
setElementVars(el, themeVars, {...})直接操作 DOM 元素; - 条件回退:利用 2.1.0+ 的
null过滤能力,让可选变量在未提供时静默回退到 CSS 默认值,保持类型安全的同时代码更简洁。
整个@vanilla-extract/dynamic的设计哲学与主项目一致:编译期全量类型检查、运行期最小化开销。它不参与样式生成,只在需要动态化时以最小代价(< 1kB)把变量值写进内联样式或元素上,是 vanilla-extract 零运行时体系在动态场景下的最佳补充。
- 前端
- 开发工具
【免费下载链接】vanilla-extract
Zero-runtime Stylesheets-in-TypeScript
相关推荐
QBDI核心组件详解:VM引擎、执行块与插桩规则的设计与应用
QBDI核心组件详解:VM引擎、执行块与插桩规则的设计与应用 QBDI是一款基于LLVM的动态二进制插桩框架,它允许开发者在程序执行过程中实时修改和监控二进制代
应用安全开发工具N_m3u8DL-RE 完整使用指南:三步搞定 M3U8/MPD 流下载、解密与直播录制
N_m3u8DL RE 完整使用指南:三步搞定 M3U8/MPD 流下载、解密与直播录制 从网页里的 m3u8 链接到可播放的视频文件 你用 F12 打开一门在
CLI音视频交叉类型与类型标记技巧:TypeScript-New-Handbook 类型组合高级玩法
交叉类型与类型标记技巧:TypeScript New Handbook 类型组合高级玩法 在 TypeScript 的类型系统里, 交叉类型 (Intersec
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考