UnoCSS 的预设开发通常从一份规则数组开始。规则数组看起来很直观,但它与 TypeScript 的类型推断结合时,经常出现函数参数变成隐式 any 的情况。比如写下 [/^m-(\d+)$/, ([, d]) => ({ margin: `${d}px` })],编辑器立刻提示 d 具有隐式 any 类型。这个问题的根源不在于规则本身,而在于预设对象没有获得来自 Preset 接口的上下文类型。下面先看一下类型签名链路,再给出修复方案。

一、先理清 Preset 与 Rule 的类型签名
在 @unocss/core 中,预设对象的类型是 Preset,它包含 rules、shortcuts、variants、theme 等可选字段。规则字段 rules 的类型通常是一个联合类型数组,每个元素要么是 [string, CSSObject] 这种静态规则,要么是 [RegExp, DynamicMatcher] 这种动态规则。DynamicMatcher 的函数签名大致是 (match: RegExpExecArray, context: RuleContext) => CSSObject | CSSEntries | undefined。只要预设对象被显式标注为 Preset,TypeScript 就会利用这个签名来推断动态规则里的函数参数。
问题出在没有标注类型的时候。直接导出一个普通对象字面量,TypeScript 会推断 rules 数组的元素为联合类型,而不是精确的元组。例如下面的代码:
const myPreset = {
name: 'my-preset',
rules: [
[/^m-(\d+)$/, ([, d]) => ({ margin: `${Number(d) * 0.25}rem` })],
],
}
此时 d 的类型会被推断为 string | undefined 或者直接变成 any,因为第二个元素的函数参数没有明确的上下文。即使给函数参数写上显式类型,每次都要重复 RegExpExecArray 也很麻烦,而且静态规则和动态规则混在一起时数组元素类型仍然会变宽。
正确的做法是给整个预设对象加上 Preset 类型标注。这样 TypeScript 会为规则数组中的每个元组提供上下文类型,动态规则的回调参数会自动获得 RegExpExecArray 和 RuleContext。下面是一个完整示例:
import type { Preset } from '@unocss/core'
const myPreset: Preset = {
name: 'my-preset',
rules: [
['m-1', { margin: '0.25rem' }],
[/^p-(\d+)$/, ([, d]) => {
const value = Number(d)
return { padding: `${value * 0.25}rem` }
}],
],
}
export default myPreset
这段代码在编译期间就能得到完整的类型检查,d 会被推断为 string,返回值也会被约束为样式对象。静态规则和动态规则共存时也不会出现数组联合类型过宽的问题。这个方案是最直接、最不容易出错的。
二、规则匹配阶段类型丢失的几个典型场景
第一种常见场景是动态规则拼接。很多预设不会一次性写完所有规则,而是先定义基础规则,再根据主题配置或者循环生成一批规则。此时如果中间变量没有标注 Rule[],拼接后的数组类型就会丢失元组结构。比如:
import type { Rule } from '@unocss/core'
const generated: Rule[] = ['m-1', 'm-2', 'p-1'].map((name) => {
const value = name.slice(1)
return [name, { margin: `${Number(value) * 0.25}rem` }] as Rule
})
这里显式声明了 Rule[],并且每个返回值用 as Rule 断言。如果不加这个断言,map 的回调返回数组会被推断为 (string | { margin: string })[],再赋予 Rule[] 时就会报错。对于动态拼接的基础规则,建议把生成函数单独封装,并让返回值类型明确为 Rule[]。
第二种场景是使用 as const 固定规则数组。有些开发者为了让字符串字面量类型更精确,会写 const rules = [...] as const。但 as const 会把数组变成只读元组,而 Preset 中的 rules 字段期望的是可变数组,只读数组不能直接赋值,反而带来新的类型不兼容。除非在预设对象中再用一次类型断言,否则不建议在预设规则上使用 as const。
第三种场景是自定义上下文。默认的 RuleContext 包含 theme、generator、rawSelector 等字段,但如果你的预设需要在规则匹配阶段读取额外数据,直接给 context 添加字段会导致类型报错。这个场景我们放到第四节单独讨论,因为修复方式涉及模块增强。
三、用 definePreset 封装与 satisfies 校验
如果不想在每个预设对象上重复写 : Preset,可以自己封装一个 definePreset 辅助函数。这个函数只是接收一个 Preset 类型的参数并原样返回,但带来的类型上下文和 : Preset 完全一致。示例:
import type { Preset } from '@unocss/core'
export function definePreset(preset: Preset): Preset {
return preset
}
export const myPreset = definePreset({
name: 'my-preset',
rules: [
[/^text-(\d+)$/, ([, size]) => ({ 'font-size': `${Number(size) * 0.25}rem` })],
],
})
这样做的好处是调用方可以省去类型标注,同时所有函数参数都会在 definePreset 的参数位置获得上下文类型。并且如果函数签名未来变化,只需要修改 definePreset 一处。它的运行时开销为零,函数体直接返回传入对象,不会影响预设的实际内容。
另一个方案是使用 TypeScript 4.9 引入的 satisfies。写法如下:
import type { Preset } from '@unocss/core'
const myPreset = {
name: 'my-preset',
rules: [
[/^p-(\d+)$/, ([, d]) => ({ padding: `${Number(d) * 0.25}rem` })],
],
} satisfies Preset
export default myPreset
satisfies 会在编译期检查对象是否符合 Preset 类型,同时保留对象字面量的原始类型,比直接 : Preset 更灵活。例如后面如果还需要读取 myPreset.name,它的类型仍然是 'my-preset' 而不是 string。需要注意,satisfies 同样会为函数表达式提供上下文类型,因此规则回调里的 d 不会被推断成 any。这个方案适合那些希望保留字面量类型、又不想单独封装函数的场景。
四、扩展规则上下文与自定义主题字段
当预设需要从主题中读取自定义颜色或者间距时,默认 Preset 的 Theme 类型可能不包含这些字段。UnoCSS 的 Preset 接口接受一个泛型参数 Theme,默认是 object。你可以定义自己的主题类型,然后写出 Preset<MyTheme>。例如:
import type { Preset, RuleContext } from '@unocss/core'
type MyTheme = {
colors?: Record<string, string>
spacing?: Record<string, string>
}
const myPreset: Preset<MyTheme> = {
name: 'my-preset',
theme: {
colors: {
brand: '#3b82f6',
},
spacing: {
lg: '2rem',
},
},
rules: [
[/^bg-brand$/, (match, context) => {
const color = context.theme.colors?.brand
return color ? { 'background-color': color } : undefined
}],
],
}
这里的 context.theme.colors?.brand 拥有完整的类型提示,不会出现 Property 'colors' does not exist on type 'object' 的错误。泛型参数只存在于类型层面,不会增加任何运行时逻辑,也不会影响最终生成 CSS 的体积。如果你的预设还需要读取其他插件提供的上下文字段,可以考虑定义自己的上下文接口,再把规则匹配函数改写成接受该接口的版本,最后通过 as unknow as Rule 断言兼容。但要尽量避免滥用断言,否则就失去了类型检查的意义。
最后对比一下三种方案:直接标注 : Preset 最稳定,适合大多数预设;definePreset 适合有多个预设文件的仓库,统一入口更易维护;satisfies 适合既要类型校验又要保留字面量类型的场景。无论选择哪种,规则匹配函数里的参数类型都会从隐式 any 变成精确的 RegExpExecArray 与 RuleContext,开发体验会明显提升。关键在于不要导出裸对象,而是让 TypeScript 有机会执行上下文推断。
UnoCSS预设TypeScript类型定义规则匹配修改时间:2026-09-21 23:05:24