Element Plus 主题构建方案
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是基础样式 -
message、loading这类服务型组件容易漏
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
所以这里做了两件事:
- 统一把
\转成/ - 强制路径带上相对前缀
第五步:生成临时 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 />
会识别成:
forminputbutton
它怎么识别服务型组件
靠这个正则:
/\b(El[A-Z][A-Za-z]+)\b/g
比如:
ElMessage.success('成功')
ElLoading.service(...)
会识别成:
messageloading
组件名为什么还要转换
因为模板和脚本中的名字,不是 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
检查:
- 是否生成了文件:
src/assets/generated/element-plus-theme.scss
src/assets/generated/element-plus-theme.css
- 页面中的 Element Plus 组件是否使用了新的主题色。
生产环境验证
执行:
pnpm build
检查:
- 构建是否通过。
- 生成后的
element-plus-theme.css是否存在。 - 打包后的页面中 Element Plus 样式是否正常。
第十二步:这套实现的边界
1. 动态拼接组件名,可能扫不到
比如:
const name = 'Button'
const component = `El${name}`
这种写法不一定能被正则准确识别。
2. 新的服务型组件如果没进白名单,可能漏样式
如果以后用了新的服务型组件,而当前正则又没扫到,就要把它补到:
const SERVICE_COMPONENTS = ['message', 'message-box', 'notification', 'loading']
里。
3. 主题变量扩展时,继续沿用 @forward ... with (...)
如果后面要覆盖的不只是颜色,比如圆角、边框、文字颜色,也继续在 createScssEntry 里往下扩。