Element Plus 主题修改

645 阅读6分钟

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变量覆盖

优点:编译时生成,性能好 缺点:配置复杂,难与按需加载兼容

我们的方案

优点

  • 配置简单
  • 预编译生成,性能好
  • 无颜色闪烁
  • 与按需加载兼容
  • 自动集成到构建流程

注意事项

  1. 修改主题颜色后需要重启开发服务器
  2. 主题文件生成在 src/assets/element-plus-theme 目录
  3. 首次编译会增加2-5秒时间,后续热更新不受影响

常见问题

  1. 缺少类型定义文件:安装对应的类型声明包
  2. 找不到主题文件:确保已安装Element Plus
  3. SCSS编译错误:检查sass和gulp-sass版本兼容性
  4. 主题颜色没更新:重启开发服务器,检查浏览器缓存

附加用法

除基本颜色外,还可以自定义更多变量:

customVars: {
  // 颜色变量
  'primary': '#3080fe',
  // 文字颜色
  'text-color': '#303133',
  // 边框颜色
  'border-color': '#dcdfe6',
  // 背景色
  'background': '#f5f7fa',
}