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:,插件就避让。

响应式尺寸的目标产物:开发者只写 base,构建期补 lg,移动端转 vw,桌面端保留 px

目标不是再造一套响应式系统

这套方案只做一件事:给「数字 / 任意值类」的 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-centerflexhidden 这类关键字 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 展开的声明,不一定在同一个阶段出现。

三轮尝试后的取舍:从 mobile-forever 单点转换,到 UnoCSS postprocess 双轨,再到统一进入 PostCSS pipeline

第一轮是最直觉的写法:在 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: 副本。它不负责把 pxvw,也不重新模拟 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

只挂 postcss-mobile-forever 时的盲区:裸 CSS 能转,UnoCSS utility 和 @apply 展开容易漏掉

配置顺序是这套方案的生命线:

最终 pipeline:autoDualClass 处理模板,autoDualApply 处理 @apply,@unocss/postcss 展开后交给 mobile-forever

// 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: truedisableLandscape: 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-12w-[1672px]shadow-[0_0_13px_rgba(...)] 会补。
  • flextext-centerrounded-full 不补。
  • lg: / md: / sm: / xl: / 2xl: 的 token 不补。
  • hover:(p-12 m-8) 这种变体组片段跳过,因为按空格拆开后很难可靠改写。
  • 如果同前缀已经出现 lg:,例如 py-12 lg:py-24py-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:主路径用字面量 classclass: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'

预期产物里应该同时出现两类结果:普通规则里的移动端尺寸已经转成 vwlg: 对应的 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、单测和集成测试:

UnoCSS 自动 dual 构建插件源码正在加载代码工作区...