背景
由于公司的产品经常需要挂客户公司牌子使用,需要根据客户公司的需要动态调整主题色。
1. 主题色官方方案
关于 Element-ui 的修改主题色官方是有介绍的, 点击查看官方说明:
一. 官方在线主题修改工具
二. 使用 scss 动态编译
以上的两种方式是在编译时决定的,无法动态修改主题,本文的要求与上面都不一样,所以这里不做详细讨论。
2. 动态调整主题
我参考了大量的资料,最终找到了 vue-element-admin 内部的解决方案,动态替换主题色变量值。
解析vue-element-admin 的方案核心有两条
- 动态加载 theme-chalk/index.css,并替换里面的变量。
- 动态替换 页面中已经加载的 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 工程
- 新建 rust 工程
cargo new util_wasm --lib
- 编写配置文件 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"]
- 编写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");
}
}
- 构建并在 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"
}
这里做法是让运维人员去动态配置,当然也可以开放颜色选择器,让用户去自定义,根据你的需求,删减代码即可。