前端工程化思维:主题切换架构

在前端基础建设中,对样式方案的处理是必不可少的。

在本文中,我们将实现一个工程化主题切换功能,并梳理现代前端样式的解决方案。

设计一个主题切换工程架构

随着iOS 13引入深色模式(Dark Mode),各大应用和网站也都开始支持深色模式。相比于传统的页面配色方案,深色模式具有较好的降噪性,也能让用户的眼睛在看内容时更舒适。

那么对于前端来说,如何高效地支持深色模式呢?

这里的高效就是指工程化、自动化。在介绍具体方案前,我们先来了解一个必会的前端工程化神器——PostCSS。

PostCSS原理和相关插件能力

简单来说,PostCSS是一款编译CSS的工具。PostCSS具有良好的插件性,其插件也是使用JavaScript编写的,非常有利于开发者进行扩展。

基于前面内容介绍的Babel思想,对比JavaScript的编译器,我们不难猜出PostCSS的工作原理:PostCSS接收一个CSS文件,并提供插件机制,提供给开发者分析、修改CSS规则的能力,具体实现方式也是基于AST技术实现的。

本文介绍的工程化主题切换架构也离不开PostCSS的基础能力。

架构思路

对于主题切换,社区介绍的方案往往是通过CSS变量(CSS自定义属性)来实现的,这无疑是一个很好的思路,但是作为架构,使用CSS自定义属性只是其中一个环节。站在更高、更中台化的视角思考,我们还需要搞清楚以下内容。

  • 如何维护不同主题色值?
  • 谁来维护不同主题色值?
  • 在研发和设计之间,如何保持不同主题色值的同步沟通?
  • 如何最小化前端工程师的开发量,让他们不必硬编码两份色值?
  • 如何使一键切换时的性能最优?
  • 如何配合JavaScript状态管理,同步主题切换的信号?

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

a {
  color: cc(GBK05A);
}


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

a {
  color: #646464;
}


html[data-theme='dark'] a {
  color: #808080;
}


我们来看看在编译时,构建环节完成了什么具体操作。

  • cc(GBK05A)这样的声明被编译为#646464。cc是一个CSS函数,而GBK05A是一组色值,即一个色组,分别包含了Light和Dark两种主题模式中的颜色。
  • 在HTML根节点上,添加属性选择器data-theme='dark',并添加a标签,color色值样式为#808080。

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

结合图1可以辅助理解上述编译过程。

图1

回到架构设计中,如何在构建时完成CSS的样式编译转换呢?

答案指向了PostCSS。

具体架构设计步骤如下。

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

• 维护一个色值,结合上例(这里以YML格式为例),配置如下。

GBK05A: [BK05, BK06]


BK05: '#808080'
BK06: '#999999'


postcss-theme-colors需要完成以下操作。

  • 识别cc函数。
  • 读取色组配置。
  • 通过色值对cc函数求值,得到两种颜色,分别对应Light和Dark主题模式。
  • 原地编译CSS中的颜色为Light主题模式色值。
  • 将Dark主题模式色值写到HTML根节点上。

这里需要补充的是,为了将Dark主题模式色值按照html[data-theme='dark']方式写到HTML根节点上,我们使用了如下两个PostCSS插件。

  • postcss-nested。
  • postcss-nesting。

整体架构设计如图2所示。

图2

2


主题色切换架构实现

有了整体架构,下面来实现其中的重点环节。

首先,我们需要了解PostCSS插件体系。

PostCSS插件体系

PostCSS具有天生的插件化体系,开发者一般很容易上手插件开发,典型的PostCSS插件编写模板如下。

var postcss = require('postcss');


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


  opts = opts || {};


  // 处理配置项
  return function (css, result) {
    // 转换AST
  };


})


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

return {
    postcssPlugin: 'PLUGIN_NAME',
    /*
    Root (root, postcss) {
      // 转换AST
    }
    */
    /*
    Declaration (decl, postcss) {
    }
    */


    /*
    Declaration: {
      color: (decl, postcss) {
      }
    }
    */
  }
}


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

动手实现postcss-theme-colors插件

在PostCSS插件设计中,我们看到了清晰的AST设计痕迹,经过之前的学习,我们应该对AST 不再陌生。根据插件代码骨架加入具体实现逻辑,如下。

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工作流程中是否使用了某些插件
    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)
    })
  }
})


上面的代码中加入了相关注释,整体逻辑并不难理解。理解了以上源码,postcss-theme-colors插件的使用方式也就呼之欲出了。

const colors = {
  C01: '#eee',
  C02: '#111',
}


const groups = {
  G01: ['C01', 'C02'],
}


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


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

架构平台化——色组和色值平台设计

在上面的示例中,我们采用了硬编码(hard coding)方式。

const colors = {
  C01: '#eee',
  C02: '#111',
}


const groups = {
  G01: ['C01', 'C02'],
}


上述代码声明了colors和groups两个变量,并将它们传递给了postcss-theme-colors插件。其中,groups变量声明了色组的概念,比如group1被命名为G01,对应了C01(日间色)、C02(夜间色)两个色值,这样做的好处显而易见。

  • 可将postcss-theme-colors插件和色值声明解耦,postcss-theme-colors插件并不关心具体的色值声明,而是接收colors和groups变量。
  • 实现了色值和色组的解耦。

colors维护具体色值。

√ groups维护具体色组。

例如,前面提到了如下的超链接样式声明。

a {
  color: cc(GBK05A);
}


在业务开发中,我们直接声明了“使用GBK05A这个色组”。业务开发者不需要关心这个色组在Light和Dark主题模式下分别对应哪些色值。而设计团队可以专门维护色组和色值,最终只提供给开发者色组。

在此基础上,我们完全可以抽象出一个色组和色值平台,方便设计团队更新内容。这个平台可以以JSON或YML等任何形式存储色值和色组的对应关系,方便各个团队协作。

在前面提到的主题切换设计架构图的基础上,我们扩充其为平台化解决方案,如图3所示。

图3

本文没有聚焦于CSS样式的具体用法,而是从更高的视角梳理了现代化前端基础建设当中的样式相关工程方案,并从“主题切换”这一话题入手,联动了PostCSS、Webpack,甚至前端状态管理流程。

本文节选自《前端架构师:基础建设与架构设计思想》一书,更多前端架构相关内容,请查看本书!

同时收录于小程序《互联网小兵》,技术人小程序,收录前端、后端、移动端、人工智能、算法等优质技术文章!

发表评论
留言与评论(共有 0 条评论) “”
   
验证码:

相关文章

推荐文章