Я скоротив функцію об'єднання CSS-класів із шести рядків до одного. У локальному бенчмарку в Node час повторюваних викликів зменшився з 234 до 11 нс. Але коли я передавав нову ширину на кожному виклику, нова функція працювала повільніше.
Під час оновлення залежностей exsesx.dev я спробував
новий пакет cn від shadcn (opens in a new tab) (відкриється в новій вкладці).
Він замінює звичну комбінацію clsx і tailwind-merge. Автори заявляють про
прискорення у 30 разів для повторюваних викликів, характерних для компонентів.
Я вирішив перевірити, як зміниться результат з іншими аргументами.
Звична функція cn
Мої компоненти вже імпортували cn зі спільного файлу утиліт. Функція об'єднувала
класи за заданими умовами через clsx, а потім передавала рядок у
tailwind-merge, щоб прибрати конфлікти:
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}Заміна виглядає так:
export { cn } from "cn";Імпорти й виклики в компонентах змінювати не довелося. Наприклад, передані ззовні класи перевизначають стандартні відступи компонента. У результаті залишається лише потрібний клас:
cn("rounded-md p-2 text-red-500", "p-4 text-blue-500 custom-class");
// "rounded-md p-4 text-blue-500 custom-class"Я зафіксував версію 0.2.5 і перевірив сценарії, які потрібні на моєму сайті:
умовне додавання класів, їх перевизначення та конфлікти модифікаторів на кшталт
hover: і data-[state=open]:. Тести пройшли. Усі можливі конфігурації Tailwind
вони, звісно, не охоплюють.
Як cn уникає повторної роботи
Пакет постачається зі скомпільованими таблицями конфліктів. Під час роботи він
також кешує цілі рядки, окремі класи та повторювані послідовності аргументів.
Виклик на кшталт cn(base, variant, extra) може повернути збережений результат,
не повторюючи об'єднання аргументів і розв'язання конфліктів.
Це видно в коді рушія (opens in a new tab) (відкриється в новій вкладці).
У tailwind-merge кеш теж є, але стара функція спочатку викликає clsx, щоб
отримати рядок для пошуку в кеші. cn може перевірити повторювані рядкові
аргументи ще до об'єднання й пропустити також цей крок.
Заявлене прискорення у 30 разів стосується повторюваних викликів із незмінними рядковими аргументами. Інші рядки опублікованого порівняння (opens in a new tab) (відкриється в новій вкладці) описують інші навантаження. Щоб перевірити вплив аргументів, я написав невеликий бенчмарк.
Я змінив вхідні дані
Я порівняв стару функцію з cn у трьох невеликих сценаріях. Перший повторює ті
самі рядки. Другий щоразу створює новий об'єкт з умовними класами й циклічно
вибирає один із 16 рядків зі стилями. Третій на кожному виклику генерує нову ширину, наприклад
w-[30001px].
Для кожної бібліотеки й сценарію я запустив п'ять окремих дочірніх процесів, чергуючи порядок бібліотек. Кожен процес виконував 30 000 викликів для прогріву, а потім вимірював 300 000 викликів. У таблиці наведена медіана. Перед вимірюванням я перевірив, чи однакові результати для 1 000 наборів аргументів у кожному сценарії. Також порівняв суми довжин вихідних рядків у всіх запусках, включно з прогрівом.
| Середовище | Вхідні дані | Стара функція | cn |
|---|---|---|---|
| Node 24.20.0 | Повторювані рядки | 234 нс | 11 нс |
| Node 24.20.0 | Нові об'єкти з умовними класами | 288 нс | 139 нс |
| Node 24.20.0 | Унікальні довільні значення ширини | 3 754 нс | 5 207 нс |
| Bun 1.4.1 | Повторювані рядки | 145 нс | 16 нс |
| Bun 1.4.1 | Нові об'єкти з умовними класами | 157 нс | 134 нс |
| Bun 1.4.1 | Унікальні довільні значення ширини | 2 462 нс | 2 924 нс |
За неокругленими значеннями повторювані рядки оброблялися приблизно у 21 раз швидше в Node і в 9 разів у Bun. З новими об'єктами виграш зменшився приблизно до 2,1x та 1,2x. Сценарій з унікальною шириною зайняв приблизно на 39% більше часу в Node і на 19% більше в Bun. Під час окремої повторної перевірки в Node нова функція теж була повільнішою.
Я повторив усі шість тестів: у кожному швидшою залишилася та сама реалізація. Окремо збільшив кількість викликів для повторюваних рядків до 300 000 для прогріву та 3 мільйонів для вимірювання в кожному процесі. Медіани становили 251 проти 5,5 нс у Node та 125 проти 7 нс у Bun. Тривалість прогріву й самого тесту змінила співвідношення. У таблиці залишив початкові результати, а до файлу для завантаження додав усі повторні перевірки.
Сценарій з унікальною шириною навмисно створює тривале синтетичне навантаження.
Сторінка портфоліо зазвичай не створює 300 000 різних ширин. Цей тест показує, наскільки
змінюється результат, коли вхідні дані перестають повторюватися. З цього не
випливає, що cn загалом уповільнює серверний рендеринг.
У сценарії з повторюваними рядками в Node різниця становить приблизно 223 наносекунди на виклик. Навіть 1 000 таких викликів заощадили б лише близько 0,22 мілісекунди за тієї самої швидкості. Це розрахунок за результатами одного циклу. Я не вимірював, чи швидше завантажуються сторінки та чи швидше сайт реагує на дії користувача.
Швидший код може важити більше після стиснення
За вимірюваннями авторів, стандартний cn займає приблизно 10,5 КБ після
мініфікації та gzip, тоді як clsx і tailwind-merge разом займають 8,6 КБ.
До стиснення cn трохи менший. Це цифри авторів бібліотеки, а не розміри
JavaScript-файлів у збірці мого сайту на Next.js.
Пояснення розміру (opens in a new tab) (відкриється в новій вкладці)
описує причину різниці.
Під час оновлення я змінив кілька залежностей одночасно, тому порівняння цих двох
збірок сайту не показало б окремо вплив cn. Зменшення розміру бандла я до переваг
цієї міграції не зараховую.
Є необов'язкова команда cn build, яка сканує проєкт і створює менші таблиці.
Звичайний імпорт уже містить стандартні скомпільовані таблиці й не потребує цього
кроку. Але сканер не завжди бачить класи, зібрані з частин рядка. Їх потрібно
явно додати до списку safelist. Інакше класи з груп, які сканер не виявив,
можуть залишитися в результаті разом, навіть якщо конфліктують.
Інструкція зі збірки (opens in a new tab) (відкриється в новій вкладці)
описує ці обмеження. Я залишив стандартний імпорт без додаткового кроку збірки.
Чому я залишив cn
Я залишив cn, бо компоненти не потребують змін, а перевірки об'єднання класів
пройшли. Бенчмарки з повторюваними рядками теж свідчать на користь заміни для
таких викликів. Якщо знадобиться повернути стару реалізацію, достатньо буде
змінити той самий файл утиліт.
Перш ніж замінювати функцію в іншому застосунку, я б перевірив, які аргументи
передають його компоненти.
Частина моїх компонентів передає кілька рядків, інші передають уже сформований
рядок зі стилями варіанта. Один коефіцієнт із бенчмарка не описує обидва випадки. Ця версія
розрахована на Tailwind 4, а інтеграції з experimentalParseClassName потребують
окремих змін.
Примітки щодо сумісності (opens in a new tab) (відкриється в новій вкладці)
містять перелік підтримуваних API та винятків.
Як повторити порівняння
Завантажте скрипт бенчмарка і повні результати. Збережіть скрипт в окремому тестовому проєкті з цими точними версіями залежностей і запустіть у потрібному середовищі:
bun add --exact cn@0.2.5 clsx@2.1.1 tailwind-merge@3.6.0
node cn-benchmark-2026-09-04.mjs
bun cn-benchmark-2026-09-04.mjsЗапускайте Node і Bun по черзі. Скрипт перевіряє версії пакетів і виводить окремі вимірювання у JSON. На короткі сценарії можуть впливати таймер, JIT і навантаження на комп'ютер. Цифри описують саме цей експеримент.
Щоб повторити довший тест із незмінними рядками, запустіть той самий скрипт із такими параметрами:
CN_BENCH_ITERATIONS=3000000 CN_BENCH_WARMUP=300000 CN_BENCH_WORKLOAD=stable node cn-benchmark-2026-09-04.mjs
CN_BENCH_ITERATIONS=3000000 CN_BENCH_WARMUP=300000 CN_BENCH_WORKLOAD=stable bun cn-benchmark-2026-09-04.mjsДжерела
- Реліз cn 0.2.5 (opens in a new tab) (відкриється в новій вкладці)
- Міграція та бенчмарки для перевіреної версії (opens in a new tab) (відкриється в новій вкладці)
- Код рушія cn (opens in a new tab) (відкриється в новій вкладці)
- Реалізація та розмір (opens in a new tab) (відкриється в новій вкладці)
- Необов'язковий крок збірки (opens in a new tab) (відкриється в новій вкладці)