自动化主题导入和修改检测

Liu Bowen

Liu Bowen / 2021, 二月, 09

为什么做自动化导入

在前端业务开发中,在书写样式,特别是由组件库提供的大量主题样式变量时,需要频繁在使用样式文件的顶端 显式地  引入主题文件,那么为了加快开发效率,剔除繁琐的主题导入,提供一种自动化导入主题文件的机制势在必行。

这里的主题自动化导入可类比于前端开发中的全局注册组件。目标是实现在全局范围下无需导入直接使用主题变量。即提供了一种类似 tailwindcss 体验的样式变量书写方式。

众所周知,前端领域的样式实现方式不外乎:

  1. 基于预处理语言,如 less, sa(c)ssstylus 以及 postcss 等。

    特别说明的是,postcss 方案略微小众,它是基于 postcss 构建特定的 CSS 语法来实现诸如 theme utility 的多种主题工具实体。如社区 tailwindcss 本质就是 postcss plugin。它在原有 CSS 的语法基础上横向拓展了多个 CSS utility 用于达到特定的样式逻辑。

  2. 纯 CSS 文件,即一般通过 CSS variables 来实现样式变量。

要实现在前端工程中实现自动化导入主题文件,那么也是以以上两个方向作为切入点。


基于预处理语言

less 语言为例,常规的使用场景是,首先在 webpack 配置 less loader,使得 webpack 具有识别并解析 less 文件的能力。而在 less 文件中,对于我们当下文件内所有的变量声明都是必须在当前文件内显式声明或导入,否则将导致 less 变量未定义的错误。

那么为了实现在 less 下全局使用主题变量的目标,我们的当下的方向即是如何自动化导入 less 主题变量。拆解以上目标为:

  1. 为了简化后续自动化导入,主题变量应该具有一个唯一出口;

  2. 借助 webpack 的能力实现自动化导入上一步的主题变量。

首先简要回顾一下 webpack 的前置核心知识点,webpack 作为一个 JS Bundler,自身仅具有导入并识别 JavaScriptJSON 文件的能力,其他任意资源类型都是通过 webpack loader interface 来实现将非合规资源 转换 为能够被 webpack 识别的基于 JavaScript 元数据信息。

assets --> loader --> JS --> webpack --> webpack chunk

回到 less 场景下,常规的 less 将经过以下处理:

*.less --(less-loader)--> css --(css loader)--> JS meta --(style-loader)--> create style nodes from JS strings

既然如此,所有的 less 文件都会走 webpack loader 的路径进而转换为 JS。那么我们可以在 less 的 “必经之路” 上处理主题变量的自动化导入。即我们可以通过实现一个 webpack theme loader 来实现自动化主题导入功能。

编译功能选型思考

在确定通过 theme loader 实现自动化主题导入后,那么接下来我们思考是否应该集成 less-loader 的原有功能,来使得 theme-loader 在具有自动化导入主题的功能基础上额外实现 less-loader 的核心编译功能。

若选择集成 less-loader 功能,就应该完全实现一个 webpack loader 来代替 less-loader,而不是在 theme-loader 中调用 less-loader。这是因为 webpack 提供了 resolveLoader 选项来指定加载 loader 的上下文,嵌套的 loader 调用将整个 loader 处理的编译流程复杂化了。所以,不应该出现在 loader 中调用其他 loader 的场景

通过浏览 webpack v5.x 的 loader interface,在 loader 不再能够获取到终端用户定义的 webpackConfiguration。即无法在 loader 中确定用户是否定义了 resolveLoader。

回到之前所说在 theme-loader 中完全包含 less-loader 的所有功能。最终在使用上,起到完全替代 less-loader 的作用。那么终端用户在使用时,应该是:

module.exports = {
  // ...
  modules: {
    rules: [
      {
        test: /\.less$/i,
        use: [
          'style-loader',
          'css-loader',
          [
-            'less-loader',
+            'theme-loader',
            {/* loader options */}
          ]
        ]
      }
    ]
  }
}

可能从配置上来说,减少了 less loader 的配置,但收益并不高。因为集成 less 编译这一方向最显著的弊端是,对于 theme loader 这一块的维护成本需要额外考虑 less 编译功能的开发成本。其本质上导致主题功能与 less 编译功能形成 耦合

而根据 webpack 官方的 writing a loader 指南阐述,一个良好的 loader 设计应该遵循四大基本原则:简单、级联、模块化、无状态。此处的功能耦合,并不能良好的体现简单,模块化的原则。

再者,less-loader 现在已经是社区成熟的预处理语言的实践方案之一了,个人没有必要造一个与 less loader 有显著差异且具有几乎相同功能的库了。所以,基于以上对于维护成本,loader 设计原则考虑,并 不推荐theme loader 中集成 less 编译的功能。less 编译的功能仍应该由特定预处理语言 loader,即 less-loader 来处理。

我们对于 theme-loader 的使用愿景应该是:theme-loader 仅提供 自动化主题导入主题合规修改 检测。最终,在使用上应该是:

module.exports = {
  // ...
  modules: {
    rules: [
      {
        test: /\.less$/i,
        use: [
          'style-loader',
          'css-loader',
          [
            'less-loader',
            {/* less loader options */}
          ],
+          [
+            'theme-loader',
+            {/* theme loader options */}
+          ]
        ]
      }
    ]
  }
}

基于预处理语言的自动化主题导入

webpack loader 自身包含同步和异步两种类型的 loader。为了不阻塞其他 module.rules 下其他 rule 处理,我们选择构建异步 loader,而这也是 webpack 官方在 writing a loader 指南中推荐的。

const themeLoader = function themeLoader(source: string) {
  const callback = this.async()

  callback(null, '')
}

themeLoader.raw = false

module.exports = themeLoader
export default themeLoader

其中 this.async() 调用是为了使得当前的 themeLoader 成为 异步 loader。当处理完当前资源后,通过调用 callback 将处理后的资源传递给下一个 loader

webpack 中,只要对给定文件后缀定义 module.rules 时,那么不论该类文件是如何被导入的,均会触发一系列 loader 的调用,并依据定义 loader 的数组的索引反序开始调用。

结合前文 theme loader 的规划,theme loader 仅提供 less 自动化导入,且 theme loader 对于处理 less 的优先级最高。那么我们可在 theme loader 中对导入的文件内容追加导入语句,即可实现在 less-loader 编译 less 之前追加主题文件的自动化导入。

const theme = `@import (reference, once) '~THEME_LIB_LESS_ENTRY';`

function themeLoader(source: string) {
  const callback = this.async()

  callback(null, `${theme}\n${source}`)
}

可能读者会问,为什么不直接使用 less-loaderadditionalData 直接导入主题变量?

当然,这样的作法并无不合理之处。但是 theme-loader 提供的功能更加易于横向拓展且更加通用化,而且后续可做到导入优化等。less-loader 这样的特定预处理语言 loader 更加侧重于给予用户一个导入的入口而已,这样的功能并不是 less-loader 的核心功能,并且根据 less-loader v8.0.0 源码可见其导入与上文的导入实现方式并无实质性差异。

if (typeof options.additionalData !== 'undefined') {
  data =
    typeof options.additionalData === 'function'
      ? `${await options.additionalData(data, this)}`
      : `${options.additionalData}\n${data}`
}

导入优化

此处以 less 语言为例,在上文示例代码中,若自动化导入的仅仅只是 less 变量,而无样式时,对于整个编译流程的效率影响并不大。但是导入的主题若包含了大量样式时。那么在每次触发 loader,均会全量多次重复导入相同的主题样式。这势必是不合理的导入。

一种最直接的优化方式是,新增 less modifier

- @import '~THEME_LIB_LESS_ENTRY';
+ @import (reference, once) '~THEME_LIB_LESS_ENTRY';

那么这种方式,只能治标不治本。在 less 中的 @import 语句本质上与一些编译语言,如 C++ 中的编译宏的作用几乎相同 —— 将对应链接内的文本内容复制到 @import 的位置,而此处 reference 的作用是修饰 @import 语句的行为,使其不再直接输出链接内的文本内容到被导入的文件中,转而仅仅只是导入其链接内容中的数据声明,可以理解为拓展 less 变量作用域。但是本质上还是没有改变读取链接文件的本质,那么该 less modifier 对编译效率的优化非常有限。

这里我们转换一种思路就是,对于导入的样式,在 每次 编译中仅导入唯一一次,在当次编译中后续的导入将被 重定向到 仅有 less 变量的主题文件,那么这对于整个自动化主题导入的编译效率提升非常明显。伪代码如下:

const themeLib = `@import '~THEME_LIB_LESS_ENTRY';`
const themeVars = `@import (reference, once) '~THEME_LIB_LESS_VARS_ENTRY'`

let theme = themeLib

function themeLoader(source: string) {
  const callback = this.async()

  callback(null, `${theme}\n${source}`)
  theme = themeVars
}

基于主题配置的自动化导入

主题样式,并不是一成不变,对于定制化需求或指定预设主题,我们借鉴 tailwindcss/babel 配置的方式,来通过主题配置文件 themerc.(js|json) 等方式进行主题定制化配置。配置内容基本领域应该包含:

  1. 设置 theme preset;

  2. 要修改的主题变量;

// <PROJECT_ROOT>/themerc.js
module.exports = {
  preset: 'npm-package-name',
  extends: {
    '@primary-color': 'blue'
  }
}

而读取配置的逻辑如下:

import memoize from 'lodash/memoize'
import fs from 'fs-extra'
import path from 'path'

const getThemeConfig = memoize(function (cwd: string) {
  const [configFilename] = ['js', 'json'].filter(ext =>
    fs.existSync(path.resolve(cwd, `themerc.${ext}`))
  )
  const configFile = path.resolve(cwd, configFilename)
  const config = /\.js$/.test(configFile)
    ? fs.readFileSync(configFile)
    : fs.readJSONSync(configFile)
  return config
})

function themeLoader(source: string) {
  // ...
  const themeConfig = getThemeConfig(this.rootContext)
}

其中,因为根据 webpack loader 的运行方式,每次静态资源的导入,均会触发对应 loaders 的链式调用,那么就会出现重复读取配置文件,为了避免重复读取配置,上文对读取函数进行了 memoize 化处理,并以 webpack context 值作为 memoize 的缓存基准。最终,在 webpack context 不发生变化的情况下,仅在第一次执行读取逻辑,后续调用均只会复用之前的结果缓存。

memoizememoization,又名记忆化,是一种优化函数执行的编程策略,当参数不变的情况下,始终返回结果缓存,而不是调用函数。

结合上一章节的自动化导入语句思路,解析配置:

const themeLoader = function themeLoader(source: string) {
  const callback = this.async()

  let data: string
  const { extends } = getThemeConfig(this.rootContext)
  const userDefinedVars = Object.keys(extends).reduce(vars, name => {
    return vars + `${name}: ${extends[name]}`
  }, ``)

  // 变量直接追加,仅限于 less 语言,对于 sass/scss,stylus 无效,额外处理方式见下文章节
  callback(null, `${source}\n${userDefinedVars}`)
}

合规性修改

对于给定的用户修改,因为我们对外暴露了主题覆盖的接口,那么在导入用户配置时,我们可以在此进行合规性检测,而检测的基准应该是由主题库额外提供的主题变量元数据。

const themeLoader = function themeLoader(source: string) {
  const callback = this.async()

  let data: string
  let warnings: string[] = []
  const { extends, meta: metaPath } = getThemeConfig(this.rootContext)
  const meta = metaPath && require(meta)
  const userDefinedVars = Object.keys(extends).reduce(vars, name => {
    if (meta[name] && meta[name] !== extends[name]) {
      warnings.push(`Illegal modification: ${name} should be ${meta[name]} instead of ${extends[name]}.`)
    }
    return vars + `${name}: ${extends[name]}`
  }, ``)
  console.warn(warnings.join('\n'))

  // 变量直接追加,仅限于 less 语言,对于 sass/scss,stylus 无效,额外处理方式见下文章节
  callback(null, `${source}\n${userDefinedVars}`)
}

边界情况

上文即是初步完成了对主题配置的解析,并将特定配置引入到各个 less 文件中,但存在以下边界情况:

  1. 当出现重复导入时,应该如何处理?

    这个问题对于不同的预处理语言有不同的处理方式。通用的思路是通过基本导入语句的文本匹配来规避重复导入的情况。而对于 less 语言来说,@import 语句默认具有 once 特性,即在单个文件中,仅有第一次导入有效,后续的重复导入将被忽略。即在 less 语言中,不需要特殊处理。

    once: The default behavior of @import statements. It means the file is imported only once and subsequent import statements for that file will be ignored.

  2. 如何保证通过 theme config 导入的主题生效?而不会被 source 中的变量再次覆盖?

    如果是在 less 语言中,直接通过追加即可,less compiler 在其内部会进行优先级覆盖,文件内后定义的重名变量始终具有高优先级。

    return `${source}\n${userDefinedVars}`
    

    但是,并不是所有的预处理语言均支持后置变量声明,如 sass/scssstylus 则不支持后声明变量。那么在这些预处理语言中,变量的声明一定在使用之前。在这样的场景下,我们应该依据以下逻辑加入用户配置的主题变量:

    1. 首先根据正则找到 source 中最后一个 import 语句所在的位置;

    2. 将终端用户定义的 userDefinedVars 追加在上一步位置之后;

    至此完成不支持后声明变量的预处理语言的变量追加逻辑。


基于 postcss 的主题导入

前文已经阐述了依据预处理样式语言 less 的自动化导入方式,那么在不使用预处理语言的场景下,即 pure CSS 的场景下,是否仍有适用的主题导入方式呢?

在现有的前端开发社区中,postcss 依据其强大的 plugin 机制得到了广泛的使用。依据 postcss架构设计,我们可以将 postcss 认为是 CSS 界的 babel,它自身并不提供额外的除解析 CSSCSS AST 以外的任何功能。

                                postcss
      +------------------------------------------------------+
      |                                                      |
      |        parser                                        |
      +----------------------+                               |
      |                      |                               |
css +---> tokens +---> AST +---> plugins +---> stringifier +---> new css
      |                      |                               |
      +----------------------+-------------------------------+

从上文 postcss workflow 可见,在其生命周期内,所有的 postcss 插件均依赖于 postcss CSS AST 进行功能增强,那么这也给予了我们在主题自动化导入功能方面方向 —— 以 postcss plugin 的形式在前端工程中加入基于 Pure CSS 的自动化导入能力。

依据 postcss plugin插件编写指南,可以得出以下 postcss plugin 的通用结构:

import type { PluginCreator } from 'postcss'

const themePlugin: PluginCreator<PluginOptions> = function themePlugin(
  options = {}
) {
  // ...
  return {
    postcssPlugin: 'postcss-theme'
    // ...
  }
}

type PluginOptions = Record<
  string,
  {
    preset: string
  }
>

themePlugin.postcss = true
module.exports = themePlugin

正如前文所述,postcss plugin 如同 babel plugin 一样具有基于 AST 的遍历能力,那么我们可以据此实现以下自动化导入语句:

import fs from 'fs-extra'

const themePlugin: PluginCreator<PluginOptions> = function themePlugin(
  options = {}
) {
  const { preset } = options
  const theme = preset && require(preset)
  return {
    postcssPlugin: 'postcss-theme'
    Once(root) {
      if (theme) {
        root.prepend(theme)
      }
    }
  }
}

其中 Once listener 表示仅在样式文件入口遍历时,执行唯一一次,那么我们在 Once listener 中判断是否在 postcss.config.jspostcss-theme 是否设置了 preset,进而自动化导入样式文件。

这里 postcss 与预处理语言处理用户定义的配置不同之处在于,postcss plugin 我们依托已有的 postcss.config.js 实现,而不是额外的配置文件,尽可能降低使用成本。从实现成本来看,使用 postcss plugin 的成本小于基于预处理语言的解决方案。

合规性检测

合规性检测与前文描述的自动化导入在实现路径上存在相似性。在逻辑上,我们要进行合规的修改检测时,同样应该遍历每个 CSS rule 的声明,那么就需要我们遍历 CSS AST 来达到我们的目的,那么依据 postcss 中的 declaration listener 我们可在每次 postcss parser 遍历 css declaration node 时,进行当下声明值合规检测。

const isCSSVariables = (declProp: string) => declProp.startsWith('--')

const themePlugin: PluginCreator<PluginOptions> = function themePlugin(
  options = {}
) {
  const { preset, meta } = options
  const theme = preset && require(preset)
  const meta = meta && require(meta)
  return {
    postcssPlugin: 'postcss-theme'

    //...

    Declaration(decl) {
      if (meta && isCSSVariable(decl.prop)) {
        const metaVal = meta[decl.prop]
        if (metaVal && metaVal !== decl.value) {
          decl.warn(`Illegal modification: ${decl.prop} should be ${metaVal} instead of ${decl.value}`)
        }
      }
    }
  }
}

在上文示例代码中,我们在初始化 postcss plugin 即读取了主题变量元数据,在 plugin 运行时,在 declaration listener 中实时比对元数据中同名键值,在出现偏差时,通过 warnpostcss 内部传入警告信息,进而在 terminal 中输出合规性检测信息。

主题元数据产出

主题元数据的产出同样与主题样式的实现方式强关联。在使用预处理语言的主题库中,可以借助预处理语言的 AST 插件,收集主题变量等元数据信息。而在基于 CSS variables 的主题库中,我们同样基于 postcss plugin 在遍历 CSS AST 时,收集对应主题变量的元数据信息。

元数据产出与之前的自动化导入存在相似性,故不再展开。

概述

前文主要从预处理语言的 webpack loaderCSS 变量下的 postcss plugin 两个方向给出了自动化主题导入和合规性修改的解法。而对于文章主题的解法也远远不止这两种。比如另外的基于预处理语言的主题的自动化主题导入解法还可以 基于预处理语言自身的插件 机制来完成,如 less plugin

less plugin 方面,并没有官方的插件指南,但我们可以借鉴一些社区解法来完成我们的目标,这里列出一些典型解法以备有心读者参考:

typelink
less visitorgithub/less/less.js - plugin visitor test suite
less visitor开发 less 插件
less visitorgithub/less/less-plugin-inline-urls
less preprocessgithub/less/less.js - plugin preprocess test suite
less preprocessless-plugin-sass2less

综上,我们的核心思路是基于前端工程化方向将自动化导入功能抽象为底层工程化能力,通过 webpack loader 实现自动化导入。并且为了规范使用者对主题的修改行为,同样基于 webpack loaderpostcss plugin 产出主题变量的 元数据 ,进而给予后续自动化导入主题和用于配置,达到合规使用的目的。