From b2c890b8a7cc33a879a219619d24521be01fb178 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 15 Sep 2026 11:19:45 +0800 Subject: [PATCH 1/2] =?UTF-8?q?perf(runtime):=20=E8=AE=A9=20cn=20=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E8=81=9A=E5=90=88=E5=8C=85=E8=A3=85=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to #1200 --- .changeset/cn-wrap-runtime-cache.md | 6 ++ packages-runtime/cn/src/index.ts | 17 ++--- packages-runtime/cn/test/index.test.ts | 7 ++ .../runtime/src/create-runtime.ts | 27 ++++++-- packages-runtime/runtime/src/index.ts | 2 +- .../runtime/test-d/index.test-d.ts | 7 ++ .../test/wrap-runtime-aggregator.test.ts | 67 +++++++++++++++++++ 7 files changed, 117 insertions(+), 16 deletions(-) create mode 100644 .changeset/cn-wrap-runtime-cache.md create mode 100644 packages-runtime/runtime/test/wrap-runtime-aggregator.test.ts diff --git a/.changeset/cn-wrap-runtime-cache.md b/.changeset/cn-wrap-runtime-cache.md new file mode 100644 index 0000000000..e23c0dfd1d --- /dev/null +++ b/.changeset/cn-wrap-runtime-cache.md @@ -0,0 +1,6 @@ +--- +'@weapp-tailwindcss/runtime': patch +'@weapp-tailwindcss/cn': patch +--- + +导出 `wrapRuntimeAggregator`,让 `@weapp-tailwindcss/cn` 复用有界 LRU 与按需 unescape/escape,避免每次调用都跑合并引擎。 diff --git a/packages-runtime/cn/src/index.ts b/packages-runtime/cn/src/index.ts index 9d7bede684..c7f17ca8b7 100644 --- a/packages-runtime/cn/src/index.ts +++ b/packages-runtime/cn/src/index.ts @@ -1,16 +1,13 @@ import type { ClassValue } from '@weapp-tailwindcss/runtime' -import { clsx, resolveTransformers } from '@weapp-tailwindcss/runtime' -import { cn as mergeClasses } from 'cn' +import { resolveTransformers, wrapRuntimeAggregator } from '@weapp-tailwindcss/runtime' +import { twMerge } from 'cn' const transformers = resolveTransformers() -/** 组合条件类名,并按 Tailwind 冲突规则保留最后一个类名。 */ -export function cn(...inputs: ClassValue[]): string { - const value = clsx(inputs) - if (!value) { - return value - } - return transformers.escape(mergeClasses(transformers.unescape(value))) -} +/** + * 组合条件类名,并按 Tailwind 冲突规则保留最后一个类名。 + * 经 runtime 聚合包装:有界 LRU、按需 unescape/escape。 + */ +export const cn = wrapRuntimeAggregator(twMerge, transformers) export type { ClassValue } diff --git a/packages-runtime/cn/test/index.test.ts b/packages-runtime/cn/test/index.test.ts index c1f22df9f7..564f993177 100644 --- a/packages-runtime/cn/test/index.test.ts +++ b/packages-runtime/cn/test/index.test.ts @@ -20,4 +20,11 @@ describe('cn', () => { it('保留非 Tailwind 类名', () => { expect(cn('custom-card', 'p-4', 'p-2')).toBe('custom-card p-2') }) + + it('同一输入重复调用结果稳定', () => { + const first = cn('p-4', 'p-2', 'hover:p-4') + const second = cn('p-4', 'p-2', 'hover:p-4') + expect(first).toBe(second) + expect(first).toBe(escapeClassName('p-2 hover:p-4')) + }) }) diff --git a/packages-runtime/runtime/src/create-runtime.ts b/packages-runtime/runtime/src/create-runtime.ts index 92519b4c32..62bc50ce17 100644 --- a/packages-runtime/runtime/src/create-runtime.ts +++ b/packages-runtime/runtime/src/create-runtime.ts @@ -6,6 +6,7 @@ import type { TailwindMergeVersion, Transformers, } from './types' +import { MappingChars2String } from '@weapp-core/escape' import { clsx } from 'clsx' import { resolveTransformers } from './transformers' @@ -40,6 +41,7 @@ interface CreateRuntimeFactoryOptions< const CACHE_LIMIT = 256 const UNESCAPE_RE = /u[0-9a-f]{3,}/i +const ESCAPE_NEEDLES = Object.keys(MappingChars2String).filter(Boolean) function hasWhitespace(value: string) { return value.includes(' ') @@ -54,6 +56,15 @@ function shouldUnescape(value: string) { return value.includes('_') || UNESCAPE_RE.test(value) } +function shouldEscape(value: string) { + for (const needle of ESCAPE_NEEDLES) { + if (value.includes(needle)) { + return true + } + } + return false +} + function transformTokens(value: string, transformFn: (token: string) => string): string { if (!value) { return value @@ -64,7 +75,11 @@ function transformTokens(value: string, transformFn: (token: string) => string): return value.split(/\s+/).filter(Boolean).map(transformFn).join(' ') } -function wrapClassAggregator( +/** + * 为任意 class 聚合函数套上 unescape / 可选 prepare-restore / escape 与有界 LRU。 + * 命中缓存时直接返回已转义结果,避免重复跑引擎。 + */ +export function wrapRuntimeAggregator( fn: TailwindMergeLibraryFn, transformers: Transformers, prepareValue?: (value: string) => string | TransformResult, @@ -107,7 +122,9 @@ function wrapClassAggregator( const merged = fn(preparedValue) const restored = restoreValue ? restoreValue(merged, metadata) : merged - const escaped = transformTokens(restored, transformers.escape) + const escaped = shouldEscape(restored) + ? transformTokens(restored, transformers.escape) + : restored if (cache.size >= CACHE_LIMIT) { const firstEntry = cache.keys().next() @@ -129,7 +146,7 @@ function wrapFactory( ): TailwindMergeFactory { return (...args: Parameters) => { const runtime = factory(...args) - return wrapClassAggregator(runtime, transformers, prepareValue, restoreValue) + return wrapRuntimeAggregator(runtime, transformers, prepareValue, restoreValue) } } @@ -152,8 +169,8 @@ export function createRuntimeFactory< return function createRuntime(createOptions?: CreateOptions) { const transformers = resolveTransformers(createOptions) - const twMerge: TailwindMergeRuntime = wrapClassAggregator(twMergeImpl, transformers, prepareValue, restoreValue) - const twJoin: TailwindMergeRuntime = wrapClassAggregator(twJoinImpl, transformers, prepareValue, restoreValue) + const twMerge: TailwindMergeRuntime = wrapRuntimeAggregator(twMergeImpl, transformers, prepareValue, restoreValue) + const twJoin: TailwindMergeRuntime = wrapRuntimeAggregator(twJoinImpl, transformers, prepareValue, restoreValue) const extendTailwindMerge = wrapFactory(extendFactory, transformers, prepareValue, restoreValue) const createTailwindMerge = wrapFactory(createFactory, transformers, prepareValue, restoreValue) diff --git a/packages-runtime/runtime/src/index.ts b/packages-runtime/runtime/src/index.ts index 99cff0bc7b..503247c28c 100644 --- a/packages-runtime/runtime/src/index.ts +++ b/packages-runtime/runtime/src/index.ts @@ -4,7 +4,7 @@ const weappTwIgnoreImpl = String.raw as typeof String.raw export { clsxFn as clsx } export const weappTwIgnore = weappTwIgnoreImpl -export { createRuntimeFactory } from './create-runtime' +export { createRuntimeFactory, wrapRuntimeAggregator } from './create-runtime' export { createRpxLengthTransform } from './rpx-length' export { identity, resolveTransformers } from './transformers' diff --git a/packages-runtime/runtime/test-d/index.test-d.ts b/packages-runtime/runtime/test-d/index.test-d.ts index 90b93a15e4..afcb9fd546 100644 --- a/packages-runtime/runtime/test-d/index.test-d.ts +++ b/packages-runtime/runtime/test-d/index.test-d.ts @@ -10,6 +10,7 @@ import { identity, resolveTransformers, weappTwIgnore, + wrapRuntimeAggregator, } from '..' expectType(clsx('text-sm', false)) @@ -39,3 +40,9 @@ expectType(runtime.twMerge('text-sm', false as ClassValue)) expectType(runtime.twJoin('text-sm', 'text-lg')) expectType(runtime.extendTailwindMerge()('text-sm')) expectType(runtime.createTailwindMerge()('text-sm')) + +const wrapped = wrapRuntimeAggregator( + (value: string) => value, + resolveTransformers(), +) +expectType(wrapped('text-sm', false as ClassValue)) diff --git a/packages-runtime/runtime/test/wrap-runtime-aggregator.test.ts b/packages-runtime/runtime/test/wrap-runtime-aggregator.test.ts new file mode 100644 index 0000000000..dbbb840177 --- /dev/null +++ b/packages-runtime/runtime/test/wrap-runtime-aggregator.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import { wrapRuntimeAggregator } from '@/create-runtime' + +describe('wrapRuntimeAggregator', () => { + it('空输入直接返回空串且不进引擎', () => { + const inner = vi.fn((value: string) => value) + const escape = vi.fn((value: string) => value) + const unescape = vi.fn((value: string) => value) + const run = wrapRuntimeAggregator(inner, { escape, unescape }) + + expect(run('')).toBe('') + expect(run(false, undefined, null)).toBe('') + expect(inner).not.toHaveBeenCalled() + }) + + it('同一输入第二次命中缓存,不再调用引擎', () => { + const inner = vi.fn((value: string) => value) + const run = wrapRuntimeAggregator(inner, { + escape: value => value, + unescape: value => value, + }) + + expect(run('p-4', 'p-2')).toBe('p-4 p-2') + expect(run('p-4', 'p-2')).toBe('p-4 p-2') + expect(inner).toHaveBeenCalledTimes(1) + expect(inner).toHaveBeenCalledWith('p-4 p-2') + }) + + it('无特殊字符时跳过 escape', () => { + const escape = vi.fn((value: string) => `esc:${value}`) + const run = wrapRuntimeAggregator(value => value, { + escape, + unescape: value => value, + }) + + expect(run('flex items-center')).toBe('flex items-center') + expect(escape).not.toHaveBeenCalled() + }) + + it('含 modifier 或任意值时仍 escape', () => { + const escape = vi.fn((value: string) => `esc:${value}`) + const run = wrapRuntimeAggregator(value => value, { + escape, + unescape: value => value, + }) + + expect(run('hover:p-2')).toBe('esc:hover:p-2') + expect(escape).toHaveBeenCalledWith('hover:p-2') + expect(run('w-[10rpx]')).toBe('esc:w-[10rpx]') + }) + + it('已转义输入才会 unescape', () => { + const unescape = vi.fn((value: string) => value.replaceAll('_c', ':')) + const inner = vi.fn((value: string) => value) + const run = wrapRuntimeAggregator(inner, { + escape: value => value, + unescape, + }) + + run('flex') + expect(unescape).not.toHaveBeenCalled() + + run('hover_cp-2') + expect(unescape).toHaveBeenCalledWith('hover_cp-2') + expect(inner).toHaveBeenCalledWith('hover:p-2') + }) +}) From 18424bbd73f56e773b262c069f8ef88b71d422b3 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 15 Sep 2026 11:25:34 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(cn):=20=E6=8E=A5=E5=85=A5=20rpx=20?= =?UTF-8?q?=E9=95=BF=E5=BA=A6=E5=BD=92=E4=B8=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to #1200 --- .changeset/cn-wrap-runtime-cache.md | 2 +- packages-runtime/cn/AGENTS.md | 2 ++ packages-runtime/cn/README.md | 2 +- packages-runtime/cn/README.zh-CN.md | 2 +- packages-runtime/cn/src/index.ts | 16 +++++++++++++--- packages-runtime/cn/test/index.test.ts | 17 +++++++++++++++++ 6 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.changeset/cn-wrap-runtime-cache.md b/.changeset/cn-wrap-runtime-cache.md index e23c0dfd1d..9b144f578c 100644 --- a/.changeset/cn-wrap-runtime-cache.md +++ b/.changeset/cn-wrap-runtime-cache.md @@ -3,4 +3,4 @@ '@weapp-tailwindcss/cn': patch --- -导出 `wrapRuntimeAggregator`,让 `@weapp-tailwindcss/cn` 复用有界 LRU 与按需 unescape/escape,避免每次调用都跑合并引擎。 +导出 `wrapRuntimeAggregator`,让 `@weapp-tailwindcss/cn` 复用有界 LRU 与按需 unescape/escape,并接入与 merge 相同的 rpx 长度归一化,使 `text-red` 与 `text-[80rpx]` 等颜色 + 长度类可以共存。 diff --git a/packages-runtime/cn/AGENTS.md b/packages-runtime/cn/AGENTS.md index f39fda3acd..eab2f4d6f4 100644 --- a/packages-runtime/cn/AGENTS.md +++ b/packages-runtime/cn/AGENTS.md @@ -2,4 +2,6 @@ 该包提供 shadcn 风格的 `cn` 类名组合函数,必须保持 `clsx` 输入语义与小程序兼容的 Tailwind 合并行为。 +默认经 `wrapRuntimeAggregator` 接入与 `@weapp-tailwindcss/merge` 相同的 `createRpxLengthTransform`(`text` / `border` / `bg` / `outline` / `ring`),使 `text-red` 与 `text-[80rpx]` 这类颜色 + rpx 长度可以共存。 + 变更类名组合或 rpx 处理时补充 Vitest 回归测试,并运行 `pnpm --filter @weapp-tailwindcss/cn test`、`tsd` 和 `build`。 diff --git a/packages-runtime/cn/README.md b/packages-runtime/cn/README.md index a96d1e1b87..8141175aee 100644 --- a/packages-runtime/cn/README.md +++ b/packages-runtime/cn/README.md @@ -2,7 +2,7 @@ > English | [简体中文](./README.zh-CN.md) -基于 npm `cn` 引擎的面向小程序 Tailwind CSS 类名组合工具,兼容条件类名、嵌套数组、Tailwind 冲突合并和 `rpx` 任意值。 +基于 npm `cn` 引擎的面向小程序 Tailwind CSS 类名组合工具,兼容条件类名、嵌套数组、Tailwind 冲突合并和 `rpx` 任意值。`text` / `border` / `bg` / `outline` / `ring` 的 rpx 长度会与颜色类共存,例如 `cn('text-red', 'text-[80rpx]')`。 ```ts import { cn } from '@weapp-tailwindcss/cn' diff --git a/packages-runtime/cn/README.zh-CN.md b/packages-runtime/cn/README.zh-CN.md index 9b7dcc613a..daf043834e 100644 --- a/packages-runtime/cn/README.zh-CN.md +++ b/packages-runtime/cn/README.zh-CN.md @@ -2,7 +2,7 @@ > [English](./README.md) | 简体中文 -基于 npm `cn` 引擎的面向小程序 Tailwind CSS 类名组合工具,兼容条件类名、嵌套数组、Tailwind 冲突合并和 `rpx` 任意值。 +基于 npm `cn` 引擎的面向小程序 Tailwind CSS 类名组合工具,兼容条件类名、嵌套数组、Tailwind 冲突合并和 `rpx` 任意值。`text` / `border` / `bg` / `outline` / `ring` 的 rpx 长度会与颜色类共存,例如 `cn('text-red', 'text-[80rpx]')`。 ```ts import { cn } from '@weapp-tailwindcss/cn' diff --git a/packages-runtime/cn/src/index.ts b/packages-runtime/cn/src/index.ts index c7f17ca8b7..3225c1f18f 100644 --- a/packages-runtime/cn/src/index.ts +++ b/packages-runtime/cn/src/index.ts @@ -1,13 +1,23 @@ import type { ClassValue } from '@weapp-tailwindcss/runtime' -import { resolveTransformers, wrapRuntimeAggregator } from '@weapp-tailwindcss/runtime' +import { + createRpxLengthTransform, + resolveTransformers, + wrapRuntimeAggregator, +} from '@weapp-tailwindcss/runtime' import { twMerge } from 'cn' const transformers = resolveTransformers() +const rpxTransform = createRpxLengthTransform() /** * 组合条件类名,并按 Tailwind 冲突规则保留最后一个类名。 - * 经 runtime 聚合包装:有界 LRU、按需 unescape/escape。 + * 经 runtime 聚合包装:有界 LRU、rpx 长度归一化、按需 unescape/escape。 */ -export const cn = wrapRuntimeAggregator(twMerge, transformers) +export const cn = wrapRuntimeAggregator( + twMerge, + transformers, + rpxTransform.prepareValue, + rpxTransform.restoreValue, +) export type { ClassValue } diff --git a/packages-runtime/cn/test/index.test.ts b/packages-runtime/cn/test/index.test.ts index 564f993177..664ca8b640 100644 --- a/packages-runtime/cn/test/index.test.ts +++ b/packages-runtime/cn/test/index.test.ts @@ -17,6 +17,23 @@ describe('cn', () => { expect(cn('hover:p-2', 'hover:p-4')).toBe(escapeClassName('hover:p-4')) }) + it('把 text/border/bg/outline/ring 的 rpx 任意值当作长度,不与颜色互斥', () => { + expect(cn('text-red', 'text-[80rpx]')).toBe(escapeClassName('text-red text-[80rpx]')) + expect(cn('border-red-500', 'border-[10rpx]')).toBe(escapeClassName('border-red-500 border-[10rpx]')) + expect(cn('bg-red-500', 'bg-[6rpx]')).toBe(escapeClassName('bg-red-500 bg-[6rpx]')) + expect(cn('outline-red-500', 'outline-[4rpx]')).toBe(escapeClassName('outline-red-500 outline-[4rpx]')) + expect(cn('ring-red-500', 'ring-[12rpx]')).toBe(escapeClassName('ring-red-500 ring-[12rpx]')) + }) + + it('同一前缀多次 rpx 只保留最后一次长度,颜色仍在', () => { + expect(cn('text-red', 'text-[20rpx]', 'text-[5rpx]', 'text-[12rpx]')).toBe( + escapeClassName('text-red text-[12rpx]'), + ) + expect(cn('hover:text-[24rpx]', 'hover:text-[12rpx]')).toBe( + escapeClassName('hover:text-[12rpx]'), + ) + }) + it('保留非 Tailwind 类名', () => { expect(cn('custom-card', 'p-4', 'p-2')).toBe('custom-card p-2') })