简单编写vite插件, 实现element plus动态主题切换

1,560 阅读7分钟

插件地址与使用示例

@pzy915/vite-plugin-element-plus-theme

2022-04-05补充

element-plus官方已经修改主题样式的添加方式,该主题切换插件又完全OK了

2022-02-09补充

重新渲染根节点,确实能够完全解决Element-Plus新版本主题切换问题,但rerender多出的一次rerender也会导致当前界面的所有组件内部(如果会发ajax请求的话)多发一次重复的ajax请求

2022-01-21补充

不知道从哪个版本开始, element-ui的主题设置方式已经改变为读取内联的css变量, 至少1.3.0-beta.5版本是如此, 如下面的按钮组件的部分代码截图所示, 造成的结果是, 已经没法简单的使用全局样式替换的方式, 修改Element Plus组件的整体的主题风格了.为什么这么说呢? 请看代码, 按钮组件的样式依赖于buttonStyle这个计算属性, 其中buttonColor是主题色, 而这个主题色又依赖于组件中的color,typeColor, color来自props, typeColor是另一个计算属性, typeColor的实际值来自于css变量. 在以前的版本, 还没改成这种风格设置之前, 直接修改根的css变量即可, 但改成这种方式之后, 你再怎么修改根的css变量, 如果组件不重新渲染, 也是已经无效的, 为啥? 因为vue并无法监听到css变量的修改. vue监听不到这个修改, 自然这段代码也就无法简单的实现动态主题切换了.

image.png

不明白element plus团队为什么会改成这种方式来设置主题样式, 当然, 现在这种方式有一个好处, 那就是对于单个组件你只要通过color属性设置了主题色, 在组件内部会帮你自动计算其他的次级颜色(如边框,hover颜色等),并设置上去, 可总不至于让我为项目中每个用到的element plus组件都去设置color属性吧. 这不科学.

不过, 就一点办法都没了吗? 也不完全是, 既然找到了原因, 还是能曲线救国的. 依然使用下面的插件方式, 实现主题切换, 然后在根组件设置一个key, 在主题样式文件加载完毕之后, 更新一下根组件的key即可. 但是 ,更新根组件的key, 虽然能够达到样式动态切换的目的, 但这个会导致整个dom的重新渲染呀. 不过除此之外, 暂未想到其他更优雅的方案.

Element-plus发布正式版本了,但貌似还有比较多的问题,哎,难搞?

适用场景

  • 使用vite开发的项目
  • ui框架是element plus
  • 有类似主题切换的需求

为什么编写这个插件

  • 项目中有类似切换主题的需求
  • 官方文档说, 现在推荐使用 css 变量的方式修改主题, 但如果一个个变量去修改太麻烦, 保不齐哪里就漏了
  • 次级颜色计算怎么弄? 搜了网上的都太麻烦了, 难道就没有一种利用原生sass的能力

实现原理

看官方有个示例项目, 可以在开发时调整主题, 修改原理是, 根据sass变量值可以被覆盖的的特点, 进行主题变量值覆盖. github.com/element-plu…

我这个插件的实现思路, 也是使用sass变量值覆盖的特点.

  1. 读取用户配置的主题变量覆盖文件的内容
  2. 读取/项目根目录/node_modules/element-plus/theme-chalk/src/index.scss文件内容
  3. 将步骤1中读取的内容,追加到步骤2的内容之前, 进行sass变量覆盖.
  4. 使用sass编译最终的sass内容, 生成对应主题的css
  5. 项目中进行主题切换时, 引入不同的主题css即可

上面是主题样式全量打包的实现思路, 按需打包的实现思路也类似

特点

  • 支持使用原生sass语法进行样式变量定制
  • 次级颜色自动计算
  • 根据配置的不同, 主题样式支持按需和全量两种方式打包

安装相关依赖

npm i rimraf serve-static sass slash2 postcss cssnano -D

插件实现代码

import {Plugin, ResolvedConfig} from 'vite'
import * as fs from 'fs'
import {existsSync, mkdirSync} from 'fs'
// @ts-ignore
import rimraf from 'rimraf'
// @ts-ignore
import serveStatic from 'serve-static'

const path = require('path')
const winPath = require('slash2')
const sass = require('sass')
const postcss = require('postcss')
const cssnano = require('cssnano')

export interface ThemeConfigType {
    /**
     * 主题唯一标识(切换主题时使用该标识)
     */
    code: string
    /**
     * 主题sass变量文件路径(路径必须以/开头表示项目根目录)
     * <br/>内容格式如下```
     @forward "./common/var.scss" with (
     // 可以在这里覆盖 /项目根目录/node_modules/element-plus/theme-chalk/src/common/var.scss 文件中所有的变量, 达到主题切换的目的
     // ./common/var.scss 这个是插件中用到的文件路径, 请不要修改, 实际是指这个文件: /项目根目录/node_modules/element-plus/theme-chalk/src/common/var.scss
     );

     主题变量文件示例:
     @forward "./common/var.scss" with (
     $colors: (
     "primary": (
     "base": #003261,
     ),
     "success": (
     "base": #21ba45,
     ),
     "warning": (
     "base": #f2711c,
     ),
     "danger": (
     "base": #db2828,
     ),
     "error": (
     "base": #db2828,
     ),
     "info": (
     "base": #42b8dd,
     ),
     ),

     $button-padding-horizontal: (
     "default": 80px
     )
     );
     ```
     */
    themeVarFilePath: string
}

export interface ElementPlusThemeConfigType {
    /**
     * 主题配置
     */
    themes?: ThemeConfigType[]
    /**
     * 如果该项有配置则会对最终形成的主体css进行按需打包, 只会打包这里配置的组件.<br/>
     * 值为 /项目根目录/node_modules/element-plus/theme-chalk/src 目录下的文件名(无需后缀)
     * 如: 只需要打包: date-picker.scss和button.scss 的样式, 那么这里就设置为: date-picker, button
     */
    compList?: string[]
}

/**
 * 将sass内容编译为css内容
 * @param projectRootDir 项目根目录的绝对路径
 * @param themeConfig 主题配置信息
 * @param elementPlusThemeDir element plus样式scss所在根目录
 * @param compList 按需打包的组件样式(为空或null则进行全量打包)
 * @param cssDestDir css文件存放目录
 * @param compress 是否将css进行压缩. 默认:false
 */
function compileSass2Css(
    projectRootDir: string,
    themeConfig: ThemeConfigType,
    compList: string[],
    elementPlusThemeDir: string,
    cssDestDir: string,
    compress: boolean = false
) {
    // 如果 themeVarFilePath 的值以/开头,则删除/, 避免readFileSync方法报错
    const themeVarFilePath = themeConfig.themeVarFilePath.startsWith('/') ? themeConfig.themeVarFilePath.substring(1) : themeConfig.themeVarFilePath
    // 读取主题变量
    const themeVar = fs
        .readFileSync(`${projectRootDir}/${themeVarFilePath}`)
        .toString()
    let themeStyleContent = `
${themeVar}
  `
    if (compList && compList.length > 0) {
        // 样式按需打包
        for (const compName of compList) {
            themeStyleContent += `
          @use './${compName}.scss';
          `
        }
    } else {
        // 样式全量打包
        themeStyleContent += `
          @use './index.scss';
          `
    }
    // 编译sass内容为css内容
    themeStyleContent = sass
        .renderSync({
            data: themeStyleContent,
            includePaths: [elementPlusThemeDir]
        })
        .css.toString()
    const destFileFullPath = `${cssDestDir}/${themeConfig.code}.css`
    if (compress) {
        postcss([cssnano])
            .process(themeStyleContent, {from: destFileFullPath})
            .then((result: any) => {
                fs.writeFileSync(destFileFullPath, result.css)
            })
    } else {
        fs.writeFileSync(destFileFullPath, themeStyleContent)
    }
}

export function ElementPlusThemePlugin(
    options?: ElementPlusThemeConfigType
): Plugin {
    // 加载样式的link标签id
    const styleLinkId = '__dyn_theme-style--'
    // 切换主题时, 自动给body标签添加的主题class的前缀
    const bodyThemeClassPrefix = 'dyn_theme_wrapper-'

    const {themes = [], compList = []} = options || {}
    // 获取项目根目录
    const projectRootDir = process.cwd()
    // 插件相关文件存放的零时目录
    const pluginTempDir = winPath(
        path.join(projectRootDir, 'node_modules', '.plugin-theme')
    )
    // element plus主题scss文件所在目录
    const elementPlusThemeDir = winPath(
        path.join(
            projectRootDir,
            'node_modules',
            'element-plus',
            'theme-chalk',
            'src'
        )
    )

    // 主题文件夹目录名
    const themeDirName = '_theme'

    // eslint-disable-next-line
    let config: ResolvedConfig
    return {
        name: 'element-plus-theme-plugin',
        configResolved(resolvedConfig: ResolvedConfig) {
            config = resolvedConfig
            // console.log(config)
        },
        /**
         * vite独有钩子. 这里用于在开发时根据配置生成主题css文件
         * @param server
         */
        configureServer(server) {
            return () => {
                const pluginTmpThemeDir = winPath(
                    path.join(pluginTempDir, themeDirName)
                )
                if (existsSync(pluginTempDir)) {
                    rimraf.sync(pluginTempDir)
                }
                if (existsSync(pluginTmpThemeDir)) {
                    rimraf.sync(pluginTmpThemeDir)
                }
                // 创建相关目录
                mkdirSync(pluginTempDir)
                mkdirSync(pluginTmpThemeDir)
                if (!themes || themes.length === 0) {
                    return
                }
                for (const themeConfig of themes) {
                    compileSass2Css(
                        projectRootDir,
                        themeConfig,
                        compList,
                        elementPlusThemeDir,
                        `${pluginTempDir}/${themeDirName}`
                    )
                }
                server.middlewares.use(serveStatic(pluginTempDir))
            }
        },
        /**
         * 向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(
                            themes
                        )};
/**
修改主题的方法(该方法会新增一个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.themes && options.themes.length > 0) {
                // 只有存在主题配置时才进行主题文件生成
                console.log('element plus动态主题打包')
                const {outDir} = config.build
                // 获取打包文件的物理输出目录
                const outputPath = path.join(process.cwd(), outDir)
                // 在这个目录指定一个theme文件夹
                const themeOutputPath = winPath(path.join(outputPath, themeDirName))
                // 如果存放element plus动态主题的文件夹存在, 则先删除
                if (existsSync(themeOutputPath)) {
                    rimraf.sync(themeOutputPath)
                }
                // 创建存放element plus动态主题的目录
                mkdirSync(themeOutputPath)
                for (const themeConfig of themes) {
                    compileSass2Css(
                        projectRootDir,
                        themeConfig,
                        compList,
                        elementPlusThemeDir,
                        themeOutputPath,
                        true
                    )
                }
                console.log('element plus动态主题打包成功!')
            }
        }
    }
}

这个不能通过vite编译打包,而是要使用tsc打包, npm上传

{
  "name": "vite-plugin-element-plus-theme",
  "version": "1.0.1",
  "description": "vite element plus theme plugin / vite element plus 主题切换插件",
  "author": "pan",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc src/index.ts --target esnext --module commonjs --outdir dist --esModuleInterop true --moduleResolution node --declaration true"
  },
  "keywords": [
    "element-plus",
    "vite",
    "element-plus theme"
  ],
  "license": "ISC",
  "files": [
    "dist"
  ],
  "dependencies": {
    "cssnano": "^5.0.11",
    "postcss": "^8.3.11",
    "sass": "^1.43.4",
    "serve-static": "^1.14.1",
    "slash2": "^2.0.0"
  },
  "devDependencies": {
    "@types/node": "^16.11.7",
    "typescript": "^4.5.2",
    "vite": "^2.6.14"
  },
  "peerDependencies": {
    "vite": ">=2.0.0"
  }
}