
Создание масштабируемого и легко поддерживаемого проекта на React — не самая простая задача. За три года разработки я видел совершенно разные подходы и практики. Но со временем начинаешь замечать закономерности и лучшие практики.
И вот я решил написать стартер для достаточно больших приложений, который сочетает в себе все необходимые инструменты для быстрой и относительно надежной разработки. В сфере разработки все развивается довольно быстро и конечно со временем я буду пересматривать выбранный стек технологий. Но на сентябрь 2023 года это, на мой сугубо личный взгляд это довольно хороший выбор технологий.
Итак приступим. Как я уже упомянул проект должен быть легко масштабируемым и надежным. Так что в нем будут использоваться следующие технологии:
- TypeScript
- Redux Toolkit / RTK Queries
- React Router v6
- Jest / Enzyme
- Eslint / Prettier / StyleLint
- StoryBook
Кроме того для удобства разработки я буду использовать алиасы в импортах.
Что касается стилей — я убедился, что модульная система ускоряет разработку потому что ты не тратишь много времени на придумывание классов. В большом приложении это плюс. Попутно установим classnames библиотеку для правильной конкатенации названия классов в html разметки.
И как дополнительные плюшки этого стартера — подключенная мультиязычность и смена тем.
Вы можете посмотреть готовую структуру проекта в этом репозитории.
Инициализация проекта с помощью CRA
Инициализировать реакт приложение я буду с помощью CRA (Create React App) с темплейтом typescript
npx create-react-app react_app_starter --template typescript
Удалим все ненужные файлы и создадим домашнюю страницу. Под ненужными файлами я имею ввиду автогенерирование файлы App и logo.svg. все страницы приложения будут располагаться в папке pages. Сюда и поместим наш папку Home с файлом Home.ts
const Home = () => {
return <div>This is a Home page</div>
}
export default Home;
Установка Redux Toolkit
Затем устанавливаем Redux toolkit
npm i react-redux @reduxjs/toolkit
Далее настраиваем стор как в официальной документации
Предпочтения по расположению файлов связанных с redux у всех разные. Я предпочитаю создать папку store и поместить в нее сам стор, типизированные хуки, апи запросы и типы связанные с redux. Возможно кто-то привык по-другому.
Итак располагаем файл index.ts в папке store и помещаем в него следующие настройки:
store/index.ts
import { configureStore } from "@reduxjs/toolkit";
export const store = configureStore({
reducer: {}
});
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
Пока reducer пуст. Следующий шаг это обернуть наше приложение в redux Provider и передать ему созданный store
root/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App';
import { store } from './store';
import './index.css';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement,
);
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
);
reportWebVitals();
Далее создаем storeHooks.ts файл в папке hooks и помещаем в него следующие хуки:
store/hooks/storeHooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { TypedUseSelectorHook } from 'react-redux'
import type { RootState, AppDispatch } from '../index'
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
Создание первого редьюсера
Создадим простую фичу чтобы удостовериться что все работает. Так как у нас должна быть возможность менять темы, давайте создадим файл Themes.slice.tsx в папке features/Themes
Создадим простую фичу чтобы удостовериться что все работает. Так как у нас должна быть возможность менять темы, давайте создадим файл Themes.slice.tsx в папке features/Themes
src/features/themes/themes.slice.ts
import { createSlice } from '@reduxjs/toolkit';
interface IInitialState {
theme: 'dark' | 'light';
}
const initialState: IInitialState = {
theme: 'light',
};
export const themesSlice = createSlice({
name: 'themes',
initialState,
reducers: {
toggleTheme: (state) => {
state.theme = state.theme === 'light' ? 'dark' : 'light';
},
},
});
export const { toggleTheme } = themesSlice.actions;
export default themesSlice.reducer;
Здесь добавлен простой reducer для переключения темы. Теперь добавим его в основной store:
store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { themesReducer } from '../features/themes';
export const store = configureStore({
reducer: {
theme: themesReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Добавим переключатель тем на нашу домашнюю страницу:
Home.tsx
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { currentThemeSelector, toggleTheme } from '../../features/themes';
import { type FC } from 'react';
const Home: FC = () => {
const currentTheme = useAppSelector(currentThemeSelector);
const dispatch = useAppDispatch();
const handleChangeTheme = () => {
dispatch(toggleTheme());
};
return (
<>
<h1>This is a Home page</h1>
<p>
Now is <b>{currentTheme}</b> theme.
</p>
<button type="button" onClick={handleChangeTheme}>
Change theme
</button>
</>
);
};
export default Home;
Обратите внимание что в приложении используются кастомные хуки useAppDispatch и useAppSelector. Пока это просто бесполезная переключалка, позже мы добавим реальную логику изменения тем.
Использование RTK Queries для подключения к API
Куда же без подключения к api. Redux Toolkit представляет прекрасный инструмент для этого — RTK Queries. Это инструмент, который будет получать данный и кэшировать их, кроме того он избавляет нас от необходимости описывать стадии получение данных (isLoading, isError, isSuccess…). Все это идет прямо из коробки.
Как это работает?
Давайте создадим простой апи слайс, в котором будем принимать todos по адресу https://jsonplaceholder.typicode.com/todos/. Поместим его в файл todosApi.ts в src/store/api директории:
todosApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { type Todo } from '@types';
export const todosApi = createApi({
reducerPath: 'todosApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com/',
}),
endpoints: (builder) => ({
getTodos: builder.query<Todo, undefined>({
query: () => 'todos',
}),
}),
});
export const { useGetTodosQuery } = todosApi;
createApi это ядро функционала RTK Query. В ней определяются endpoints, настраиваются запросы (например headers) и можно приводить полученные данные к нужному виду.
Немного обсудим что мы передали в функцию createApi:
- reducerPath — это путь api слайса, который мы сможем увидеть в Redux Dev Tools extension в браузере Chrome.
- baseQuery — здесь ожидается обертка для fetch api — fetchBaseQuery. Сюда мы можем передать baseUrl, установить нужные нам headers (например Authorization)
- endpoints — создаем сами эндпоинты. По сути это callback, который возвращает объект, где ключи это названия endpoints и значения это callbacks возвращающие данные.
С расширением приложения мы будем создавать новые api слайсы, но чаще всего baseUrl у нас будет одинаковый. Поэтому вынесем его в отдельный файл. Кроме того, в реальных проектах все rest api urls хранятся в .env файле:
.env
REACT_APP_DOMAIN_API=https://jsonplaceholder.typicode.com/
Для кастомных env переменных мы должны подставлять REACT_APP префикс.
Вот как будет выглядеть файл client.ts куда мы вынесем общий baseUrl:
client.ts
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const baseUrl = fetchBaseQuery({
baseUrl: process.env.REACT_APP_DOMAIN_API,
});
Так как информация в файле .env не должна быть в открытом доступе и находиться в репозитории, мы создадим .env.development файл с теми переменными, которые есть в .env файле, а затем на сервере вручную добавлять все необходимые данные. Здесь могут быть секретные ключи, данные для acceses и refresh токенов и т.п. Cам файл .env добавим .gitignore.
Обновим todosApi.ts:
todosApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { baseQuery } from '@store/api';
import { type Todo } from '@types';
export const todosApi = createApi({
reducerPath: 'todosApi',
baseQuery,
endpoints: (builder) => ({
getTodos: builder.query<Todo, undefined>({
query: () => 'todos',
}),
}),
});
export const { useGetTodosQuery } = todosApi;
А теперь добавим созданный апи слайс в стор:
store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { todosApi } from '@store/api';
import { themesReducer } from '@features/themes';
export const store = configureStore({
reducer: {
theme: themesReducer,
[todosApi.reducerPath]: todosApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(todosApi.middleware),
});
Теперь мы можем использовать хук useGetTodosQuery в нашем компоненте.
Home.tsx
import { type FC } from 'react';
import { Button } from '@components';
import { useAppDispatch, useAppSelector } from '@store/hooks';
import { useGetTodosQuery } from '@store/api';
import { currentThemeSelector, toggleTheme } from '@features/themes';
import styles from './Home.module.scss';
const Home: FC = () => {
const currentTheme = useAppSelector(currentThemeSelector);
const dispatch = useAppDispatch();
const { data, isLoading, isError } = useGetTodosQuery(undefined);
const handleChangeTheme = () => {
dispatch(toggleTheme());
};
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Something went wrong</p>;
console.log('todos ', data);
return (
<div className={styles.home_page}>
<h1 className={styles.home_page__title}> This is a Home page</h1>
<p className={styles.home_page__paragraph}>
Now is <b>{currentTheme}</b> theme.
</p>
<Button type="button" onClick={handleChangeTheme} label="Change theme" />
</div>
);
};
export default Home;
Как видим RTK Query генерируют хук, который формирует респонс и автоматически описывает стадии ответа (isLoading, isError, isSuccess, isFetching и т.п).
Подключение React Router DOM v6
Для роутинга между страницами мы будем использовать библиотеку React Router DOM v6.
Для этого запустим установку с помощью команды:
npm i react-router-dom
И обновим App компонент. Позже переменную router можно будет вынести в отдельный файл.
App.tsx
import { type FC, useMemo } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { Home } from '@pages';
const App: FC = () => {
const router = useMemo(
() =>
createBrowserRouter([
{
path: '/',
element: <Home />,
},
]),
[],
);
return <RouterProvider router={router} />;
};
export default App;
Подключение ESlint и Prettier
Что такое ESlint?
Eslint это инструмент который анализирует ваш код и помогает обнаружить и исправить проблемы. В конфигурационном файле указывается набор правил, которым код должен соответствовать (порядок импортов, добавление точки с запятой в конце и т.п.). Эти правила по сути отдельные плагины, которые со временем могут добавляться. Благодаря этому инструменту, вся команда пишет в одном стиле.
Что такое Prettier?
Prettier это инструмент для выравнивания кода. Имеется в виду соблюдение установленных в конфигурационном файле правил. Например, размер отступов, длина строки, одинарные или двойные кавычки и тому подобное. Можно настроить редактор кода так, чтобы при сохранении применялись весь код выравнивался автоматически. Это сохраняет много времени.
Установка ESlint
Давайте добавим эти полезные тулзы.
npm i --save-dev eslint
И инициализируем конфигурационный файл:
npx eslint --init
В процессе установки вам нужно будет ответить на несколько вопросов о том чего вы ожидаете от Eslint.
How would you like to use Eslint? — To check syntax, find problem, end enforce code style
What type of modules does your project use? — JavaScript modules (import/export)
Which framework does your project use? — React
Does your project use TypeScript? — Yes
Where does your code run? — Browser
How would you like to define a style for your project? — Use a popular style guide
Which style guide do you want to follow? — Standard
What format do you want your config file to be in? — JS or JSON
В процессе инициализации у меня вылетела ошибка — Conflicting peer dependency: @typescript-eslint/parser@6.7.0
Это можно решить добавив в package.json overrides объект
package.json
"overrides": {
"@typescript-eslint/parser": "6.7.0"
}
У вас может возникнуть другой конфликт, но паттерн решения скорее всего будет таким же.
Так как мы собираемся использовать тесты, то в будущем столкнемся с проблемой что test или expect не найден. Eslint тоже должен знать что мы используем jest. Поэтому добавим в созданный .eslintrc.json файл jest:true внутри env:
.eslintrc.json
"env": {
"browser": true,
"es2021": true,
"jest": true
},
Установка Prettier
Затем устанавливаем prettier и пакеты для совместной с eslint работы.
npm i —save-dev eslint-config-prettier eslint-plugin-prettier prettier
У меня снова возник конфликт зависимостей Conflicting peer dependency: typescript@4.9.5. Как его решить мы уже знаем. Добавить в overrides туже версию что в devDependencies:
package.json
"overrides": {
"@typescript-eslint/parser": "6.7.0",
"typescript": "^5.2.2"
}
Добавим установленные плагины в eslintrc.json:
.eslintrc.json
"extends": [
"standard-with-typescript",
"plugin:react/recommended",
"eslint:recommended",
"plugin:prettier/recommended"
],
Теперь необходимо настроить Prettier. Для этого создадим конфигурационный файл .prettierrc в корне проекта и добавим в него базовые настройки стиля кода:
.prettierrc
{
"printWidth": 80,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"trailingComma": "all"
}
В данном случае мы указали максимальную длину строки, таб по сути является 2-мя пробелами, в конце ставить точку с запятой, использовать одинарные кавычки и поставить запятые в конце везде где это возможно. Другие опции вы можете найти на официальном сайте.
Итак, Eslint и Prettier настроены. Давайте добавим новые скрипты, чтобы запускать linter и prettier.
package.json
"scripts": {
...
"lint": "eslint --ext .tsx,.ts .",
"lint:fix": "eslint --fix --ext .tsx,.ts .",
"format": "prettier --write './**/*.{js,jsx,ts,tsx,css,md,json}' --config ./.prettierrc"
},
Но сила этих инструментов в автоматическом форматировании кода. Обычно эти скрипты навешиваются на событие сохранения. Посмотрите как это сделать в своем редакторе кода. Если вы используйте webstorm, то:
- Зайдите в Settings -> Languages & Frameworks -> JavaScript -> Code Quality tools -> ESlint
- Выберите Automatic ESlint configuration
- Поставите галочку в чекбокс Run eslint –fix on save
- Нажмите Apply
- Затем перейдите в Settings -> Languages & Frameworks -> JavaScript -> Prettier
- Выберите Automatic Prettier configuration
- Поставите галочку в чекбокс Run on save
- Нажмите OK
Теперь откройте какй-нибудь файл и удалите точку с запятой или добавьте отступ. Сохраните (Ctrl + S). Код должен отформатироваться согласно правилам в файле .prettierrc.
Порядок импортов.
Благодаря ESlint мы можем настроить порядок импортов. Это полезно, особенно для больших компонентов и помогает придерживаться единого стиля в коде. Обычно для этого устанавливают дополнительный плагин, который автоматически (например в момент сохранения) выстраивает импорты в нужном порядке. Это eslint-plugin-import.
Возможно он уже установился автоматически. Если нет то выполните эту команду.
npm install eslint-plugin-import --save-dev
Давайте добавим сам плагин в наш файл .eslintrc.json и некоторые правила:
.eslintrc.json
{
...
"extends": [
...
"plugin:import/recommended",
"plugin:import/typescript"
],
...
"rules": {
...
"import/order": [
"error",
{
"groups": [
"builtin",
"external",
"internal",
["sibling", "parent"],
"index",
"unknown"
],
"newlines-between": "always",
"alphabetize": {
"order": "asc",
"caseInsensitive": true
}
}
]
}
}
Вы можете более подробно посмотреть настройки плагина здесь.
Теперь при сохранении импорты сортируются. Давайте добавим алиасы, чтобы длина самих путей не разрасталась, особенно для вложенных компонентов. Решить эту проблему помогут алиасы. И после немного поправим это правило.
Добавление алиасов путей в импорты
Добавив сами алиасы в tsconfig.json файл:
tsconfig.json
{
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@components": ["./components"],
"@components/*": ["./components/*"],
"@store": ["./store"],
"@store/*": ["./store/*"],
"@features/*": ["./features/*"],
"@providers/*": ["./providers/*"],
"@pages": ["./pages"],
"@pages/*": ["./pages/*"],
"@constants/*": ["./constants/*"]
},
...
}
Обратите внимание что алиасы @components и @components/* для typescript разные.
Я столкнулся с ошибкой import/no-unresolved. Дело в резолвере который используется в typescript проекте. Чтобы исправить это установим еще один пакет:
eslint-import-resolver-typescript
и добавим следующий код в .eslintrc.json
.eslintrc.json
{
...
"settings": {
"import/resolver": {
"typescript": {} // this loads <rootdir>/tsconfig.json to eslint
}
},
...
}
Перезагрузите редактор кода и попробуйте заменить импорты на алиасы.
Возможно некоторые пути не будут работать. Проблема в конфигурации webpack. Поскольку он не знает об этих алиасах. Нам нужно объяснить webpack, что мы используем алиасы.
Но как изменить конфигурацию webpack если мы создали приложение путем create-react-app. Для этого существует специальный инструмент craco (Create React App Configuration Override)
Установим его с помощью простой команды (как дев зависимость):
npm i -D @craco/craco
Теперь мы можем создать файл craco.config.ts который будет дополнять webpack конфиги:
.craco.config.ts
const path = require('path');
module.exports = {
webpack: {
alias: {
'@components': path.resolve(__dirname, 'src/components'),
'@store': path.resolve(__dirname, 'src/store'),
'@features': path.resolve(__dirname, 'src/features'),
'@providers': path.resolve(__dirname, 'src/providers'),
'@pages': path.resolve(__dirname, 'src/pages'),
'@constants': path.resolve(__dirname, 'src/constants'),
},
},
};
У меня возникл ошибка ESLint (Parsing Error). Решил путем добавления файла .eslintignore в который включил следующее:
.eslintignore
.eslintrc.json
*.config.ts
А также нужно заменить теперь наши скрипты в package.json
package.json
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test",
"eject": "craco eject",
...
}
Запускаем, проверяем — все должно работать.
Так теперь выглядит Home.tsx компонент:
Home.tsx
import { type FC } from 'react';
import { Button } from '@components';
import { currentThemeSelector, toggleTheme } from '@features/themes';
import { useAppDispatch, useAppSelector } from '@store/hooks';
import styles from './Home.module.scss';const Home: FC = () => ...
Мы можем пойти еще дальше и добавить паттерны импортов в exlint import/order правило:
.eslintrc.json
{
...
"rules": {
...
"import/order": [
"error",
{
"groups": [
"builtin",
"external",
"internal",
["sibling", "parent"],
"index",
"unknown"
],
"pathGroups": [
{
"pattern": "+(react|react-dom|react-router|react-router-dom|react-redux|react-intl)",
"group": "builtin",
"position": "before"
},
{
"pattern": "@components",
"group": "external",
"position": "after",
"patternOptions": { "partial": true }
},
{
"pattern": "@pages/**",
"group": "external",
"position": "after",
"patternOptions": { "partial": true }
},
{
"pattern": "@store/**",
"group": "external",
"position": "after",
"patternOptions": { "partial": true }
},
{
"pattern": "@hooks/**",
"group": "external",
"position": "after",
"patternOptions": { "partial": true }
},
{
"pattern": "@constants/**",
"group": "external",
"position": "after",
"patternOptions": { "partial": true }
},
{
"pattern": "*.constants",
"group": "unknown",
"position": "after",
"patternOptions": { "matchBase": true }
},
{
"pattern": "*.+(scss|css|json)",
"group": "unknown",
"position": "after",
"patternOptions": { "matchBase": true }
}
],
"newlines-between": "always",
"alphabetize": {
"order": "asc",
"caseInsensitive": true
}
}
]
}
}
Здесь в pathGroups массиве мы объединяем импорты в группы и указываем в какой последовательности они будут располагаться. Теперь импорты в файле Home.tsx будут выглядеть так:
Home.tsx
import { type FC } from 'react';
import { Button } from '@components';
import { useAppDispatch, useAppSelector } from '@store/hooks';
import { currentThemeSelector, toggleTheme } from '@features/themes';
import styles from './Home.module.scss';
const Home: FC = () => { ...
Запустим npm run lint:fix чтобы использовать эти настройки для всех компонентов.
Настройка Stylelint
Что такое Stylelint?
По сути это такой же линтер, но для файлов стиля (CSS CSS-like: SCSS, Sass, Less and SugarSS). Благодаря нему, всей команде удастся писать в одинаков стиле, хотят они того или нет.
Первым делом установим сам препроцессор sass с помощью команды:
npm i sass --save-dev
Установим stylelint и стандартную конфигурацию с помощью команды:
npm i --save-dev stylelint stylelint-config-standard stylelint-config-standard-scss stylelint-config-property-sort-order-smacss
Создадим файл .stylelintrc.json в корне проекта. И впишем в него следующее:
.stylelintrc.json
"extends": [
"stylelint-config-standard",
"stylelint-config-standard-scss",
"stylelint-config-property-sort-order-smacss"
]
Теперь протестируем. Создадим файл Home.scss в src/pages/Home папке и напишем несколько стилей для домашней страницы.
Home.scss
.home-page {
padding: 20px;
&__title {
color: dodgerblue;
}
&__paragraph {
border: 1px solid dodgerblue;
display: inline-block;
padding: 10px;
border-radius: 4px;
}
&__button {
margin-left: 20px;
background-color: dodgerblue;
color: white;
border: 1px solid dodgerblue;
cursor: pointer;
border-radius: 4px;
padding: 5px;
transition: all 0.2s ease;
&:hover {
color: black;
background-color: azure;
}
}
}
Я специально расположил селекторы в хаотичном порядке. Stylelint должен все это упорядочить. Добавим новую команду в scripts в package.json
"scripts": {
...
"stylelint:fix": "stylelint --fix **/*.{css,scss}",
}
Запустив его мы сразу увидим ряд ошибок. А возможно ваш редактор кода сразу покажет эти ошибки. Если нет, то настройте stylelint в своем редакторе кода.
Установка CSS Modules
Что такое CSS Modules?
Это не какие-то файлы с особым расширением. Это подход к организации CSS файлов. В связке со сборщиками, такими как Webpack CSS Modules изолируют ваши классы.
Делается это путем добавления названия компонента и рандомного хэша к названию вашего класса. Таким образом мы избегаем конфликтов при пересечении названий классов. И можем не тратить много времени на выдумывание уникального названия класса.
Как это работает?
В CRA автоматическая поддержка модулей. Поэтому достаточно просто добавить слово module к названию файла стилей. В нашем случае мы меняем Home.scss -> Home.module.scss
Но и присвоение классов к html элементам тоже отличается. Нужно помнить что после переименования scss файла он возвращает нам объект, где ключи это имена классов (кстати поэтому не стоит использовать kebab-case, вместо этого используйте ‘snake_case’, так как webpack из kebab-case делает camelCase).
Возможно stylelint будет ругаться на такое наименование классов и ожидать kebab-case. Добавим одно правило для этого в .stylelintrc.json файл:
"rules": {
"selector-class-pattern": [
"^([a-z][__a-z0-9-]*)(-[a-z0-9]+)*$",
{
"message": "Expected class selector to be kebab-case"
}
]
}
Прежде всего мы импортируем стили следующим образом:
import styles from './Home.module.scss';
и меняем className=”home_page” на className={styles.home_page}
Готово. Теперь если мы посмотрим DOM дерево в консоли, то увидим, что к названиям классов добавились имя компонента и сгенерированный хэш

Таким образом удается достичь уникальности в названиях классов.
Установка classnames
Установим classnames библиотеку с помощью простой команды npm install classnames
Можем использовать ее таким образом
Установка StoryBook
Что такое StoryBook и зачем он нужен?
StoryBook это как сказано в на их официальном сайте — мастерская для создания UI компонентов и страниц в изолированном виде. Это open source проект. С его помощью ведется документация о проекте и обо всех его компонентах.
Зачем это нужно?
Это очень удобно когда нужно посмотреть как ведет себя компонент в разных темах и на разных размерах экрана. А также как меняется его дизайн в зависимости от состояния. Если мы говорим об инпуте — hover, invalid, required, etc. Мы можем проверить все состояния компонента не разворачивая весь проект.
StoryBook поддерживает подключение дополнений, благодаря которым можно воспроизвести реальные события в изолированном виде.
Эта библиотека подразумевает, что сначала ты создаешь все UI компоненты в разных состояниях и ситуациях (которые тебе известны на данный момент), отдаешь тестировщику, а уже потом переносишь их в свой проект.
Когда каждый разработчик в команде имеет полное представление обо всех компонентов, как минимум это помогает избавиться от дублирования кода и от ненужных пропсов в компоненте.
И конечно вместо того чтобы тратить время тим лида, ты просто направляешь вновь прибывшего разработчика почитать StoryBook документацию.
Кроме того отдельные компоненты можно протестировать.
Историями или stories называют именно состояния каждого варианта компонента.
Установка StoryBook
Установим StoryBook CLI в наш проект с помощью команды:
npx storybook@latest init
Важный момент, что StroyBook должен устанавливаться в существующий проект. Он не может быть установлен в пустой проект.
В корень нашего проекта добавляется папка .storybook с двумя файлами внутри (мы к ним еще вернемся), а также в package.json автоматически добавляется 2 скрипта:
"scripts": {
...
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
После чего StoryBook сам и запускается (если нет — npm run storybook). А в сам проект добавляется папка stories. В моем случае линтер и prettier стали ругаться, если у вас то же самое — пофиксите ошибки и попробуйте запустить storybook снова.
Наигравшись с автоматически созданными компонентами и их историями, давайте создадим нашу собственную. У нас есть кнопка на Home странице. Давайте вынесем ее в отдельный компонент и напишем для нее историю.
В созданной директории componenets мы создаем Button/Button.tsx файл со следующим содержимым.
Button.tsx
import { type FC, type ButtonHTMLAttributes } from 'react';
import s from './Button.module.scss';
interface IButton extends ButtonHTMLAttributes<HTMLButtonElement> {
children: string;
}
const Button: FC<IButton> = ({ children, ...props }) => {
return (
<button className={s.button} {...props}>
{children}
</button>
);
};
export default Button;
Далее создаем историю, как бы пафосно это не звучало (Button.stories.tsx) со следующим кодом:
Button.stories.tsx
import { type Meta, type StoryObj } from '@storybook/react';
import Button from './Button';
const meta = {
title: 'components/Button',
component: Button,
parameters: {
layout: 'centered',
},
argTypes: {
variant: {
options: ['primary', 'secondary'],
control: { type: 'select' },
},
onClick: console.log,
},
tags: ['autodocs'],
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Basic: Story = {
args: {
outline: true,
label: 'Button',
},
};
Что здесь происходит?
В константе meta мы по сути и описываем нашу общую историю о компоненте.
- В title мы создаем заголовок который будет отражаться в StoryBook. Причем Заголовок Button будет находиться в разделе components. Благодаря такому названию, мы можем создавать внутреннюю вложенность компонентов, которое будет отражать наш реальный проект (components/path/to/Button)
- В parameters мы указали layout: ‘centered’. С помощью этого параметра мы конфигурируем расположение кнопки в превью.
- В argTypes мы указываем пропсы которые передаются компоненту. Причем мы можем сразу превратить их в контролсы (инпуты, радио кнопки, селекты и т.п.). И затем проще и быстрее протестировать компонент в разных его сотояних. В нашем случае мы передаем пропс variants в виде селекта с двуми опциями: ‘primary’ и ‘secondary’
- Мощной фичей StoryBook является автодокументация. Она начинает собираться после добавления в историю tags: [‘autodocs’]. Но откуда он берет данные? Из основного файла компонента. StoryBook включает в документацию комментарии написанные следующим образом:
/**
* This is a Button component
*/
В шапку документации добавляется комментарий до объявления компонента, а в описание пропсов — перед пропсами в интерфейсе.
В конце мы экспортируем константу Basic. Это собственно компонент с переданными пропсами (outline и label).
Мы еще вернемся к сторибук после реализации логики смены тем.
Установка Смены тем
Прежде всего мы создадим новую директорию providers, куда поместим наш провайдер тем со следующим содержанием:
ThemeProvider.tsx
import { type FC, type ReactElement, useEffect } from 'react';
import { currentThemeSelector } from '../../features/themes';
import { useAppSelector } from '../../store/hooks';
import { LS_THEME } from '../../constants';
interface IThemeProvider {
children: ReactElement;
}
const ThemeProvider: FC<IThemeProvider> = ({ children }) => {
const theme = useAppSelector(currentThemeSelector);
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem(LS_THEME, theme);
}, [theme]);
return <>{children}</>;
};
export default ThemeProvider;
Все что будет делать этот провайдер это добавлять атрибут data-theme с нашей темой в тег html. И сохранять значение переменной ‘theme’ в localStorage.
Обернем этим провайдером наше приложение:
index.tsx
root.render(
<React.StrictMode>
<Provider store={store}>
<ThemeProvider>
<App />
</ThemeProvider>
</Provider>
</React.StrictMode>,
);
И добавим логику в слайс, чтобы при обновлении страницы в наш redux стор загружалась тема из localStorage.
Добавим следующий код в
theme.slice.ts
import { createSlice } from '@reduxjs/toolkit';
const themes = ['dark', 'light'] as const;
type themeType = (typeof themes)[number];
const isThemeType = (theme: string): theme is themeType =>
themes.includes(theme as themeType);
interface IInitialState {
theme: themeType;
}
const themeInStorage = localStorage.getItem('theme');
const initialState: IInitialState = {
theme:
!!themeInStorage && isThemeType(themeInStorage) ? themeInStorage : 'light',
};
export const themesSlice = createSlice({
name: 'themes',
initialState,
reducers: {
toggleTheme: (state) => {
state.theme = state.theme === 'light' ? 'dark' : 'light';
},
},
});
export const { toggleTheme } = themesSlice.actions;
export default themesSlice.reducer;
Отлично, теперь осталось добавить логику изменения стилей, при переключении темы.
Создадим файл стилей themes.scss в папке assets и импортируем его в основной index.scss. Укажем в нем две переменные для разных тем — цвет фона и цвет текста:
themes.scss
:root[data-theme="light"] {
--background-color: #f0f0f0;
--text-color: #000;
}
:root[data-theme="dark"] {
--background-color: #282828;
--text-color: #fff;
}
При расширении проекта этот файл можно разбить на 2 для темной и светлой темы.
Теперь добавим эти переменные в Home.modules.scss.
Home.modules.scss
.home_page {
height: 100vh;
padding: 20px;
background-color: var(--background-color);
&__title {
color: dodgerblue;
}
&__paragraph {
display: inline-block;
padding: 10px;
border: 1px solid dodgerblue;
border-radius: 4px;
color: var(--text-color);
}
}
Теперь при изменении темы будет меняться цвет фона и цвет текста.
Добавление функционала изменения тем в StoryBook.
Чтобы правильно вести документацию проекта в StoryBook нам нужно добавить в него функционал изменения тем.
Для этого в автоматически созданный файл preview.tsx добавим следующий код:
preview.tsx
import { useEffect } from 'react';
import type { Decorator, Preview } from '@storybook/react';
import '../src/assets/themes.scss';
const withThemeDecorator: Decorator = (Story, context) => {
useEffect(() => {
const { theme } = context.globals;
document.documentElement.dataset.theme = theme;
}, [context]);
return <Story />;
};
export const decorators = [withThemeDecorator];
export const globalTypes = {
theme: {
name: 'Theme',
description: 'Global theme for components',
defaultValue: 'light',
toolbar: {
icon: 'circlehollow',
items: [
{ value: 'light', icon: 'circlehollow', title: 'light' },
{ value: 'dark', icon: 'circle', title: 'dark' },
],
showName: true,
},
},
};
export const parameters = {
layout: 'centered',
};
const preview: Preview = {};
export default preview;
Мы создаем кастомный декоратор, который меняет тему по нашей логике — добавляет data с темой в html тег. И экспортируем массиы decorators с нашим созданным декоратором.
Но чтобы иметь возможность переключать тему прямо из панели storybook, мы экспортируем константу globalTypes. В нее можно поместить что угодно. мы помещаем тему. А в toolbar указываем какую иконку использовать и что будет стоять за выбранным значением темы.
Готово!
Проверим как это работает. Добавим несколько переменных для нашей кнопки в файл со стилями темы:
themes.scss
:root[data-theme="light"] {
// MAIN
--background-color: #f0f0f0;
--text-color: #000;
// BUTTON
--primary-btn-bg: darkslateblue;
--primary-btn-color: white;
--secondary-btn-bg: darkslategrey;
--secondary-btn-color: white;
}
:root[data-theme="dark"] {
// MAIN
--background-color: #282828;
--text-color: #fff;
// BUTTON
--primary-btn-bg: hotpink;
--primary-btn-color: white;
--secondary-btn-bg: aquamarine;
--secondary-btn-color: white;
}
Изменим стили нашей кнопки, добавив переменные вместо установленных цветов:
Button.module.scss
.button {
margin-left: 20px;
padding: 5px;
transition: all 0.2s ease;
border-radius: 4px;
cursor: pointer;
}
.primary {
border: 1px solid var(--primary-btn-bg);
background-color: var(--primary-btn-bg);
color: var(--primary-btn-color);
&:hover {
background-color: var(--primary-btn-color);
color: var(--primary-btn-bg);
}
&__outline {
background-color: var(--primary-btn-color);
color: var(--primary-btn-bg);
&:hover {
background-color: var(--primary-btn-bg);
color: var(--primary-btn-color);
}
}
}
.secondary {
border: 1px solid var(--secondary-btn-bg);
background-color: var(--secondary-btn-bg);
color: var(--secondary-btn-color);
&:hover {
background-color: var(--secondary-btn-color);
color: var(--secondary-btn-bg);
}
&__outline {
background-color: var(--secondary-btn-color);
color: var(--secondary-btn-bg);
&:hover {
background-color: var(--secondary-btn-bg);
color: var(--secondary-btn-color);
}
}
}
Имплементация локализации.
Зачем нужна локализация?
Для многих приложений это необходимое требование, чтобы привлекать клиентов по всему миру. Речь идет не только о переводе. Но и правильном отображении времени, валюты и т.п.
Знакомство с react-intl.
Для локализации, мы использовали react-intl библиотеку на разных проектах. Это библиотека, которая полагается на относительно недавно встроенный в ECMAScript Intl API.
Здесь вы можете почитать какие методы предоставляет эта библиотека. Мы в основном будем использовать FormattedMessage.
Имплементация i18n (internationalization) довольно проста. Приложение будет хранить json файл для каждого языка, ключи в этих файлах будут одинаковые, а вот значения будут меняться в зависимости от языка. А вокруг приложения мы создадим контекст который будет подставлять устанавливать локализацию. И уже в самом коде функция FormattedMessage по ключу будет обращаться к json файлу выбранного в контексте языка. В будущем чтобы добавить язык, достаточно будет создать новый json файл со всеми ключами и новыми значениями (которые как раз и будут переводом).
Для начала установим саму библиотеку:
npm install react-intl
Затем создадим 2 файла json в src/lang — en.json и ru.json. Поместим них следующее содержание:
ru.json
{
"HomePageTitle": "Это домашняя страница"
}
en.json
{
"HomePageTitle": "This is a Home page"
}
Затем создаем провайдер и оборачиваем им наше приложение. В папке providers создадим файл LocalizationProvider
LocalizationProvider.tsx
import { type FC, type ReactElement } from 'react';
import { IntlProvider } from 'react-intl';
import { useAppSelector } from '@store/hooks';
import { currentLocaleSelector, Locale } from '@features/settings';
import messagesEn from '../../lang/en.json';
import messagesRu from '../../lang/ru.json';
const intlMessages = {
[Locale.English]: messagesEn,
[Locale.Russian]: messagesRu,
};
interface ILocalizationProvider {
children: ReactElement;
}
const LocalizationProvider: FC<ILocalizationProvider> = ({ children }) => {
const locale = useAppSelector(currentLocaleSelector);
return (
<IntlProvider
messages={intlMessages[locale]}
locale={locale}
defaultLocale={Locale.English}
>
{children}
</IntlProvider>
);
};
export default LocalizationProvider;
Теперь нужно создать свитчер который бы переключал эти языки. Добавим функцию в settings.slice.ts файл
settings.slice.ts
export const settingsSlice = createSlice({
name: 'settings',
initialState,
reducers: {
toggleTheme: (state) => {
state.theme = state.theme === Theme.Light ? Theme.Dark : Theme.Light;
},
setLocal: (state, action: PayloadAction<string>) => {
if (isLocaleType(action.payload)) {
state.locale = action.payload;
}
},
},
});
А в Home.tsx странице мы добавим сам селект, который будет переключать язык
Home.tsx
const handleLangChange = ({
target: { value },
}: ChangeEvent<HTMLSelectElement>) => {
dispatch(setLocal(value));
};
Заключение
Теперь все готово. Этот стартер поможет вам сразу приступить к разработке бизнес логики, не тратя время на настройку окружения и подключение необходимых библиотек.
Хорошего кодинга!