1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TL;DR

  • cnfastはclsx + tailwind-mergeをひとつにまとめたcn関数で、既存コードをそのまま置き換えられる。tailwind-mergeより平均3.8倍、コンポーネントが多いコードでは最大7倍速い
  • 速さの正体は「全文字列キャッシュ」「トークンごとの解析結果キャッシュ」「クラスグループ名の整数ID変換」「Setの代わりに整数配列+連番管理を使ったグループ記録方式」の組み合わせ
  • npx cnfast migrate1コマンドで既存プロジェクトのインポートを書き換えられる
  • 速度改善の恩恵は「再レンダリングが多い画面」「clsx + tailwind-mergeを一緒に使っている」プロジェクトで最も大きい

はじめに

自分のプロジェクトではcnユーティリティとして以下のパターンを使っていた。

import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));

このパターンはshadcn/uiでも採用されており、Tailwind CSSを使うReactプロジェクトではほぼ定番だ。
ここに先日「cnfastが速い」という話を見かけ、気になって調べた。

cnfastとは

cnfastは2026年6月に公開されたライブラリで、作者はMillionJSでも知られる@aidenybaiだ。
GitHubのスターは公開後1週間で975に達している。

clsx + tailwind-mergeをひとつにまとめたcn関数で、既存コードをそのまま置き換えられるのが特徴だ。
APIは既存のcnと完全互換で、出力結果もtailwind-mergeと一致することをfuzzテスト(ランダムな入力を大量に生成してtailwind-mergeの出力と比較する自動テスト)で保証している。

import { cn } from "cnfast";

cn("px-2 py-1", isActive && "px-4", { "text-red-500": hasError });
// "py-1 px-4 text-red-500"

バンドルサイズはgzip後9.43 KBで、clsx + tailwind-mergeの8.45 KBより約1 KB大きい。

ベンチマーク数値

V8(Node.js / Chrome)でのベンチマーク。

Workload tailwind-merge cnfast Speedup
Cached re-render 2,025 ops/s 8,709 ops/s 4.3x
Merge engine, cold 1,440 ops/s 5,411 ops/s 3.8x
Component corpus 1,585 ops/s 6,506 ops/s 4.1x
Page render 4,249 ops/s 11,908 ops/s 2.8x
Live data grid 500 ops/s 2,185 ops/s 4.4x

なぜ速いのか

アーキテクチャドキュメントが公開されていたので読んだ。

2段階のLRUキャッシュ

LRU(Least Recently Used)は「使われた時点が最も古いものを先に削除する」方式のキャッシュだ。tailwind-mergeが全文字列単位でキャッシュするのに対して、cnfastはもう1段追加している。

  • 外側キャッシュ(500エントリ):入力クラス文字列全体を結果にマップ。tailwind-mergeと同じ方式
  • 内側キャッシュ(4096エントリ):個々のトークン(px-4flexなど)をClassDescriptor(そのクラスがどのクラスグループに属するかを格納したオブジェクト)にマップ

内側キャッシュが速度に直結する。
異なる2つのクラス文字列がflexというトークンを共有している場合、tailwind-mergeは文字列ごとにflexをパースして分類するが、cnfastは1回だけ処理して解析結果を使い回す。

両方のキャッシュともnull-prototypeオブジェクト(Object.create(null))で実装している。
文字列キーの読み取りがMap.getより速いためだ。

キャッシュの実装は2世代ローテーション方式で、エントリを1件ずつ削除するのではなく満杯になったら世代ごと入れ替える。
書き込み時のアロケーションがゼロになる。

クラスグループ名の整数ID変換

tailwind-mergeは「クラスグループ」という単位で管理している。
同じCSSプロパティを制御するクラスのまとまりで、たとえば px-1px-2px-4 はどれも padding-x を変えるため同じクラスグループに属する。
同じグループのクラスが複数あるとき、後から書かれたものだけが残る。

このクラスグループを識別する文字列(hover:pxのような形式)を文字列のまま比較すると、毎回ハッシュ計算が走る。
cnfastはこれを初出時に整数IDに変換して使い回し、以降の比較を整数同士で行う。

文字列比較から整数比較に変えるだけでも、ループが数万回回るコールドスタートの処理では差が出る。

Setの代わりに整数配列で管理する

マージ処理では「後から書かれたクラスの勝ちが決まったクラスグループ」を記録する必要がある。
tailwind-mergeの実装はSetを使うため、マージのたびに新しいSetを作る必要がある。

cnfastはInt32Arrayと連番管理で置き換えた。

// 新しいマージを始めるたびに連番を1増やすだけ
currentGeneration = (currentGeneration + 1) | 0;

// キーを「使用済み」として記録するのは配列への書き込みだけ(アロケーションなし)
claimedGeneration[classId] = generation;

// 判定も配列の読み取りだけ
if (claimedGeneration[classId] === generation) continue;

Setのインスタンス生成をなくし、マージ開始時の処理を連番を1増やすだけに減らしている。

引数の扱い

cn関数の実装はfunctionキーワードで書かれており、rest parameterを使っていない。

export const cn = function(): string {
  if (arguments.length === 1) {
    const only = arguments[0];
    return typeof only === "string"
      ? twMerge.mergeString(only)
      : twMerge.mergeString(resolveClassValue(only));
  }
  // ...
};

rest parameter(...inputs)を使うと、V8が呼び出しごとに配列を確保する。
argumentsオブジェクトは長さとインデックス参照だけに使い、スコープ外で参照されない状態を保てばV8がアロケーションを省略できる。

タグ付きテンプレートリテラルによるハッシュスキップ

キャッシュヒット時のコストの約半分が「文字列のハッシュ計算」だとプロファイルで確認されているという。
これをスキップする方法がタグ付きテンプレートリテラル構文だ。

cn`px-2 ${active && "bg-blue-500"}`;

タグ付きテンプレートリテラルは、ソースコード上の同じ記述箇所から毎回同一の配列オブジェクトが渡されることがJavaScriptの言語仕様で保証されている。
cnfastはこれをWeakMapのキーとして使い、文字列のハッシュ計算を完全にスキップする。

V8上で同じ場所のcn()を繰り返し呼ぶと、tailwind-mergeより4.3倍速い。
ただしcn(...)形式はV8がすでに引数をキャッシュしているため、V8上でのタグ付きテンプレートリテラルとの差は1.2倍にとどまる。
V8以外のエンジンではcn(...)のキャッシュが効かないぶん差が広がる。

効果が出やすいプロジェクト

cnfastの速度改善は呼び出し回数に比例する。READMEには以下の記述がある。

cn runs once per element, so its cost scales with how much you render. Server-rendering a large page calls it across the whole tree; a client app that re-renders often (data grids, virtualized tables, live dashboards) calls it thousands of times per second, where a faster cn keeps frames within budget. On a small or rarely updated page, the saving stays within run-to-run noise.

(訳: cnは要素ごとに1回呼ばれるため、コストはレンダリング量に比例する。大きなページをサーバーレンダリングするとツリー全体で呼ばれ、再レンダリングが多いクライアントアプリでは毎秒数千回に達することもある。更新頻度が低いページでは、改善幅は計測誤差の範囲に収まる。)

再レンダリングが多い画面(大量データの一覧テーブル、仮想スクロールのリスト、リアルタイム更新のダッシュボードなど)ほど効果が大きく、更新頻度が低いページや小規模なコンポーネントでは誤差の範囲に収まる。

clsxだけを単体で使っているケース(tailwind-mergeなし)も同様で、clsxは元々軽量なのでcnfastへの乗り換えによる体感差は小さい。
恩恵が大きいのはclsx + tailwind-mergeを組み合わせて使っているプロジェクトだ。

移行方法

npx cnfast migrate を実際に走らせてみた

試用プロジェクトで実際に走らせてみた。
clsx・tailwind-mergeのインポートが含まれるファイルを自動スキャンし、変更前に差分をプレビューして確認を求める流れだ。

✿ cnfast 0.0.8

- Scanning files.
✔ Found 5 import(s) across 3 file(s).

components/Button.js
- const { clsx } = require("clsx");
+ const { clsx } = require("cnfast");
- const { twMerge } = require("tailwind-merge");
+ const { twMerge } = require("cnfast");

lib/utils.js
- const { clsx } = require("clsx");
+ const { clsx } = require("cnfast");
- const { twMerge } = require("tailwind-merge");
+ const { twMerge } = require("cnfast");

? Migrate 3 file(s) to cnfast? › (Y/n)
✔ Migrated 3 file(s) to cnfast.

Next: install cnfast and remove unused deps with npm i cnfast.

変更はインポート元の書き換えだけで、ロジックには一切手が入らない。

実際の出力が一致することも確認した。コンフリクト解消(px-2 px-4px-4 など)の挙動も変わらず、5パターン全て一致した。

インポートの書き換えだけでは最大限の速度にならない

ひとつ気になった点がある。
多くのプロジェクトは lib/utils.ts でこのようなラッパーを定義している。

// よくある cn ヘルパーの定義
import { clsx, type ClassValue } from "cnfast";   // ← migrate後
import { twMerge } from "cnfast";                  // ← migrate後

export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));

npx cnfast migrate はインポート元を書き換えるだけで、この関数定義には手を入れない。
ラッパーが残ると、rest parameter(...inputs)で配列が確保され続けるため、cnfastが避けているアロケーションが残る。

最大限の速度を出すには、ラッパーごと cn の直接エクスポートに切り替える。

// before: ラッパー経由
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));

// after: cnfastのcnを直接エクスポート
export { cn } from "cnfast";

npx cnfast migrate は安全第一の機械的な書き換えに留めており、この最終ステップは手動になる。

shadcn/uiプロジェクト

shadcn/uiのregistryに対応しており、lib/utils.ts を上記の最終形まで含めて自動で書き換えられる。

npx shadcn@latest add aidenybai/cnfast/cn

clsxのみ使っているケース

cnfastはclsxも個別にエクスポートしているので、clsxの代替としてそのまま使える。

import { clsx } from "cnfast";

ただし前述のとおり、clsx単体の速度改善幅は限定的だ。
tailwind-mergeを併用していない場合は、cnパターンへ移行するかどうかをあわせて検討した方がいい。

注意点

出力の互換性:tailwind-mergeとの出力一致はfuzzテストで保証されているが、プロジェクト固有のtailwind-mergeの設定(extendTailwindMergeなど)をそのまま移行するには対応が必要になる可能性がある。
カスタム設定を使っている場合は動作確認を丁寧に行う。

Bunでの数値:ベンチマーク数値はV8(Node.js / Chrome)のものだ。
BunはV8と異なるエンジンを使っているため、同じ最適化が効くとは限らず、高速化の幅が変わる。
プロジェクトの実行環境に応じて確認する。

まだ新しいライブラリ:公開から1週間のライブラリなので、エコシステムへの定着やエッジケースの洗い出しはこれからだ。
導入する場合は出力の差分テストを入れておくと安心できる。

まとめ

cnfastの速度向上は機能削減ではなく、V8がコードを最適化する仕組みを踏まえた実装改善で達成されている。
全文字列キャッシュとトークンごとの解析結果キャッシュの2段構成、クラスグループ名の整数ID変換、Setの代わりに整数配列+連番管理を使ったグループ記録、いずれもアロケーションを削ることへの一貫したこだわりから来ている。

実際に npx cnfast migrate を走らせてみて、5パターンの出力が全て一致することを確認した。
コマンド自体はインポート元の書き換えに留まるため、export { cn } from "cnfast" への最終ステップは手動が必要だが、その一手間を含めても移行コストは低い。
再レンダリングが多い画面を持つプロジェクトであれば、試してみる価値はある。

参考

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?