重新思考 weapp-tailwindcss 的未来

重新思考 weapp-tailwindcss 的未来

大家好,我是 weapp-tailwindcssweapp-vite 的作者 icebreaker。

最近我一直在思考 weapp-tailwindcss 的未来,以至于都没有怎么玩,最近回归的星际争霸2。

巨大的阻碍

为什么?因为之前有一个很重要的问题,严重阻碍了 weapp-tailwindcss 发展的脚步。

那就是 tailwind-merge / class-variance-authority / tailwind-variants 这些极其重要的原子化样式基础包,没有什么很好的办法在小程序里使用。

为什么无法在小程序里使用?

简短一点来说,核心原因是,小程序 wxml 类名中,不允许很多特殊字符串,比如 ![]# 等字符。

所以 weapp-tailwindcss 根据这个设计,在编译时 ,就对 tailwindcss 类名进行转换,从而达到了兼容市面上众多小程序的编译插件。

比如用户写的是 bg-[#123456],被 weapp-tailwindcss 捕获到了之后,在编译 的时候,就会同时把 wxmljswxss 里面的这个类名转换成小程序可以接受的 bg-_h123456_

tailwind-merge 它们都是在运行时 进行计算的,那时候它们接收到的,已经是 bg-_h123456_ 这种转译之后的字符串,自然合并不了,导致到处出错。

为了兼容,我做了非常多的尝试!给大家展示一下我的受苦之路吧!

1. tailwind-merge plugin / createTailwindMerge

最直观的念头,就是给 tailwind-merge 写一个 weapp-tailwindcss 专用插件就好了!

于是我开始阅读 tailwind-merge 源代码,并尝试使用 extendTailwindMergecreateTailwindMerge 完全创建出一个属于我自己的 weapp-tailwind-merge 来。

在尝试过程中,我把 tailwind-merge 的内部冲突表导出,尝试用自定义 escape hook 覆盖那些非法字符;甚至写了一个半成品的 createTailwindMerge 变体,希望能在编译阶段就生成完全符合小程序命名规则的类名。

然而,现实很快给了我当头棒喝:tailwind-merge运行时字符串的依赖极强,部分字符是强依赖,根本无法替换。

下面这几个字符串都是写在常量里的,无法通过配置更换

ts 复制代码
export const IMPORTANT_MODIFIER = '!' // 小程序不行
const MODIFIER_SEPARATOR = ':' // 小程序不行

详见 github.com/dcastil/tai...

所以这已经不是 extendTailwindMergecreateTailwindMerge 能够解决的问题了。

摆在我面前的,是一条看不到未来的路:为了强行兼容,我需要重写它的核心,fork 一个全新的包,这个成本是巨大的。

2. 编译期豁免

第二条路看起来更务实:沿用我熟悉的编译期管线,给 twMerge / twJoin / cva 等函数做"豁免处理"。

我当时是这样想的,只要在编译时 忽略它们内部的转义,运行时拿到的就是完整的 class 字符串,那 tailwind-merge 不就能工作了吗?

然后我再包装一下 twMerge 函数,让它获取最后的结果的时候 escape 不就行了吗?

大概长这样:

ts 复制代码
export function cn(...inputs: ClassValue[]) {
  const result = twMerge(inputs)
  return escape(result)
}

然后我让 cn 里面的字面量和模板字符串跳过转义不就行了吗?

ts 复制代码
// 第一个是字符串,第二个是模板字符串,它们对应的 ast 类型不同,需要分开处理
// 里面的不转译
cn('bg-[#123456]', `bg-[#987654]`)

// 假如转译那么,结果如下
// cn('bg-_h123456_',`bg-_h987654_`)

看上去运行良好,然而情况正在变得越来越复杂:

嘿,变量引用来了:

ts 复制代码
const a = 'bg-[#123456]'
cn(a, 'xx', 'yy')

嘿嘿,变量引用 + 表达式来了:

ts 复制代码
const a = 'bg-[#123456]' + ' bb' + ` text-[#123456]`
cn(a, 'xx', 'yy')

嘿嘿嘿,变量引用链路 + 表达式 + 模板插值来了:

ts 复制代码
const b = 'after:xx'; const a = 'bg-[#123456]' + ' bb' + `${b} text-[#123456]`
cn(a, 'xx', 'yy')

哈哈,只是在考验我操作 ast 进行预编译的水平而已!

吃我一拳:ASTNodePathWalker + scope.getBinding + WeakMap,哈哈轻松消灭!

于是我以为这条思路可行,编写了 @weapp-tailwindcss/merge 的 v1 版本。

直到用户提交了新的 case!

新的挑战

什么,怎么还有你们这种相互引用的情况!

js 复制代码
// shared2.js
export const ddd = 'bg-[#123456]'

const a = 'bg-[#123456]'

export {
  a as default
}
js 复制代码
// shared.js
export const a = 'bg-[#123456]'

const b = 'bg-[#123456]'

const c = 'bg-[#123456]'

const d = 'bg-[#123456]'

export default d

export {
  b
}

export {
  c as xaxaxaxa,
}

export * from './shared2'
js 复制代码
// main.js
import cc, { b as aa, a as bb } from './shared'
import * as shared from './shared'

cn(bb, cc, aa, shared.default, shared.a, '[]', '()')

......我吐了,这是要我自己去实现一个 webpack / rollup 打包器嘛?有点搞不定啊!

不过困难怕什么,我要迎难而上!于是我仿照了 rollup 的思路,收集了每个模块的 import / export 这里面大量的 ast 节点,并构建出了一个 ModuleGraph

另外表面上看这条路是可行的,我甚至找到了几个 demo 可以跑通,我还把豁免名单抽离出来,变成了 ignoreCallExpressionIdentifiers 配置项,以为自己解决了问题。

然而理想很丰满,现实很骨感

这套方案高度依赖 AST 解析和构建工具的配合,我写的插件无法保证运行时 得到的类名永远完整。构建链路上的任一环节------Terser、esbuild、rollup 插件甚至手写 Babel 宏------都可能把函数名或模板字符串的标识符压缩重命名,导致最后留给运行时的是一个残缺的字符串。

用人话说就是 cn , twMerge, tv 这种方法,在产物里面被重命名成 e/a/c 这种玩意,所以我必须在压缩之前就进行豁免操作,但是那时候我似乎无法去准确收集产物的模块依赖情况(可能是水平不够导致的)。

那一刻我意识到,所谓"编译期豁免"只是在延迟爆炸时间,而不是解除危机。

在两条路都走到尽头之后,只剩下一个选择:彻底重构 merge,让逃逸逻辑回归运行时,让编译阶段恢复简单纯粹。

为什么要重写 merge?

复盘 1.x 旧版 merge,我发现我当时的设计基于两个假设:一是 tailwind-merge 的输入输出始终可控,二是编译器可以精准标记所有"需要放行"的调用。 这两个假设已经被现实击碎。

早期的 @weapp-tailwindcss/merge 主要目标是"把 tailwind-merge 的结果变成小程序合法类名"。我采取的策略是:

  • 继续使用 tailwind-merge 做冲突解析;
  • 在编译阶段通过函数名黑名单 ignoreCallExpressionIdentifiers 跳过对 twMerge / twJoin / cva 等调用的转义;
  • 把责任交给开发者:运行时得到的类名包含非法字符,需要手动再 escape。

这种模式在 Tailwind CSS v3 勉强能用,但一到 v4 就崩溃了:

  1. 编译期豁免并不等于安全 twMerge('text-[#ececec]', 'text-(--my-custom-color)') 最终仍然输出原始字符串。稍微复杂一点的条件拼接、链式调用、动态导入,编译器根本判断不出来该不该跳过。
  2. 函数名黑名单无法覆盖新的 API 新版本开始导出 create()、variants(tv)等工厂,调用形式千奇百怪,编译阶段根本匹配不到。
  3. 任意值语法越来越灵活 Tailwind v4 的任意值可以是 text-[theme(my.scale.foo)] 这种无法静态推断类型的写法。靠黑名单永远落后,反而让用户更困惑。

新版 merge 的核心思路

决定"把锅背回运行时 "以后,我做的第一件事就是把入口全部进行统一:twMerge / twJoin / createTailwindMerge / extendTailwindMerge / cva / variants......统统绑进同一套 transformer 里。

思路很简单:先找出它们共有的"进场"和"退场"动作,再把逃逸拆成前后两个钩子, escapeunescape

ts 复制代码
const transformers = resolveTransformers(options)

const aggregators = {
  escape: transformers.escape,
  unescape: transformers.unescape,
}

在实现里我刻意把 escape 和 unescape 拆成两个"齿轮"。不管是用户直接手点 twMerge,还是 variants 工厂兜一圈回来,都会先进统一的预处理,再丢给 tailwind-merge。

这等于在运行时补了一层"语义编译器"。

双向处理链

所以现在每次 merge 现在都得过一遍 unescape -> tailwind-merge -> escape 这样的流程:

ts 复制代码
const normalized = transformers.unescape(clsx(...inputs))
return transformers.escape(fn(normalized))

但是这样还不够,为了实现 escapeunescape 我还必须从源头上出发,更改 @weapp-core/escape 的转译规则,才能让每一个字符串映射变得独一无二

重写 @weapp-core/escape

老 escape 工具一直挂在 @weapp-core/escape 上,它走的是"多对一"映射,贴一段旧代码大家感受一下:

ts 复制代码
export const MappingChars2String: MappingStringDictionary = {
  '[': '_',
  ']': '_',
  // for tailwindcss v4
  '(': 'y',
  ')': 'y',
  '{': 'z',
  '}': 'z',
  '+': 'a',
  ',': 'b',
  ':': 'c',
  '.': 'd',
  '=': 'e',
  ';': 'f',
  '>': 'g',
  '#': 'h',
  '!': 'i',
  '@': 'j',
  '^': 'k',
  '<': 'l',
  '*': 'm',
  '&': 'n',
  '?': 'o',
  '%': 'p',
  '\'': 'q',
  '$': 'r',
  '/': 's',
  '~': 't',
  '|': 'u',
  '`': 'v',
  '\\': 'w',
  '"': 'x',
}

问题马上就来了:它完全做不到配对 unescape[] 被一起砸成 _( / ){ / } 也全堆在同一个值上,运行时 根本还原不回去。举个让人头疼的例子:escape('[bg:red]') === '__bg_red_'

所以我直接把 @weapp-core/escape 推倒重练,写成一个可逆的"状态机"。每个非法字符都分到独一无二的逃逸片段,还带长度前缀,跑完 unescape(escape(input)) 就一定回到原样。为了防止它在极端输入上翻车,我拉了十几组 property-based 测试,emoji、空格、重复 escape 全安排上写了大量的单元测试,确保往返都符合预期。

下面是当前版本的核心映射表,展示了我如何为每个非法字符分配唯一的 escape 片段,便于和旧版多对一的写法做对比:

ts 复制代码
export const MappingChars2String = {
  '[': '_b',
  ']': '_B',
  '(': '_p',
  ')': '_P',
  '#': '_h',
  '!': '_e',
  '/': '_f',
  '\\': '_r',
  '.': '_d',
  ':': '_c',
  '%': '_v',
  ',': '_m',
  '\'': '_a',
  '"': '_q',
  '*': '_x',
  '&': '_n',
  '@': '_t',
  '{': '_k',
  '}': '_K',
  '+': '_u',
  ';': '_j',
  '<': '_l',
  '~': '_w',
  '=': '_z',
  '>': '_g',
  '?': '_Q',
  '^': '_y',
  '`': '_i',
  '|': '_o',
  '$': '_s',
} as const

文章里我只放这份"简化表",因为它才是运行时默认用的版本,开发者平时看到的也是它。更复杂的兼容映射我留在文档和测试里。

运行时配置

新的 create() 可以随手关掉任意环节,这是和社区聊得最多的诉求。有团队想"开箱默认就好",也有老项目背着一堆历史包袱,得慢慢迁移。所以我直接给了一排明确开关,想保守就保守,想激进就激进。

ts 复制代码
const { twMerge: passthrough } = create({ escape: false, unescape: false })

配合 SSR 或老数据兼容的时候,也不用再额外写工具函数:服务端直接把 escape 全关掉,只做 merge 校验;到小程序再开回完整逃逸步奏,迁移过程就能一步一步踩稳。

另外还开放了 map 字段,用于统一用自己的字符映射。

发布 4.7.x 版本

绕了这么多弯,所有成果最终都塞进了 weapp-tailwindcss@4.7.x@weapp-tailwindcss/merge@2.x 中。算是 weapp-tailwindcss 运行时时代的第一声号角。

欢迎大家把新版 @weapp-tailwindcss/merge 用到真实项目里,更欢迎在社区继续砸想法,我会把这些反馈当作下一轮迭代的燃料,让 Tailwind CSS 在小程序世界里始终"开箱即用"。

有时候我也在想,为小程序这个逐渐感觉不怎么活跃的生态,花了这么多时间,感觉有点不值。但是转念一想,起码在我这个领域我已经通过不断的学习,真的掌握了很多东西。

起码,对 Tailwind CSS 进行符合中国小程序技术特色的改造方面,我也算是第一人了吧。每每想到这,就感觉自己好像还稍微有这么一点点自豪呢,哈哈哈。

如果你也在思考工具链,编译,AST 等等方面的问题,希望这篇文章能给你一点启发。

源代码附录

相关推荐
焦糖小布丁2 小时前
为什么IP地址SSL证书比域名证书更贵?
前端
光影少年2 小时前
WEBNN是什么,对前端工程带来哪些优势
前端·web3·web
djk88882 小时前
极简后台框架
前端·css·css3
LilySesy2 小时前
ABAP+如果在join的时候需要表1的字段某几位等于表2的字段的某几位,需要怎么做?
服务器·前端·数据库·sap·abap·alv
涤生啊3 小时前
一键搭建 Coze 智能体对话页面:支持流式输出 + 图片直显,开发效率拉满!
javascript·html5
吃饺子不吃馅3 小时前
⚡️ Zustand 撤销重做利器:Zundo 实现原理深度解析
前端·javascript·github
幼儿园技术家3 小时前
网站在苹果 Safari 进行适配遇到的问题
前端
IT_陈寒3 小时前
7个鲜为人知的JavaScript性能优化技巧,让你的网页加载速度提升50%
前端·人工智能·后端
不坑老师3 小时前
不坑盒子的插入网页功能是完全免费的!
前端·html