做一个 Element Plus 按需主题的 Vite 插件
@rdeam/vite-plugin-element-plus-theme-builder 一个 Vite 插件,做两件事:
- 按你给定的主题色重新编译 Element Plus 样式(含派生色梯度)
- 只把页面真正用到的组件 CSS 打进产物
解决什么问题
Element Plus 官方两条主题定制路径,各有短板:
| 路径 | 派生色梯度 | 按需打包 | 用户成本 |
|---|---|---|---|
:root { --el-color-primary: ... } | ❌ 只覆盖一层 | ❌ 必须 import 'element-plus/dist/index.css' | 低 |
手写 SCSS 入口 + @forward ... with | ✅ | ⚠️ 要手动维护 @use 清单 | 高 |
快速使用
pnpm add -D @rdeam/vite-plugin-element-plus-theme-builder sass
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { elementPlusThemeBuilder } from '@rdeam/vite-plugin-element-plus-theme-builder';
export default defineConfig({
plugins: [
vue(),
elementPlusThemeBuilder({
colors: { primary: '#215476' },
}),
],
});
不需要在 main.ts 写 import './xxx.css',插件会自动把 <link> 注入到 index.html。
工作原理
整体流程
build 模式分四步:
transform → 扫每个模块,记录用到的组件名
generateBundle → 用收集到的组件清单调 Sass 编译主题 CSS
emitFile → 把 CSS 作为资源交给 Vite(自动带 content hash)
transformIndexHtml → 把 <link> 写进 index.html
收集组件:transform hook + 三个正则
挂 Vite 的 transform hook:
{
name: 'element-plus-theme-builder',
enforce: 'pre',
transform(code) {
scanCode(code, collected);
},
}
只要一段代码进入产物,它一定经过 transform。src/、workspace 包、node_modules 都覆盖到。
enforce: 'pre' 是为了在 @vitejs/plugin-vue 编译 SFC 之前拿到模板原文。
三个正则:
/<\s*(el-[a-z0-9-]+)/g // <el-button>
/\b(El[A-Z][A-Za-z]+)\b/g // ElButton / ElMessageBox
/_?resolveComponent\s*\(\s*["'](el-[a-z0-9-]+)["']/g // _resolveComponent("el-button")
前两条覆盖用户源码,第三条覆盖 node_modules 里已被 Vue 编译过的 .vue 产物——它们的模板已经变成 _resolveComponent("el-button") 调用,字面量不存在了。
编译:动态生成 SCSS 入口
generateBundle 阶段,根据收集到的组件名拼一份临时 SCSS 入口字符串:
@forward "<theme-chalk>/common/var.scss" with (
$colors: (
"primary": ("base": #215476),
...
)
);
@use "<theme-chalk>/button.scss" as *;
@use "<theme-chalk>/dialog.scss" as *;
// ...
直接喂给 sass.compileStringAsync,拿到最终 CSS。
输出:emitFile,不写源码目录
this.emitFile({
type: 'asset',
name: 'element-plus-theme.css',
source: css,
});
文件名自动带 content hash(element-plus-theme-abc123.css),随 Vite 一起落盘到 dist/,不污染 src/。
transformIndexHtml 把 <link> 写进 HTML。注入位置可配 head / head-prepend / body / body-prepend,默认 head。
dev 模式:直接全量
dev 不扫描,直接编一份全量主题 SCSS 灌进内存,挂中间件吐 CSS:
async configureServer(server) {
devThemeCss = await compileThemeCss(['index'], colors);
server.middlewares.use('/__element-plus-theme.css', (_req, res) => {
res.setHeader('Content-Type', 'text/css; charset=utf-8');
res.end(devThemeCss);
});
}
理由:dev 热更频繁,每次重扫重编代价大;而且漏一个组件页面就花。dev 不优化体积,全量最稳。
白名单兜底
有些组件静态扫描原理上扫不到,必须显式声明。插件默认包含这 6 个:
| 组件 | 为什么扫不到 |
|---|---|
base | 全局基础样式,无具体标识符 |
overlay | message-box / notification 内部用,无 <el-overlay> 标签 |
message | 用法 ElMessage(...),无标签 |
message-box | 用法 ElMessageBox.confirm(...) |
notification | 用法 ElNotification(...) |
loading | 用法 ElLoading.service(...) 或 v-loading 指令 |
普通模板组件(table / dialog / menu / card 等)不要加白名单——它们会被 transform 扫到,加进去只会让 CSS 比应有的大。
只有一种场景需要扩展白名单:组件名完全由运行时变量拼接(如 <component :is="apiResponse.name" />),静态扫描原理上无法识别。
配置项
全部字段都是可选的,不传就用默认值。绝大多数项目只需要写一行 colors:
elementPlusThemeBuilder({
colors: { primary: '#215476' }, // 其他色保持默认
})
下面是完整配置形态,注释里写的就是默认值,你只需要挑你想改的字段写:
elementPlusThemeBuilder({
// 主题色,缺省的字段使用 Element Plus 原生色
colors: {
primary: '#409eff',
success: '#67c23a',
warning: '#e6a23c',
danger: '#f56c6c',
error: '#f56c6c',
info: '#909399',
},
// 主题 <link> 注入位置
// 'head-prepend' 让主题排在 <head> 最前,业务样式可覆盖
injectTo: 'head',
// 兜底白名单:默认值已涵盖所有"扫不到"的组件(函数式 API 等),开箱即用
// 只有"运行时拼接组件名"的特殊场景才往里加(详见上一节)
alwaysIncludeComponents: ['base', 'overlay', 'message', 'message-box', 'notification', 'loading'],
// 参与扫描的文件后缀;如果你想让 .md 之类的也参与扫描,改这里
scanFilePattern: /\.(vue|jsx|tsx|ts|js|mjs|cjs)$/,
// 性能调优:明确不引用 Element Plus 的大依赖可以跳过扫描
// 一般不用配
scanIgnore: [],
// Element Plus theme-chalk SCSS 源码目录
// 一般不用配;只有 pnpm hoist 场景下 node_modules 在 workspace 根才需要显式指过去
elementPlusThemeChalkDir: 'node_modules/element-plus/theme-chalk/src',
})
90% 的项目只需要写 colors。剩下的字段都是边缘场景兜底用的。
FAQ
改了 colors 没生效?
重启 dev server。颜色在 configureServer 阶段一次性编进内存,不会跟着热更。
构建报错找不到 theme-chalk?
确认装了 element-plus。pnpm hoist 场景下 node_modules 可能在 workspace 根,要显式指 elementPlusThemeChalkDir。
想让主题样式被业务样式覆盖?
injectTo: 'head-prepend',让主题 <link> 排在 <head> 最前。
某个动态组件没样式?
如果组件名是运行时字符串拼接的(API 响应 / 计算属性),加进 alwaysIncludeComponents。普通模板组件不要加。