Webpack + CSS Modules + TS = Love

в 23:31, , рубрики: css, TypeScript, webpack, Разработка веб-сайтов

Я считаю, что 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 позволяют писать изолированные строго типизированные стили. Я надеюсь, эта статья поможет вам улучшить надежность вашей кодовой базы. Спасибо за прочтение :)

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

Автор: Артур Стамбульцян

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js