工程化-项目主题切换--一键定乾坤

554 阅读5分钟

背景

     方式大概有几种 切换css文件,webpack插件配置,less.modifyVars(只用于less),css3 css变量定义方式。

      那么对于前端来说,如何高效地支持深色模式呢?这里的高效就是指工程化、自动化方案,不需要开发中 hard coding。

PostCSS

简单来说, PostCSS 是一款编译 CSS 的工具。  

       postCSS 具有良好的插件性,其插件也是使用 JavaScript 编写的,非常有利于开发者扩展。基于前几节介绍的 Babel 思想,对比 JavaScript 的编译器,我们不难猜出 PostCSS 的工作原理:PostCSS 接收一个 CSS 文件,并提供了插件机制,提供给开发者分析、修改 CSS 的规则,具体实现方式也是基于 AST 技术。

PostCSS思路(一)

主题切换——社区上介绍的方案往往通过 CSS 变量(CSS 自定义属性)来实现,关于 CSS 变量的介绍,相对基础,这里我们只贴出知识链接:CSS 自定义属性。

这无疑是一个很好的思路,但是作为架构来说,使用 CSS 自定义属性——只是其中一个环节。站在更高、更中台化的视觉思考,我们还需要设计: 

  •  如何维护不同主题色值;

  • 谁来维护不同主题色值;

  • 研发和设计之间,如何保持不同主题色值的同步沟通

  • 如何最小化前端工程师的开发量,不需要 hard coding 两份颜色数值;

  • 如何做到一键切换时的性能最优;

  • 如何配合 JavaScript 状态管理,同步主题切换的信号。

基于以上考虑,以一个超链接样式为例,我们希望做到在开发时,编写: 

a {

  color: cc(GBK05A);

}

 这样的代码,就能一劳永逸——直接支持两套(light/dark)主题模式。也就是说,在应用编译时,上述代码预期被编译为下面这样的代码:

a {

  color: #646464;

}

 

html[data-theme='dark'] a {

  color: #808080;

}

我们来看看在编译时,构建环节发生了什么:

  • cc(GBK05A)这样的声明,被编译为#646464

  • 也就是说,cc是一个 CSS function,而GBK05A是一组色值,分别包含了 light 和 dark 两种主题的颜色;

  • 同时在 HTML 根节点上,添加属性选择器data-theme='dark',并添加a标签 color 色值样式为#808080

我们设想,用户点击“切换主题”按钮时,首先通过 JavaScript 将 HTML 根节点标签添加data-themedark的属性值,这时CSS 选择器html[data-theme='dark'] a将起作用,实现了样式的切换。

如何在构建时完成 CSS 的样式编译转换呢?答案指向了 PostCSS。我们来盘点一下具体架构步骤。

  1. 首先编写一个名为 postcss-theme-colors 的 PostCSS 插件,实现上述编译过程。

  2. 维护一个色值,结合上例(这里以 YML 格式为例)就是:

    GBK05A: [BK05, BK06]

    BK05: '#808080'

    BK06: '#999999'

postcss-theme-colors 需要:

  1. 识别cc()方法;

  2. 读取色值;

  3. 通过色值,对cc()方法求值,得到两种颜色,分别对应 dark 和 light 模式;

  4. 原地编译 CSS 中的颜色为 light 模式色值;

  5. 同时 dark 模式色值写到 HTML 节点上。

  6. 这里需要补充的是,为了将 dark 模式色值按照html[data-theme='dark']方式写到 HTML 节点上,我们使用了另外两个 PostCSS 插件完成:

主题色切换架构实现

有了架构,这部分我们就来实现架构环节中的重点环节。首先,我们需要了解 PostCSS 插件体系。

var postcss = require('postcss');

module.exports = postcss.plugin('pluginname', function (opts) {

  opts = opts || {};

  // Work with options here

  return function (css, result) {

    // Transform the CSS AST

  };

})

一个 PostCSS 就是一个 Node.js 模块,开发者调用postcss.plugin(源码链接定义在postcss.plugin中 )工厂方法返回一个插件实体,形如:

return {

    postcssPlugin: 'PLUGIN_NAME',

    /*

    Root (root, postcss) {

      // Transform CSS AST here

    }

    */

    /*

    Declaration (decl, postcss) {

      // The faster way to find Declaration node

    }

    */

    /*

    Declaration: {

      color: (decl, postcss) {

        // The fastest way find Declaration node if you know property name

      }

    }

    */

  }

}

在编写 PostCSS 插件时,我们可以直接使用postcss.plugin方法完成实际开发。接下来,我们就开始动手实现 postcss-theme-colors。

postcss-theme-colors

const postcss = require('postcss')

const defaults = {

  function: 'cc',

  groups: {},

  colors: {},

  useCustomProperties: false,

  darkThemeSelector: 'html[data-theme="dark"]',

  nestingPlugin: null,

}

const resolveColor = (options, theme, group, defaultValue) => {

  const [lightColor, darkColor] = options.groups[group] || []

  const color = theme === 'dark' ? darkColor : lightColor

  if (!color) {

    return defaultValue

  }

  if (options.useCustomProperties) {

    return color.startsWith('--') ? `var(${color})` : `var(--${color})`

  }

  return options.colors[color] || defaultValue

}

module.exports = postcss.plugin('postcss-theme-colors', options => {

  options = Object.assign({}, defaults, options)

  // 获取色值函数(默认为 cc())

  const reGroup = new RegExp(`\\b${options.function}\\(([^)]+)\\)`, 'g')

  return (style, result) => {

    // 判断 PostCSS 工作流程中,是否使用了某些 plugins

    const hasPlugin = name =>

      name.replace(/^postcss-/, '') === options.nestingPlugin ||

      result.processor.plugins.some(p => p.postcssPlugin === name)

    // 获取最终 CSS 值

    const getValue = (value, theme) => {

      return value.replace(reGroup, (match, group) => {

        return resolveColor(options, theme, group, match)

      })

    }

    // 遍历 CSS 声明

    style.walkDecls(decl => {

      const value = decl.value

      // 如果不含有色值函数调用,则提前退出

      if (!value || !reGroup.test(value)) {

        return

      }

      const lightValue = getValue(value, 'light') 

      const darkValue = getValue(value, 'dark') 

      const darkDecl = decl.clone({value: darkValue})

      let darkRule

      // 使用插件,生成 dark 样式

      if (hasPlugin('postcss-nesting')) {

        darkRule = postcss.atRule({

          name: 'nest',

          params: `${options.darkThemeSelector} &`,

        })

      } else if (hasPlugin('postcss-nested')) {

        darkRule = postcss.rule({

          selector: `${options.darkThemeSelector} &`,

        })

      } else {

        decl.warn(result, `Plugin(postcss-nesting or postcss-nested) not found`)

      }

      // 添加 dark 样式到目标 HTML 节点中

      if (darkRule) {

        darkRule.append(darkDecl)

        decl.after(darkRule)

      }

      const lightDecl = decl.clone({value: lightValue})

      decl.replaceWith(lightDecl)

    })

  }

})

使用方式

const colors = {

  C01: '#eee',

  C02: '#111',

}

const groups = {

  G01: ['C01', 'C02'],

}

postcss([  require('postcss-theme-colors')({colors, groups}),]).process(css)

实现了 postcss-theme-colors 插件,整体架构也就完成了大半。接下来,我们将继续完善,最终打造出一个更符合基础建设要求的方案。

声明了colorsgroups两个常量,并传递给 postcss-theme-colors 插件。其中groups变量声明了色组的概念,比如 group1 命名为 G01,它对应了 C01(日间色),C02(夜间色)两个色值

const colors = {

  C01: '#eee',

  C02: '#111',

}

const groups = {

  G01: ['C01', 'C02'],

}
  • 我们将 postcss-theme-colors 插件和色值声明解藕, postcss-theme-colors 插件并不关系颜色,而是接受colorsgroups变量。

  • 色值和色组解耦:

    1. colors维护具体色值;

    2. groups维护具体色组。

基于Css Variable的主题切换(二)

        业界关于主题切换的方案还挺多的,css链接替换、className更改、less.modifyVars、css in js等等,但每一种方案听起来都是又累又贵。有没有那种代码侵入低,小白无脑又好维护的方案呢?那自然是有的,确切的说是css它本身就支持。

Css3 Variable

定义一个全局颜色变量,改变这个变量的值页面内所有引用这个变量的元素都会进行改变。好简单是不是?

// base.less :root {
    --primary: green;
    --warning: yellow;
    --info: white;
    --danger: red;
}

// var.less 
@primary: var(--primary) 
@danger: var(--danger) 
@info: var(--info) 

// page.less 
.header {
    background-color: @primary;
    color: @info;
}
.content {
    border: 1px solid @danger;
}

使用

// change.js
function changeTheme(themeObj) {
    const vars = Object.keys(themeObj).map(key => `--${key}:${themeObj[key]}`).join(';')
    document.documentElement.setAttribute('style', vars)
}

它不支持**IE--**css vars ponyfill

是的,还真有polyfill能兼容IE:css-vars-ponyfill。它搞定IE的方式大概是这样子的

在支持css var的浏览器中不会进行处理,改造一下代码

// store/theme.js
import cssVars from 'css-vars-ponyfill'

export default {
    state: {
        'primary': 'green',
        'danger': 'white'
    },
    mutations: {
        UPDATE_THEME(state, payload) {
            const variables = {}
            Object.assign(state, payload)
            Object.keys(state).forEach((key) => {
                variables[`--${key}`] = state[key]
            })
            cssVars({
                variables
            })
        }
    },
    actions: {
        changeTheme({
            commit
        }, theme = {}) {
            commit('UPDATE_THEME', theme)
        }
    }
}

// router.js
// 因为路由跳转后的页面会按需加载新的css资源,重新转换
const convertedPages = new Set()
router.afterEach((to) => {
    if (convertedPages.has(to.path)) return
    convertedPages.add(to.path)
    context.store.dispatch('theme/changeTheme')
})

SSR项目闪屏问题优化

因为css-vars-ponyfill是依赖dom元素来实现转换的,在node中无法使用,所以从server直出未转换的css代码到client加载js文件转换css间存在一段样式空档。

解决问题:只需要在每个用到css var的地方加上一个兼容写法

@_primary: red
@primary: var(--primary)

:root{
  --primary: @_primary
}

.theme {
  color: @primary;
}

// 改为
.theme {
  color: @_primary;
  color: @primary;
}

在不支持css var的浏览器上会渲染默认颜色red,等待js加载完毕后ponyfill替换样式覆盖。

Webpack插件开发

手动在每个用到的地方添加兼容写法既幸苦又不好维护,这个时候我们需要了解一些webpack生命周期以及插件开发相关的知识,我们可以通过手写一个webpack插件,在normalModuleLoader(

v5版本被废弃,使用NormalModule.getCompilationHooks(compilation).loader

)的hooks中为所有css module添加一个loader来处理兼容代码。
笔者项目使用了less,注意webpack中loader执行顺序是类似栈的先进后出,所以我需要把转换loader添加到less-loader之前,确保我们处理的是编译后的css var写法而非less变量。

插件实现

// plugin.js
export default class HackCss {
    constructor(theme = {}) {
        this.themeVars = theme
    }

    apply(compiler) {
        compiler.hooks.thisCompilation.tap('HackCss', (compilation) => {
            compilation.hooks.normalModuleLoader.tap(
                'HackCss',
                (_, moduleContext) => {
                    if (/\.vue\?vue&type=style/.test(moduleContext.userRequest)) {
                        // ssr项目同构会有2次compiler,如果module中存在loader则不继续添加
                        if (hasLoader(moduleContext.loaders, 'hackcss-loader.js')) {
                            return
                        }

                        let lessLoaderIndex = 0
                        // 项目用了less,找到less-loader的位置
                        moduleContext.loaders.forEach((loader, index) => {
                            if (/less-loader/.test(loader.loader)) {
                                lessLoaderIndex = index
                            }
                        })

                        moduleContext.loaders.splice(lessLoaderIndex, 0, {
                            loader: path.resolve(__dirname, 'hackcss-loader.js'),
                            options: this.themeVars
                        })
                    }
                }
            )
        })
    }
})
}

// loader.js
const {
    getOptions
} = require('loader-utils')

module.exports = function (source) {
    if (/module\.exports/.test(source)) return source
    const theme = getOptions(this) || {}
    return source.replace(
        /\n(.+)?var\(--(.+)?\)(.+)?;/g,
        (content, before, name, after = '') => {
            const [key, indent] = before.split(':')
            const add = after.split(';')[0]
            return `\n${key}:${indent}${theme[name]}${after}${add};${content}`
        }
    )
}

自由切换完结。