|
| 1 | +--- |
| 2 | +title: 4.7.0:运行时重构的 weapp-tailwindcss/merge |
| 3 | +date: 2025-11-02 |
| 4 | +authors: sonofmagic |
| 5 | +description: 从编译期黑名单到运行时自管理,weapp-tailwindcss/merge 的重构思路与未来规划。 |
| 6 | +tags: |
| 7 | + - merge |
| 8 | + - runtime |
| 9 | + - tailwindcss |
| 10 | +--- |
| 11 | + |
| 12 | +这篇文章算是给 `weapp-tailwindcss` 的未来定调:我们终于和旧版 `@weapp-tailwindcss/merge` 告别,把全部逃逸逻辑搬到了运行时。新的实现不仅兼容 Tailwind CSS v4,也顺手解决了“编译期黑名单 + 手动逃逸”的历史包袱。 |
| 13 | + |
| 14 | + |
| 15 | +## 为什么要重写 merge? |
| 16 | + |
| 17 | +早期的 `@weapp-tailwindcss/merge` 主要目标是“把 tailwind-merge 的结果变成小程序合法类名”。我们采取的策略是: |
| 18 | + |
| 19 | +- 继续使用 `tailwind-merge` 做冲突解析; |
| 20 | +- 在编译阶段通过 `ignoreCallExpressionIdentifiers` 跳过对 `twMerge` / `twJoin` / `cva` 等调用的转义; |
| 21 | +- 把责任交给开发者:运行时得到的类名包含非法字符,需要手动再 escape。 |
| 22 | + |
| 23 | +这种模式在 Tailwind CSS v3 勉强能用,但一到 v4 就崩溃了: |
| 24 | + |
| 25 | +1. **编译期豁免并不等于安全** |
| 26 | + `twMerge('text-[#ececec]', 'text-[#654321]')` 最终仍然输出原始字符串。稍微复杂一点的条件拼接、链式调用、动态导入,编译器根本判断不出来该不该跳过。 |
| 27 | +2. **函数名黑名单无法覆盖新的 API** |
| 28 | + 新版本开始导出 `create()`、variants(`tv`)等工厂,调用形式千奇百怪,编译阶段根本匹配不到。 |
| 29 | +3. **任意值语法越来越灵活** |
| 30 | + Tailwind v4 的任意值可以是 `text-[theme(my.scale.foo)]` 这种无法静态推断类型的写法。靠黑名单永远落后,反而让用户更困惑。 |
| 31 | + |
| 32 | +这些问题其实在 Tailwind CSS v3 时代就已经存在,只是 v4 的任意值和语法开放程度把它们放大到了“不可忽视”的级别。结论很直接:与其不断弥补编译期的漏洞,不如把逃逸控制权彻底收回运行时。 |
| 33 | + |
| 34 | +## 新版 merge 的核心思路 |
| 35 | + |
| 36 | +这次重构把所有入口(`twMerge` / `twJoin` / `createTailwindMerge` / `extendTailwindMerge` / `cva` / `variants`)全部挂到同一套 transformer 上: |
| 37 | + |
| 38 | +```ts |
| 39 | +const transformers = resolveTransformers(options) |
| 40 | + |
| 41 | +const aggregators = { |
| 42 | + escape: transformers.escape, |
| 43 | + unescape: transformers.unescape, |
| 44 | +} |
| 45 | +``` |
| 46 | + |
| 47 | +### 双向处理链 |
| 48 | + |
| 49 | +每一次 merge 都会经历 `unescape -> tailwind-merge -> escape` 三段式: |
| 50 | + |
| 51 | +```ts |
| 52 | +const normalized = transformers.unescape(clsx(...inputs)) |
| 53 | +return transformers.escape(fn(normalized)) |
| 54 | +``` |
| 55 | + |
| 56 | +这样即使调用链前端已经做过一次 escape,我们也能先还原再处理,避免输出重复的 `_b` / `_c` 前缀。 |
| 57 | + |
| 58 | +### 运行时配置 |
| 59 | + |
| 60 | +新的 `create()` 支持关闭任意环节: |
| 61 | + |
| 62 | +```ts |
| 63 | +const { twMerge: passthrough } = create({ escape: false, unescape: false }) |
| 64 | +``` |
| 65 | + |
| 66 | +配合 SSR、老数据兼容场景时就不用再写额外工具函数了。 |
| 67 | + |
| 68 | +同时我们开放 `map` 字段,让团队可以统一使用自定义字符映射表,满足“类名要和现有规范保持一致”的需求。 |
| 69 | + |
| 70 | +### 全家桶一致 |
| 71 | + |
| 72 | +`@weapp-tailwindcss/merge/variants` 是这次同步升级的亮点:我们直接复用了 tailwind-variants 的工厂,把自定义 `cn` 换成套上 escape/unescape 的版本。这样构建复杂组件状态时,合并结果天然符合小程序规范。 |
| 73 | + |
| 74 | +## 为什么不再依赖 ignoreCallExpressionIdentifiers? |
| 75 | + |
| 76 | +`ignoreCallExpressionIdentifiers` 从诞生那天起就只是“不得已”的补丁。它最多做到下面几件事: |
| 77 | + |
| 78 | +- 标记“这个函数返回的类名先别动”,却无法保证运行时会再逃逸; |
| 79 | +- 无法识别链式调用、解构赋值、动态导入等写法; |
| 80 | +- **压缩阶段会把黑名单完全破坏**:一旦交给 `terser` / `esbuild` / `rolldown` 之类的压缩器,函数名会被改写成 `e()`、`r()`,AST 名称自然对不上。除非逐个工具配置 `mangle.reserved`(告诉它们不要改 `twMerge` 这些名字),否则黑名单形同虚设,维护成本和副作用非常大。 |
| 81 | +- 需要和插件边际合作(例如 Babel、SWC、JITI 各有一套实现,维护成本巨大)。 |
| 82 | + |
| 83 | +在我们开始支持 variants、运行时工厂和更开放的 Tailwind v4 语法后,这个黑名单变得意义不大,甚至会误导开发者: |
| 84 | + |
| 85 | +> “为什么编译时给我放行了,但页面还是报 `invalid selector`?” |
| 86 | +
|
| 87 | +升级到运行时方案之后,上面的问题就迎刃而解:因为最终的 escape 都由 merge 内部做,我们可以保证**任何入口调用**都只会输出合法类名。编译阶段完全不需要额外逻辑。 |
| 88 | + |
| 89 | +## 4.7.0 版本的实际成果 |
| 90 | + |
| 91 | +- `resolveTransformers` 统一管理 escape/unescape,支持 toggle、自定义 mapping; |
| 92 | +- `twMerge` / `twJoin` / `createTailwindMerge` / `extendTailwindMerge` / `cva` / `variants` 共享同一套转换; |
| 93 | +- 覆盖 tailwind-merge 功能列表的单测,同时新增大量小程序专属场景(`rpx`、任意变体、重要修饰符等); |
| 94 | +- `apps/vite-native-ts` 提供全景 Demo:页面上实时展示不同版本 merge 行为、运行时选项、CVA/variants 输出,方便开发者对照理解; |
| 95 | +- 新增中文 Changeset,宣布这是一次 breaking change。 |
| 96 | + |
| 97 | +## 展望 |
| 98 | + |
| 99 | +把逃逸逻辑收回运行时后,我们已经具备以下能力: |
| 100 | + |
| 101 | +- **跨端一致**:无论在 weapp-vite、uni-app 还是其他自定义构建中,只要用 runtime 工厂就能保证输出安全; |
| 102 | +- **更丰富的 DSL**:variants 的案例证明我们可以继续封装更高级的组合 API,而不用担心“最终类名不符合规则”; |
| 103 | +- **插件负担更小**:Babel/SWC 只负责常规替换任务,再也不用维护黑名单补丁; |
| 104 | +- **面向未来的扩展**:下一步我们计划扩展 mapping 插件化,支持按平台、按团队切换 escape 字典。 |
| 105 | + |
| 106 | +4.7.0 是 weapp-tailwindcss “运行时时代” 的起点。欢迎大家在实际项目里试用新版 `@weapp-tailwindcss/merge`,也欢迎在社区继续提出想法,我们会持续让 Tailwind CSS 在小程序生态里保持“开箱即用”的体验。 |
0 commit comments