Element Plus 主题修改

Element Plus 主题构建器

概述

Element Plus 主题构建器是一个 Vite 插件,用于在项目构建过程中自动生成自定义 Element Plus 主题。它允许开发者通过简单的配置覆盖默认的 Element Plus 主题颜色,使应用程序的视觉风格与设计规范保持一致,无需手动编辑 SCSS 文件或复杂的主题配置。

功能特点

  • 自动提取 Element Plus 主题源文件
  • 支持自定义主题颜色变量
  • 自动编译和压缩主题 CSS 文件
  • 集成到 Vite 构建流程中
  • 统一的主题管理

安装依赖

使用主题构建器前,需确保项目中已安装以下依赖:

bash 复制代码
# 安装主要依赖
npm install --save-dev gulp gulp-sass sass gulp-autoprefixer postcss cssnano

# 安装类型声明文件
npm install --save-dev @types/gulp @types/gulp-sass @types/gulp-autoprefixer

使用方法

在 Vite 配置中添加插件

在项目的 vite.config.ts 文件中注册主题构建器插件:

typescript 复制代码
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',     // 信息色
      }
    })
  ],
})

在项目中引入自定义主题

在项目的主入口文件(如 main.ts)中,引入生成的主题 CSS 文件:

typescript 复制代码
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import App from './App.vue'

// 引入自定义主题(而不是默认的 Element Plus 主题)
import './assets/element-plus-theme/index.css'

const app = createApp(App)
app.use(ElementPlus)
app.mount('#app')

完整代码

以下是 element-plus-theme-builder.ts 的完整代码:

typescript 复制代码
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],
        }),
      )
      .pipe(autoprefixer({ cascade: false }))
      .pipe(compressWithCssnano())
      .pipe(dest(destDir))

    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
      }
    },
  }
}

## 配置项说明

### `customVars`

自定义颜色变量对象,用于覆盖 Element Plus 默认主题颜色。格式为键值对,其中键是颜色变量名,值是十六进制颜色代码。

常用的颜色变量包括:

| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| primary | 主色 | #409eff |
| success | 成功色 | #67c23a |
| warning | 警告色 | #e6a23c |
| danger | 危险色 | #f56c6c |
| info | 信息色 | #909399 |
| white | 白色 | #ffffff |
| black | 黑色 | #000000 |

## 工作原理

1. 插件在 Vite 构建过程开始时执行
2. 从 node_modules 中提取 Element Plus 主题源文件
3. 根据提供的 `customVars` 修改颜色变量
4. 编译 SCSS 文件生成自定义主题 CSS
5. 将编译后的 CSS 输出到 `src/assets/element-plus-theme` 目录

## 解决类型声明问题

如果遇到类型声明问题,可以在项目中创建一个类型声明文件 `src/types/element-plus-theme.d.ts`:

```typescript
// 声明 gulp 相关模块
declare module 'gulp' {
  export function src(globs: string | string[], options?: any): any
  export function dest(path: string, options?: any): any
}

declare module 'gulp-sass' {
  function sass(compiler: any): any
  export default sass
}

declare module 'gulp-autoprefixer' {
  function autoprefixer(options?: any): any
  export default autoprefixer
}

注意事项

  1. 依赖安装:确保已正确安装所有必要的依赖。
  2. 主题引入:确保在项目入口文件中引入自定义主题 CSS,而不是默认的 Element Plus 主题。
  3. 构建过程:主题构建会在开发服务器启动和生产构建时执行,可能会增加启动时间。
  4. 更新主题:修改主题颜色后需要重启开发服务器才能看到效果。
  5. 存储位置 :自定义主题文件将生成在 src/assets/element-plus-theme 目录下。

常见问题解决

1. 缺少类型定义文件

arduino 复制代码
Could not find a declaration file for module 'gulp'/'gulp-sass'/'gulp-autoprefixer'

解决方案:安装对应的类型声明包或创建自定义类型声明文件,如上文所示。

2. 找不到 Element Plus 主题文件

解决方案:确保已安装 Element Plus,并检查主题文件路径是否与代码中的路径一致。

3. SCSS 编译错误

解决方案:检查 SCSS 语法错误,确保 sass 和 gulp-sass 版本兼容,必要时升级依赖版本。

4. 主题颜色没有更新

解决方案:重启开发服务器,确保正确引入了自定义主题文件,并检查浏览器缓存。

高级用法

添加更多自定义变量

除了基本颜色外,Element Plus 还提供了其他可自定义的变量,如:

typescript 复制代码
customVars: {
  // 颜色变量
  'primary': '#3080fe',
  'success': '#67c23a',
  'warning': '#e6a23c',
  'danger': '#f56c6c',
  'info': '#909399',
  
  // 文字颜色
  'text-color': '#303133',
  'text-color-secondary': '#909399',
  
  // 边框颜色
  'border-color': '#dcdfe6',
  'border-color-light': '#e4e7ed',
  
  // 背景色
  'background': '#f5f7fa',
}

与 Element Plus 官方主题定制方案的对比

Element Plus 官方提供了两种主题定制方案,而我们的主题构建器提供了第三种更为高效的解决方案。下面是三种方案的对比分析:

1. Element Plus 官方方案一:CSS 变量方式

原理:Element Plus 内部使用 CSS 变量定义主题色,可以通过 JavaScript 动态修改这些变量。

javascript 复制代码
// 官方 CSS 变量方式
import { useTheme } from 'element-plus'

// 运行时修改主题色
const theme = useTheme()
theme.setTheme({
  primary: '#ff0000'
})

特点

  • 支持运行时动态修改主题
  • 无需重新编译,修改即时生效
  • 适合需要动态切换多套主题的应用

缺点

  • 运行时计算会占用浏览器资源
  • 页面加载时可能出现颜色闪烁 (FOUC - Flash of Unstyled Content)
  • 依赖 CSS 变量,在某些旧浏览器中兼容性不佳

2. Element Plus 官方方案二:SCSS 变量覆盖

原理:通过覆盖 Element Plus 的 SCSS 变量文件来修改主题色。

scss 复制代码
// 官方 SCSS 变量覆盖方式
// 需要在 vite.config.js 中进行复杂配置
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  $colors: (
    'primary': (
      'base': #ff0000,
    ),
  )
);

特点

  • 编译时生成,性能较好
  • 可以深度定制多个变量

缺点

  • 配置复杂,需要额外的 webpack/vite 设置
  • 会增加整体编译时间
  • 每次修改都需要重新编译
  • 难以与按需加载方案完美兼容

3. 我们的主题构建器方案

原理:提取 Element Plus 原始主题文件,替换颜色变量后重新编译生成定制 CSS。

typescript 复制代码
// 我们的主题构建器方式
ElementPlusThemeBuilder({
  customVars: {
    'primary': '#3080fe',
    'success': '#67c23a',
  }
})

优势

  • 配置简单:只需提供颜色变量对象,无需复杂的 SCSS 配置
  • 预编译而非运行时计算:在构建阶段生成完整的 CSS 文件,避免了运行时的样式计算开销
  • 无颜色闪烁:避免了页面加载过程中出现的主题颜色闪烁问题
  • 更小的包体积:生成的 CSS 文件经过优化和压缩,比完整的主题文件更小
  • 与按需加载完美兼容:不干扰 Element Plus 的按需导入机制
  • 集成到构建流程:自动集成到 Vite 构建流程,无需额外配置

对编译速度的影响

不同的主题定制方案对编译速度有不同的影响:

官方 SCSS 变量覆盖方式

  • 影响显著:每次编译都需要处理大量 SCSS 文件
  • 热更新较慢:修改变量后整个应用需要重新编译
  • 配置复杂:需要特定的 loader 和配置,增加构建复杂度

我们的主题构建器

  • 首次编译:首次启动或构建时会增加约 2-5 秒的编译时间,取决于机器性能
  • 增量编译影响小:后续热更新几乎不受影响,因为主题文件已生成并缓存
  • 按需执行:使用执行标志确保每次构建过程中只执行一次主题生成
typescript 复制代码
// 执行标志确保只执行一次,降低对编译速度的影响
let executed = false

async buildStart() {
  if (executed) return
  // ...生成主题
  executed = true
}

关于实时修改

官方 CSS 变量方式

  • 支持实时修改:可以在运行时通过 JavaScript 动态切换主题
  • 无需重新编译:修改即时生效,适合需要用户自定义主题的应用

我们的主题构建器

  • 预编译模式:主题是预编译生成的,不支持运行时动态切换
  • 需要重启服务:修改主题变量后需要重启开发服务器才能看到效果
  • 适合固定主题:更适合主题色相对固定的项目,或有明确品牌色的企业应用

实际应用建议

根据不同的需求场景,可以选择不同的主题定制方案:

  1. 固定品牌主题:如果你的项目使用固定的品牌色系,使用我们的主题构建器可以获得更好的性能和用户体验。

  2. 需要动态换肤:如果应用需要支持用户自定义主题或动态切换多套主题,可以:

    • 使用官方的 CSS 变量方案
    • 或为每种预设主题生成单独的 CSS 文件,通过动态加载切换
  3. 与设计系统集成:主题构建器易于与公司统一的设计系统集成,保证视觉一致性。

  4. 追求极致性能:如果项目对性能和首屏加载时间有极高要求,主题构建器是理想选择。

技术原理总结

我们的主题构建器采用了更高效的预编译策略,具体流程如下:

  1. 提取原始主题:从 node_modules 中提取 Element Plus 原始主题文件
  2. 精确替换变量:使用正则表达式精确定位和替换颜色变量
  3. 高效编译:使用高性能的 dart-sass 编译 SCSS 文件
  4. 优化输出:应用 autoprefixer 添加浏览器前缀,使用 cssnano 压缩优化 CSS
  5. 生成独立文件:输出到独立的主题 CSS 文件,避免与其他样式冲突

这种方式在性能、体验和开发效率上都优于官方方案,特别适合企业级应用和对用户体验要求较高的项目。

相关推荐
IT_陈寒2 分钟前
Python 3.12性能优化实战:5个让你的代码提速30%的新特性
前端·人工智能·后端
赛博切图仔3 分钟前
「从零到一」我用 Node BFF 手撸一个 Vue3 SSR 项目(附源码)
前端·javascript·vue.js
爱写程序的小高3 分钟前
npm ERR! code ERESOLVE npm ERR! ERESOLVE unable to resolve dependency tree
前端·npm·node.js
loonggg3 分钟前
竖屏,其实是程序员的一个集体误解
前端·后端·程序员
程序员爱钓鱼13 分钟前
Node.js 编程实战:测试与调试 - 单元测试与集成测试
前端·后端·node.js
码界奇点19 分钟前
基于Vue.js与Element UI的后台管理系统设计与实现
前端·vue.js·ui·毕业设计·源代码管理
时光少年26 分钟前
Android KeyEvent传递与焦点拦截
前端
踢球的打工仔33 分钟前
typescript-引用和const常量
前端·javascript·typescript
OEC小胖胖34 分钟前
03|从 `ensureRootIsScheduled` 到 `commitRoot`:React 工作循环(WorkLoop)全景
前端·react.js·前端框架
时光少年36 分钟前
ExoPlayer MediaCodec视频解码Buffer模式GPU渲染加速
前端