Skill 约束不了响应式尺寸时:用构建插件给 UnoCSS 自动补 lg 副本
有些样式约定只写进 Cursor Rules 或 Skill 里是不够的。Agent 会读,开发者也会读,但只要项目持续迭代,就总会有人漏掉一条响应式细节。
这次遇到的是一个 Astro / UnoCSS 官网页项目。移动端希望按 375 设计稿把 base 层 px 转成 vw,桌面端又要保留设计稿里的固定 px。最直观的写法是每个尺寸 utility 写两份:
<!-- examples/usage.astro,阅读版示例 -->
<section class="px-16 lg:px-16 py-32 lg:py-32">
<div class="gap-12 lg:gap-12"></div>
</section>这事靠约定很难稳住。代码里同样的尺寸可能写在模板 class,也可能写在 <style> 里的 @apply;有些地方移动端和桌面端同值,有些地方又要显式 lg: override。最后更适合落在构建期:开发者只写 base,构建插件自动补同值 lg: 副本;一旦开发者已经写了 lg:,插件就避让。
目标不是再造一套响应式系统
这套方案只做一件事:给「数字 / 任意值类」的 UnoCSS utility 自动补 lg: 副本。
/* 写的时候 */
.section {
@apply px-16 py-32 text-center;
}
/* 下游处理器看到的是 */
.section {
@apply px-16 lg:px-16 py-32 lg:py-32 text-center;
}然后继续交给原来的链路处理:
- base utility 由
postcss-mobile-forever转成vw,服务移动端。 lg:utility 进@media (min-width: 1024px),因为enableMediaQuery: true保留px。text-center、flex、hidden这类关键字 utility 不补,因为它们没有单位转换问题。site-header__pill这类 BEM class 不补,避免污染 DOM 里出现lg:site-header__pill这种无效 class。
如果移动端和桌面端确实不同值,仍然手写 lg::
.section {
/* base 写移动端,lg 锁桌面端 */
@apply px-16 py-32 lg:px-[240px] lg:py-[73px];
}构建插件看到 lg:px-[240px] 和 lg:py-[73px] 后,会跳过 px-16 / py-32 的自动副本,让显式 override 优先生效。
三轮尝试后定下这条链路
这个结论不是一开始就想出来的。最早只想把 postcss-mobile-forever 接上,让它把项目里的 px 全部转成 vw。结果很快发现:这个插件只处理 PostCSS 看到的 CSS 声明,UnoCSS 生成的 utility 和 @apply 展开的声明,不一定在同一个阶段出现。
第一轮是最直觉的写法:在 postcss.config.ts 里挂 postcss-mobile-forever。手写裸 CSS 没问题,padding: 16px 会进 PostCSS AST,也会被转成 4.267vw。但模板里的 class="px-16" 和样式里的 @apply px-16 不是普通 CSS 声明,mobile-forever 看不到它们展开后的 padding-left: 16px,那部分 px 就会原样留下。
第二轮曾经把转换逻辑挪到 UnoCSS 的 postprocess。utility 是能改了,但这条路很快变成双轨:UnoCSS utility 走自写转换,手写裸 CSS 仍然走 mobile-forever。这样一来,lg: media query 要不要跳过、0px 要不要简化、1px hairline 要不要保留,都得在自写逻辑里重新实现一遍。能跑,但维护的是两套单位转换语义。
最后留下来的方案更克制:让 @unocss/postcss 把 UnoCSS 也接进 PostCSS pipeline,单位转换仍然只交给 mobile-forever。自定义插件只做一件事:在 UnoCSS 展开之前补 lg: 副本。它不负责把 px 转 vw,也不重新模拟 mobile-forever 的边界。
为什么要拆成两个插件
一开始很容易想:既然 UnoCSS 有 SourceCodeTransformer,那就写一个 transformer,把模板和样式里的 token 都扫一遍。
实际不行。模板主文件和 <style> / CSS 不是同一条管线。
模板里的 class="..."、class:list={...} 还在 .astro / .vue / .tsx 主文件里,适合用 UnoCSS SourceCodeTransformer 改写。<style> 里的 @apply 在 Vite CSS 处理链里会先进入 PostCSS;一旦 @unocss/postcss 把 @apply 展开成最终 CSS,后面的 SourceCodeTransformer 就再也看不到 @apply 了。
所以最后拆成两块:
| 来源 | 插件类型 | 注册位置 | 作用 |
|---|---|---|---|
模板里的 class 字面量 |
UnoCSS SourceCodeTransformer | uno.config.ts |
在 UnoCSS 提取 utility 前补 lg: |
<style> / CSS 里的 @apply |
PostCSS plugin | postcss.config.ts |
在 @unocss/postcss 展开前补 lg: |
两块共用同一个 dualTokens() 算法。真正要统一的是语义,不是强行统一运行时入口。
这里还连着 mobile-forever 的盲区。只要 UnoCSS 产物没进入 PostCSS,它就没有机会转换那些 px。
配置顺序是这套方案的生命线:
// copy/postcss.config.ts:1-37
import autoprefixer from 'autoprefixer';
import UnoCSS from '@unocss/postcss';
import postcssMobileForever from 'postcss-mobile-forever';
import { autoDualApply } from './build-plugins';
export default {
plugins: [
autoDualApply(),
UnoCSS(),
autoprefixer({
overrideBrowserslist: ['Chrome >= 40', 'Safari >= 10'],
cascade: false,
}),
postcssMobileForever({
viewportWidth: () => 375,
// 开启 mq-mode,@media 内的 px 保持为桌面端设计稿尺寸。
enableMediaQuery: true,
disableDesktop: true,
disableLandscape: true,
}),
],
};autoDualApply() 必须排在 UnoCSS() 之前,UnoCSS() 必须排在 postcss-mobile-forever 之前。前者保证 @apply 还没被展开时可以改参数,后者保证移动端转换能拿到 UnoCSS 展开的最终 CSS。
这里真正麻烦的是 postcss-mobile-forever 并不天然理解 UnoCSS。它只处理已经进入 PostCSS AST 的 CSS;如果项目只靠 unocss/astro 或 Vite 插件生成 utility CSS,PostCSS 阶段看不到 px-16 展开的 padding-left: 16px,也看不到 @apply px-16 展开后的属性。结果就是页面里看起来都在用 UnoCSS,只有手写裸 CSS 被转成 vw,utility 和 @apply 里的 px 仍然原样留在产物里。
所以这篇文章里的 @unocss/postcss 是关键配置:它把所有来源拉进同一条 PostCSS pipeline。autoDualApply() 先补 lg:,@unocss/postcss 再展开 utility,postcss-mobile-forever 最后统一把 base 层 px 转成 vw。
enableMediaQuery: true 也不是随手打开的参数。桌面端的 lg: 最终会变成 @media (min-width: 1024px) 里的规则,这些规则必须保留 px,否则自动补出来的 lg: 副本也会被转成 vw,桌面端就失去了固定设计稿尺寸。postcss-mobile-forever 文档里把 viewportWidth + enableMediaQuery 称作 mq-mode;当前配置再配合 disableDesktop: true 和 disableLandscape: true,只保留「base 转 vw、已有 media query 内保留 px」这件事,不让插件额外生成桌面居中或横屏适配块。
模板侧则挂在 UnoCSS transformer 里:
// copy/uno.config.ts:14-29
export default defineConfig({
preflights: [{ getCSS: () => readFileSync(resetCSSPath, 'utf-8') }],
presets: [
presetWind3(),
presetRemToPx({
baseFontSize: 4,
}),
],
shortcuts: {},
rules: [],
transformers: [autoDualClass(), transformerDirectives(), transformerVariantGroup()],
});这里还有一个小坑:如果 preflights 依赖 reset.css 这类外部文件,不要在模块顶层提前 readFileSync。把读取放进 getCSS(),dev 重新编译时才能重新读到文件内容。
核心算法只看 token 语义
dualTokens() 不调用 UnoCSS preset 去解析每个 token。它只做纯字符串判断:这个 token 剥掉 variant 后,是不是「前缀 + 数字 / 任意值」的形态。
// copy/build-plugins/global-utils/dual-tokens/index.ts:94-124
export default (input: string, extraContextTokens = ''): string => {
const tokens = input.split(/\s+/).filter(Boolean);
const lgPrefixes = new Set<string>();
collectLgPrefixes(tokens, lgPrefixes);
if (extraContextTokens) {
const contextTokens = extraContextTokens.split(/\s+/).filter(Boolean);
collectLgPrefixes(contextTokens, lgPrefixes);
}
const out: string[] = [];
for (const t of tokens) {
out.push(t);
if (hasMediaVariant(t)) continue;
if (isVariantGroupFragment(t)) continue;
const name = stripVariants(t);
const prefix = utilityPrefix(name);
if (!prefix) continue;
if (lgPrefixes.has(prefix)) continue;
out.push(`lg:${t}`);
}
return out.join(' ');
};这段逻辑故意保守:
py-12、w-[1672px]、shadow-[0_0_13px_rgba(...)]会补。flex、text-center、rounded-full不补。- 含
lg:/md:/sm:/xl:/2xl:的 token 不补。 hover:(p-12 m-8)这种变体组片段跳过,因为按空格拆开后很难可靠改写。- 如果同前缀已经出现
lg:,例如py-12 lg:py-24,py-12不再补lg:py-12。
它不是完整 CSS 语义解析器。换来的是同步、可测、可解释,并且不会和 UnoCSS preset 强绑定。对于一个构建期护栏,这个取舍更稳。
模板 class 只能改字面量
autoDualClass 的职责是改模板主文件。它支持普通 class="...",也支持 Astro 常见的 class:list={['py-12', { 'mt-24': active }]}。
// copy/build-plugins/auto-dual-class/index.ts:133-177
export default (): SourceCodeTransformer => ({
name: 'auto-dual-class',
enforce: 'pre',
transform(code) {
const src = code.original;
CLASS_ATTR_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = CLASS_ATTR_RE.exec(src)) !== null) {
const content = m[2];
const contentStart = m.index + m[0].length - content.length - 1;
const contentEnd = contentStart + content.length;
const dualed = dualTokens(content);
if (dualed !== content) {
code.overwrite(contentStart, contentEnd, dualed);
}
}
const ranges = findClassListBraceRanges(src);
for (const range of ranges) {
const literals = findPlainStringLiterals(src, range.start + 1, range.end - 1);
for (let i = literals.length - 1; i >= 0; i--) {
const { contentStart, contentEnd } = literals[i];
const content = src.slice(contentStart, contentEnd);
if (!content.trim()) continue;
const dualed = dualTokens(content);
if (dualed !== content) {
code.overwrite(contentStart, contentEnd, dualed);
}
}
}
},
});它不处理 class={someVar},也不处理模板字符串。这里不追求「聪明地改一部分静态片段」,因为动态表达式里混着变量、条件和字符串拼接,误改一次比漏改一次更危险。
这也是为什么 Cursor rule 仍然有价值。规则负责告诉 Agent:主路径用字面量 class 或 class:list,不要在这个项目里写 attributify、className 或模板字符串 class。构建插件负责兜住「合规写法里仍然会漏的 lg: 副本」。
手写裸 CSS 和 @apply 不完全等价
这套方案会让 utility / @apply 走双端语义:base 负责移动端,自动补出来的 lg: 负责桌面端。手写裸 CSS 没有这个自动副本。
.foo {
padding: 12px;
@apply p-16 text-center;
}这段代码最终会有一个容易忽略的分叉:padding: 12px 只是一条普通声明,它会被 mobile-forever 转成 vw;@apply p-16 会先被 dual 补出 lg:p-16,再由 @unocss/postcss 展开,所以桌面端 media query 里会有一份 padding: 16px。
如果某段样式希望同时享受移动端 vw 和桌面端 px,优先写成 utility 或 @apply。手写裸 CSS 适合表达 utility 不好写的东西,比如复杂渐变、clip-path、少量特殊阴影;一旦里面有尺寸,并且桌面端也要固定 px,就要自己写对应的 media query。
@apply 要走 PostCSS,并且用 Once
autoDualApply 只看 PostCSS AST 里的 @apply AtRule。这里有两个踩坑值得单独记下来。
第一,不能把它放在 UnoCSS SourceCodeTransformer 里。接入 @unocss/postcss 后,@apply 会在 Vite CSS pipeline 内被更早展开,Transformer 轮到时已经没有目标。
第二,PostCSS 8 的 AtRule: { apply(rule) {} } visitor 在这个 Vite 调用环境里并不稳定;实测 visitor 注册了但没有触发。Once(root) 一定会执行,所以插件用 Once 再手动 walkAtRules('apply')。
// copy/build-plugins/auto-dual-apply/index.ts:42-91
function autoDualApply(): Plugin {
return {
postcssPlugin: 'auto-dual-apply',
Once(root) {
const parentSharedContextCache = new Map<unknown, string>();
const collectParentContext = (parent: unknown) => {
let context = parentSharedContextCache.get(parent);
if (context !== undefined) return context;
const collected: string[] = [];
(parent as { each: (cb: (child: { type: string; name?: string; params?: string }) => void) => void }).each((child) => {
if (child.type === 'atrule' && child.name === 'apply' && child.params) {
collected.push(child.params);
}
});
context = collected.join(' ');
parentSharedContextCache.set(parent, context);
return context;
};
root.walkAtRules('apply', (rule) => {
const parent = rule.parent;
if (!parent) return;
const sharedContext = collectParentContext(parent);
const dualed = dualTokens(rule.params, sharedContext);
if (dualed !== rule.params) rule.params = dualed;
});
},
};
}
autoDualApply.postcss = true as const;
export default autoDualApply;这里的 sharedContext 是后来补上的关键修复。
如果同一个规则块里分两行写:
.x {
@apply mb-30;
@apply lg:mb-72;
}第一条 @apply 只看自己时,会补出 lg:mb-30。接着 @unocss/postcss 展开时,lg:mb-30 和用户写的 lg:mb-72 都会进入 media 块,层叠顺序还有可能让自动副本压过用户 override。现在 autoDualApply 会把同一个 parent 下的兄弟 @apply 合成避让上下文,第一条处理 mb-30 时也能看到第二条里的 lg:mb-72,于是跳过自动副本。
这个问题很适合写测试。单测只能证明 dualTokens('mb-30', 'mb-30 lg:mb-72') 会避让;集成测试要真实跑 autoDualApply → @unocss/postcss,确认最终 CSS 的 media 块里没有错误的 margin-bottom: 30px。
验证要看真实产物
这类构建期工具最容易出现「源码看着对、产物不对」的误判。尤其是 Astro / Vite dev 模式下,直接 curl 页面 HTML 看到的 <style> 内容可能还是源码占位,不代表浏览器最终拿到的 CSS。
更稳的验证方式有三层:
- 跑插件测试,确认 token 改写和 PostCSS 集成链路没有回归。
- 看 Vite dev endpoint 或构建后的 CSS,确认 base 层是
vw,@media (min-width: 1024px)内仍然是px。 - 用浏览器 DevTools 的 Computed 面板看真实计算值,避免被源码占位或缓存误导。
# 在目标项目里跑插件单测和集成测试
pnpm test:build-plugins
# 生产构建后看 dist 里的 CSS
pnpm build
# dev 模式下看某个 Astro style endpoint,而不是只看页面 HTML
curl -s 'http://localhost:4322/src/components/SectionTitle/index.astro?astro&type=style&index=0&lang.scss' \
| grep -A 10 'min-width: 1024px'预期产物里应该同时出现两类结果:普通规则里的移动端尺寸已经转成 vw,lg: 对应的 media query 里保留 px。如果只看到其中一类,优先检查 postcss.config.ts 的顺序和 enableMediaQuery,再检查 dev server 是否已经重启。
可复制源码
如果要让 Agent 接入这套源码包,可以直接给它这段 prompt:
工具包根路径:https://shengsheng.fun/files/unocss-auto-dual-build-plugins/kits/unocss-auto-dual-guardrail/
先读 README.md、MANIFEST.json、FILES.json、CHANGELOG.md、AGENT_PROMPT.md 和 copy/cursor-rules/unocss-auto-dual-styling.mdc。
把 copy/build-plugins/ 复制到目标项目,分别在 uno.config.ts 接入 autoDualClass()、在 postcss.config.ts 接入 autoDualApply()。
确认 postcss 顺序是 autoDualApply() → UnoCSS() → postcss-mobile-forever,uno transformer 顺序是 autoDualClass() → transformerDirectives() → transformerVariantGroup()。
运行 build-plugin 测试和项目构建;不要顺手重构业务样式或改页面结构。完整源码包在下面,包含构建插件、配置示例、Cursor rule、单测和集成测试:
