
大家好,我是 weapp-tailwindcss、weapp-vite 的作者 icebreaker。
最近我一直在思考 weapp-tailwindcss 的未來,以至於都沒有怎么玩,最近回归的星際爭霸2。
為什麼?因為之前有一個很重要的問題,嚴重阻礙了 weapp-tailwindcss 發展的步伐。
那就是 tailwind-merge / class-variance-authority / tailwind-variants 這些極其重要的原子化樣式基礎包,沒有什麼很好的辦法在小程序裡使用。
簡短來說,核心原因是,小程序 wxml 類名中,不允許很多特殊字符串,比如 !、[、]、# 等字符。
所以 weapp-tailwindcss 根據這個設計,在編譯時,就對 tailwindcss 類名進行轉換,從而達到了兼容市面上眾多小程序的編譯插件。
比如用戶寫的是 bg-[#123456],被 weapp-tailwindcss 捕獲到了之後,在編譯的時候,就會同時把 wxml、js、wxss 裡面的這個類名轉換成小程序可以接受的 bg-_h123456_。
而 tailwind-merge 它們都是在運行時進行計算的,那時候它們接收到的,已經是 bg-_h123456_ 這種轉譯之後的字符串,自然合併不了,導致到處出錯。
為了兼容,我做了非常多的嘗試!給大家展示一下我的受苦之路吧!
最直觀的念頭,就是給 tailwind-merge 寫一個 weapp-tailwindcss 專用插件就好了!
於是我開始閱讀 tailwind-merge 源代碼,並嘗試使用 extendTailwindMerge 和 createTailwindMerge 完全創建出一個屬於我自己的 weapp-tailwind-merge 來。
在嘗試過程中,我把 tailwind-merge 的內部沖突表導出,嘗試用自定義 escape hook 覆蓋那些非法字符;甚至寫了一個半成品的 createTailwindMerge 變體,希望能在編譯階段就生成完全符合小程序命名規則的類名。
然而,現實很快給了我當頭棒喝:tailwind-merge 對運行時字符串的依賴極強,部分字符是強依賴,根本無法替換。
下面這幾個字符串都是寫在常量裡的,無法通過配置更換
export const IMPORTANT_MODIFIER = '!' // 小程序不行
const MODIFIER_SEPARATOR = ':' // 小程序不行
所以這已經不是 extendTailwindMerge 和 createTailwindMerge 能夠解決的問題了。
擺在我面前的,是一條看不到未來的路:為了強行兼容,我需要重寫它的核心,fork 一個全新的包,這個成本是巨大的。
第二條路看起來更務實:沿用我熟悉的編譯期管線,給 twMerge / twJoin / cva 等函數做“豁免處理”。
我當時是這樣想的,只要在編譯時忽略它們內部的轉義,運行時拿到的就是完整的 class 字符串,那 tailwind-merge 不就能工作了嗎?
然後我再包裝一下 twMerge 函數,讓它獲取最後的結果時候 escape 不就行了嗎?
大概長這樣:
export function cn(...inputs: ClassValue[]) {
const result = twMerge(inputs)
return escape(result)
}
然後我讓 cn 裡面的字面量和模板字符串跳過轉義不就行了嗎?
// 第一个是字符串,第二个是模板字符串,它们对应的 ast 类型不同,需要分开处理
// 里面的不转译
cn('bg-[#123456]', `bg-[#987654]`)
// 假如转译那么,结果如下
// cn('bg-_h123456_',`bg-_h987654_`)
看上去運行良好,然而情況正在變得越來越複雜:
嘿,變量引用來了:
const a = 'bg-[#123456]'
cn(a, 'xx', 'yy')
嘿嘿,變量引用 + 表達式來了:
const a = 'bg-[#123456]' + ' bb' + ` text-[#123456]`
cn(a, 'xx', 'yy')
嘿嘿嘿,變量引用鏈路 + 表達式 + 模板插值來了:
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!
什麼,怎麼還有你們這種相互引用的情況!
// shared2.js
export const ddd = 'bg-[#123456]'
const a = 'bg-[#123456]'
export {
a as default
}
// 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'
// 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,讓逃逸邏輯回歸運行時,讓編譯階段恢復簡單純粹。
復盤 1.x 舊版 merge,我發現我當時的設計基於兩個假設:一是 tailwind-merge 的輸入輸出始終可控,二是編譯器可以精准標記所有“需要放行”的調用。
這兩個假設已經被現實擊碎。
早期的 @weapp-tailwindcss/merge 主要目標是“把 tailwind-merge 的結果變成小程序合法類名”。我採取的策略是:
tailwind-merge 做衝突解析;ignoreCallExpressionIdentifiers 跳過對 twMerge / twJoin / cva 等調用的轉義;這種模式在 Tailwind CSS v3 勉強能用,但一到 v4 就崩潰了:
twMerge('text-[#ececec]', 'text-(--my-custom-color)') 最終仍然輸出原始字符串。稍微複雜一點的條件拼接、鏈式調用、動態導入,編譯器根本判斷不出該不該跳過。create()、variants(tv)等工廠,調用形式千奇百怪,編譯階段根本匹配不到。text-[theme(my.scale.foo)] 這種無法靜態推斷類型的寫法。靠黑名單永遠落後,反而讓用戶更困惑。決定“把鍋背回運行時”以後,我做的第一件事就是把入口全部進行統一:twMerge / twJoin / createTailwindMerge / extendTailwindMerge / cva / variants……統統綁進同一套 transformer 裡。
思路很簡單:先找出它們共有的“進場”和“退場”動作,再把逃逸拆成前後兩個鉤子, escape 和 unescape。
const transformers = resolveTransformers(options)
const aggregators = {
escape: transformers.escape,
unescape: transformers.unescape,
}
在實現裡我刻意把 escape 和 unescape 拆成兩個“齒輪”。不管是用戶直接手點 twMerge,還是 variants 工廠兜一圈回來,都会先進統一的預處理,再丟給 tailwind-merge。
這等於在運行時補了一層“語義編譯器”。
所以現在每次 merge 現在都得過一遍 unescape -> tailwind-merge -> escape 這樣的流程:
const normalized = transformers.unescape(clsx(...inputs))
return transformers.escape(fn(normalized))
但是這樣還不夠,為了實現 escape 和 unescape 我還必須從源頭上出發,更改 @weapp-core/escape 的轉譯規則,才能讓每一個字符串映射變得獨一無二
老 escape 工具一直掛在 @weapp-core/escape 上,它走的是“多對一”映射,貼一段舊代碼大家感受一下:
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]') === '__bgred'。
所以我直接把 @weapp-core/escape 推倒重練,寫成一個可逆的“狀態機”。每個非法字符都分到獨一無二的逃逸片段,還帶長度前綴,跑完 unescape(escape(input)) 就一定回到原樣。為了防止它在極端輸入上翻車,我拉了十幾組 property-based 測試,emoji、空格、重複 escape 全安排上寫了大量的單元測試,確保往返都符合預期。
下面是當前版本的核心映射表,展示了我如何為每個非法字符分配唯一的 escape 片段,便於和舊版多對一的寫法做對比:
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() 可以隨手關掉任意環節,這是和社區聊得最多的訴求。有團隊想“開箱默認就好”,也有老項目背著一堆歷史包袱,得慢慢遷移。所以我直接給了一排明確開關,想保守就保守,想激進就激進。
const { twMerge: passthrough } = create({ escape: false, unescape: false })
配合 SSR 或老數據兼容的時候,也不用再額外寫工具函數:服務端直接把 escape 全關掉,只做 merge 校驗;到小程序再開回完整逃逸步奏,遷移過程就能一步一步踩穩。
另外還開放了 map 字段,用於統一用自己的字符映射。
繞了這麼多彎,所有成果最終都塞進了 <a href="/cdn-cgi/l/email-protection">[email protected]</a> 和 @weapp-tailwindcss/<a href="/cdn-cgi/l/email-protection">[email protected]</a> 中。算是 weapp-tailwindcss 運行時時代的第一聲號角。
歡迎大家把新版 @weapp-tailwindcss/merge 用到真實項目裡,更歡迎在社區繼續砸想法,我會把這些反饋當作下一輪迭代的燃料,讓 Tailwind CSS 在小程序世界裡始終“開箱即用”。
有時候我也在想,為小程序這個逐漸感覺不怎麼活躍的生態,花了這麼多時間,感覺有點不值。但是轉念一想,起碼在我這個領域我已經通過不斷的學習,真的掌握了很多東西。
起碼,對 Tailwind CSS 進行符合中國小程序技術特色的改造方面,我也算是第一人了吧。每每想到這,就感覺自己好像還稍微有這麼一點點自豪呢,哈哈哈。
如果你也在思考工具鏈,編譯,AST 等等方面的問題,希望這篇文章能給你一點啟發。