前端基础建设与架构 - 前百度资深前端开发工程师 - 拉勾教育

前几讲内容,我们主要围绕 JavaScript 和项目相关工程化方案展开。实际上,在前端基础建设中,样式方案的处理也必不可少。这一讲,就让我们设计一个工程化主题切换功能,并梳理现代前端样式的解决方案。

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

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

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

在介绍具体方案前,我们先来了解一个必备概念和工程化神器——PostCSS。

PostCSS 原理和相关插件能力

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

PostCSS is a tool for transforming styles with JS plugins. These plugins can lint your CSS, support variables and mixins, transpile future CSS syntax, inline images, and more.

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

而我们这一讲介绍的工程化主题切换架构,也离不开 PostCSS 的基础能力。我们马上来设计一个相关结构能力。

架构思路总结

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

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

  • 如何维护不同主题色值;
  • 谁来维护不同主题色值;
  • 研发和设计之间,如何保持不同主题色值的同步沟通;
  • 如何最小化前端工程师的开发量,不需要 hard coding 两份颜色数值;
  • 如何做到一键切换时的性能最优;
  • 如何配合 JavaScript 状态管理,同步主题切换的信号。

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

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

  1. a {
  2. color: #646464;
  3. }
  4. html[data-theme='dark'] a {
  5. color: #808080;
  6. }

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

  • 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将起作用,实现了样式的切换。

结合下图理解:

13 | 工程化思维处理方案:如何实现应用主题切换功能? - 图1

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

  • 首先编写一个名为 postcss-theme-colors 的 PostCSS 插件,实现上述编译过程。
  • 维护一个色值,结合上例(这里以 YML 格式为例)就是:
  1. GBK05A: [BK05, BK06]
  2. BK05: '#808080'
  3. BK06: '#999999'

postcss-theme-colors 需要:

  1. 识别cc()方法;
  2. 读取色值;
  3. 通过色值,对cc()方法求值,得到两种颜色,分别对应 dark 和 light 模式;
  4. 原地编译 CSS 中的颜色为 light 模式色值;
  5. 同时 dark 模式色值写到 HTML 节点上。

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

整体架构设计,总结为下图:

13 | 工程化思维处理方案:如何实现应用主题切换功能? - 图2

PostCSS 架构转换设计

主题色切换架构实现

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

PostCSS 插件体系

PostCSS 具有天生的插件化体系,开发者一般很容易上手插件开发:

  1. var postcss = require('postcss');
  2. module.exports = postcss.plugin('pluginname', function (opts) {
  3. opts = opts || {};
  4. return function (css, result) {
  5. };
  6. })

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

  1. return {
  2. postcssPlugin: 'PLUGIN_NAME',
  3. Root (root, postcss) {
  4. // Transform CSS AST here
  5. }
  6. */
  7. Declaration (decl, postcss) {
  8. // The faster way to find Declaration node
  9. }
  10. */
  11. Declaration: {
  12. color: (decl, postcss) {
  13. // The fastest way find Declaration node if you know property name
  14. }
  15. }
  16. */
  17. }
  18. }

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

动手实现 postcss-theme-colors

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

  1. const postcss = require('postcss')
  2. const defaults = {
  3. function: 'cc',
  4. groups: {},
  5. colors: {},
  6. useCustomProperties: false,
  7. darkThemeSelector: 'html[data-theme="dark"]',
  8. nestingPlugin: null,
  9. }
  10. const resolveColor = (options, theme, group, defaultValue) => {
  11. const [lightColor, darkColor] = options.groups[group] || []
  12. const color = theme === 'dark' ? darkColor : lightColor
  13. if (!color) {
  14. return defaultValue
  15. }
  16. if (options.useCustomProperties) {
  17. return color.startsWith('--') ? `var(${color})` : `var(--${color})`
  18. }
  19. return options.colors[color] || defaultValue
  20. }
  21. module.exports = postcss.plugin('postcss-theme-colors', options => {
  22. options = Object.assign({}, defaults, options)
  23. const reGroup = new RegExp(`\\b${options.function}\\(([^)]+)\\)`, 'g')
  24. return (style, result) => {
  25. const hasPlugin = name =>
  26. name.replace(/^postcss-/, '') === options.nestingPlugin ||
  27. result.processor.plugins.some(p => p.postcssPlugin === name)
  28. const getValue = (value, theme) => {
  29. return value.replace(reGroup, (match, group) => {
  30. return resolveColor(options, theme, group, match)
  31. })
  32. }
  33. style.walkDecls(decl => {
  34. const value = decl.value
  35. if (!value || !reGroup.test(value)) {
  36. return
  37. }
  38. const lightValue = getValue(value, 'light')
  39. const darkValue = getValue(value, 'dark')
  40. const darkDecl = decl.clone({value: darkValue})
  41. let darkRule
  42. if (hasPlugin('postcss-nesting')) {
  43. darkRule = postcss.atRule({
  44. name: 'nest',
  45. params: `${options.darkThemeSelector} &`,
  46. })
  47. } else if (hasPlugin('postcss-nested')) {
  48. darkRule = postcss.rule({
  49. selector: `${options.darkThemeSelector} &`,
  50. })
  51. } else {
  52. decl.warn(result, `Plugin(postcss-nesting or postcss-nested) not found`)
  53. }
  54. if (darkRule) {
  55. darkRule.append(darkDecl)
  56. decl.after(darkRule)
  57. }
  58. const lightDecl = decl.clone({value: lightValue})
  59. decl.replaceWith(lightDecl)
  60. })
  61. }
  62. })

上面代码我加入了相关注释,整体逻辑并不难理解。理解了这部分源码,使用方式也就呼之欲出了:

  1. const colors = {
  2. C01: '#eee',
  3. C02: '#111',
  4. }
  5. const groups = {
  6. G01: ['C01', 'C02'],
  7. }
  8. postcss([
  9. require('postcss-theme-colors')({colors, groups}),
  10. ]).process(css)

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

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

上面的使用示例中,我们采用了 hard coding 的方式,如下代码:

  1. const colors = {
  2. C01: '#eee',
  3. C02: '#111',
  4. }
  5. const groups = {
  6. G01: ['C01', 'C02'],
  7. }

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

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

  • 色值和色组解耦:

    1. colors维护具体色值;
    2. groups维护具体色组。

这样一来,如前文一个超链接样式声明:

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

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

在前文提到的主题切换设计架构图的基础上,我们扩充其为平台化的解决方案:

13 | 工程化思维处理方案:如何实现应用主题切换功能? - 图3

总结

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

13 | 工程化思维处理方案:如何实现应用主题切换功能? - 图4

这里,我想给大家留几个思考问题,postcss-custom-properties 有什么作用,它又是如何实现的?基于 CSS 变量新特性,我们可以如何优化本讲中的 postcss-theme-colors 插件?(答案已经在源代码中了。)