Вторая статья здесь

В этих двух статьях я буду сравнивать TailwindCSS с чистым CSS + BEM. Цель - разобраться что является лучшим решением для хорошей архитектуры приложения. Это не вопрос предпочтений, от этого выбора будет зависеть очень многое на поздних этапах разработки и оно должно быть очень хорошо обосновано. Начну со сравнения производительности. Tailwind позволяет значительно уменьшить размер итогового CSS и тем самым ускорить время отображения страницы. Но это сработает только в том случае, если Tailwind классы будут написаны прямо в HTML коде, а не в виде @apply в CSS. Tailwind уменьшает CSS, но увеличивает HTML. Давайте посчитаем разницу с учетом HTML. Будем сравнивать чистый Tailwind с чистым CSS + BEM.

Расчеты

На каждый Tailwind класс - одно свойство. На каждый HTML элемент - полтора BEM класса (если учитывать модификаторы).

lt - средняя длина Tailwind класса
lc - средняя длина BEM класса
lp - средняя длина CSS свойства
Pu - количество уникальных свойств
P - среднее количество свойств на элемент
C - среднее количество BEM классов на элемент
E - количество элементов
Eu - количество уникальных элементов

Структура CSS для Tailwind:
.lt * Pu {
  lp * Pu;
}

Структура CSS для BEM:
.lc * Eu * C { 
  lp * Eu * P;
}

Структура HTML для Tailwind:
<div class="lt * E * P">

Структура HTML для BEM
<div class="lc * E * C">

Считаем общий размер:
Tailwind: lt * Pu + lp * Pu + lt * E * P
BEM: lc * Eu * C + lp * Eu * P + lc * E * C

Функция для экспериментов в браузере:

function calc({lt, lc, lp, Pu, P, C, E, Eu}) {
  const Tailwind = lt * Pu + lp * Pu + lt * E * P
  const BEM = lc * Eu * C + lp * Eu * P + lc * E * C
  return {
    Tailwind,
    BEM,
    diff: Tailwind / BEM
  }
}

Для анализа я взял главную страницу GitHub после логина. Вот некоторые инструменты для анализа:

// Количество элементов на сайте:
console.log(Array.from(document.querySelectorAll('[class]')).length)

// Количество уникальных (по классам) элементов на сайте:
console.log(Array.from(document.querySelectorAll('[class]'))
  .reduce((a, o) => {
    a.add(Array.from(o.classList.values()).sort().join(' '))
    return a
  }, new Set()).size)
Страница https://github.com/
3164 элементов
502 уникальных элементов
12101 всех CSS свойств
3956 уникальных CSS свойств
404088 общая длина всех CSS свойств
163733 общая длина уникальных CSS свойств
33 средняя длина всех CSS свойств
41 средняя длина уникальных CSS свойств
24 свойства на элемент
calc({
  lt:  10, // средняя длина Tailwind класса
  lc:  35, // средняя длина BEM класса
  lp:  35, // средняя длина CSS свойства
  Pu:  3956, // количество уникальных свойств
  P:  24, // среднее количество свойств на элемент
  C:  1.5, // среднее количество BEM классов на элемент
  E:  3164, // количество элементов
  Eu:  502, // количество уникальных элементов
})

// Tailwind: 937380,
// BEM: 614145,
// Tailwind / BEM: 1.5263170749578682

Размер Tailwind CSS в 1.5 раза больше

Оптимизация

Для BEM мы могли бы сделать оптимизацию CSS и для каждого свойства прописать все классы с этим свойства в селекторе. Тогда общая длина всех свойств должна уменьшиться:

.lc * Eu * C * P { 
  lp * Eu;
}

Формула для BEM будет такой:

lc * Eu * C * P + lp * Eu + lc * E * C

Рассмотрим тот же GitHub, но с обфускацией имен классов и оптимизацией:

function calc({lt, lc, lp, Pu, P, C, E, Eu}) {
  const Tailwind = lt * Pu + lp * Pu + lt * E * P
  const BEM = lc * Eu * C * P + lp * Eu + lc * E * C
  return {
    Tailwind,
    BEM,
    diff: Tailwind / BEM
  }
}

calc({
  lt:  5, // средняя длина Tailwind класса (с обфускацией)
  lc:  5, // средняя длина BEM класса (с обфускацией)
  lp:  35, // средняя длина CSS свойства
  Pu:  3956, // количество уникальных свойств
  P:  24, // среднее количество свойств на элемент
  C:  1.5, // среднее количество BEM классов на элемент
  E:  3164, // количество элементов
  Eu:  502, // количество уникальных элементов
})

// Tailwind: 537920
// BEM: 131660
// Tailwind / BEM: 4.085675224061978

С оптимизацией и обфускацией размер Tailwind уже в 4 раз больше BEM.

Но, к сожалению, такая оптимизация невозможна. Этот пример иллюстрирует почему:

<style>
.a {
  color: red;
}
.b {
  color: blue;
}
.c {
  color: red;
}
</style>

<div class="a b"></div> <!-- Blue -->
<div class="b c"></div> <!-- Red -->

=========================

<style>
.a, .c {
  color: red;
}
.b {
  color: blue;
}
</style>

<div class="a b"></div> <!-- Blue -->
<div class="b c"></div> <!-- Blue -->

=========================

<style>
.b {
  color: blue;
}
.a, .c {
  color: red;
}
</style>

<div class="a b"></div> <!-- Red -->
<div class="b c"></div> <!-- Red -->

Но обфускация имен классов возможна. Рассмотрим случай без невозможной оптимизации, но с обфускацией.

calc({
  lt:  5, // средняя длина Tailwind класса (с обфускацией)
  lc:  5, // средняя длина BEM класса (с обфускацией)
  lp:  35, // средняя длина CSS свойства
  Pu:  3956, // количество уникальных свойств
  P:  24, // среднее количество свойств на элемент
  C:  1.5, // среднее количество BEM классов на элемент
  E:  3164, // количество элементов
  Eu:  502, // количество уникальных элементов
})

// Tailwind: 537920,
// BEM: 449175,
// Tailwind / BEM: 1.1975733288807258

Еще, размер класса Tailwind намного сильнее влияет на общий размер HTML/CSS, чем размер класса BEM. И это понятно, ведь общее число элементов на странице в 7 раз больше уникальных.

Важен ли размер CSS/HTML и на сколько?

Насколько вообще большим может быть CSS? На BEM не сложно получить 3 мегабайта. Но gzip сжимает 3MB до всего 60kB. Конечно, после скачивания браузеру нужно еще распаковать CSS, а затем проанализировать, что занимает какое-то время. По моим тестам это 0.5-1 сек на десктопе. На мобильных устройствах это время должно быть значительно больше. Быстрая загрузка и быстрый анализ CSS/HTML важнее, чем изображения или даже скрипты. Скриптам дается небольшая фора, т.к. пользователь не сразу взаимодействует со страницей. А без HTML/CSS страница даже не отобразится корректно, и не начнется загрузка изображений.

Скрипт для анализа конкретного сайта

Запусти этот скрипт в консоли для любого сайта и ты узнаешь разницу в размерах между Tailwind и BEM.
Запускать желательно отключив защиту браузера, чтобы скрипт учел все стили загружаемые из CDN:

chrome.exe --disable-web-security --user-data-dir="C:/Temp/ChromeDevSession"

Алгоритм не учитывает каскадные стили, т.е все которые содержат символы " >+~". Учесть их очень сложно, поэтому лучше просто выбирать качественно сверстанные сайты, где каскада немного. Так же алгоритм считает, что для BEM для каждого уникального элемента есть своя уникальная таблица стилей. Поэтому в реальности размер CSS для BEM будет меньше чем оцениваемый алгоритмом.
В расчетах не учитывается текущий способ именования классов. На результат влияет только качество дизайна и его верстки. Utility first подход заставляет делать дизайн и верстку более качественной, т.е. придерживаться определенных заранее наборов стилей. Поэтому наверное сайты сверстанные на Tailwind показывают лучшие результаты по размеру HTML/CSS.
Скрипт выполняется примерно минуту из-за функции element.matches(rule.selectorText). Я не знаю как это оптимизировать.

function walkRules(rules, callback, mediaRule) {
  Array.from(rules).forEach(rule => {
    if (rule instanceof CSSStyleRule) {
      callback(rule, mediaRule)
    } else if (rule instanceof CSSMediaRule) {
      walkRules(rule.cssRules, callback, rule)
    }
  })
}

function getRules() {
  const rules = []
  Array.from(document.styleSheets).forEach(sheet => {
    // ignore cross-origin stylesheets
    try {
      sheet.cssRules
    } catch (err) {
      console.warn(`Stylesheet ${sheet.href} is not accessible`)
      return
    }

    walkRules(sheet.cssRules, (rule, mediaRule) => {
      if (
        !/\.[\w-]/.test(rule.selectorText) || // only class selectors
        /[ >+~]/.test(rule.selectorText) // exclude cascading selectors
      ) {
        return
      }
      const style = {}
      for (let i = 0; i < rule.style.length; i++) {
        const key = rule.style[i]
        style[key] = rule.style.getPropertyValue(key).trim().toLowerCase()
      }
      rules.push({ rule, mediaRule, style })
    })
  })

  return rules
}

function getElements(rules) {
  const allElements = Array.from(document.querySelectorAll('[class]'))
  const filteredElements = []

  const time0 = Date.now()
  console.log('getElements START')

  let logTime = Date.now()
  const nonVisualTags = ['SCRIPT', 'STYLE', 'LINK', 'META', 'TITLE', 'NOSCRIPT']
  allElements.forEach((element, i) => {
    if (nonVisualTags.includes(element.tagName)) {
      return
    }
    const elementRules = rules.filter(({ rule }) => {
      return element.matches(rule.selectorText)
    })
    if (elementRules.length === 0) {
      return
    }
    filteredElements.push({ element, rules: elementRules })
    if (Date.now() - logTime > 1000) {
      console.log(
        `getElements: ${((i / allElements.length) * 100).toFixed(2)}% ${
          (Date.now() - time0) / 1000
        }s`,
      )
      logTime = Date.now()
    }
  })

  console.log('getElements END', performance.now() - time0)

  return filteredElements
}

function calcStat() {
  const rules = getRules()
  const elements = getElements(rules)

  const uniqueElements = Array.from(
    elements
      .reduce((a, e) => {
        const key = e.rules.map(({ rule }) => rule.selectorText).join(' ')
        if (!a.has(key)) {
          a.set(key, e)
        }
        return a
      }, new Map())
      .values(),
  )

  const stat = {
    total: {
      html: {
        elementsCount: 0,
      },
      css: {
        selectorCount: 0,
        mediaCount: 0,
        mediaSize: 0,
        styleSize: 0,
        propCount: 0,
      },
    },
    tailwind: {
      html: {
        classCount: 0,
      },
      css: {
        selectorCount: 0,
        styleSize: 0,
        propCount: 0,
      },
    },
    bem: {
      html: {
        elementsCount: 0,
      },
      css: {
        selectorCount: 0,
        mediaCount: 0,
        mediaSize: 0,
        styleSize: 0,
        propCount: 0,
      },
    },
  }

  const uniqueProps = uniqueElements.reduce((a, e) => {
    e.rules.forEach(({ style }) => {
      Object.keys(style).forEach(key => {
        a.add(key + ':' + style[key] + ';')
      })
    })
    return a
  }, new Set())

  // Total
  stat.total.html.elementsCount = elements.length
  rules.forEach(({ rule, mediaRule, style }) => {
    Object.keys(style).forEach(key => {
      stat.total.css.propCount++
      const prop = key + ':' + style[key] + ';'
      stat.total.css.styleSize += prop.length
    })
    stat.total.css.selectorCount++
    if (mediaRule) {
      stat.total.css.mediaCount++
      stat.total.css.mediaSize += mediaRule.media.mediaText.trim().length
    }
  })

  // Tailwind
  stat.tailwind.css.selectorCount = uniqueProps.size
  stat.tailwind.css.propCount = uniqueProps.size
  uniqueProps.forEach(prop => {
    stat.tailwind.css.styleSize += prop.length
  })
  elements.forEach(({ element, rules }) => {
    const elemUniqueProps = new Set()
    rules.forEach(({ rule, mediaRule, style }) => {
      Object.keys(style).forEach(key => {
        elemUniqueProps.add(
          key +
            ':' +
            style[key] +
            ';' +
            (mediaRule?.media?.mediaText?.trim() || ''),
        )
      })
    })
    if (!elemUniqueProps.size) {
      debugger
    }
    stat.tailwind.html.classCount += elemUniqueProps.size
  })

  // BEM
  stat.bem.html.elementsCount = elements.length
  stat.bem.css.selectorCount = uniqueElements.length
  uniqueElements.forEach(({ element, rules }) => {
    const uniqueMedia = new Set()
    rules.forEach(({ rule, mediaRule, style }) => {
      const mediaText = mediaRule?.media?.mediaText?.trim() || ''
      if (mediaText) {
        uniqueMedia.add(mediaText)
      }
      Object.keys(style).forEach(key => {
        const prop = key + ':' + style[key] + ';'
        stat.bem.css.styleSize += prop.length
        stat.bem.css.propCount++
      })
    })
    stat.bem.css.mediaCount += uniqueMedia.size
    uniqueMedia.forEach(media => {
      stat.bem.css.mediaSize += media.length
    })
  })

  return stat
}

var stat = calcStat()
console.log(JSON.stringify(stat, null, 2))

function tailwindVsBem({
  stat,
  bemClassSize,
  bemClassesPerElement,
  tailwindClassSize,
}) {
  const bemCssSize =
    stat.bem.css.styleSize +
    stat.bem.css.mediaSize +
    stat.bem.css.selectorCount * bemClassSize +
    stat.bem.css.mediaCount * bemClassSize
  const bemHtmlSize =
    stat.bem.html.elementsCount * bemClassSize * bemClassesPerElement

  const tailwindCssSize =
    stat.tailwind.css.styleSize +
    stat.tailwind.css.selectorCount * tailwindClassSize
  const tailwindHtmlSize = stat.tailwind.html.classCount * tailwindClassSize

  const bemSize = bemCssSize + bemHtmlSize
  const tailwindSize = tailwindCssSize + tailwindHtmlSize
  const diff = tailwindSize / bemSize

  console.log(`
${document.location.href}
Tailwind = ${tailwindCssSize} (CSS) + ${tailwindHtmlSize} (HTML) = ${tailwindSize}
BEM = ${bemCssSize} (CSS) + ${bemHtmlSize} (HTML) = ${bemSize}
Tailwind / BEM = ${diff}
`)
}

tailwindVsBem({
  stat,
  bemClassSize: 35,
  bemClassesPerElement: 1.5,
  tailwindClassSize: 10,
})

Результаты

Страницы на чистом CSS:

https://www.youtube.com
Tailwind = 38936 (CSS) + 347670 (HTML) = 386606
BEM = 129424 (CSS) + 202230 (HTML) = 331654
Tailwind / BEM = 1.1656907499984923

https://dzen.ru/
Tailwind = 19449 (CSS) + 165240 (HTML) = 184689
BEM = 80186 (CSS) + 75232.5 (HTML) = 155418.5
Tailwind / BEM = 1.1883334352088073


https://habr.com/ru/articles/774524/
Tailwind = 1173 (CSS) + 370 (HTML) = 1543
BEM = 946 (CSS) + 157.5 (HTML) = 1103.5
Tailwind / BEM = 1.3982782057091074

Страницы на Tailwind или другом Utility First подходе:

https://github.com
(Микс чистого CSS и Tailwind)
Tailwind = 25103 (CSS) + 482850 (HTML) = 507953
BEM = 155295 (CSS) + 155190 (HTML) = 310485
Tailwind / BEM = 1.6359985184469459

https://stackoverflow.com/questions/588004/is-floating-point-math-broken/588014
Tailwind = 25317 (CSS) + 449170 (HTML) = 474487
BEM = 132373 (CSS) + 140437.5 (HTML) = 272810.5
Tailwind / BEM = 1.7392549040451155

https://www.facebook.com/random_user
Tailwind = 42286 (CSS) + 400300 (HTML) = 442586
BEM = 378248 (CSS) + 170415 (HTML) = 548663
Tailwind / BEM = 0.8066627419745819

https://tailwindcss.com
Tailwind = 22325 (CSS) + 159330 (HTML) = 181655
BEM = 120739 (CSS) + 121800 (HTML) = 242539
Tailwind / BEM = 0.7489723302231805

https://www.shopify.com
Tailwind = 25053 (CSS) + 102080 (HTML) = 127133
BEM = 179634 (CSS) + 78750 (HTML) = 258384
Tailwind / BEM = 0.4920312403244783

https://www.netflix.com/tudum/top10/
Tailwind = 13288 (CSS) + 80360 (HTML) = 93648
BEM = 48737 (CSS) + 60375 (HTML) = 109112
Tailwind / BEM = 0.8582740670137107

https://io.google/2022/
Tailwind = 11124 (CSS) + 27390 (HTML) = 38514
BEM = 43079 (CSS) + 21682.5 (HTML) = 64761.5
Tailwind / BEM = 0.5947051874956572

https://dotnet.microsoft.com/en-us/
Tailwind = 15199 (CSS) + 27520 (HTML) = 42719
BEM = 49361 (CSS) + 13860 (HTML) = 63221
Tailwind / BEM = 0.6757090207367805

Выводы

Tailwind уменьшает размер HTML/CSS только если вся веб страница (и дизайн, и верстка) проектируется с использованием Utility First подхода. Об этом конечно должны договориться все: и дизайнер, и верстальщик, и заказчик. При очень хорошем проектировании и верстке можно получить размер в 2 раза меньше чем при верстке на BEM.
Если есть произвольный макет и нужно верстать в Pixel Perfect, то Tailwind скорее увеличит размер в 1.5 раза. Так же Tailwind не поможет если на странице очень много повторяющихся элементов (например сайт StackOverflow)


Буду рад услышать любую критику, замечания, ваш личный опыт, ...

Комментарии (7)


  1. s0k0108
    17.11.2023 08:38
    +1

    Добрый день. Простите, а можете показать как выглядят страницы для которых css файл весит 3 мегабайта. И с 2000 уникальных для стилизации элементов.


    1. NikolayMakhonin Автор
      17.11.2023 08:38

      Да, 2000 слишком много. Я посчитал сейчас на разных сайтах. Там от 300 до 700 элементов с уникальным набором классов:

      Array.from(document.querySelectorAll('[class]'))
        .reduce((a, o) => {
          a.add(Array.from(o.classList.values()).sort().join(' '))
          return a
        }, new Set()).size
      

      Еще я обнаружил ошибку в расчетах. Я не учел, что CSS пишется для уникальных элементов, которых гораздо меньше чем всех. И результаты противоположны полученным мной, чистый CSS + BEM оказался экономичнее даже без обфускации и оптимизации. Я исправлю тогда статью.


    1. NikolayMakhonin Автор
      17.11.2023 08:38

      статью исправил


  1. LeMaX
    17.11.2023 08:38

    В целом tw скорее utility only, а не utility firts. сложность макета должна где-то существовать, это стоит учитывать при дальнейшей поддержке или переиспользовании макета. Либо во всяких sass, либо четко ограниченная дизайн системой с переиспользуемыми стилями.

    Если в целом не нравится много перечислений, есть apply… это всеравно как постояннно народ пихает svg одинаковые на странице разными экземплярами, когда можно реюзнуть через символы вставив иконку полностью только один раз.


  1. youngpirate32
    17.11.2023 08:38

    А размеры указаны с учётом сжатия? Например с gzip и brotli


    1. NikolayMakhonin Автор
      17.11.2023 08:38

      Все размеры без сжатия. CSS сжимается очень сильно, раз в 50 меньше становится. Так что размер для загрузки не критичен. Время тратится не на загрузку, а на распаковку и анализ файлов.


      1. NikolayMakhonin Автор
        17.11.2023 08:38

        Я сжал gzip-ом большой CSS одного реального проекта с 2929KB до 75KB (сжатие в 40 раз), проект был на BEM.
        Для эксперимента я сжал CSS страницы GitHub в 794KB до 111KB (сжатие в 7 раз), он на половину на Tailwind.