简单编写vite插件,实现antd vue的主题切换

1,372 阅读6分钟

情景描述

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语法解析错误。遂终止。