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 文件,避免与其他样式冲突

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

相关推荐
zy0101011 分钟前
React 直接操作 DOM
前端·javascript·react.js·dom·react操作dom
SuperherRo5 分钟前
Web开发-JS应用&VueJS框架&Vite构建&启动打包&渲染XSS&源码泄露&代码审计
前端·javascript·vue.js·xss·源码泄露·启动打包
患得患失9496 分钟前
【前端】【React】第四章:深入理解 React Router 及前端路由管理
前端·react.js·前端框架
患得患失9497 分钟前
【前端】【React】第三章:深入理解 React 事件处理与性能优化
前端·react.js·性能优化
andeyeluguo7 分钟前
【智能体】 react functioncall
前端·javascript·react.js
患得患失94923 分钟前
【前端】【react】第一章:React 基础,组件数据管理,事件处理
前端·react.js·前端框架
菲兹园长25 分钟前
Spring Web MVC(Spring MVC)
前端·spring·mvc
好_快27 分钟前
Lodash源码阅读-baseIsEqualDeep
前端·javascript·源码阅读
YiHanXii29 分钟前
Axios 相关的面试题
前端·http·vue·react