对不起!我错怪你了,UnoCSS:一次和 AI 一起排查 DevTools 卡顿的经历

0 阅读17分钟

前言

这次问题说大不大,说小也不小。

项目里同时用了 Element Plus、Vant、UnoCSS、SCSS mixin、CSS Modules、CSS 变量,还有一批第三方组件库样式。页面本身能正常跑,交互也不卡,但只要打开 Chrome DevTools,在 Elements 面板里选元素、看 Styles、展开 Computed,就会明显卡顿。

就是那种:你只是想改个 padding,浏览器像是开始认真思考人生。

96c89497691a40caa4c08eeea8f90475.png

我第一反应是:不会是 UnoCSS 吧?

这个反应很正常。很多人一看到原子化 CSS,就会联想到“类很多、规则很多、DevTools 匹配慢”。再加上项目里还有各种全局样式、主题变量、组件库样式,锅看起来很好甩。

但最后排查下来发现:这次真不是 UnoCSS 的主锅。

这篇文章我主要记录一下这个过程,也就整体两件事:

  1. DevTools 样式调试卡顿到底怎么一步步定位。
  2. 在 AI 时代,怎么合理地和 AI 配合,把一个模糊的性能问题拆开、验证、修改、再验证。

我先说结论:AI 很有用,但不能把它当“大仙”。 它适合帮你整理假设、写排查脚本、读代码、批量改代码、总结验证结果。 但最后是不是这个原因,必须靠运行时数据说话。

这次最大的收获不是“删掉了多少 CSS”,而是:不要拿感觉修改性能问题,要拿数据修。AI 也一样。

一、问题到底是什么

问题主要发生在开发调试阶段:

  • 页面可以正常打开。
  • 业务功能基本可用。
  • 页面交互没有明显卡顿。
  • 但打开 Chrome DevTools 后,Elements / Styles / Computed 面板明显卡顿。
  • 选中元素、切换元素、展开 computed 属性、临时改样式时,体感不顺。

这里要先分清两个概念。

一个是用户态性能:

  • 点击是否慢。
  • 滚动是否掉帧。
  • 动画是否卡。
  • 输入是否延迟。

另一个是 DevTools 调试性能:

  • DevTools 要读取 CSSOM。
  • DevTools 要给当前元素匹配所有可能命中的 CSS 规则。
  • DevTools 要展示 cascade 层叠关系。
  • DevTools 要算 CSS 变量、继承、覆盖、伪类、媒体查询。
  • 如果是 Vite dev 模式,还会有很多 <style> 标签通过 HMR 注入。

这次主要是后者。

也就是说,用户不一定卡,但开发者调样式很痛苦。

二、我一开始是怎么“错怪” UnoCSS 的

项目里的样式来源很多:

  • element-plus/dist/index.css
  • Vant 组件样式
  • UnoCSS virtual:uno.css
  • @unocss/reset/tailwind-compat.css
  • @xxxx/theme-lib/scss/util.scss
  • AppCommon.scss
  • AppMix.scss
  • Scrollbar.scss
  • CSS Modules
  • vue-color/style.css
  • quill/dist/quill.snow.css
  • animate.css
  • 多个 @xxxx/* 第三方包 CSS

如果不看数据,光靠经验猜,很容易列出这些嫌疑人:

  1. UnoCSS 生成了大量工具类。
  2. SCSS 公共 mixin 被到处注入,重复输出。
  3. Element Plus + Vant 两套 UI 库 CSS 叠加。
  4. CSS 变量太多,运行时计算成本高。
  5. Vue scoped / CSS Modules 生成了很多哈希类名。
  6. DOM 节点太多。

这些猜想有没有道理?都有一点。

但问题是:有道理不代表它就是主因。

这也是我这次和 AI 沟通时刻意强调的一点:不要上来就让 AI 改代码。

如果你一开始就问:

项目 DevTools 样式卡,是不是 UnoCSS 的问题?帮我去掉 UnoCSS。

AI 很可能会顺着你的话往下走,给你一套迁移方案。看起来很完整,但可能方向就是错的。

更好的问法是:

项目 DevTools 样式调试卡顿,怀疑和 UnoCSS、Element Plus、SCSS 主题变量重复输出有关。先不要改代码,帮我列出可能原因、每个原因的验证方式、优先级和风险。

这个区别很大。

前一种问法是在让 AI 替你背锅。 后一种问法是在让 AI 帮你设计排查路径。

三、AI 在这次排查里真正帮了什么

这次我没有把 AI 当“直接给答案的人”,而是当一个比较耐心的搭档。因为我也怕 一失足成千古恨,再回头已百年身抖音百科(把项目直接搞瘫痪了,😄)

我主要让它做几类事情。

3.1 先拆假设,不急着改

一开始先让 AI 帮我把嫌疑点拆出来:

  • 哪些是构建期问题。
  • 哪些是运行时问题。
  • 哪些会影响页面性能。
  • 哪些只会影响 DevTools 调试体验。
  • 哪些低风险,可以先动。
  • 哪些高风险,需要单独分支验证。

这样做的好处是,不会一上来就把一个大工程拆烂。

性能问题最怕“我感觉是它,所以我把它删了”。 删完以后问题还在,回归风险倒是先来了。你们有没有遇到过?hahaha

3.2 让 AI 写诊断脚本,而不是让 AI 猜

后面我们需要知道运行时到底加载了多少样式表、多少 CSS rules、最大的是谁。

这种脚本手写不难,但很琐碎。让 AI 写就很合适。

我给它的方向大概是:

写一个浏览器 Console 脚本,统计 document.styleSheets,每个 stylesheet 的 cssRules 数量、ownerNode、textSize、preview,再统计 DOM 节点数量、带 class 的元素数量。

然后拿到了这个脚本:

(() => {
  const sheets = [...document.styleSheets];

  const getRules = (sheet) => {
    try {
      return sheet.cssRules ? sheet.cssRules.length : 0;
    } catch {
      return -1;
    }
  };

  const rows = sheets
    .map((sheet, index) => {
      const owner = sheet.ownerNode;
      const text = owner?.textContent || '';
      return {
        index,
        rules: getRules(sheet),
        href: sheet.href || '',
        tag: owner?.tagName || '',
        id: owner?.id || '',
        className: owner?.className || '',
        textSize: text.length,
        preview: text.slice(0, 120).replace(/\s+/g, ' '),
      };
    })
    .sort((a, b) => b.rules - a.rules);

  const allElements = [...document.querySelectorAll('*')];

  const summary = {
    styleSheets: sheets.length,
    totalRules: rows.reduce((sum, item) => sum + Math.max(item.rules, 0), 0),
    inaccessibleSheets: rows.filter(item => item.rules < 0).length,
    styleTags: document.querySelectorAll('style').length,
    linkStyles: document.querySelectorAll('link[rel="stylesheet"]').length,
    domNodes: allElements.length,
    elementsWithClass: allElements.filter(el => el.className).length,
    maxClassCount: Math.max(
      0,
      ...allElements.map(el =>
        typeof el.className === 'string' ? el.className.split(/\s+/).filter(Boolean).length : 0,
      ),
    ),
  };

  console.table(rows.slice(0, 30));
  console.log('STYLE_DIAG_SUMMARY:', summary);

  return { summary, topStyleSheets: rows.slice(0, 30) };
})();

控制台就会有输出,Console 就可以直接复制结果了。

这个过程也挺典型:AI 写脚本,人来跑脚本,人把结果再喂给 AI,AI 再帮你分析。AI 现在是一个比较耐心的搭档。

3.3 把真实输出贴给 AI,让它改判断

最后我们拿到的数据是:

{
  "styleSheets": 75,
  "totalRules": 4487,
  "inaccessibleSheets": 0,
  "styleTags": 75,
  "linkStyles": 0,
  "domNodes": 2055,
  "elementsWithClass": 1885,
  "maxClassCount": 10
}

Top 样式表里最大的是:

{
  "index": 27,
  "rules": 2504,
  "textSize": 343511,
  "preview": "@charset \"UTF-8\";:root{--el-color-white:#ffffff;--el-color-black:#000000;--el-color-primary-rgb..."
}

这个 preview 基本已经把答案写脸上了:Element Plus。

再看 UnoCSS:

  • UnoCSS 主样式大概 75 rules
  • Uno reset 大概 35 rules

所以这个时候就不能继续说“肯定是 UnoCSS”。 数据已经不支持这个结论了。

这也是使用 AI 时很关键的一点:当数据和最初猜想冲突时,要让 AI 跟着数据走,而不是让 AI 继续证明你是对的。

四、第一轮真正动手:清理主题变量重复输出

静态排查时,先发现了一个比较确定的问题:SCSS 公共内容重复输出。

项目在 vite.config.ts 里通过 scss.additionalData 给每个 SCSS 文件注入公共 SCSS:

css: {
  preprocessorOptions: {
    scss: {
      additionalData: `
        @use "@/AppCommon.scss" as *;
      `,
    },
  },
},

这个配置不是一定有问题,但前提是:被注入的 SCSS 文件最好只包含变量、函数、mixin,不要包含会输出真实 CSS 的选择器、keyframes、全局规则。

因为每个 .scss.module.scss、Vue style 都会作为独立入口编译。

如果公共文件里有真实 CSS,那就会被每个入口重复输出。这里可以重点记一下。

4.1 useThemeVar 带来的重复主题块

大量业务 SCSS 中有类似代码:

@use '@xxxx/theme-lib/scss/util.scss' as *;

.foo {
  color: useThemeVar('gray1');
}

这会引入主题库里的辅助逻辑,并在 CSS Modules 场景中产生类似这样的块:

._ls-theme__light_xxx {
  --ls-color_primary_var: #697dff;
  --ls-primary_hover_var: #5464cc;
  ...
}

如果这种块在很多组件 CSS 里重复出现,CSS 规则数量就会膨胀,DevTools 匹配样式也会更累。

处理方式是把高频 useThemeVar('xxx') 替换成原生 CSS 变量:

color: var(--ls-gray1, #222222);
background: var(--ls-button_gray_bg1, #f3f4f6);
border-color: var(--ls-divider, #e6e6e6);

然后把主题变量统一放到 src/styles/theme-palette.scss,只在全局入口单次引入。

处理后验证:

  • PC 入口 CSS 中 _ls-theme__light/_dark = 0
  • H5 入口 CSS 中 _ls-theme__light/_dark = 0

这一步属于低风险、高收益。

4.2 第三方依赖里还有残留怎么办

业务源码清掉后,产物中仍有少量 _ls-theme__light 块。

继续追踪发现,不是我们 src 里的代码,而是编译后的 @xxxx/* 依赖包带出来的。

这里没有粗暴删依赖,而是在 vite.config.ts 里加了一个 PostCSS 清理插件,只清理满足严格条件的规则:

  1. selector 包含 ls-theme__lightls-theme__dark
  2. declarations 全部是 --ls-*_var

同时对 @xxxx/* 依赖样式里重复的 Uno preflight 变量块做兜底清理。

关键逻辑大概是这样:

function stripRedundantStyleRules() {
  return {
    postcssPlugin: 'strip-redundant-style-rules',
    Once(root: any, { result }: any) {
      const from = String(result?.opts?.from || '')
      const isDependencyStyle = from.includes('/node_modules/') && from.includes('/@xxxx+')

      root.walkRules((rule: any) => {
        if (isThemeLibVarBlock(rule)) {
          rule.remove()
          return
        }

        if (isDependencyStyle && isUnoPreflightVarBlock(rule)) {
          rule.remove()
        }
      })
    },
  }
}

注意这里的重点不是“写了个插件很厉害”,而是它只删可证明重复的东西。 性能优化里,最怕那种“看着没用就删”的操作。

五、第二轮:setting-form.scss 重复输出

后面继续看最新 CSS chunk,发现 TextInput 懒加载 CSS 中有大量表单相关规则重复命中。

原因是 src/styles/setting-form.scss 里直接定义了类:

.setting-form-basic {
  :global {
    .form-item-vertical {
      ...
    }
  }
}

但很多 CSS Module 只是这样写:

@use '@/styles/setting-form.scss';

即使没有真正使用 .setting-form-basic,Sass 也会把 setting-form.scss 里的真实 CSS 输出进当前模块。

处理方式是把它改成纯 mixin:

@mixin setting-form-basic {
  :global {
    .form-item-vertical {
      ...
    }
  }
}

调用方改成:

@use '@/styles/setting-form.scss' as setting-form;

.name-wrapper {
  @include setting-form.setting-form-basic;
}

无用 @use 删除。

效果:

  • TextInput 懒加载 CSS 中表单相关命中从约 120 降到约 29
  • 剩余基本是组件自身 .el-form-item__* 样式,不是公共 SCSS 重复输出。

这个点也很适合让 AI 辅助处理。 因为它不是特别难,但涉及文件很多,人工一个个找容易漏。AI 擅长做这种“按规则批量替换 + 构建验证”的工作。

但这里也要注意:让 AI 改之前,要先把规则讲清楚。

比如:

只删除没有实际使用 mixin 的 @use '@/styles/setting-form.scss'。如果文件里确实需要 .setting-form-basic,改成显式 @include。不要改其他样式。

规则越明确,AI 越不容易乱发挥。

六、第三轮:把不该全局加载的第三方 CSS 挪出去

6.1 Quill 样式

原来 PC 入口直接引入:

import 'quill/dist/quill.snow.css'

但 Quill 只服务表达式编辑器,不应该污染设计器主入口 CSS。

处理方式是从 src/entries/create-pc-app.ts 移除,改到表达式编辑器组件里:

// src/components/express-editor/index.vue
import 'quill/dist/quill.snow.css'

结果:

  • PC 入口 CSS 中 .ql- 规则为 0
  • Quill 样式跟随表达式编辑器 chunk 加载。

6.2 animate.css

项目实际使用的动画类很少:

animate__animated
animate__faster
animate__slideInRight
animate__slideOutRight
animate__shakeX

但原来引入的是完整 animate.css

import 'animate.css'

这就有点像只想吃一口饭,结果点了一桌席。

处理方式是新增 src/styles/animate-lite.scss,只保留实际用到的动画类和 keyframes:

.animate__animated {
  animation-duration: 1s;
  animation-fill-mode: both;
}

.animate__faster {
  animation-duration: 0.5s;
}

.animate__slideInRight {
  animation-name: animateSlideInRight;
}

.animate__slideOutRight {
  animation-name: animateSlideOutRight;
}

.animate__shakeX {
  animation-name: animateShakeX;
}

然后在 global.scss 单次引入:

@use '@/styles/animate-lite.scss' as *;

同时清理 vite.config.ts 中 H5 manualChunks 里已经无用的配置:

'vendor-quill': ['quill'],
'vendor-animate': ['animate.css'],

否则即使源码不 import,打包配置也可能继续把它拉进产物。

七、为什么说 UnoCSS 不是主因

中间最关键的转折,就是我们真的去量了 UnoCSS 的占比。

项目确实使用了:

presetWind3({
  prefix: 'ls-',
})

UnoCSS 在 Vite 中会扫描源码按需生成工具类,不会像 Tailwind 那样预加载整套完整规则库。

运行时数据里:

  • UnoCSS 主样式约 75 rules
  • Uno reset 约 35 rules
  • Element Plus 全量样式约 2504 rules

这个量级差距很明显。

所以“去掉 UnoCSS 能解决主要卡顿”的判断,在这个项目里不成立。

这一步对我挺有提醒意义: 我们平时很容易根据技术标签判断问题,比如“用了 Tailwind 所以 CSS 多”“用了 scoped 所以慢”“用了 CSS 变量所以卡”。但真实项目里,最后还是要看产物和运行时。

AI 也一样。

你给它一个强引导,它可能会顺着你说。 你给它真实数据,它就能帮你重新排序嫌疑人。我们也是讲证据的。

八、最终效果

构建后的入口 CSS 有明显下降。

PC 入口 CSS:

阶段bytesrules
清理前大约556KB5332
当前461KB4251

当前 PC:

{
  "file": "index-B-viWbtn.css",
  "bytes": 461538,
  "rules": 4251,
  "lsTheme": 0,
  "ql": 0,
  "animate": 5,
  "unoDecl": 154
}

当前 H5:

{
  "file": "index-XRJnZ5i7.css",
  "bytes": 354690,
  "rules": 3342,
  "lsTheme": 0,
  "ql": 0,
  "animate": 5,
  "unoDecl": 102
}

已确认的效果:

  • 重复主题块清零。
  • setting-form.scss 不再到处重复输出。
  • Quill 样式不再进入 PC 主入口。
  • animate.css 全量包不再进入入口。
  • UnoCSS 占比很低,不再作为主要优化对象。
  • DevTools 卡顿体感改善,但仍有轻微卡顿。

九、为什么还有一点卡顿

处理到目前,效果已经好很多了,但是仍有轻微卡顿。

因为当前最大头还在:

element-plus/dist/index.css

运行时它大概是:

2504 rules
343511 textSize

这一份样式表就超过总规则量一半。

如果继续优化,最大收益点就是 Element Plus 按需化。

但它风险也最大。

原因是:

  • 项目用了 app.use(ElementPlus)
  • 很多组件依赖全局注册。
  • 消息、弹窗、选择器、表格、表单、树组件都可能间接依赖 Element Plus 样式。
  • 直接删除全量 CSS,可能导致一些浮层或边缘组件样式缺失。

所以它不适合在当前分支直接硬改。 正确做法是单独开实验分支,明确验证清单和回滚标准。

下一步可以实验:

// 移除
import 'element-plus/dist/index.css'

// 改成 resolver 按需注入
ElementPlusResolver({ importStyle: 'css' })

成功标准:

  • 构建通过。
  • 页面主要组件样式正常。
  • 运行时不再存在 2500 rules 级别的 Element Plus 全量样式。
  • totalRules 明显下降。
  • DevTools Computed 面板体感继续改善。

失败标准:

  • 大量 Element Plus 样式缺失。
  • 浮层组件异常。
  • 需要手工维护大量样式 import。
  • 规则数下降不明显。

如果 Element Plus 按需化风险太高,再继续拆这些局部第三方 CSS:

  • vue-color/style.css
  • @xxxx/attr-selector/dist/index.css
  • @xxxx/js-high-code/dist/index.css
  • @xxxx/data-comparision/dist/index.css
  • @xxxx/user-contact/dist/index.css

但每次只动一个,改完就构建、跑页面、重新统计。

十、这次我是怎么更好地用 AI 的

这次排查下来,我觉得 AI 真是个好伙伴。

真正好用的方式,不是问:

帮我优化一下性能。

这个问题太大了,AI 容易给一堆正确但没用的话。

更好的方式是分阶段问。

10.1 第一阶段:让 AI 帮你列假设

可以这样问:

DevTools Elements / Computed 面板调试样式很卡。项目用了 Element Plus、Vant、UnoCSS、CSS Modules、SCSS additionalData、CSS 变量。先不要改代码,帮我列出可能原因、验证方式和优先级。

这一步的目标不是拿答案,而是拿排查地图。

10.2 第二阶段:让 AI 帮你写可执行的诊断脚本

可以这样问:

写一个浏览器 Console 脚本,统计当前页面所有 stylesheet 的 rules 数、文本大小、ownerNode、preview,并统计 DOM 节点数量。结果要方便复制给你继续分析。

这个阶段 AI 很擅长。

因为它不需要猜业务,只是在帮你快速生成工具。

10.3 第三阶段:把真实数据贴回去

不要只说“还是卡”。

要把数据贴回去:

这是运行结果:styleSheets 75,totalRules 4487,最大 stylesheet rules 2504,preview 是 :root{--el-color...},UnoCSS 只有 75 rules。重新判断主因和下一步。

这样 AI 才会从“可能是 UnoCSS”修正到“更可能是 Element Plus 全量 CSS”。

10.4 第四阶段:让 AI 先做低风险改动

比如:

先不要动 Element Plus。先找出项目里重复输出的主题变量块、公共 SCSS 真实 CSS 输出、入口里不该全局加载的第三方 CSS。每次只做一类改动,改完跑构建。

这样比较稳。

别一上来就让 AI 做大迁移。 大迁移看起来爽,回归起来也很爽。

10.5 第五阶段:让 AI 帮你写验证标准和回滚方案

这一点很多人会忽略。

我们平时问 AI,常常只问“怎么改”。 其实更应该问:

怎么证明这个改动有效?怎么证明它没引入回归?如果失败,怎么回滚?

这次 Element Plus 按需化之所以没有直接硬上,就是因为验证清单和失败标准很明确。

这也是 AI 特别适合做的事: 帮你把脑子里模糊的“应该没问题吧”,变成具体的 checklist。

十一、这次总结出来的几条经验

11.1 性能问题不要靠猜

这次最开始很容易怀疑 UnoCSS,但数据证明它不是主因。

判断样式性能问题时,至少要拿到这些数据:

  • CSS rule 数量
  • stylesheet 来源
  • DOM 节点数量
  • 最大 style tag
  • 构建产物规则数
  • dev 模式和生产模式的差异

11.2 公共 SCSS 要分清“定义”和“输出”

适合被多处 @use 的文件:

  • 变量
  • 函数
  • mixin
  • placeholder

不适合被多处 @use 的文件:

  • 普通 class
  • 全局选择器
  • keyframes
  • reset

如果公共文件会输出真实 CSS,就应该只在入口单次引入。

11.3 第三方 CSS 不要无脑放入口

全局入口只适合放真正全局必需的样式。

像 Quill、颜色选择器、动画库这类只在少数功能中使用的 CSS,尽量跟随组件 chunk 加载。

11.4 Dev 模式和生产模式要分开看

Dev 模式:

  • 多个 <style> 标签
  • HMR 注入
  • source map / module style 更碎

生产模式:

  • CSS 更可能合并成文件
  • 规则数量可能相近,但 stylesheet 组织方式不同

DevTools 卡顿在 dev 模式下更明显,不一定代表用户生产环境同等卡顿。

11.5 AI 能加速,但不能代替验证

AI 可以帮你:

  • 整理嫌疑点。
  • 写诊断脚本。
  • 搜索重复模式。
  • 批量改低风险代码。
  • 生成验证 checklist。
  • 写复盘文档。

但 AI 不能替你:

  • 判断页面真实体感。
  • 替代构建结果。
  • 替代运行时数据。
  • 替代视觉回归检查。
  • 替代你对业务风险的判断。

说白了,AI 是放大器。 你给它猜测,它会放大猜测。 你给它数据,它会放大分析能力。

十二、最终结论

这次优化不是简单地“干掉某个库”,而是一次逐层收敛:

  1. 先列嫌疑点。
  2. 再写脚本拿运行时数据。
  3. 然后清掉确定重复的主题变量块。
  4. 再清掉公共 SCSS 的重复输出。
  5. 再把不该出现在入口的第三方 CSS 挪到实际使用组件。
  6. 最后用运行时数据确认剩余瓶颈。

目前剩余最大点是 Element Plus 全量 CSS。 它值得继续实验,但必须单独开分支、单独验证、明确回滚标准。

最后再说回标题:

UnoCSS,这次确实是我错怪你了。

CSS 性能优化最怕“按感觉删库”。 AI 时代也一样,最怕“让 AI 按你的感觉证明你是对的”。

更可靠的方式是:

把 AI 当搭档,不当裁判;把猜想变成数据,把数据变成决策。