やかんです。

突然ですが、tailwind-mergeというものがあります。

https://github.com/dcastil/tailwind-merge

↑こちらですね。tailwind-cssのクラス名について、まるっとよしなにやってくれる便利ライブラリなんですが、挙動が気になったので備忘録的にまとめます。

tailwind-mergeとは。

https://github.com/dcastil/tailwind-merge

↑これです。tailwind cssのユーティル関数で、クラス名の競合周りをまるっと解決してくれます。

仕組みを理解する。

OSSなので、コードの掲載は特に問題ないんじゃないかなって思ってます。問題がある場合は教えてください。

twMerge/src/lib/tw-merge.tsで以下のように定義されています。

import { createTailwindMerge } from './create-tailwind-merge'
import { getDefaultConfig } from './default-config'

export const twMerge = createTailwindMerge(getDefaultConfig)

となると、createTailwindMerge, getDefaultConfigの理解が必要ですね。twMergeが関数なので、createTailwindMergeは「関数を返す関数」であってほしいです、定義を見てみましょう。

東大生やかんのブログ
やかん

「関数を返す関数」のことを「高階関数」と呼びます。

import { createConfigUtils } from './config-utils'
import { mergeClassList } from './merge-classlist'
import { ClassNameValue, twJoin } from './tw-join'
import { AnyConfig } from './types'

type CreateConfigFirst = () => AnyConfig
type CreateConfigSubsequent = (config: AnyConfig) => AnyConfig
type TailwindMerge = (...classLists: ClassNameValue[]) => string
type ConfigUtils = ReturnType<typeof createConfigUtils>

export function createTailwindMerge(
    createConfigFirst: CreateConfigFirst,
    ...createConfigRest: CreateConfigSubsequent[]
): TailwindMerge {
    let configUtils: ConfigUtils
    let cacheGet: ConfigUtils['cache']['get']
    let cacheSet: ConfigUtils['cache']['set']
    let functionToCall = initTailwindMerge

    function initTailwindMerge(classList: string) {
        const config = createConfigRest.reduce(
            (previousConfig, createConfigCurrent) => createConfigCurrent(previousConfig),
            createConfigFirst() as AnyConfig,
        )

        configUtils = createConfigUtils(config)
        cacheGet = configUtils.cache.get
        cacheSet = configUtils.cache.set
        functionToCall = tailwindMerge

        return tailwindMerge(classList)
    }

    function tailwindMerge(classList: string) {
        const cachedResult = cacheGet(classList)

        if (cachedResult) {
            return cachedResult
        }

        const result = mergeClassList(classList, configUtils)
        cacheSet(classList, result)

        return result
    }

    return function callTailwindMerge() {
        return functionToCall(twJoin.apply(null, arguments as any))
    }
}

↑こちらが、/src/lib/create-tailwind-merge.tsです。所望のcreateTailwindMergeが定義されています。引数にも着目しますが、まずは返り値に着目します。

    return function callTailwindMerge() {
        return functionToCall(twJoin.apply(null, arguments as any))
    }

関数が返されています。functionToCallという関数は、29行目で以下のように与えられています。

        functionToCall = tailwindMerge

では、右辺のtailwindMergeとは何か、と言うと、34行目から定義されていますね。

    function tailwindMerge(classList: string) {
        const cachedResult = cacheGet(classList)

        if (cachedResult) {
            return cachedResult
        }

        const result = mergeClassList(classList, configUtils)
        cacheSet(classList, result)

        return result
    }

この関数を読むと、twMergeがまずキャッシュを見ていることがわかります。天下り的で恐縮ですが、ここで言うところののキャッシュはブラウザのキャッシュじゃないです。プログラムのメモリ上に確保されるデータで、そのためSSRでもCSRでもtwMergeは使えるという話だと思います。プログラム上のメモリなので、CSRの場合画面をリロードするとこの「キャッシュ」は消えます。

で、この関数の返り値はresultでして、以下のように定義されています。

        const result = mergeClassList(classList, configUtils)

その下の行でこのresultがcache(プログラム上のメモリ)に保存されたりしてますが、一旦キャッシュ周辺は置いといて、気になるのはmergeClassListですね。mergeClassList/src/lib/merge-classlist.tsで以下のように定義されています。

import { ConfigUtils } from './config-utils'
import { IMPORTANT_MODIFIER } from './parse-class-name'

const SPLIT_CLASSES_REGEX = /\s+/

export const mergeClassList = (classList: string, configUtils: ConfigUtils) => {
    const { parseClassName, getClassGroupId, getConflictingClassGroupIds, sortModifiers } =
        configUtils

    /**
     * Set of classGroupIds in following format:
     * `{importantModifier}{variantModifiers}{classGroupId}`
     * @example 'float'
     * @example 'hover:focus:bg-color'
     * @example 'md:!pr'
     */
    const classGroupsInConflict: string[] = []
    const classNames = classList.trim().split(SPLIT_CLASSES_REGEX)

    let result = ''

    for (let index = classNames.length - 1; index >= 0; index -= 1) {
        const originalClassName = classNames[index]!

        const {
            isExternal,
            modifiers,
            hasImportantModifier,
            baseClassName,
            maybePostfixModifierPosition,
        } = parseClassName(originalClassName)

        if (isExternal) {
            result = originalClassName + (result.length > 0 ? ' ' + result : result)
            continue
        }

        let hasPostfixModifier = !!maybePostfixModifierPosition
        let classGroupId = getClassGroupId(
            hasPostfixModifier
                ? baseClassName.substring(0, maybePostfixModifierPosition)
                : baseClassName,
        )

        if (!classGroupId) {
            if (!hasPostfixModifier) {
                // Not a Tailwind class
                result = originalClassName + (result.length > 0 ? ' ' + result : result)
                continue
            }

            classGroupId = getClassGroupId(baseClassName)

            if (!classGroupId) {
                // Not a Tailwind class
                result = originalClassName + (result.length > 0 ? ' ' + result : result)
                continue
            }

            hasPostfixModifier = false
        }

        const variantModifier = sortModifiers(modifiers).join(':')

        const modifierId = hasImportantModifier
            ? variantModifier + IMPORTANT_MODIFIER
            : variantModifier

        const classId = modifierId + classGroupId

        if (classGroupsInConflict.includes(classId)) {
            // Tailwind class omitted due to conflict
            continue
        }

        classGroupsInConflict.push(classId)

        const conflictGroups = getConflictingClassGroupIds(classGroupId, hasPostfixModifier)
        for (let i = 0; i < conflictGroups.length; ++i) {
            const group = conflictGroups[i]!
            classGroupsInConflict.push(modifierId + group)
        }

        // Tailwind class not in conflict
        result = originalClassName + (result.length > 0 ? ' ' + result : result)
    }

    return result
}

mergeClassListの返り値はresultという変数で、これは20行目でletで定義されています。letで定義されているところからも、「ああ、今からパースするのね」という雰囲気が伝わってきます。なわけで以下のコードが大事になるはず。

    for (let index = classNames.length - 1; index >= 0; index -= 1) {
        const originalClassName = classNames[index]!

        const {
            isExternal,
            modifiers,
            hasImportantModifier,
            baseClassName,
            maybePostfixModifierPosition,
        } = parseClassName(originalClassName)

        if (isExternal) {
            result = originalClassName + (result.length > 0 ? ' ' + result : result)
            continue
        }

        let hasPostfixModifier = !!maybePostfixModifierPosition
        let classGroupId = getClassGroupId(
            hasPostfixModifier
                ? baseClassName.substring(0, maybePostfixModifierPosition)
                : baseClassName,
        )

        if (!classGroupId) {
            if (!hasPostfixModifier) {
                // Not a Tailwind class
                result = originalClassName + (result.length > 0 ? ' ' + result : result)
                continue
            }

            classGroupId = getClassGroupId(baseClassName)

            if (!classGroupId) {
                // Not a Tailwind class
                result = originalClassName + (result.length > 0 ? ' ' + result : result)
                continue
            }

            hasPostfixModifier = false
        }

        const variantModifier = sortModifiers(modifiers).join(':')

        const modifierId = hasImportantModifier
            ? variantModifier + IMPORTANT_MODIFIER
            : variantModifier

        const classId = modifierId + classGroupId

        if (classGroupsInConflict.includes(classId)) {
            // Tailwind class omitted due to conflict
            continue
        }

        classGroupsInConflict.push(classId)

        const conflictGroups = getConflictingClassGroupIds(classGroupId, hasPostfixModifier)
        for (let i = 0; i < conflictGroups.length; ++i) {
            const group = conflictGroups[i]!
            classGroupsInConflict.push(modifierId + group)
        }

        // Tailwind class not in conflict
        result = originalClassName + (result.length > 0 ? ' ' + result : result)
    }

このfor文は、classNamesという事前に定義された変数を起点にループされます。このclassNamesというのは18行で定義されていて、

    const classNames = classList.trim().split(SPLIT_CLASSES_REGEX)

SPLIT_CLASSES_REGEXは4行目で/\s+/が与えられています。これはスペースの正規表現なので、classNamesはスペースでclassList(これはmergeClassListの第一引数、つまりtailwind cssの文字列)を区切ったものということになります。名前の通り、「classNameたち」ですね。

このclassNamesについてループを回し、当該classNameoriginalClassNameという変数に入れます。丁寧な命名でわかりやすいです。

parseClassNameといういかにもクラス名をパースしてくれそうな関数にこのoriginalClassNameを噛ませたあと、その返り値を元にコードが実行されます。

まずこちら。

        if (isExternal) {
            result = originalClassName + (result.length > 0 ? ' ' + result : result)
            continue
        }

再度天下り的で恐縮ですが、isExternalというフラグは「tailwindcssのクラス名として有効か否か」を表しています。trueの場合は「externalなクラス名だよ」ということで(独自にcssファイルなどで与えているクラス名など)、特に何もされずにcontinueを迎えます。

次に、以下のコードですね。

        let hasPostfixModifier = !!maybePostfixModifierPosition
        let classGroupId = getClassGroupId(
            hasPostfixModifier
                ? baseClassName.substring(0, maybePostfixModifierPosition)
                : baseClassName,
        )

        if (!classGroupId) {
            if (!hasPostfixModifier) {
                // Not a Tailwind class
                result = originalClassName + (result.length > 0 ? ' ' + result : result)
                continue
            }

            classGroupId = getClassGroupId(baseClassName)

            if (!classGroupId) {
                // Not a Tailwind class
                result = originalClassName + (result.length > 0 ? ' ' + result : result)
                continue
            }

            hasPostfixModifier = false
        }

これは、いろいろややこしいので一旦スキップしてしまっていいかと思います。天下り的に述べると、「/(スラッシュ)が含まれるクラス名についていい感じに処理する」というコードになっているかと思います。widthとかですね。

めちゃんこキリが悪いですが、一身上の都合でこの記事一旦終わります。続き書きます!

最後までお読みいただき、ありがとうございます。