Перейти до основного вмісту

Я спробував новий cn від shadcn на своєму сайті

Порівняв новий cn із clsx і tailwind-merge у Node та Bun. На повторюваних рядках він швидший, але з унікальними значеннями програє.

Tailwind CSSPerformanceDeveloper tools

Я скоротив функцію об'єднання 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

Джерела

dc27d1a