Привет, друзья!


В этой статье я покажу вам, как начать разработку библиотеки компонентов с помощью Vite, React, TypeScript и Storybook.


Мы разработаем библиотеку, состоящую из одного простого компонента — кнопки, подготовим библиотеку к публикации в реестре npm, а также сгенерируем и визуализируем документацию для кнопки.


Репозиторий с кодом проекта.


Если вам это интересно, прошу под кат.


Подготовка и настройка проекта


Создаем шаблон проекта с помощью Vite:


# npm 7+
# react-ts-lib - название проекта
# react-ts - используемый шаблон
npm create vite react-ts-lib -- --template react-ts

Переходим в созданную директорию, устанавливаем зависимости и запускаем сервер для разработки:


cd react-ts-lib
npm i
npm run dev

Приводим директорию к следующей структуре:


- src
  - lib
    - Button
      - Button.tsx
    - index.ts
- App.tsx
- index.css
- vite.config.ts
- ...

Устанавливаем библиотеку styled-components (мы будем использовать эту библиотеку для стилизации кнопки) и типы для нее:


npm i styled-componets
npm i -D @types/styled-components

Устанавливаем плагин Vite для автоматической генерации файла с определениями типов:


npm i -D vite-plugin-dts

Настраиваем сборку, редактируя файл vite.config.ts:


import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
import path from "path";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [
    // поддержка синтаксиса React (JSX и прочее)
    react(),
    // генерация файла `index.d.ts`
    dts({
      insertTypesEntry: true,
    }),
  ],
  build: {
    lib: {
      // путь к основному файлу библиотеки
      entry: path.resolve(__dirname, "src/lib/index.ts"),
      // название библиотеки
      name: "ReactTSLib",
      // форматы генерируемых файлов
      formats: ["es", "umd"],
      // названия генерируемых файлов
      fileName: (format) => `react-ts-lib.${format}.js`,
    },
    // https://vitejs.dev/config/build-options.html#build-rollupoptions
    rollupOptions: {
      external: ["react", "react-dom", "styled-components"],
      output: {
        globals: {
          react: "React",
          "react-dom": "ReactDOM",
          "styled-components": "styled",
        },
      },
    },
  },
});

Разработка компонента


Определяем минимальные стили и несколько переменных в файле index.css:


/* импортируем шрифт */
@import url("https://fonts.googleapis.com/css2?family=Montserrat&display=swap");

/* определяем переменные */
/* палитра `Bootstrap` */
:root {
  --primary: #0275d8;
  --success: #5cb85c;
  --warning: #f0ad4e;
  --danger: #d9534f;
  --light: #f7f7f7;
  --dark: #292b2c;
  --gray: rgb(155, 155, 155);
}

/* "легкий" сброс стилей */
*,
*::before,
*::after {
  box-sizing: border-box;
  font-family: "Montserrat", sans-serif;
  margin: 0;
  padding: 0;
}

/* выравнивание по центру */
#root {
  align-items: center;
  display: flex;
  gap: 0.6rem;
  height: 100vh;
  justify-content: center;
}

Приступаем к разработке кнопки.


Работаем с файлом src/lib/Button/Button.tsx.


Импортируем зависимости:


import {
  ButtonHTMLAttributes,
  FC,
  MouseEventHandler,
  PropsWithChildren,
} from "react";
import styled from "styled-components";

Определяем перечисление с вариантами кнопки:


export enum BUTTON_VARIANTS {
  PRIMARY = "primary",
  SUCCESS = "success",
  WARNING = "warning",
  DANGER = "danger",
}

Определяем типы пропов:


type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: BUTTON_VARIANTS;
  onClick?: MouseEventHandler<HTMLButtonElement>;
};

Кроме стандартных атрибутов, кнопка принимает 2 пропа:


  • variant — вариант кнопки (primary и др.);
  • onClick — обработчик нажатия кнопки.

Определяем компонент кнопки:


const Button: FC<PropsWithChildren<Props>> = ({
  children,
  disabled,
  onClick,
  variant = BUTTON_VARIANTS.PRIMARY,
  ...restProps
}) => {
  // если кнопка заблокирована, переданный обработчик не вызывается
  const handleClick: MouseEventHandler<HTMLButtonElement> = (e) => {
    if (disabled) return;
    onClick && onClick(e);
  };

  return (
    <button disabled={disabled} onClick={handleClick} {...restProps}>
      {children}
    </button>
  );
};

Определяем стилизованную кнопку с помощью styled:


const StyledButton = styled(Button)`
  background-color: var(
    --${(props) => (props.disabled ? "gray" : props.variant ?? "primary")}
  );
  border-radius: 6px;
  border: none;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
  color: var(
    ${(props) =>
      props.variant &&
      (props.variant === BUTTON_VARIANTS.SUCCESS ??
        props.variant === BUTTON_VARIANTS.WARNING)
        ? "--dark"
        : "--light"}
  );
  cursor: ${(props) => (props.disabled ? "default" : "pointer")};
  font-weight: 600;
  letter-spacing: 1px;
  opacity: ${(props) => (props.disabled ? "0.6" : "1")};
  outline: none;
  padding: 0.8rem;
  text-transform: uppercase;
  transition: 0.4s;

  &:not([disabled]):hover {
    opacity: 0.8;
  }

  &:active {
    box-shadow: none;
  }
`;

Здесь хочется отметить 2 момента:


  • background-color: var(--${(props) => (props.disabled ? "gray" : props.variant ?? "primary")}); означает, что фоновый цвет зависит от варианта кнопки и определяется с помощью переменных, объявленных в index.css. Фон заблокированной кнопки — --gray или rgb(155, 155, 155), дефолтный фон — --primary или #0275d8;
  • это:

color: var(
  ${(props) =>
    props.variant &&
    (props.variant === BUTTON_VARIANTS.SUCCESS ??
      props.variant === BUTTON_VARIANTS.WARNING)
      ? "--dark"
      : "--light"}
);

означает, что цвет текста также зависит от варианта кнопки и определяется с помощью переменных CSS. Цвет текста кнопки успеха или предупреждения — --dark или #292b2c, цвет остальных кнопок — --light или #f7f7f7.


Полагаю, остальные стили вопросов не вызывают.


Повторно экспортируем кнопку и перечисление в файле src/lib/index.ts:


export { default as Button, BUTTON_VARIANTS } from "./Button/Button";

Посмотрим, как выглядит и работает наша кнопка.


Редактируем файл App.tsx:


import { Button, BUTTON_VARIANTS } from "./lib";

function App() {
  // обработчик нажатия кнопки
  // принимает вариант кнопки
  const onClick = (variant: string) => {
    // выводим сообщение в консоль инструментов разработчика в браузере
    console.log(`${variant} button clicked`);
  };

  return (
    <>
      {/* дефолтная кнопка */}
      <Button onClick={() => onClick("primary")}>primary</Button>
      {/* заблокированная кнопка */}
      <Button onClick={() => onClick("disabled")} disabled>
        disabled
      </Button>
      {/* успех */}
      <Button
        variant={BUTTON_VARIANTS.SUCCESS}
        onClick={() => onClick(BUTTON_VARIANTS.SUCCESS)}
      >
        {BUTTON_VARIANTS.SUCCESS}
      </Button>
      {/* предупреждение */}
      <Button
        variant={BUTTON_VARIANTS.WARNING}
        onClick={() => onClick(BUTTON_VARIANTS.WARNING)}
      >
        {BUTTON_VARIANTS.WARNING}
      </Button>
      {/* опасность */}
      <Button
        variant={BUTTON_VARIANTS.DANGER}
        onClick={() => onClick(BUTTON_VARIANTS.DANGER)}
      >
        {BUTTON_VARIANTS.DANGER}
      </Button>
    </>
  );
}

export default App;

Запускаем сервер для разработки с помощью команды npm run dev:





Сборка и публикация пакета


Редактируем файл package.json, определяя в нем название пакета (наш пакет будет иметь scope с оригинальным названием @my-scope (в данном случае префикс @ является обязательным)), его версию, лицензию, директорию с файлами, файл с типами, а также настраивая экспорты (разделы scripts, dependencies и devDependencies опущены):


{
  "name": "@my-scope/react-ts-lib",
  "version": "0.0.0",
  "license": "MIT",
  "files": [
    "dist"
  ],
  "main": "./dist/react-ts-lib.umd.js",
  "module": "./dist/react-ts-lib.es.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/react-ts-lib.es.js",
      "require": "./dist/react-ts-lib.umd.js"
    }
  }
}

Пример package.json (с дополнительными полями) реальной библиотеки можно найти здесь.


Обратите внимание: перед сборкой имеет смысл "чистить" package.json.


Устанавливаем пакет json в качестве зависимости для разработки:


npm i -D json

И определяем в разделе scripts следующую команду:


"prepack": "json -f package.json -I -e \"delete this.devDependencies; delete this.dependencies\"",

Выполняем сборку с помощью команды npm run build:





Это приводит к генерации директории dist с файлами библиотеки.


Для локального тестирования библиотеки необходимо сделать следующее:


  • находясь в корневой директории проекта, выполняем команду npm link для создания символической ссылки. Эта команда приводит к добавлению пакета в глобальную директорию node_modules. Список глобально установленных пакетов можно получить с помощью команды npm -g list --depth 0:




  • находясь в корневой директории (или любой другой), выполняем команду npm link @my-scope/react-ts-lib для привязки пакета к проекту.

Редактируем импорт в файле App.tsx:


import { Button, BUTTON_VARIANTS } from "@my-scope/react-ts-lib";

И запускаем сервер для разработки с помощью команды npm run dev:





Обратите внимание: после локального тестирования пакета необходимо выполнить 2 команды:


  • npm unlink @my-scope/react-ts-lib для того, чтобы отвязать пакет от проекта;
  • npm -g rm @my-scope/react-ts-lib для удаления пакета из node_modules на глобальном уровне.

Для публикации пакета в реестре npm необходимо сделать следующее:


  • создаем аккаунт npm;
  • авторизуемся с помощью команды npm login;
  • публикуем пакет с помощью команды npm publish.

Список опубликованных пакетов можно увидеть на странице своего профиля (в моем случае — это https://www.npmjs.com/~igor_agapov):





Генерация и визуализация документации


Устанавливаем пакет @storybook/builder-vite
в качестве зависимости для разработки:


npm i -D @storybook/builder-vite

И инициализируем Storybook с помощью следующей команды:


npx sb init --builder @storybook/builder-vite

Это приводит к генерации директории .storybook. Убедитесь, что файл main.js в этой директории имеет следующий вид:


module.exports = {
  "stories": [
    "../src/**/*.stories.mdx",
    "../src/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@storybook/addon-interactions"
  ],
  "framework": "@storybook/react",
  "core": {
    "builder": "@storybook/builder-vite"
  },
  "features": {
    "storyStoreV7": true
  }
}

Создаем в корневой директории файл .npmrc следующего содержания:


legacy-peer-deps=true

Создаем файл src/lib/Button/Button.stories.tsx следующего содержания:


import { ComponentMeta, ComponentStoryObj } from "@storybook/react";
import Button, { BUTTON_VARIANTS } from "./Button";
// импортируем стили
import "../../index.css";

// описание компонента и ссылка на него
const meta: ComponentMeta<typeof Button> = {
  title: "Design System/Button",
  component: Button,
};
export default meta;

// истории
// дефолтная кнопка
export const Default: ComponentStoryObj<typeof Button> = {
  args: {
    children: "primary",
  },
};
// заблокированная кнопка
export const Disabled: ComponentStoryObj<typeof Button> = {
  args: {
    children: "disabled",
    disabled: true,
  },
};
// успех
export const SuccessVariant: ComponentStoryObj<typeof Button> = {
  args: {
    children: "success",
    variant: BUTTON_VARIANTS.SUCCESS,
  },
};
// кнопка с обработчиком нажатия
export const WithClickHandler: ComponentStoryObj<typeof Button> = {
  args: {
    children: "click me",
    onClick: () => alert("button clicked"),
  },
};

Выполняем команду npm run storybook:







Пожалуй, это все, о чем я хотел рассказать в этой статье.


Надеюсь, вы узнали что-то новое и не зря потратили время.


Благодарю за внимание и happy coding!




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