rust 可以用来加速 element-ui 主题换肤?

50 阅读5分钟

背景

由于公司的产品经常需要挂客户公司牌子使用,需要根据客户公司的需要动态调整主题色。

1. 主题色官方方案

关于 Element-ui 的修改主题色官方是有介绍的, 点击查看官方说明

一. 官方在线主题修改工具

二. 使用 scss 动态编译

以上的两种方式是在编译时决定的,无法动态修改主题,本文的要求与上面都不一样,所以这里不做详细讨论。

2. 动态调整主题

我参考了大量的资料,最终找到了 vue-element-admin 内部的解决方案,动态替换主题色变量值。

解析vue-element-admin 的方案核心有两条

  1. 动态加载 theme-chalk/index.css,并替换里面的变量。
  2. 动态替换 页面中已经加载的 style 中的 css 颜色值。

由于工程体量较大的原因,在替换变量的时候,浏览器居然奔溃了...,经过不断调试终于定位到了奔溃的地方,居然是插入 dom 的时候崩溃了...

无奈.jpg

想着刚学习了 rust,正好试试看编译成 wasm 是不是能加速替换过程!!

fight.jpg

目录结构

主题组件文件
src/components/theme-picker
├── index.vue
├── script.js
├── theme-chalk-rem.css.txt
├── theme-chalk.css
└── util.js
rust 工程结构
util_wasm
├── Cargo.toml
├── index.html # 测试文件
├── pkg # build 之后的文件
│   ├── package.json
│   ├── util_wasm_bg.js
│   ├── util_wasm_bg.wasm
│   ├── util_wasm_bg.wasm.d.ts
│   ├── util_wasm.d.ts
│   └── util_wasm.js
└── src
    └── lib.rs

rust 工程

  1. 新建 rust 工程
cargo new util_wasm --lib
  1. 编写配置文件 Cargo.toml
[package]
name = "util_wasm"
version = "0.1.0"
edition = "2024"

# 告诉 Cargo 我们要生成一个动态链接库(cdylib),这是 WebAssembly 所需要的格式
[lib]
crate-type=["cdylib"]

[dependencies]
wasm-bindgen = "0.2" #  Rust 与 JavaScript 之间进行交互的重要工具
regex ="1.11.2"

[dependencies.web-sys]
version = "0.3"
# 不声明无法在 js 环境中使用
features = ["Window", "Document", "HtmlElement", "Element", "console", "HtmlCollection"]
  1. 编写lib.rs
use regex::Regex;
use wasm_bindgen::prelude::*;
use web_sys;

// 标记导出 javascript 可调用
#[wasm_bindgen]
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[wasm_bindgen]
pub fn update_chalk_style(
    html: String,
    origin_cluster: Vec<String>,
    theme_cluster: Vec<String>,
) -> String {
    let mut result = html;
    for (i, old) in origin_cluster.iter().enumerate() {
        let re = Regex::new(&format!(r"(?i){}", regex::escape(old))).unwrap();
        // 执行替换
        result = re.replace_all(&result, &theme_cluster[i]).to_string();
    }
    result
}

#[wasm_bindgen]
pub fn update_style(origin_cluster: Vec<String>, theme_cluster: Vec<String>) {
    let window = web_sys::window().expect("no global window exists");
    let document = window.document().expect("should have a document on window");
    let styles: web_sys::HtmlCollection = document.get_elements_by_tag_name("style");

    let len = styles.length();
    let mut index = 0;

    while index < len {
        let style = styles.item(index);
        let el = style.unwrap();

        let html = el.inner_html();

        el.set_inner_html(&update_chalk_style(
            html,
            origin_cluster.clone(),
            theme_cluster.clone(),
        ));
        index = index + 1;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn it_update_chalk_style() {
        let result = update_chalk_style(
            "#cccCCC,#cccccc".to_string(),
            vec!["#cccccc".to_string()],
            vec!["#334455".to_string()],
        );
        println!("{}", result);
        assert_eq!(result, "#334455,#334455");
    }
}

  1. 构建并在 web 中测试
wasm-pack build --target web
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Rust wasm Hello world</title>
  <style>
    code {
      color: #ff0000
    }
  </style>
  <script type="module" src="./pkg//util_wasm.js"></script>
  <script type="module">
    import init, { add, update_style } from './pkg/util_wasm.js'
    init().then(() => {
      const result = add(100n, 200n);
      console.log("the result is " + result);

      update_style(["#ff0000"], ["#00ff00"])
    })
  </script>
  <style>
    body {
      text-align: center;
    }
  </style>
</head>
<body>
  rust to wasm<br>
  <code>wasm-pack build --target web</code>
</body>
</html>

前端工程改造

1. 加载 theme-chalk.css 到本地

下载 unpkg.com/element-ui@… 到本地,保存为 theme-chalk.css

2. 改造 theme-chalk.css

由于使用了 postcss-pxtorem 对元素单位进行了 rem 化,所以需要转化单位,视情况可掠过。 script.js

const fs = require('fs')
const postcss = require('postcss')
const pxtorem = require('postcss-pxtorem')
const css = fs.readFileSync('theme-chalk.css', 'utf8')
const { updateStyle, getThemeCluster } = require('./util')

const options = {
  rootValue: 10,
  propList: ['*'], // 可以将 px 转换为 rem 的属性
  selectorBlackList: [
    /^html$/, // 如果是 regexp,它将检查选择器是否匹配 regexp,这里表示 html 标签不会被转换
    '.px-', // 如果是字符串,它将检查选择器是否包含字符串,这里表示 .px- 开头的都不会转换
    'el-time-'
  ], // px 不会被转换为 rem 的 选择器
  minPixelValue: 2 // 设置要替换的最小像素值(2px会被转rem)。 默认 0
}
const processedCss = postcss(pxtorem(options)).process(css).css

// 将默认主题转为当前使用的主题
const cssText = updateStyle(processedCss, getThemeCluster('#409EFF'.replace('#', '')), getThemeCluster('#3C68BD'.replace('#', '')))

// 默认转换一次到当前主题色
fs.writeFile('theme-chalk-rem.css.txt', cssText, function (err) {
  if (err) {
    throw err
  }
  console.log('Rem file written.')
})

生成 theme-chalk-rem.css.txt 文件

node ./script.js

3. 编写主题组件

theme-picker/util.js

module.exports = {
  updateStyle (style, oldCluster, newCluster) {
    let newStyle = style
    oldCluster.forEach((color, index) => {
      newStyle = newStyle.replace(new RegExp(color, 'ig'), newCluster[index])
    })
    return newStyle
  },
  getThemeCluster (theme) {
    const tintColor = (color, tint) => {
      let red = parseInt(color.slice(0, 2), 16)
      let green = parseInt(color.slice(2, 4), 16)
      let blue = parseInt(color.slice(4, 6), 16)

      if (tint === 0) { // when primary color is in its rgb space
        return [red, green, blue].join(',')
      } else {
        red += Math.round(tint * (255 - red))
        green += Math.round(tint * (255 - green))
        blue += Math.round(tint * (255 - blue))

        red = red.toString(16)
        green = green.toString(16)
        blue = blue.toString(16)

        return `#${red}${green}${blue}`
      }
    }

    const shadeColor = (color, shade) => {
      let red = parseInt(color.slice(0, 2), 16)
      let green = parseInt(color.slice(2, 4), 16)
      let blue = parseInt(color.slice(4, 6), 16)

      red = Math.round((1 - shade) * red)
      green = Math.round((1 - shade) * green)
      blue = Math.round((1 - shade) * blue)

      red = red.toString(16)
      green = green.toString(16)
      blue = blue.toString(16)

      return `#${red}${green}${blue}`
    }

    const clusters = [theme]
    for (let i = 0; i <= 9; i++) {
      clusters.push(tintColor(theme, Number((i / 10).toFixed(2))))
    }
    clusters.push(shadeColor(theme, 0.1))
    return clusters
  }
}

src/store/modules/theme


import { VuexModule, Module, Action, Mutation, getModule } from 'vuex-module-decorators'
import store from '&/store'
import cssVar from '&/assets/sass/variable.scss'

export interface ThemeState {
  theme: string;
}

export const themeStorageId = '--primary-color'

@Module({ dynamic: true, store, name: 'theme' })
class Theme extends VuexModule implements ThemeState {
  public theme = localStorage.getItem(themeStorageId) || (cssVar as any)['primary-color']

  @Mutation
  private SET_THEME (theme: string) {
    this.theme = theme
  }

  @Action
  public setTheme (theme: string) {
    this.SET_THEME(theme)
    localStorage.setItem(themeStorageId, theme)
  }
}

export const ThemeModule = getModule(Theme)

theme-picker/index.vue

<!-- https://gitee.com/shunxuan/vue-element-admin/blob/master/src/components/ThemePicker/index.vue# -->
<template>
  <div style="display: none">
  <button v-for="(color) in ['#409EFF', '#1890ff', '#304156','#212121','#11a983', '#13c2c2', '#6959CD', '#f5222d', ]"
  :style="{background: color}"
  :key="color" @click="theme = color"> {{ color }} </button>
  </div>
</template>

<script>
import { ThemeModule, themeStorageId } from '&/store/modules/theme'
import { getOem } from '&/api/user'
const { updateStyle, getThemeCluster } = require('./util')
const ORIGINAL_THEME = '#3C68BD' // default color

export default {
  data () {
    return {
      chalk: '',
      theme: ''
    }
  },
  computed: {
    defaultTheme () {
      return ThemeModule.theme
    }
  },
  watch: {
    defaultTheme: {
      handler: function (val, oldVal) {
        this.theme = val
      },
      immediate: true
    },
    theme (val) {
      this.themeChanged(val)
    }
  },
  methods: {
    async trigger () {
      const localTheme = localStorage.getItem(themeStorageId)
      // 先设置一遍
      for (const key in localStorage) {
        if (key.startsWith('--')) {
          document.documentElement.style.setProperty(key, localStorage.getItem(key))
        }
      }
      this.themeChanged(localTheme)

      // 检测主题是否发生变化
      const res = await getOem()
      const theme = res.data[themeStorageId]
      if (theme !== localTheme) {
        this.themeChanged(theme)
      }
      this.setCssVariables(res.data)
    },
    setCssVariables (variables) {
      for (const key in variables) {
        if (key.startsWith('--')) {
          document.documentElement.style.setProperty(key, variables[key])
        }
      }
    },
    async themeChanged (val) {
      const oldVal = this.chalk ? this.theme : ORIGINAL_THEME
      if (typeof val !== 'string') return

      if (!this.chalk) {
        await import('!!raw-loader!./theme-chalk-rem.css.txt').then((res) => {
          this.chalk = res.default.replace(/@font-face{[^}]+}/, '')
        })
      }
      const themeCluster = getThemeCluster(val.replace('#', ''))
      {
        const originalCluster = getThemeCluster(ORIGINAL_THEME.replace('#', ''))
        const text = updateStyle(this.chalk, originalCluster, themeCluster)
        this.setStyleContent('chalk-style', text)
      }

      // 常规做法 js 超时无法恢复,这里采用 webassembly 加速文本替换
      const originalCluster = getThemeCluster(oldVal.replace('#', ''))
      const textContent = `
        import init, { update_style, add } from './pkg/util_wasm.js'
        init().then(() => {
          update_style("${originalCluster}".split(','), "${themeCluster}".split(','))
        }).catch((err) => console.info(err))
      `
      this.setScriptContent('theme-inline-style', textContent)
      this.$emit('change', val)
    },

    setScriptContent (id, text) {
      const el = document.getElementById(id)
      const tag = el || document.createElement('script')
      if (!el) {
        tag.type = 'module'
        tag.id = id
        document.head.append(tag)
      }
      tag.textContent = text
    },

    setStyleContent (id, text) {
      const el = document.getElementById(id)
      const tag = el || document.createElement('style')
      if (!el) {
        tag.id = id
        document.head.append(tag)
      }
      tag.textContent = text
    },

    getCSSString (url, variable) {
      return new Promise(resolve => {
        const xhr = new XMLHttpRequest()
        xhr.onreadystatechange = () => {
          if (xhr.readyState === 4 && xhr.status === 200) {
            this[variable] = xhr.responseText.replace(/@font-face{[^}]+}/, '')
            resolve()
          }
        }
        xhr.open('GET', url)
        xhr.send()
      })
    }
  }
}
</script>

/vue.config.js

加载纯文本文件需要 raw-loader

{
  chainWebpack: config => {
      ...
      config.module
      .rule('raw')
      .test(/\.txt$/)
      .use('raw-loader')
      .loader('raw-loader')
      .end()
  }
}

/src/api/user.ts

import { _axios } from '&/plugins/axios'
import { parse } from 'json5'

export function getOem (baseURL = './') {
  return _axios({
    url: '/oem/data.json5',
    baseURL,
    transformResponse: (data) => 
       // 将 json5 转化为 json
      return parse(data)
    }
  })
}

4. 引入 wasm

将 rust 构建产物放到 /public/pkg 目录下

public/pkg
├── util_wasm_bg.wasm
├── util_wasm_bg.wasm.d.ts
├── util_wasm.d.ts
└── util_wasm.js

5. 使用组件

/src/App.vue

<template>
  <div id="app" class="auto">
    <router-view></router-view>
    <themePicker ref="theme" @change="themeChange"/>
  </div>
</template>
<script lang="ts">
import Vue from 'vue'
import { Component } from 'vue-property-decorator'
import themePicker from '&/components/theme-picker/index.vue'

@Component({
  name: 'App',
  components: {
    themePicker
  }
})
export default class extends mixins(AppMixin) {
  public themeChange (theme: string) {
    ThemeModule.setTheme(theme)
  }
  
  async mounted () {
    const theme = this.$refs.theme as any
    await theme.trigger()
  }
</script>

6. 配置主题

/public/oem/data.json5

{
    // 主题色
    "--primary-color": "#3C68BD",
    // 顶部 bar - 背景色
    "--top-bg-color": "#474C63"
}

这里做法是让运维人员去动态配置,当然也可以开放颜色选择器,让用户去自定义,根据你的需求,删减代码即可。