Personalização Dinâmica de Temas no Vue.js com Element UI

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 processThemeUpdate ao detectar mudanças;
  • Monitoramento de rotas no App.vue para 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 }
    })
  ]
})

Tags: vue Element-UI webpack SCSS frontend-theming

Publicado em 10-7 05:48