让你的小程序用上原汁原味的 Tailwind/Windi CSS (JIT 兼容版)

让你的小程序用上原汁原味的 Tailwind/Windi CSS (JIT 兼容版)

Tailwind CSS 以及 Windi CSS 是 Atomic CSS(原子化 CSS)思想的优秀实践者,它们为我们的 Web 项目开发带来了非常可观的 开发效率提升 冗余样式的优化 。那我们能否将该思想实践到小程序项目中使其同样受益呢?

在本篇文章中,我将给大家带来一个思路以解决在小程序中集成 Tailwind/Windi CSS 时所遇到的兼容性问题。该思路的优点是 不会增加小程序开发者的心智负担 ,不会分裂 Web 项目与小程序项目统一的开发习惯,希望可以给大家带来一些启发。文章中的思路已被实现为开箱即用的插件: wechat-mini-program-tailwind ,欢迎 star !

我们遇到的问题

若我们想将 Tailwind/Windi CSS 实践到小程序项目中,会遗憾地发现以下几点 劝退因素

  1. 小程序的精简版 CSS 标准与 Web 不一致,其对于选择器的名称 不支持反斜线 \ 转义字符的存在,这使得 Tailwind/Windi CSS 中部分 classes 与 JIT/Value auto-infer 功能无法在小程序中使用
  2. 小程序独有的响应式尺寸单位 rpx 自然不会被 Tailwind/Windi 默认主题配置考虑到,其对于多端 UI 适配带来的独有特性会被 rem px 埋没

其中第 1 个痛点对于开发效率来说是一个 致命打击 。首先是对于一些常用的 classes,例如 h-2.5 translate-x-1/2 ,由于 . / 在 CSS 选择器中都需要被转义才能执行,而小程序又不支持转义字符本身,那就导致以上类似的 classes 的使用会直接使小程序 crash。此外,我们在开发 UI 时会经常遇到设计中的 特例样式 ,UI 设计不可能保证每一处样式都从样式规范中排列组合而来, 任何设计都无法避免 特例样式的存在。因此,若不能在开发过程中使用诸如 w-[0.5px] translate-y-[1.5px] bg-[#62baf3] 等灵活的一次性样式声明,则会迫使我们的思维与光标在主题配置文件与 UI 代码文件两端 反复横跳 ,来新增与应用新的特例样式来达到保证配置文件在实际样式中 100% 的覆盖率。但在这个过程中,主题配置文件已从对项目有益的约束规范变成了过度工程化的开发效率的 绊脚石

小程序因转义字符而 crash

对于以上 2 个痛点,其实早已有开发者着手解决。其中一种常见方式是通过 改写主题配置 的方式去替换所有 classes 名称中不被小程序支持的字符串,如将 w-1.5 改写成 w-1-dot-5 并让开发者习惯用 新的命名方式 去书写 classes,从而避免转义字符在开发者编写的 class 属性中出现。这的确是一个变通方法,但这么做仍然无法解决 JIT/Value auto-infer 的兼容性问题。此外,为了解决第 2 个痛点,还得要求开发者用 rpx 单位的值去改写主题配置, 重新定义 所有原本用 rem px 定义的尺寸数值。

这些方法的确可以避免问题发生,但这无疑增加了小程序开发者的 心智负担 。因为在开发过程中为了规避掉不被小程序支持的字符,他们需要新建一套独立于 Tailwind/Windi 官方文档之外的 classes 命名规范去书写样式,而这个新的命名规范可能因个人书写偏好而异, 不适合团队协作 。此外,这也 分裂 了 Web 与小程序项目开发规范,使得同一套主题配置文件无法在两种类型的项目中复用,增加了团队的 沟通与维护成本

理想中的开发体验

我们理想中的开发体验是可以让开发者在用 Tailwind/Windi CSS 开发小程序时 感受不到差异 ,让它和预期一样正常工作。

具体点说就是让小程序的开发

  • 仍然能用 Tailwind/Windi CSS 官方的规范去书写 classes,并且让所有涉及到转义的 classes 与 JIT/Value auto-infer 正常工作
  • 可以和 Web 项目 复用同一份主题配置 (除了响应式媒体查询以及一些小程序用不到的配置),对于主题配置中所有定义的 px rem 值,应该在最终生成的小程序样式文件中 自动转换 rpx

解决问题并实现理想体验

替换模版与样式文件中所有的转义字符

首先我们可以根据 Tailwind/Windi CSS 的 classes 系列找到所有可能在 CSS 中生成的不被小程序支持的字符。如我们在使用 h-[0.5px] 时生成的选择器为 .h-\[0\.5px\] ,使用 !translate-x-1/2 生成的选择器则为 .\!translate-x-1\/2 ,根据这些规律我们找到了所有的特殊字符串 \[ \] \( \) \# \! \/ \. \: \, 。如果是在浏览器环境中,通过反斜线 \ 转义的字符是可以作为选择器正常使用的,但一旦这些字符串出现在小程序中作为选择器名称,程序会直接 crash。

我们可以通过正则表达式匹配到这些字符串然后将其替换为可被支持的普通字符串,这里我设置了以下字符串映射:

const charactersMap = {
    '[': '-',
    ']': '-',
    '(': '-',
    ')': '-',
    '#': '-h-',
    '!': '-i-',
    '/': '-s-',
    '.': '-d-',
    ':': '-c-',
    ',': '-2c-',

也许你会问每个特殊字符对应的普通字符是如何选取的,其实这里并无特殊规则,在遵循尽量 少占用字符空间 、不会与其他字符应用场景 产生碰撞 的原则下,无论替换成什么都不会影响应用开发者的使用,因为这里替换的不是他们编写的 UI 源码,而是生成代码,我们接下来做的事会在幕后 静默处理 这些。

有了输入和输出的目标后,我们只需要找到时机在 Tailwind/Windi CSS 生成最终样式文件前替换目标字符串然后更新到输出结果中,并生成最终文件便可让 JIT/Value auto-infer 功能在小程序中正常工作,而且也顺便解决了部分 classes 不兼容的问题。

接下来我会介绍 2 种方式来实现对生成文件内容的更新。

基于 Webpack 实现

如果你的小程序项目是基于 Webpack,那我们可以利用恰当的 hooks 在合适的时机下修改生成文件,这里我选用了 processAssets hook 作为执行时机。( 具体实践

compiler.hooks.thisCompilation.tap(
    'mini-program-tailwind-webpack-plugin',
    compilation => {
        compilation.hooks.processAssets.tap(
                name: 'mini-program-tailwind-webpack-plugin',
                stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
            assets => {
                for (const pathname in assets) {
                    const originalSource = assets[ pathname ]
                    const rawSource = originalSource.source().toString()
                    let handledSource = ''
                    if (isStyleFile(pathname)) {
                        // 处理样式文件
                        handledSource = handleSource('style', rawSource)
                    } else if (isTemplateFile(pathname)) {
                        // 处理模板文件
                        handledSource = handleSource('template', rawSource)
                    if (handledSource) {
                        const source = new ConcatSource(handledSource)
                        compilation.updateAsset(pathname, source)

也许你会注意到这里我们不光处理了样式文件,也处理了模版文件。这是因为如果我们只把样式文件里的选择器名称替换掉而不替换掉 class 属性里对应的 classes 使其名称保持对应关系,最终 Tailwind/Windi 生成的样式也不会作用到 UI。

在对样式文件进行处理时,我们需要借助 PostCSS 来操作 CSS AST 中的 selector 类型节点。( 具体实践

在对模版文件进行处理时,我们则需要借助 WXML parser 来操作 WXML AST 中的 class 属性节点,另外还得借助 Babel 来操作 JavaScript AST。之所以还需要用到 Babel 是因为开发者有可能在 class 属性中使用小程序支持的 JavaScript 表达式来动态声明 classes,比如:

<view class="{{isVisible ? 'opacity-100' : 'opacity-0' }}"></view>

所以我们需要借助 Babel 来操作 StringLiteral 类型的节点。( 具体实践

自定义实现

也许你的小程序项目并不是基于 Webpack,这并没有关系。但需要明确的一点是无论你的项目基于什么 bundler 或 task runner 工具进行开发,只要有一个 可编程的文件监听与处理服务 便可以进行自定义实现。但如果你是通过 Tailwind/Windi CSS 官方的 CLI 进行小程序 UI 开发,那遗憾的是由于该 CLI 不支持插件机制而且不可能支持对于模板文件的修改,所以无法进行实现自定义。

这里我可以演示一个基于古老但又很适合小程序开发场景的 Gulp 来进行自定义实现。除此之外,我推荐在自定义实现过程中使用 Windi CSS ,原因是它提供了更为 灵活且底层 JavaScript API ,可以让我们在可编程的文件处理服务中使用,这是必要条件。

首先我们可以引入需要的 API 与主题配置文件

// 引入 Windi CSS API
const { Processor } = require('windicss/lib')
const { HTMLParser } = require('windicss/utils/parser')
// 引入 Windi 主题配置文件
const windiConfig = require('./windi.config.js')
// 新建 Windi 文件处理器实例
const processor = new Processor(windiConfig)

然后通过 Gulp 监听样式文件变动并进行字符串处理

gulp.task('handle:style', () =>
    gulp.src(
            styleSrc,
                since: gulp.lastRun('handle:style')
        .pipe(sourcemaps.init())
        .pipe(change(content => {
            // 处理样式文件
            return handleSource('style', content, {enableRpx: true})
        .pipe(sourcemaps.write())
        .pipe(gulp.dest(outDir))

当然也不能忘记处理模板文件

gulp.task('handle:template',
    gulp.series(
        () => gulp.src(templateSrc)
            .pipe(changed(outDir))
            .pipe(change((content) => {
                // 处理模板文件
                return handleSource('template', content)
            .pipe(gulp.dest(outDir)),
        () => gulp.src(templateSrc)
            .pipe(through2.obj(function(file, enc, cb) {
                let result = ''
                if (file.isBuffer()) {
                    const wxmlContent = file.contents.toString()
                    // 扫描并收集所有模版中用到的 Windi classes
                    const content = new HTMLParser(wxmlContent)
                        .parseClasses()
                        .map(i => i.result)
                        .join(' ')
                    // 解释所有模板中用到的 Windi classes
                    const interpretedSheet = processor.interpret(content, true).styleSheet
                    const MINIFY = true
                    // 生成样式文件
                    const styles = interpretedSheet.build(MINIFY)
                    // 处理生成的样式文件
                    result = handleSource('style', styles, {enableRpx: true})
                cb(null, result)
            .pipe(fs.createWriteStream('./dist/windi.wxss', {'flags': 'a'}))

需要注意的是,对于 Windi CSS 的 processor.interpret 方法我们需要在第二个参数位传入 true 来开启 processor 的 增量更新模式 ,而不是每一次文件更新都触发一轮从头开始的扫描、处理与生成周期。这样一方面可以 节省计算资源 ,另一方面可以对重复使用的 classes 在生成文件中进行去重,实现 去除重复冗余样式 的优化。

如果你对以上的自定义实现的细节感兴趣,可以查看 具体实践

转换尺寸单位为 rpx

当实现了对模板文件以及样式文件的处理与更新后,我们可以在此基础之上实现一个锦上添花的功能:将 Tailwind/Windi CSS 主题配置中所有的 rem px 尺寸单位的值在生成最终的样式文件时 自动转换 rpx 单位的值。这样就可以达到与 Web 项目 复用同一套主题配置 的目的,而不是让小程序开发者重写所有的尺寸数值与单位。

这个过程其实很简单,通过借助 PostCSS 对 CSS AST 的操作能力,我们可以写一个简单的插件。

export function transformValue(options: Options) {
    const processed = Symbol('processed')
    return {
        postcssPlugin: 'transformValue',
        Declaration(node) {
            if (!node[ processed ]) {
                // 排除特例:不用处理 CSS url() 值中的字符串
                if (node.value.includes('url')) { return }
                // 通过正则表达式匹配目标值
                const remValues = findValues(node.value, 'rem')
                const pxValues = findValues(node.value, 'px')
                if (remValues?.length) {
                    // 通过计算公式将 rem 值转换为 rpx 值
                    node.value = transformAllValue(node.value, remValues, 'rem')
                if (pxValues?.length) {
                    // 通过计算公式将 px 值转换为 rpx 值
                    node.value = transformAllValue(node.value, pxValues, 'px')
                node[ processed ] = true