Arquitetura da Solução
A estratégia central consiste na criação de um mixin global responsável pelo gerenciamento de temas. Ao utilizar o componente el-color-picker para selecionar uma nova cor, o valor hexadecimal é propagado para a instância raiz da aplicação. Esta instância monitora as alterações na cor principal e executa a atualização dos estilos. Para garantir que as rotas específicas apliquem as modificações corretas nos estilos do head e nos atributos style dos elementos DOM, o componnete App.vue observa as mudanças de rota e invoca o método de atualização.
O fluxo depende do uso de provide e inject para compartilhamento de estado, juntamente com um mecanismo personalizado para enviar eventos da instância filho para a raiz, simulando o comportamento de dispatch.
Resumo do funcionamento:
- Envio da cor selecionada para a instância raiz, que dispara o método
processThemeUpdateao detectar mudanças; - Monitoramento de rotas no
App.vuepara reaplicar os estilos dinâmicos sempre que a navegação ocorrer; - Substituição de valores hexadecimais e RGB nos estilos externos e inline.
Configuração de Variáveis SCSS
Para permitir a sobrescrita das variáveis padrão do Element UI, crie um arquivo theme-variables.scss dentro do diretório de estilos. Este arquivo define a cor primária e o caminho das fontes.
/* Sobrescrita das variáveis de tema */
$--color-primary: #42b983;
/* Caminho das fontes do icones, obrigatório */
$--font-path: '~element-ui/lib/theme-chalk/fonts';
@import "~element-ui/packages/theme-chalk/src/index";
:export {
colorPrimary: $--color-primary
}
No arquivo de entrada principal (main.js), importe este módulo para acessar a cor padrão via JavaScript.
import themeVars from '@/styles/theme-variables.scss'
Implementação dos Mixins
Dois mixins são necessários: um para a lógica de cores (themeMixin.js) e outro para o disparo de eventos (eventDispatcher.js). O primeiro contém o método processThemeUpdate, enquanto o segundo implementa a função $notifyRoot para comunicar mudenças à instância superior.
Registre ambos globalmente no main.js:
import themeMixin from '@/mixins/themeMixin.js'
import dispatcherMixin from '@/mixins/eventDispatcher.js'
Vue.mixin(themeMixin)
Vue.mixin(dispatcherMixin)
Lógica no Componente Raiz e App
No componente App.vue, injete a configuração do tema e observe as mudanças de rota. Se houver uma cor personalizada ativa, aplique-a após a atualização do DOM.
export default {
name: 'App',
inject: {
globalTheme: {
default: () => ({
activeTheme: '',
baseTheme: ''
})
}
},
data() {
return {
currentColor: ''
}
},
watch: {
$route() {
// Aplica o tema ao navegar para novas rotas
if (this.globalTheme.activeTheme && this.globalTheme.activeTheme !== this.globalTheme.baseTheme) {
this.$nextTick(() => {
this.processThemeUpdate(this.globalTheme.activeTheme, this.globalTheme.baseTheme)
})
}
}
},
created() {
// Recupera preferência salva localmente
if (Cookies.get('appTheme')) {
this.currentColor = Cookies.get('appTheme');
this.$notifyRoot('root', 'theme:update', [this.currentColor, true]);
} else {
this.currentColor = this.globalTheme.activeTheme;
}
},
methods: {
updateThemeColor(value) {
this.$notifyRoot('root', 'theme:update', value);
Cookies.set('appTheme', value, { path: '/' });
}
}
}
A instância raiz, definida no main.js, fornece o estado do tema e escuta o evento personalizado para atualizar suas propriedades reativas.
new Vue({
el: '#app',
name: 'root',
provide() {
return {
globalTheme: this
}
},
data() {
return {
activeTheme: themeVars.colorPrimary.toLowerCase(),
baseTheme: themeVars.colorPrimary.toLowerCase(),
isFirstLoad: true,
}
},
created() {
this.$on('theme:update', (color, skipFlag) => {
this.activeTheme = color.toLowerCase();
this.isFirstLoad = skipFlag;
})
},
watch: {
activeTheme(next, prev) {
if (!this.isFirstLoad) {
this.processThemeUpdate(next, prev);
}
}
},
router,
components: { App },
template: '<App/>'
})
Manipulação de Estilos e Cores
O mixin themeMixin.js contém a lógica pesada de substituição. Ele gera variações de claridade e escuridão da cor base e substitui occurrences nos estilos carregados.
export default {
methods: {
swapColorTokens(styleContent, oldPalette, newPalette) {
let updatedContent = styleContent;
oldPalette.forEach((color, index) => {
let pattern;
if (color.includes(',')) {
const rgbParts = color.split(',');
pattern = new RegExp("\\s*" + rgbParts[0] + "\\s*,\\s*" + rgbParts[1] + "\\s*,\\s*" + rgbParts[2] + "\\s*", 'ig');
} else {
pattern = new RegExp(color, 'ig');
}
updatedContent = updatedContent.replace(pattern, newPalette[index])
})
return updatedContent;
},
buildColorSeries(baseColor) {
const series = [baseColor];
for (let i = 0; i <= 9; i++) {
series.push(this.computeTint(baseColor, Number(i / 10).toFixed(2)));
}
series.push(this.computeShade(baseColor, 0.1));
return series;
},
computeTint(hex, factor) {
let r = parseInt(hex.slice(0, 2), 16);
let g = parseInt(hex.slice(2, 4), 16);
let b = parseInt(hex.slice(4, 6), 16);
if (factor == 0) return [r, g, b].join(',');
r += Math.round((255 - r) * factor);
g += Math.round((255 - g) * factor);
b += Math.round((255 - b) * factor);
return `#${r.toString(16)}${g.toString(16)}${b.toString(16)}`
},
computeShade(hex, factor) {
let r = parseInt(hex.slice(0, 2), 16);
let g = parseInt(hex.slice(2, 4), 16);
let b = parseInt(hex.slice(4, 6), 16);
r = Math.round((1 - factor) * r);
g = Math.round((1 - factor) * g);
b = Math.round((1 - factor) * b);
return `#${r.toString(16)}${g.toString(16)}${b.toString(16)}`
},
retrieveExternalCSS(url) {
return new Promise((resolve, reject) => {
const req = new XMLHttpRequest()
req.onload = () => {
if (req.status === 200) {
const content = req.responseText.replace(/@font-face{[^}]+}/, '')
resolve(content);
}
}
req.open('GET', url)
req.send()
})
},
resolveAssetPath(src) {
if (/^(http|https):\/\//g.test(src)) return src;
let basePath = this.locateBasePath();
let depth = 0;
const relativePattern = /\.\.\//g;
while (relativePattern.exec(src)) depth++;
while (depth--) basePath = basePath.substring(0, basePath.lastIndexOf('/'));
return basePath + "/" + src.replace(/\.\.\//g, "");
},
locateBasePath() {
const currentUrl = window.location.href;
if (currentUrl.indexOf('/#/') != -1) {
return currentUrl.substring(0, currentUrl.indexOf('/#/'));
}
return currentUrl.substring(0, currentUrl.lastIndexOf('/') + 1);
},
async processThemeUpdate(nextColor, prevColor) {
if (typeof nextColor !== 'string') return;
const newSeries = this.buildColorSeries(nextColor.replace('#', ''));
const oldSeries = this.buildColorSeries(prevColor.replace('#', ''));
const rgbRef = oldSeries[1].split(',');
const rgbPattern = new RegExp("\\(\\s*" + rgbRef[0] + "\\s*,\\s*" + rgbRef[1] + "\\s*,\\s*" + rgbRef[2] + "\\s*\\)", 'i');
let styleContainer = document.getElementById('dynamic-theme-styles');
const linkTags = document.querySelectorAll('link');
if (!styleContainer && linkTags.length) {
styleContainer = document.createElement('style')
styleContainer.setAttribute('id', 'dynamic-theme-styles')
document.head.appendChild(styleContainer);
const links = Array.prototype.slice.call(linkTags);
let aggregatedCSS = '';
for (let item of links) {
const href = item.getAttribute('href');
const fullUrl = this.resolveAssetPath(href);
const cssText = await this.retrieveExternalCSS(fullUrl);
if (new RegExp(prevColor, 'i').test(cssText) || rgbPattern.test(cssText)) {
aggregatedCSS += this.swapColorTokens(cssText, oldSeries, newSeries);
}
}
styleContainer.innerText = aggregatedCSS;
}
const internalStyles = [].slice.call(document.querySelectorAll('style')).filter((el) => {
const text = el.innerText;
return new RegExp(prevColor, 'i').test(text) || rgbPattern.test(text);
})
internalStyles.forEach((el) => {
if (typeof el.innerText !== 'string') return;
el.innerText = this.swapColorTokens(el.innerText, oldSeries, newSeries);
})
const elementsWithStyle = [].slice.call(document.getElementsByTagName('*')).filter((el) => {
const inline = el.getAttribute('style');
return inline && (new RegExp(prevColor, 'i').test(inline) || rgbPattern.test(inline))
})
elementsWithStyle.forEach((el) => {
const inlineStyle = el.getAttribute('style');
el.style = this.swapColorTokens(inlineStyle, oldSeries, newSeries);
})
}
}
}
Para que a substituição de estilos externos funcione corretamente, é essencial que o CSS seja extraído para um arquivo único e minimizado durante o build. No ambiente de desenvolvimento, configrue o Webpack para extrair o CSS.
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin')
const devWebpackConfig = merge(baseWebpackConfig, {
module: {
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, extract: true, usePostCSS: true })
},
plugins: [
new MiniCssExtractPlugin({
filename: 'bundle.css',
allChunks: true
}),
new OptimizeCSSPlugin({
cssProcessorOptions: config.build.productionSourceMap
? { safe: true, map: { inline: false } }
: { safe: true }
})
]
})