情景描述
vite 2.x项目, ui框架为ant design vue 2.x, vue版本3.x, 想实现一个主题切换功能
在网上搜了很久, 有找到这么几种方案:
- 基于
less.js直接进行动态替换(不打包less文件, 然后通过less.js提供的方法进行切换) - 通过插件方式切换
-
antd-theme-generator本质上好像还是通过less.js的方法, 然后不打包less文件, 配置比较多, 最重要的是, 我没有配置成功(且只适用于使用webpack的情况,并且打包之后是antd所有组件的样式,而无法实现按需打包) -
vite-plugin-antd-theme该插件内部依然是通过antd-theme-generator实现, 只是套了一层vite的壳子 -
@zougt/vite-plugin-theme-preprocessor基于打包时的变量替换, 开始我是倾向于这个插件的, 但把这个应用于element-plus项目时出现了问题, 虽然,有询问作者,作者也给予了回复,但后面又遇到了其他问题,让我有一种不踏实的感觉。遂放弃
-
使用注意点
请先完成业务功能的开发,最后再来调试主题切换时的样式问题,毕竟每次vite重新加载,都会根据配置重新去生成一次antd的主题
插件特点与实现
特点
- 支持将antd的样式进行
按需打包和全量打包 - 是将样式文件编译成了css,而非less, 因此样式切换只需要调用
window.changeTheme(主题key)进行不同的样式文件引入即可. 因此不依赖less.js
大概原理
- 通过less工具, 将antd vue的less样式直接编译为css.(每一种主题生成一套完整的antd的css)
- 然后通过js, 动态修改style标签, 引入不同的antd主题css, 达到主题切换的效果
相关依赖安装
npm i rimraf serve-static less slash2 postcss cssnano -D
实现代码
MyVitePlugin.ts
// >>>>>>>>>>>>>>>>>>> MyVitePlugin.ts
import { Plugin, ResolvedConfig } from 'vite'
// @ts-ignore
import fs, { existsSync, mkdirSync } from 'fs'
import { join } from 'path'
// @ts-ignore
import rimraf from 'rimraf'
// @ts-ignore
import serveStatic from 'serve-static'
const less = require('less')
const winPath = require('slash2')
const postcss = require('postcss')
const cssnano = require('cssnano')
export interface Theme {
key?: string;
modifyVars?: Record<string, string>;
}
export interface Options {
/**
* 该配置项有值时, 表示仅打包指定组件的css(按需打包), 否则进行全量打包
*/
onlyImport?: string[]
/**
* 主题配置
*/
theme?: Theme[]
}
/**
* 将less内容编译为css内容,并写入文件
* @param lessContent less内容
* @param antdVueDistPah antd vue的dist所在目录的物理路径
* @param themeOutputDir 主题文件最终生成的目录的物理路径
* @param themeArr 主题配置数据
*/
function buildLess2Css(lessContent: string, antdVueDistPah: string, themeOutputDir: string, themeArr: Theme[]) {
const lessBaseConfig = {
javascriptEnabled: true,
paths: [antdVueDistPah]
}
// less配置信息
const lessConfig = { ...lessBaseConfig }
themeArr.forEach((themeConfig) => {
if (themeConfig.modifyVars) {
// 当前主题变量
// @ts-ignore
lessConfig.modifyVars = themeConfig.modifyVars
}
// 将less文件编译为css文件
less.render(lessContent, lessConfig).then((output: any) => {
console.log(`生成主题文件:${themeOutputDir}/${themeConfig.key}.css`)
// 将生成的css写入文件
postcss([cssnano]).process(output.css, { from: `${themeOutputDir}/${themeConfig.key}.css` }).then((result: any) => {
fs.writeFileSync(`${themeOutputDir}/${themeConfig.key}.css`, result.css)
})
}).catch((err: any) => {
console.log('动态主题插件: less编译css时出错', err)
})
})
}
// @ts-ignore
const MyVitePlugin = function(options?: Options): Plugin {
// eslint-disable-next-line
let config: ResolvedConfig
// 项目中antd vue的dist目录
let antdVueDistPah: string
// 项目中antd.less路径
let antdLessPath: string
// 插件存放文件的临时目录
let pluginTempDir: string
// 主题文件夹目录名
const themeDirName = '_theme'
// 加载样式的link标签id
const styleLinkId = '__dyn_theme-style--'
// 切换主题时, 自动给body标签添加的主题class的前缀
const bodyThemeClassPrefix = 'dyn_theme_wrapper-'
const { onlyImport = [], theme = [] } = options || {}
return {
name: 'my-vite-plugin',
/**
* vite独有钩子
* 在解析 Vite 配置后调用。使用这个钩子读取和存储最终解析的配置。当插件需要根据运行的命令做一些不同的事情时,它也很有用。
* @param resolvedConfig 解析到的配置
*/
configResolved(resolvedConfig) {
pluginTempDir = winPath(join(process.cwd(), 'node_modules', '.plugin-theme'))
console.log('主题插件临时文件目录:', pluginTempDir)
// 项目中antd vue的dist目录
antdVueDistPah = `${process.cwd()}/node_modules/ant-design-vue/dist`
antdLessPath = `${antdVueDistPah}/antd.less`
console.log('项目中 antd vue 的dist目录', antdVueDistPah)
console.log('项目中 antd vue 的 antd.less路径', antdLessPath)
config = resolvedConfig
},
/**
* vite启动时基于antd vue的less文件,以及配置的theme变量, 生成最终的主题css
* vite独有钩子: 用于配置开发服务器的钩子
* @param server
*/
configureServer(server) {
return () => {
// 得到
const pluginTmpThemeDir = winPath(join(pluginTempDir, themeDirName))
if (existsSync(pluginTempDir)) {
rimraf.sync(pluginTempDir)
}
if (existsSync(pluginTmpThemeDir)) {
rimraf.sync(pluginTmpThemeDir)
}
if (theme && theme.length > 0) {
// 只有当有主题配置时才进行less的编译
mkdirSync(pluginTempDir)
mkdirSync(pluginTmpThemeDir)
let lessContent: string
if (onlyImport && onlyImport.length > 0) {
console.log('antd动态主题,执行按需打包...')
const tmpOnlyImport: string[] = []
onlyImport.forEach(item => {
if (tmpOnlyImport.indexOf(item) === -1) {
tmpOnlyImport.push(item)
}
})
lessContent = '@import "../lib/style/index.less";\n'
tmpOnlyImport.forEach((item) => {
lessContent += `\n@import "../es/${item}/style/index.less";`
})
console.log('按需打包内容:\n' + lessContent)
} else {
console.log('antd动态主题,执行全量打包...')
// 获取less内容
lessContent = fs.readFileSync(antdLessPath).toString()
}
// 根据主题配置,将less编译为不同主题的css
buildLess2Css(lessContent, antdVueDistPah, pluginTmpThemeDir, theme)
server.middlewares.use(serveStatic(pluginTempDir))
} else {
console.log('未配置额外的主题,因此无需生成特定的antd主题样式')
}
}
},
/**
* 向html页面注入一段script脚本: 在window对象上定义一个changeTheme方法, 用于主题切换, 参数名即为 主题配置时的key
* @param html
* @param ctx
*/
transformIndexHtml(html, ctx) {
return {
html,
tags: [
{
tag: 'script',
children: `window.vite_plugin_ant_themeVar = ${JSON.stringify(theme)};
/**
修改主题的方法(该方法会新增一个style标签引入antd的不同主题,以及会在body标签添加一个主题的class,以便用户根据这个主题class编写不同的主题下自定义组件的样式)
@param themeKey 主题配置时的key, 如果不传或传null或空, 则表示还原回默认主题
**/
window.changeTheme = (themeKey) => {
if(!window.vite_plugin_ant_themeVar || window.vite_plugin_ant_themeVar.length===0){
return;
}
var tmpThemeKey = themeKey?themeKey:'default'
var body = document.getElementsByTagName('body')[0]
var oldThemeStyleDom = document.getElementById('${styleLinkId}')
if(oldThemeStyleDom && 'default'===tmpThemeKey){
// 删除引入主题样式的style标签, 表示还原回默认主题
oldThemeStyleDom.remove()
body.setAttribute('data-dynTheme','${bodyThemeClassPrefix}default')
return ;
}
if(oldThemeStyleDom){
// 已存在引入主题样式的style标签, 则更新属性值引入不同的样式即可
var oldThemeName = body.getAttribute('data-dynTheme')
if(oldThemeName){
body.classList.remove(oldThemeName)
}
oldThemeStyleDom.setAttribute('href','./${themeDirName}/'+tmpThemeKey+'.css')
}else{
if(tmpThemeKey!=='default'){
const styleLink = document.createElement('link')
styleLink.type = 'text/css'
styleLink.rel = 'stylesheet'
styleLink.id = '${styleLinkId}'
styleLink.href = './${themeDirName}/'+tmpThemeKey+'.css'
document.body.append(styleLink)
}
body.classList.add('${bodyThemeClassPrefix}'+tmpThemeKey)
}
body.setAttribute('data-dynTheme','${bodyThemeClassPrefix}'+tmpThemeKey)
}
window.onload=()=>{
var body = document.getElementsByTagName('body')[0]
body.setAttribute('data-dynTheme','${bodyThemeClassPrefix}default')
body.classList.add('${bodyThemeClassPrefix}default')
}
`,
injectTo: 'head'
}
]
}
},
// 项目打包时会执行这里
generateBundle() {
if (options && options.theme && options.theme.length > 0) {
// 只有当有主题配置时, 打包时才生成主题配置
console.log('antd动态主题打包')
const { outDir } = config.build
// 获取打包文件的物理输出目录
const outputPath = join(process.cwd(), outDir)
// 在这个目录指定一个theme文件夹
const themeOutputPath = winPath(join(outputPath, themeDirName))
// 如果存放antd动态主题的文件夹存在, 则先删除
if (existsSync(themeOutputPath)) {
rimraf.sync(themeOutputPath)
}
// 创建存放antd动态主题的目录
mkdirSync(themeOutputPath)
// 获取antd的less文件内容
const lessContent = fs.readFileSync(antdLessPath).toString()
// 根据主题配置,将less编译为css, 并放入 themeOutputPath 目录
buildLess2Css(lessContent, antdVueDistPah, themeOutputPath, options.theme)
console.log('antd动态主题打包成功!')
}
}
}
}
export default MyVitePlugin
vite.config.ts
import { ConfigEnv, PluginOption, UserConfigExport } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import path from 'path'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
import viteSvgIcons from 'vite-plugin-svg-icons'
// 引入自定义ant design主题变量(用于自定义主题定制)
import antdThemeVars from './src/antdThemeVars'
import eslintPlugin from 'vite-plugin-eslint'
import visualizer from 'rollup-plugin-visualizer'
import { viteMockServe } from 'vite-plugin-mock'
// >>>>>>>>>>>>>>>>>>>>>>>>>>>>> 这里引入了动态主题插件
import MyVitePlugin from './MyVitePlugin'
// >>>>>>>>>>>>>>>>>>>>>>>>>>>>> 这里是动态主题插件的配置
const themeConfig = {
onlyImport: ['button', 'checkbox', 'date-picker', 'form', 'icon', 'input', 'message', 'menu', 'radio', 'select', 'switch'],
theme: [{
// 通过该key切换主题
key: 'dust',
// 这里与antd主题配置一致
modifyVars: {
'@primary-color': '#F5222D'
}
}]
}
// >>>>>>>>>>>>>>>>>>>>>>>>>>>>> 这里是将动态主题插件配置到vite
const plugins: PluginOption[] = [MyVitePlugin(themeConfig)]
if (process.env.vis) {
// 打包依赖展示
plugins.push(
visualizer({
open: true,
gzipSize: true,
brotliSize: true
})
)
}
if (process.env.dev) {
plugins.push(eslintPlugin({ cache: false }))
}
// https://vitejs.dev/config/
export default ({ command }: ConfigEnv): UserConfigExport => {
return {
server: {
proxy: {
'/api': {
target: 'http://172.16.46.38:8811',
changeOrigin: true,
rewrite: (path) => path.replace(/^/api/, '')
}
}
// open: true
},
plugins: [
vue(),
vueJsx(),
viteMockServe({
// default
mockPath: 'mock',
localEnabled: command === 'serve'
}),
Components({
dts: true,
resolvers: [AntDesignVueResolver({ importStyle: 'less' })]
}),
viteSvgIcons({
// 指定需要缓存的图标文件夹
iconDirs: [path.resolve(process.cwd(), 'src/assets/svg')],
// 指定symbolId格式
symbolId: 'icon-[dir]-[name]'
}),
...plugins
],
css: {
// 🔥此处添加全局scss🔥
preprocessorOptions: {
less: {
modifyVars: {
hack: 'true;@import "./src/assets/less/_var.less";',
...antdThemeVars
},
javascriptEnabled: true
}
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
components: path.resolve(__dirname, 'src/components'),
layouts: path.resolve(__dirname, 'src/layouts'),
utils: path.resolve(__dirname, 'src/utils'),
less: path.resolve(__dirname, 'src/assets/less'),
store: path.resolve(__dirname, 'src/store'),
hooks: path.resolve(__dirname, 'src/hooks'),
modules: path.resolve(__dirname, 'src/store/modules'),
pages: path.resolve(__dirname, 'src/pages')
}
}
}
}
还有什么优化点?
- gzip: 虽然已经使用cssnano进行了css压缩, 但还不知道nodejs怎么进行gzip压缩, 因为gzip之后的文件大小会进一步减小
参考过的插件实现
- vite-plugin-antd-static-theme: 为什么不直接用这个插件?一方面是这个是针对蚂蚁的antd的,更重要的是antd vue的我没运行成功
- antd-pro-merge-less 如果你有看这个插件的源代码的话, 你会发现,我这个只能算是借鉴了他的实现思路,但实现方式就比较简单粗暴了, 直接通过less工具进行编译,为什么不直接用这个作者的实现逻辑? 我试过,但貌似不知道是我哪里出了问题,开始几次还没报错,后面就不停的报posscss语法解析错误。遂终止。