Я считаю, что CSS Модули — это монументальный проект. С его помощью можно решить одну из худших проблем CSS — коллизию имен классов. Давайте рассмотрим простой пример, чтобы было понятно, о чем идет речь.

Представим, что мы разрабатываем компонент Button. Использовать "чистый" CSS опасно, потому что есть риск, что кто-то ещё в вашем проекте (или ещё хуже — в подключенной библиотеке) использует то же имя класса:

/* Button.css */

.button {
  color: #f00;
  padding: 10px;
  font-size: 18px;
}

/* node_modules/some_lib/styles.css */

.button {
  color: #0f0;
}
// Button.tsx

import { FC } from "react";
import "./Button.module.css";
import "some_lib/styles.css";

export const Button: FC = (props) => {
  // Какого цвета будет кнопка остаётся только гадать
  return <button {...props} className="button" />;
};

CSS Modules решают эту проблему достаточно изящно. Модули хешируют все имена классов в файле и генерируют мап типа такого:

export default {
  button: '_2bs4j'
}

Разработчик импортирует этот объект к себе и обращается к его ключам:

// Button.tsx

import { FC } from "react";
import styles from "./Button.module.css";
import "some_lib/styles.css";

export const Button: FC = (props) => {
  // Теперь мы можем не беспокоиться о конфликте селекторов, так как наш селектор 
  // на этапе билда превратится в "_2bs4j"
  return <button {...props} className={styles.button} />;
};

Если вы используете Webpack, то включить поддержку CSS Modules можно практически не трогая конфиг:

// webpack.config.js

module.exports = {
  module: {
    rules: [
      {
        test: /\.module.css$/,
        use: [
          { 
            loader: "css-loader",
            options: {
              modules: true, // Раз — и готово
            },
          },
        ],
      },
    ],
  },
};

Проблема

Если на проекте вы используете TypeScript (я очень на это надеюсь), при подключении CSS Modules вы скорее всего столкнетесь с проблемой, так как TS не знает, что делать с CSS файлами. Компилятору неоткуда брать типизацию для них. Но проблема достаточно легко решается написанием простенькой декларации:

// global.d.ts

declare module "*.css" {
  export default {
    [index: string]: string;
  }
}

Переводя на человеческий, мы говорим, что любые файлы с расширением .css генерируют строковый мап. TS доволен. Но теперь есть другая проблема — он вообще всем доволен :D Я про то, что вы можете обращаться к несуществующему имени класса и TS вас об этом не предупредит:

// Button.tsx

import { FC } from "react";
import styles from "./Button.module.css";

export const Button: FC = (props) => {
  // По ошибке пропустили "t" в слове "button"
  return <button {...props} className={styles.buton} />;
};

Я думаю, нет необходимости рассуждать на тему того, почему это плохо. Давайте попробуем исправить ситуацию.

Сообщество бежит на помощь

К счастью, сообщество уже думало над этой проблемой: typescript-plugin-css-modules. Данный плагин создаёт виртуальный .d.ts для каждого CSS файла и таким образом помогает IDE находить возможные баги:

// tsconfig.json

{
  "compilerOptions": {
    "plugins": [
      {
        "name": "typescript-plugin-css-modules"
      }
    ]
  }
}
// Button.tsx

import { FC } from "react";
import styles from "./Button.module.css";

export const Button: FC = (props) => {
  // Теперь IDE скажет, что тут мы обращаемся к несуществующему полю "buton"
  return <button {...props} className={styles.buton} />;
};

Вроде бы круто, мы добились чего хотели. Но если запустить TypeScript через CLI, то внезапно никакой ошибки показано не будет. Это происходит потому что TypeScript не очень поддерживает эти плагины. Более того, Webpack нас об опечатке тоже не уведомит. С его точки зрения код корректен. То есть проблему мы решили лишь частично. Риск того, что подобный код попадет на прод всё ещё велик.

Давайте попрограммируем

Перед тем как решать вышеописанную проблему, я предлагаю вспомнить кое что из основ JS. Поговорим о ES Modules. Этот стандарт предоставляет два типа экспорта (и импорта): именованный и дефолтный. В своих проектах я предпочитаю использовать именованный экспорт, потому считаю, что он делает код более надежным. Давайте рассмотрим два примера:

// module1.ts
export const foo = "foo";
export const bar = "bar";

// module2.ts
import { foo, bar } from "./module1";

console.log(foo); // "foo"
// module1.ts
export default { foo: "foo", bar: "bar" };

// module2.ts
import module1 from "./module1";

console.log(module1.foo); // "foo"

Примеры похожие, но между ними есть существенная разница. Если вы используете именованный экспорт, инструменты вроде Webpack кинут ошибку при попытке импортировать то, что не экспортируется:

// module1.ts
export const foo = "foo";
export const bar = "bar";

// module2.ts

import { foo, bar, baz } from "./module1";
// ERROR: export 'baz' (imported as 'module1') was not found in './module1.ts' (possible exports: foo, bar)

Дефолтный экспорт работает иначе. Например, если вы экспортируете объект в качестве дефолта и пытаетесь получить доступ к свойству, которого не существует, бандлер не скажет, что что-то идет не так:

// module1.ts
export default { foo: "foo", bar: "bar" };

// module2.ts
import module1 from "./module1";

console.log(module1.baz) // undefined

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

Возвращаемся к нашей проблеме

Итак, как я понимаю, нам нужно экспортировать мапу с классами, используя именованные экспорты вместо дефолтных. Такой апдейт поможет нам отлавливать ошибки на этапе сборки проекта. Конфиг Webpack нужно будет обновить примерно так:

// webpack.config.js

module.exports = {
  module: {
    strictExportPresence: true, // Включаем строгий режим, чтобы попытка импортировать несуществующие объекты приводила к падению билда
    rules: [{
      test: /\.module.css$/,
      use: ["css-loader", {
        options: {
          esModule: true, // Говорим о том, что хотим использовать ES Modules
          modules: {
            namedExport: true, // Указываем, что предпочитаем именованый экспорт дефолтному
          },
        }
      }]
    }]
  }
};

Теперь подобный код упадет с ошибкой при попытке его собрать:

import styles from "./Button.module.css"; // Дефолтного экспорта больше нет

Сборка такого кода тоже рухнет, так как в импорте допущена опечатка:

import { buton } from "./Button.module.css";

С новой конфигурацией Вебпака использование стилей будет выглядеть так:

import { button } from "./Button.module.css";

console.log(button); // "_2bs4j"

Или так (так даже удобнее, чтобы не плодить кучу локальных констант):

import * as styles from "./Button.module.css";

console.log(styles.button); // "_2bs4j"

Кстати, styles.buton тоже приведет к падению билда. Напомню, что при дефолтном импорте такой код был воспринят сборщиком как корректный.

Отлично. Теперь мы можем быть уверены в нашем коде. Он не попадет на прод в случае, если в нем мы обращаемся к несуществующим классам. Мне нравится это решение ещё и тем, что оно работает даже в проектах без TS.

Последний штрих

Плагин TS всё ещё думает, что стили экспортируются по-дефолту. Это не прикольно, потому что IDE не предупредит нас о том, что мы делаем что-то не так. Да, билд упадет, но мы же не хотим всякий раз смотреть в консоль, когда что-то делаем с кодом? Будет здорово уже на этапе печатания получить информацию о том, что в коде появился баг.

Согласно доке, плагин всегда генерирует типизацию дефолтного экспорта. Грустно. Но у нас есть возможность изменить это поведение. Для этого воспользуемся опцией customTemplate. В качестве значения пропишем путь к модулю с функцией, задача которой будет принимать мап с css классами и выдавать его типизацию:

// tsconfig.json

{
  "compilerOptions": {
    "plugins": [
      {
        "name": "typescript-plugin-css-modules",
        "options": {
          "customTemplate": "./customTemplate.js"
        }
      }
    ]
  }
}
// customTemplate.js

module.exports = (dts, { classes }) => {
  return Object.keys(classes)
    .map((key) => `export const ${key}: string`)
    .join("\n");
};

Всё, в .d.ts файлах больше не будет export default .

Теперь и IDE, и бандлер будут предостерегать нас от использования несуществующих CSS классов.

Конклюжен

Webpack, TypeScript и CSS Modules позволяют писать изолированные строго типизированные стили. Я надеюсь, эта статья поможет вам улучшить надежность вашей кодовой базы. Спасибо за прочтение :)

Репозиторий с полным примером

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


  1. ramil_trinion
    18.09.2022 09:19

    По мне так лучше решать эту проблему организационно, просто распределив элементы для стилизации или создав список имен заранее. Если все же напутали, то git вам в помощь. Сравниваем файлы и если есть конфликты в названии, то решаем какой оставляем.


    1. Yozi
      18.09.2022 13:22
      +5

      "Глобальные переменные - плохо", тут мало кто спорит. Глобальные имена классов из css - почему-то часто.

      Представил бы я современный проект, где заранее создан список имён переменных и используется гит для контроля их уникальности


  1. Alexufo
    18.09.2022 11:18
    +2

    Что на счёт vite вместо webpack?


    1. ArthurSupertramp Автор
      18.09.2022 13:56

      Я дед, мне с вебпаком удобно :) если вопрос про то, как настроить похожую инфраструктуру в Vite, то сходу я сказать не могу, так как не пользовался им ни разу.


      1. Alexufo
        18.09.2022 14:21

        ну не дед инсайд же) все еще впереди)


        1. ArthurSupertramp Автор
          18.09.2022 15:13

          Я думаю, что настроить то же самое для Vite изи, так как цсс модули и тайпскрипт — это инструменты, не привязанные к бандлеру. Я использовал тут вебпак скорее для примера.


  1. Helltraitor
    18.09.2022 15:53
    +1

    Веб все больше приобретает черты адекватной сферы деятельности человека :D


    1. starfayer
      18.09.2022 21:30

      Такими темпами и JS языком со своей философией (помимо прото-ООП) станет


    1. Sap_ru
      19.09.2022 01:00
      -1

      Ну, такое оно... Если SCSS проекта использует какие-то общие файлы, то они включаются в проекте несколько раз.

      Алиасы модулей CSS/SCSS не поддерживаются в VSCode. Причём, просто потому, что разработчики плагина -"гордые и независимые'.

      Выгрузить модуль стилей нельзя. В результате для больших и долгоживущих страниц html body круто засирается мусором

      Генерация sourcemap кривая. Причем, у каждого плагина по своему кривая. И только потому, что разработчики ленивые жопы.

      Ну, и куча всего подобного, что в "нормальных" языках и средах просто невозможно представить - таким продуктом просто никто не стал бы пользоваться, а в авторов бы тыкали пальцами и высмеивали.


      1. ArthurSupertramp Автор
        19.09.2022 10:43
        +1

        А мы точно про один и тот же плагин говорим?


        1. Sap_ru
          19.09.2022 14:25

          Это не про плагин, это про общий урвень тулчейна. Плагин работает. Только выгружать стили нельзя.


          1. ArthurSupertramp Автор
            19.09.2022 15:07

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


            1. Sap_ru
              19.09.2022 15:19

              Модуль-то вы выгрузите, но сами добавленные стили от этого из страницы не пропадут. Для того, чтобы выгрузить модуль полностью (а не просто из кэша загрузчика webpack), нужно знать какому тэгу <style> соответствует модуль, а это информации нет. Т.е. только велосипед с ручной загрузкой/выгрузкой модуля.


              1. ArthurSupertramp Автор
                19.09.2022 16:15

                Кажется, я перестал понимать, какую задачу нужно решить)


                1. Sap_ru
                  19.09.2022 16:33

                  Грузите вы CCS-модули. Программа большая, в ней могут быть какие-то динамические части (модули). Если это single page application, то это всё грузится и никогда не выгружается.
                  Даже если вы динамически грузите CSS-модуль через require, то его содержимое добавляется в <head> и больше никогда не удаляется. В результате там скапливается фантастическое количество мусора и могут начаться тормоза браузера даже на небольших страницах по причине разрастания CSS таблиц до неприличных размеров.
                  Всё это усугубляется отсутствием поддержки каскадный модулей - т.е. если вы в CSS/SCCS используете (импортите) какие-то общие (глобальные) файлы со стилями, то это всё дублируется - на каждый импорт из CSS будет сгенерирован свой комплект одинаковых селекторов и правил. Даже если их в SCCS глобальными объявлять, то они на практике в стилях страницы будет куча одинаковых объявлений одинаковых селекторов. В сумме с невозможностью выгрузки CSS-модулей это приводит к тому, что в небольшом WEB-приложении может оказаться миллион селекторов и всё начинает тормозить и лагать при обновлении стилей.
                  А причина - сугубо в сырости непродуманности тулчейна. Все эти проблемы были сразу видны и предсказуемы, но во фронте своя атмосфера, и принято каждое новое колесо, которое уже 20 лет есть в других сферах IT, изобретать с болью и торжествами.


  1. Amareis
    18.09.2022 16:09

    А можно было просто ttsc вместо tsc использовать и плагины заработают...


    1. ArthurSupertramp Автор
      18.09.2022 16:21

      ttsc заработает, но бандлеру это не помешает собрать код с ошибкой.


  1. ProRules
    19.09.2022 10:43
    -2

    Если бы была возможность даунвота у меня я бы задаунвотил. Вообще уже опустили уровень хабра.. Если бы я подобные "статьи" публиковал репутации для даунвота было бы достаточно... Но в отличие от автора я не хочу такими "статьями" превращать хабр в помойку...


    1. ArthurSupertramp Автор
      19.09.2022 10:44
      +3

      По существу есть что?)


  1. adaturing
    20.09.2022 11:49
    +1

    Статья отличная. Есть чётко описанная задача, какую она проблематику решает и собственно представлено решение с описанием. Кому-то будет полезно.