普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月17日首页

Element Plus 主题构建方案

作者 一壶纱
2026年4月17日 11:33

Element Plus 主题构建步骤

第一步:安装依赖

项目里需要 sass 来编译 element-plus/theme-chalk 的 SCSS 源码。

pnpm add -D sass

第二步:新建主题构建插件文件

新建文件:

build/plugins/element-plus-theme.ts

最终代码如下:

import path from 'node:path'
import { promises as fs } from 'node:fs'

import { compileAsync } from 'sass'
import type { Plugin } from 'vite'

const OUTPUT_DIR = 'src/assets/generated'
const OUTPUT_FILE = 'element-plus-theme.css'
const TEMP_SCSS_FILE = 'element-plus-theme.scss'

const SERVICE_COMPONENTS = ['message', 'message-box', 'notification', 'loading']
const BASE_COMPONENTS = ['base']

const THEME_COLORS = {
  primary: '#215476',
  success: '#67c23a',
  warning: '#e6a23c',
  danger: '#f56c6c',
  error: '#f56c6c',
  info: '#909399',
}

const normalizePath = (targetPath: string) => targetPath.replace(/\\/g, '/')

const ensureRelativeImportPath = (targetPath: string) => {
  const normalizedPath = normalizePath(targetPath)

  if (normalizedPath.startsWith('./') || normalizedPath.startsWith('../')) {
    return normalizedPath
  }

  return `./${normalizedPath}`
}

const tagToComponentName = (tagName: string) => {
  return tagName.replace(/^el-/, '')
}

const scriptToComponentName = (componentName: string) => {
  return componentName
    .replace(/^El/, '')
    .replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)
    .replace(/^-/, '')
}

const createScssEntry = (themeChalkSrcImportPath: string, componentNames: string[]) => {
  const imports = componentNames
    .map(name => `@use "${themeChalkSrcImportPath}/${name}.scss" as *;`)
    .join('\n')

  return `@forward "${themeChalkSrcImportPath}/common/var.scss" with (
  $colors: (
    "primary": ("base": ${THEME_COLORS.primary}),
    "success": ("base": ${THEME_COLORS.success}),
    "warning": ("base": ${THEME_COLORS.warning}),
    "danger": ("base": ${THEME_COLORS.danger}),
    "error": ("base": ${THEME_COLORS.error}),
    "info": ("base": ${THEME_COLORS.info})
  )
);

${imports}
`
}

const scanUsedComponents = async (root: string) => {
  const srcDir = path.resolve(root, 'src')
  const usedComponents = new Set<string>([...BASE_COMPONENTS, ...SERVICE_COMPONENTS])

  const visit = async (targetPath: string): Promise<void> => {
    const stat = await fs.stat(targetPath)

    if (stat.isDirectory()) {
      const children = await fs.readdir(targetPath)
      await Promise.all(children.map(name => visit(path.join(targetPath, name))))
      return
    }

    if (!/\.(vue|ts|tsx|js|jsx)$/.test(targetPath)) {
      return
    }

    const source = await fs.readFile(targetPath, 'utf-8')
    const templateMatches = source.matchAll(/<\s*(el-[a-z0-9-]+)/g)
    const scriptMatches = source.matchAll(/\b(El[A-Z][A-Za-z]+)\b/g)

    for (const match of templateMatches) {
      usedComponents.add(tagToComponentName(match[1]))
    }

    for (const match of scriptMatches) {
      usedComponents.add(scriptToComponentName(match[1]))
    }
  }

  await visit(srcDir)
  return [...usedComponents].sort()
}

const buildThemeCss = async (root: string, command: 'serve' | 'build') => {
  const outputDir = path.resolve(root, OUTPUT_DIR)
  const outputPath = path.resolve(outputDir, OUTPUT_FILE)
  const tempScssPath = path.resolve(outputDir, TEMP_SCSS_FILE)
  const themeChalkSrcPath = path.resolve(root, 'node_modules/element-plus/theme-chalk/src')
  const themeChalkSrcImportPath = ensureRelativeImportPath(
    path.relative(outputDir, themeChalkSrcPath),
  )
  const componentNames = command === 'serve' ? ['index'] : await scanUsedComponents(root)
  const source = createScssEntry(themeChalkSrcImportPath, componentNames)

  await fs.mkdir(outputDir, { recursive: true })
  await fs.writeFile(tempScssPath, source, 'utf-8')

  const result = await compileAsync(tempScssPath, {
    loadPaths: [root],
    sourceMap: command === 'serve',
    style: command === 'serve' ? 'expanded' : 'compressed',
  })

  await fs.writeFile(outputPath, result.css, 'utf-8')
}

export const elementPlusThemePlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-plugin',
    apply: 'serve',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'serve')
    },
  }
}

export const elementPlusThemeBuildPlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-build-plugin',
    apply: 'build',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'build')
    },
  }
}

第三步:先看懂这几个常量

1. 输出目录

const OUTPUT_DIR = 'src/assets/generated'
const OUTPUT_FILE = 'element-plus-theme.css'
const TEMP_SCSS_FILE = 'element-plus-theme.scss'

最终会生成两个文件:

src/assets/generated/element-plus-theme.scss
src/assets/generated/element-plus-theme.css
  • scss 是临时入口文件
  • css 是项目真正引入的文件

2. 默认保留的组件

const SERVICE_COMPONENTS = ['message', 'message-box', 'notification', 'loading']
const BASE_COMPONENTS = ['base']

即使源码没扫到,也会保留这些样式。

原因:

  • base 是基础样式
  • messageloading 这类服务型组件容易漏

3. 主题色配置

const THEME_COLORS = {
  primary: '#215476',
  success: '#67c23a',
  warning: '#e6a23c',
  danger: '#f56c6c',
  error: '#f56c6c',
  info: '#909399',
}

后面改主题色,优先改这里。


第四步:处理 Sass 导入路径

看这两个函数:

const normalizePath = (targetPath: string) => targetPath.replace(/\\/g, '/')

const ensureRelativeImportPath = (targetPath: string) => {
  const normalizedPath = normalizePath(targetPath)

  if (normalizedPath.startsWith('./') || normalizedPath.startsWith('../')) {
    return normalizedPath
  }

  return `./${normalizedPath}`
}

这一步必须有。

因为 Sass 在 Windows 下处理路径时,如果路径不是:

./xxx
../xxx

就可能把它当成包名,然后报错:

Can't find stylesheet to import

所以这里做了两件事:

  1. 统一把 \ 转成 /
  2. 强制路径带上相对前缀

第五步:生成临时 SCSS 入口文件

核心函数:

const createScssEntry = (themeChalkSrcImportPath: string, componentNames: string[]) => {
  const imports = componentNames
    .map(name => `@use "${themeChalkSrcImportPath}/${name}.scss" as *;`)
    .join('\n')

  return `@forward "${themeChalkSrcImportPath}/common/var.scss" with (
  $colors: (
    "primary": ("base": ${THEME_COLORS.primary}),
    "success": ("base": ${THEME_COLORS.success}),
    "warning": ("base": ${THEME_COLORS.warning}),
    "danger": ("base": ${THEME_COLORS.danger}),
    "error": ("base": ${THEME_COLORS.error}),
    "info": ("base": ${THEME_COLORS.info})
  )
);

${imports}
`
}

这段代码做两件事:

1. 用 @forward 覆盖变量

最终会生成类似:

@forward "../../../node_modules/element-plus/theme-chalk/src/common/var.scss" with (
  $colors: (
    "primary": ("base": #215476),
    "success": ("base": #67c23a),
    "warning": ("base": #e6a23c),
    "danger": ("base": #f56c6c),
    "error": ("base": #f56c6c),
    "info": ("base": #909399)
  )
);

2. 用 @use 引入组件样式

开发环境会生成:

@use "../../../node_modules/element-plus/theme-chalk/src/index.scss" as *;

生产环境会生成:

@use "../../../node_modules/element-plus/theme-chalk/src/base.scss" as *;
@use "../../../node_modules/element-plus/theme-chalk/src/button.scss" as *;
@use "../../../node_modules/element-plus/theme-chalk/src/form.scss" as *;

所以这个函数的作用就是:

  • 先覆盖变量
  • 再拼接本次要构建的样式入口

第六步:扫描项目里实际使用到的 Element Plus 组件

核心函数:

const scanUsedComponents = async (root: string) => {
  const srcDir = path.resolve(root, 'src')
  const usedComponents = new Set<string>([...BASE_COMPONENTS, ...SERVICE_COMPONENTS])

  const visit = async (targetPath: string): Promise<void> => {
    const stat = await fs.stat(targetPath)

    if (stat.isDirectory()) {
      const children = await fs.readdir(targetPath)
      await Promise.all(children.map(name => visit(path.join(targetPath, name))))
      return
    }

    if (!/\.(vue|ts|tsx|js|jsx)$/.test(targetPath)) {
      return
    }

    const source = await fs.readFile(targetPath, 'utf-8')
    const templateMatches = source.matchAll(/<\s*(el-[a-z0-9-]+)/g)
    const scriptMatches = source.matchAll(/\b(El[A-Z][A-Za-z]+)\b/g)

    for (const match of templateMatches) {
      usedComponents.add(tagToComponentName(match[1]))
    }

    for (const match of scriptMatches) {
      usedComponents.add(scriptToComponentName(match[1]))
    }
  }

  await visit(srcDir)
  return [...usedComponents].sort()
}

它扫描哪些文件

  • .vue
  • .ts
  • .tsx
  • .js
  • .jsx

它怎么识别模板中的组件

靠这个正则:

/<\s*(el-[a-z0-9-]+)/g

比如:

<el-form />
<el-input />
<el-button />

会识别成:

  • form
  • input
  • button

它怎么识别服务型组件

靠这个正则:

/\b(El[A-Z][A-Za-z]+)\b/g

比如:

ElMessage.success('成功')
ElLoading.service(...)

会识别成:

  • message
  • loading

组件名为什么还要转换

因为模板和脚本中的名字,不是 theme-chalk 的文件名格式。

比如:

  • <el-form> 需要变成 form.scss
  • ElMessageBox 需要变成 message-box.scss

所以这里配了两个转换函数:

const tagToComponentName = (tagName: string) => {
  return tagName.replace(/^el-/, '')
}

const scriptToComponentName = (componentName: string) => {
  return componentName
    .replace(/^El/, '')
    .replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)
    .replace(/^-/, '')
}

第七步:真正执行主题构建

核心函数:

const buildThemeCss = async (root: string, command: 'serve' | 'build') => {
  const outputDir = path.resolve(root, OUTPUT_DIR)
  const outputPath = path.resolve(outputDir, OUTPUT_FILE)
  const tempScssPath = path.resolve(outputDir, TEMP_SCSS_FILE)
  const themeChalkSrcPath = path.resolve(root, 'node_modules/element-plus/theme-chalk/src')
  const themeChalkSrcImportPath = ensureRelativeImportPath(
    path.relative(outputDir, themeChalkSrcPath),
  )
  const componentNames = command === 'serve' ? ['index'] : await scanUsedComponents(root)
  const source = createScssEntry(themeChalkSrcImportPath, componentNames)

  await fs.mkdir(outputDir, { recursive: true })
  await fs.writeFile(tempScssPath, source, 'utf-8')

  const result = await compileAsync(tempScssPath, {
    loadPaths: [root],
    sourceMap: command === 'serve',
    style: command === 'serve' ? 'expanded' : 'compressed',
  })

  await fs.writeFile(outputPath, result.css, 'utf-8')
}

按执行顺序看:

1. 找到输出目录和输出文件

const outputDir = path.resolve(root, OUTPUT_DIR)
const outputPath = path.resolve(outputDir, OUTPUT_FILE)
const tempScssPath = path.resolve(outputDir, TEMP_SCSS_FILE)

2. 找到 theme-chalk/src

const themeChalkSrcPath = path.resolve(root, 'node_modules/element-plus/theme-chalk/src')

3. 转成 Sass 可识别的相对导入路径

const themeChalkSrcImportPath = ensureRelativeImportPath(
  path.relative(outputDir, themeChalkSrcPath),
)

4. 决定是全量还是按需

const componentNames = command === 'serve' ? ['index'] : await scanUsedComponents(root)
  • serve 时:直接用 index.scss
  • build 时:扫描后按需引入组件样式

5. 生成临时 SCSS 入口源码

const source = createScssEntry(themeChalkSrcImportPath, componentNames)

6. 写入临时文件

await fs.mkdir(outputDir, { recursive: true })
await fs.writeFile(tempScssPath, source, 'utf-8')

7. 调用 Sass 编译

const result = await compileAsync(tempScssPath, {
  loadPaths: [root],
  sourceMap: command === 'serve',
  style: command === 'serve' ? 'expanded' : 'compressed',
})

这里注意两个配置:

  • sourceMap: command === 'serve' 开发环境保留 sourceMap,方便调试。
  • style: command === 'serve' ? 'expanded' : 'compressed' 开发环境不压缩,生产环境压缩。

8. 输出最终 CSS

await fs.writeFile(outputPath, result.css, 'utf-8')

第八步:分别在开发和生产环境触发它

开发环境插件

export const elementPlusThemePlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-plugin',
    apply: 'serve',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'serve')
    },
  }
}

这段代码的意思是:

  • 只在 vite dev 时执行
  • 启动时构建一次全量主题

注意:

  • 不跟着业务源码热更新反复执行
  • 因为这里构建的是 Element Plus 主题,不是业务页面样式

生产环境插件

export const elementPlusThemeBuildPlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-build-plugin',
    apply: 'build',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'build')
    },
  }
}

这段代码的意思是:

  • 只在 vite build 时执行
  • 构建前按需扫描组件,再生成 CSS

第九步:在 Vite 中注册插件

文件: vite.config.ts

写法如下:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import vueJsx from '@vitejs/plugin-vue-jsx'
import UnoCSS from 'unocss/vite'
import { elementPlusThemeBuildPlugin, elementPlusThemePlugin } from './build/plugins/element-plus-theme'

export default defineConfig({
  resolve: {
    alias: {
      '@': '/src',
    },
  },
  plugins: [
    elementPlusThemePlugin(),
    elementPlusThemeBuildPlugin(),
    vue(),
    vueJsx(),
    UnoCSS(),
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/types/auto-imports.d.ts',
    }),
  ],
})

虽然这里写了两个插件,但不会同时执行:

  • pnpm dev 只执行 elementPlusThemePlugin
  • pnpm build 只执行 elementPlusThemeBuildPlugin

第十步:在应用入口引入生成后的 CSS

文件: main.ts

最终写法:

import { createApp } from 'vue'
import ElementPlus from 'element-plus'

import App from './App.vue'
import './assets/styles/ress.min.css'
import './assets/generated/element-plus-theme.css'
import 'virtual:uno.css'
import i18n from './i18n'

import router from './router'
import pinia from './store'

const app = createApp(App)

app.use(ElementPlus)
app.use(router)
app.use(pinia)
app.use(i18n)
app.mount('#app')

这里最关键的是:

import './assets/generated/element-plus-theme.css'

如果你之前有:

import 'element-plus/dist/index.css'

要把它删掉,不然就会和生成后的主题文件重复。


第十一步:怎么验证它是否生效

开发环境验证

执行:

pnpm dev

检查:

  1. 是否生成了文件:
src/assets/generated/element-plus-theme.scss
src/assets/generated/element-plus-theme.css
  1. 页面中的 Element Plus 组件是否使用了新的主题色。

生产环境验证

执行:

pnpm build

检查:

  1. 构建是否通过。
  2. 生成后的 element-plus-theme.css 是否存在。
  3. 打包后的页面中 Element Plus 样式是否正常。

第十二步:这套实现的边界

1. 动态拼接组件名,可能扫不到

比如:

const name = 'Button'
const component = `El${name}`

这种写法不一定能被正则准确识别。

2. 新的服务型组件如果没进白名单,可能漏样式

如果以后用了新的服务型组件,而当前正则又没扫到,就要把它补到:

const SERVICE_COMPONENTS = ['message', 'message-box', 'notification', 'loading']

里。

3. 主题变量扩展时,继续沿用 @forward ... with (...)

如果后面要覆盖的不只是颜色,比如圆角、边框、文字颜色,也继续在 createScssEntry 里往下扩。

❌
❌