Element Plus 主题构建器
背景
Element Plus 按需导入虽好,但改主题色很麻烦。官方配置太复杂,而且会导致页面加载时颜色闪烁,改主题时页面还会刷新,体验差。
这个 Vite 插件就是解决这些问题的。构建时自动生成自定义主题,你只需简单配置就能改变主题颜色。
安装依赖
sass 版本很重要,我使用的是 1.86.0
npm install --save-dev gulp gulp-sass sass gulp-autoprefixer postcss cssnano @types/gulp @types/gulp-sass @types/gulp-autoprefixer
使用方法
在 Vite 配置中添加插件
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { ElementPlusThemeBuilder } from './src/plugins/element-plus-theme-builder'
export default defineConfig({
plugins: [
vue(),
ElementPlusThemeBuilder({
customVars: {
primary: '#3080fe', // 主色
success: '#67c23a', // 成功色
warning: '#e6a23c', // 警告色
danger: '#f56c6c', // 危险色
info: '#909399', // 信息色
},
}),
],
})
在项目中引入自定义主题
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import App from './App.vue'
import './assets/element-plus-theme/index.css' // 引入自定义主题
const app = createApp(App)
app.use(ElementPlus)
app.mount('#app')
完整代码
以下是 element-plus-theme-builder.ts 的完整代码:
import path from 'path'
import { Transform } from 'stream'
import { dest, src } from 'gulp'
import gulpSass from 'gulp-sass'
import * as dartSass from 'sass'
import autoprefixer from 'gulp-autoprefixer'
import postcss from 'postcss'
import cssnano from 'cssnano'
import type { Plugin } from 'vite'
import fs from 'fs'
// 设置输出目录为 assets/element-plus-theme
const distFolder = path.resolve(__dirname, '../assets/element-plus-theme')
/**
* 清空目标文件夹并确保它存在
* @param folderPath 要清空的文件夹路径
*/
function ensureEmptyDir(folderPath: string): void {
// 如果目录存在,先删除它
if (fs.existsSync(folderPath)) {
fs.rmSync(folderPath, { recursive: true, force: true })
}
// 创建新的空目录
fs.mkdirSync(folderPath, { recursive: true })
}
/**
* 完全删除目录及其内容
* @param folderPath 要删除的文件夹路径
*/
function removeDir(folderPath: string): void {
if (fs.existsSync(folderPath)) {
fs.rmSync(folderPath, { recursive: true, force: true })
// console.log(`🗑️ 已删除目录: ${folderPath}`)
}
}
/**
* 使用 postcss 和 cssnano 压缩 CSS
* @returns Transform 流转换器
*/
function compressWithCssnano() {
const processor = postcss([
cssnano({
preset: [
'default',
{
// 避免颜色转换
colormin: false,
// 避免字体值转换
minifyFontValues: false,
},
],
}),
])
return new Transform({
objectMode: true,
transform(chunk, _encoding, callback) {
const file = chunk
if (file.isNull()) {
callback(null, file)
return
}
if (file.isStream()) {
callback(new Error('Streaming not supported'))
return
}
const cssString = file.contents!.toString()
processor.process(cssString, { from: file.path }).then((result: any) => {
file.contents = Buffer.from(result.css)
callback(null, file)
})
},
})
}
/**
* 替换 SCSS 中 $colors 映射中的变量
* @param customVars 要替换的颜色变量对象,格式如 { 'primary': '#ff0000' }
* @returns Transform 流转换器
*/
function replaceScssVariables(customVars: Record<string, string>) {
return new Transform({
objectMode: true,
transform(chunk, _encoding, callback) {
const file = chunk
if (file.isNull()) {
callback(null, file)
return
}
if (file.isStream()) {
callback(new Error('Streaming not supported'))
return
}
// 只处理 var.scss 文件
if (path.basename(file.path) === 'var.scss') {
let content = file.contents!.toString()
// 在内容中查找 $colors 映射块
const colorsMapRegex = /\$colors:\s*map\.deep-merge\(\s*\(([\s\S]*?)\),\s*\$colors\s*\);/
const colorsMatch = content.match(colorsMapRegex)
if (colorsMatch && colorsMatch[1]) {
let colorsMapContent = colorsMatch[1]
// 更新映射中的颜色值
Object.entries(customVars).forEach(([colorName, colorValue]) => {
// 匹配 $colors 映射中的特定颜色模式
// 这个模式寻找 'colorName': ( 'base': #value, ) 或简单的 'colorName': #value
const colorPattern = new RegExp(`'${colorName}':\\s*\\(\\s*'base':\\s*[^,)]+`, 'g')
const simpleColorPattern = new RegExp(`'${colorName}':\\s*[^,)]+`, 'g')
if (colorPattern.test(colorsMapContent)) {
// 替换嵌套映射中的颜色(如 primary, success 等)
colorsMapContent = colorsMapContent.replace(colorPattern, `'${colorName}': ( 'base': ${colorValue}`)
} else if (simpleColorPattern.test(colorsMapContent)) {
// 替换简单颜色(如 white, black 等)
colorsMapContent = colorsMapContent.replace(simpleColorPattern, `'${colorName}': ${colorValue}`)
}
})
// 用更新后的版本替换整个 $colors 映射
content = content.replace(colorsMapRegex, `$colors: map.deep-merge(\n (${colorsMapContent}),\n $colors\n);`)
// console.log(`🎨 已替换颜色变量:`, customVars)
}
file.contents = Buffer.from(content)
}
callback(null, file)
},
})
}
/**
* 创建一个临时目录并复制所有主题文件
* @param customVars 自定义颜色变量
* @returns 创建的临时目录路径
*/
async function prepareThemeFiles(customVars: Record<string, string>): Promise<string> {
// 创建临时目录
const tempDir = path.resolve(__dirname, '../.temp-theme')
ensureEmptyDir(tempDir)
// Element Plus 主题源文件路径
const themeSourceDir = path.resolve(__dirname, '../../node_modules/element-plus/theme-chalk/src')
// var.scss 文件路径
const varScssPath = path.resolve(themeSourceDir, 'common/var.scss')
return new Promise<string>((resolve, reject) => {
// 先复制和处理 var.scss 文件
src(varScssPath)
.pipe(replaceScssVariables(customVars))
.pipe(dest(path.resolve(tempDir, 'common')))
.on('end', () => {
// console.log('✓ 变量文件处理完成, 正在复制其他文件...')
// 复制 common 下的其他文件 (除了 var.scss)
src([path.resolve(themeSourceDir, 'common/**/*.scss'), `!${varScssPath}`])
.pipe(dest(path.resolve(tempDir, 'common')))
.on('end', () => {
// 复制其他所有 scss 文件
src([path.resolve(themeSourceDir, '**/*.scss'), `!${path.resolve(themeSourceDir, 'common/**')}`])
.pipe(dest(tempDir))
.on('end', () => {
console.log('✓ 所有主题源文件准备完成')
resolve(tempDir)
})
.on('error', reject)
})
.on('error', reject)
})
.on('error', reject)
})
}
/**
* 编译 SCSS 文件到目标目录
* @param sourceDir 源文件目录
* @param destDir 目标目录
*/
async function compileScss(sourceDir: string, destDir: string): Promise<void> {
const sass = gulpSass(dartSass)
return new Promise<void>((resolve, reject) => {
console.log(`正在编译 Element Plus 主题: ${sourceDir}/index.scss -> ${destDir}`)
const stream = src(path.resolve(sourceDir, 'index.scss'))
.pipe(
sass.sync({
includePaths: [sourceDir],
}),
)
.on('error', reject)
.pipe(autoprefixer({ cascade: false }))
.on('error', reject)
.pipe(compressWithCssnano())
.on('error', reject)
.pipe(dest(destDir))
.on('error', reject)
stream.on('end', () => {
// 编译完成后检查文件是否成功生成
const outputFile = path.resolve(destDir, 'index.css')
if (fs.existsSync(outputFile)) {
const stats = fs.statSync(outputFile)
console.log(`✓ SCSS 编译完成: ${outputFile} (${(stats.size / 1024).toFixed(2)} KB)`)
} else {
console.warn('⚠️ 输出文件未找到:', outputFile)
}
resolve()
})
stream.on('error', (err: Error) => {
console.error('❌ SCSS 编译失败:', err)
reject(err)
})
})
}
/**
* 使用自定义变量构建 Element Plus 主题
* @param customVars 要覆盖的自定义颜色变量
*/
async function buildThemeChalkWithCustomVars(customVars: Record<string, string>): Promise<void> {
// 临时目录路径
const tempDir = path.resolve(__dirname, '../.temp-theme')
try {
// 1. 清空输出目录
ensureEmptyDir(distFolder)
// 2. 准备临时文件
const preparedDir = await prepareThemeFiles(customVars)
// 3. 编译 SCSS 到目标目录
await compileScss(preparedDir, distFolder)
// 4. 完全删除临时目录
removeDir(tempDir)
} catch (error) {
// 发生错误时也尝试清理临时目录
try {
removeDir(tempDir)
} catch (cleanupError) {
console.error('清理临时目录失败:', cleanupError)
}
console.error('❌ 主题构建过程失败:', error)
throw error
}
}
/**
* Element Plus 主题构建器选项接口
*/
interface ThemeBuilderOptions {
/**
* 要覆盖的自定义颜色变量
* 示例:
* - 'primary': '#ff0000' - 将主色改为红色
* - 'success': '#00ff00' - 将成功色改为绿色
* - 'white': '#f8f8f8' - 修改白色
*/
customVars: Record<string, string>
}
/**
* Element Plus 主题构建器 Vite 插件
* 在开发和构建模式下均会执行一次,用于生成自定义主题
* @param options 主题构建器选项
* @returns Vite 插件
*/
export function ElementPlusThemeBuilder(options: ThemeBuilderOptions): Plugin {
// 执行标志,确保只执行一次
let executed = false
return {
// 插件名称
name: 'vite-plugin-element-plus-theme-builder',
// 确保在其他插件之前执行
enforce: 'pre',
// 在开发服务器启动和构建开始时执行
async buildStart() {
// 如果已执行过,则跳过
if (executed) return
try {
console.log(`🎨 正在构建 Element Plus 主题...`)
// 执行主题构建
await buildThemeChalkWithCustomVars(options.customVars)
// 标记为已执行
executed = true
console.log('✨ Element Plus 主题构建成功')
} catch (error) {
console.error('❌ Element Plus 主题构建失败:', error)
throw error
}
},
}
}
常用颜色变量
| 变量名 | 说明 | 默认值 |
|---|---|---|
| primary | 主色 | #409eff |
| success | 成功色 | #67c23a |
| warning | 警告色 | #e6a23c |
| danger | 危险色 | #f56c6c |
| info | 信息色 | #909399 |
与官方方案对比
官方方案一:CSS变量
优点:可动态修改,不用重新编译 缺点:页面加载时颜色闪烁,占用浏览器资源
官方方案二:SCSS变量覆盖
优点:编译时生成,性能好 缺点:配置复杂,难与按需加载兼容
我们的方案
优点:
- 配置简单
- 预编译生成,性能好
- 无颜色闪烁
- 与按需加载兼容
- 自动集成到构建流程
注意事项
- 修改主题颜色后需要重启开发服务器
- 主题文件生成在
src/assets/element-plus-theme目录 - 首次编译会增加2-5秒时间,后续热更新不受影响
常见问题
- 缺少类型定义文件:安装对应的类型声明包
- 找不到主题文件:确保已安装Element Plus
- SCSS编译错误:检查sass和gulp-sass版本兼容性
- 主题颜色没更新:重启开发服务器,检查浏览器缓存
附加用法
除基本颜色外,还可以自定义更多变量:
customVars: {
// 颜色变量
'primary': '#3080fe',
// 文字颜色
'text-color': '#303133',
// 边框颜色
'border-color': '#dcdfe6',
// 背景色
'background': '#f5f7fa',
}