В мире веб-разработки создание масштабируемого и удобного в сопровождении приложения React может оказаться непростой задачей. Приложение с хорошей архитектурой легче разрабатывать, поддерживать и тестировать, оно обеспечивает лучший опыт как для разработчиков, так и для пользователей. Эта статья расскажет вам о некоторых передовых методах и шаблонах проектирования, которые следует учитывать при разработке приложений React. Давайте погрузимся!

Оформить репозиторий Github здесь

Создание правильной структуры папок

Организация представлений и бизнес-логики в хорошо организованной структуре папок играет решающую роль в архитектуре приложения. Это упрощает навигацию, обслуживание и понимание базы кода. Давайте посмотрим, как мы можем этого добиться.

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

  • /api
    Каталог /api содержит все вызовы API к серверной части. У каждого отдельного домена API есть отдельная папка (например, /user, /weather). Внутри этих папок вы можете иметь index.ts для вызовов API и types.ts для типов TypeScript, относящихся к этому домену API, а apiRoutes содержит все маршруты в отдельном файле. Файл types.ts корневого уровня может содержать типы, используемые в разных доменах API. Иногда каталог /api упоминается как services, термин с более широким охватом, который может охватывать как бизнес-логику конкретного домена, так и вызовы API. Если вы предвидите, что ваше приложение значительно вырастет и выиграет от этой структуры, вы можете принять ее. Лично я склоняюсь к тому, чтобы начать с более простой структуры, а затем расширять ее по мере необходимости.

  • /assets
    Каталог /assets предназначен для статических ресурсов, таких как изображения и значки, которые будут использоваться в приложении.

  • /components
    Каталог /components — это место, где хранятся повторно используемые компоненты пользовательского интерфейса, которые совместно используются другими компонентами или страницами. Конкретные компоненты для страниц, которые используются только на одной странице, должны быть помещены в каталог pages/[pageName]/components. Каждый компонент имеет свою папку с кодом компонента (index.ts), стилями (styles.ts) и тестами (component.test.ts).

  • /config
    Каталог /config можно использовать для хранения различных файлов конфигурации, таких как конфигурация API или конфигурация темы. Все константы и переменные окружения должны быть импортированы отсюда, поэтому в будущем изменить любую из этих конфигураций будет проще.

  • /context
    Непосредственно в /context будут содержаться все поставщики контекстных API, используемые во всем приложении.

  • /hooks
    Каталог /hooks предназначен для пользовательских хуков React. Эти хуки можно использовать для абстрагирования сложной логики состояния или для совместного использования логики состояния между компонентами. Здесь мы будем размещать только общие или не связанные с редукцией хуки состояния.

  • /navigation
    Каталог /navigation содержит логику маршрутизации и конфигурацию.

  • /pages
    Каталог /pages содержит компоненты для отдельных страниц/маршрутов приложения. Каждая страница имеет свою папку с кодом компонента страницы (index.ts), стилями (styles.ts) и, возможно, каталогом /components. для компонентов, специфичных для этой страницы.

  • /state
    Каталог /state содержит всю логику глобального состояния приложения. Каждая функция/домен может иметь свой собственный каталог и включать index.ts для фактической логики состояния, types.ts для любых типов TypeScript, специфичных для этого состояния, и hooks.ts для предоставления пользовательских ловушек для этого состояния.

  • /utils
    Каталог /utils содержит служебные функции и вспомогательный код, которые можно использовать в приложении.

  • /App.tsx и /index.tsx
    App.tsx – это корневой компонент вашего приложения, в котором мне нравится заключать приложение в redux поставщик магазина, поставщик темы или любая другая оболочка уровня приложения, которую вы хотите поместить, а index.tsx — это точка входа вашего приложения, где React загружается вместе с вашим компонентом приложения.
  • /.prettierrc
    Использование файла .prettierrc в проекте может дать несколько преимуществ, когда речь идет о форматировании и согласованности кода. . Prettier — это популярный форматировщик кода, который помогает автоматизировать и обеспечивать единый стиль кода в проекте.

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

Использование шаблонов проектирования React

Шаблоны проектирования предоставляют рекомендации по решению распространенных проблем при проектировании программного обеспечения. Разработчики React используют несколько шаблонов проектирования. Вот несколько шаблонов проектирования React, которые могут помочь в создании надежных приложений:

  1. Шаблон контейнера/презентатора. Разделите компоненты на два типа: контейнеры (как все работает) и презентаторы (как все выглядит).
  2. Компоненты высшего порядка (HOC). Это функции, которые принимают компонент и возвращают новый компонент с дополнительными параметрами или поведением.
  3. Шаблон Render Props. Этот шаблон позволяет совместно использовать код между компонентами React, используя свойство, значением которого является функция.
  4. Context API и Provider Pattern: Context API в React позволяет обмениваться состоянием и передавать его через дерево компонентов без необходимости вручную передавать реквизиты на каждом уровне.
  5. Составные компоненты. В этом шаблоне родительские и дочерние компоненты работают вместе для достижения общей цели.
  6. Пользовательские хуки. Мощный шаблон, в котором вы можете создавать свои собственные хуки для совместного использования логики с отслеживанием состояния между компонентами.

Для приложений малого и среднего размера использование Context API и хуков в сочетании с упомянутыми шаблонами проектирования часто может обеспечить достаточные возможности управления состоянием. Этот подход может устранить необходимость во внешней библиотеке, тем самым уменьшив сложность и размер пакета вашего приложения.

Государственное управление

Для более крупных приложений с более сложными потребностями в управлении состоянием Redux (или аналогичные библиотеки, такие как MobX) часто становятся полезными. Эти библиотеки обеспечивают более структурированный подход к управлению состоянием с такими функциями, как поддержка промежуточного программного обеспечения для асинхронных действий, инструменты разработки для отладки с перемещением во времени и более предсказуемый контейнер состояния, который поддерживает состояние приложения в одном центральном хранилище.

Я предпочитаю использовать @reduxjs/toolkit, что значительно упрощает реализацию хранилища и действий. Мой типичный фрагмент редуктора выглядит следующим образом

import { getWeatherForecast } from "../../api/weather";
import { WeatherState } from "./types";
import { notifyDismiss, notifyError, notifyLoading, notifySuccess } 
 from "../../components/Notification";

const initialState: WeatherState = {
  loadingForcast: false,
  city: "Karachi",
  forcast: {
    latitude: 0,
    longitude: 0,
    temperature: 0,
    windspeed: 0,
  },
};

const weatherSlice = createSlice({
  name: "weather",
  initialState: initialState,
  reducers: {
    setCityAction: (state, action: PayloadAction<{ city: string }>) => {
      state.city = action.payload.city;
    },
  },
  extraReducers: (builder) => {
    //#region fetchWeatherForcast
    builder.addCase(fetchWeatherForcastThunk.pending, (state) => {
      state.loadingForcast = true;
    });
    builder.addCase(fetchWeatherForcastThunk.rejected, (state) => {
      state.loadingForcast = false;
    });
    builder.addCase(fetchWeatherForcastThunk.fulfilled, 
     (state, action) => {
      state.loadingForcast = false;
      state.forcast = {
        latitude: action.payload.latitude,
        longitude: action.payload.longitude,
        temperature: action.payload.current_weather.temperature,
        windspeed: action.payload.current_weather.windspeed,
      };
    });
    //#endregion
  },
});

export const fetchWeatherForcastThunk = 
createAsyncThunk("weather/fetchWeatherForcast", async (args, thunkApi) => {
  const { dispatch } = thunkApi;
  const loadingId = notifyLoading("Fetching forcast...");
  const response = await getWeatherForecast();
  notifyDismiss(loadingId);
  if (response.status && response.data) {
    notifySuccess("Successfully fetched forcast!");
    return response.data;
  }

  notifyError(response.error);
  throw new Error(response.error);
});

export const { setCityAction } = weatherSlice.actions;
export default weatherSlice.reducer;

Я бы порекомендовал использовать соглашение об именах постфиксов Action и Thunk в действиях редьюсера и асинхронных переходниках. Я склонен выдавать сообщения о загрузке или ошибках внутри самого асинхронного преобразователя, чтобы уведомить пользователей. Я использую react-toastifyдля оповещений, поэтому я могу просто вызывать статические функции, но если у вас есть собственная система оповещений на основе избыточности, вы можете использовать thunkApi из createAsyncThunk, чтобы отправить оповещение.

Разделение представлений и состояний

Чтобы управлять состоянием и потреблять его, я предпочитаю использовать пользовательские хуки. Эти крючки абстрагируются от бизнес-логики и обеспечивают необходимое состояние представлений. Эта практика способствует разделению задач, максимально отделяя уровень представления от бизнес-логики. Теперь давайте рассмотрим, как мы можем использовать состояние Redux с помощью пользовательского хука.

import { useSelector } from "react-redux";
import { fetchWeatherForecastThunk, setCityAction } from ".";
import { RootState, useAppDispatch } from "../store";

export const useWeatherForecast = () => {
  const weather = useSelector((state: RootState) => state.weather);
  const dispatch = useAppDispatch();

  const fetch = () => {
    dispatch(fetchWeatherForecastThunk());
  };

  return {
    fetch,
    fetchDep: [],
    loading: weather.loadingForecast,
    forecast: weather.forecast,
  };
};

export const useWeatherCity = () => {
  const { city } = useSelector((state: RootState) => state.weather);
  const dispatch = useAppDispatch();

  const setCity = (city: string) => {
    dispatch(setCityAction({ city }));
  };

  return {
    city,
    setCity,
  };
};

// usage in component
const Home = () => {
  const { fetch, fetchDeps } = useWeatherForecast();
  
  // call fetch everytime the fetchDeps change
  useEffect(()=> {
    fetch()
  },fetchDeps)

  return (
    ...
};

Следующие хуки являются прекрасными примерами инкапсуляции бизнес-логики в хуки, чтобы отделить их от представлений, представление даже не знает, откуда поступают данные, вызов API, локальное хранилище или избыточность, они просто отвечают за просмотр данных. . Я бы порекомендовал разработать эти хуки в соответствии с вашими взглядами, чтобы они могли обеспечить необходимое состояние, необходимое конкретному компоненту. Если мы посмотрим на useWeatherForcast, он используется в компоненте WeatherForcastDetails, тогда как хук useWeatherCityиспользуется только внутри SelectCity, отображая только то состояние, которое необходимо компоненту SelectCity.

Еще один важный аспект, о котором следует упомянуть, заключается в том, что у нас была возможность включить useEffect для вызова функции fetch внутри самого хука useWeatherForecast. Однако, поскольку мы также возвращаем прогноз, который можно использовать в разных местах, размещение useEffect внутри ловушки приведет к множественным вызовам выборки везде, где используется ловушка. . Однако наше намерение состоит в том, чтобы вызвать его только один раз. Вот почему выгоднее возвращать функцию выборки и вызывать ее только в том месте, где она необходима.

Когда использовать Context API или Redux?

Когда я начал работать с React, меня озадачил вопрос: можем ли мы по-прежнему использовать Context API для некоторых задач, если мы уже используем решение для управления глобальным состоянием, такое как Redux? Ответ: Да!

Проиллюстрируем это понятие примером.

Предположим, мы создаем пользовательский компонент формы, который содержит несколько внутренних компонентов, каждый из которых должен знать о состоянии своих родительских или одноуровневых компонентов. Хотя мы можем использовать либо Redux, либо Context API для решения этой проблемы, Context API кажется более подходящим. Почему?

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

Вот как вы можете структурировать такой компонент с помощью Context API:

// Context
const UserEditContext = React.createContext<UserEditFormState>({} as UserEditFormState);

const UserEditFormProvider: React.FC<Props> = ({ children }) => {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [website, setWebsite] = useState("");

  return (
    <UserEditContext.Provider
      value={{
        name,
        email,
        website,
        setName,
        setEmail,
        setWebsite,
      }}
    >
      {children}
    </UserEditContext.Provider>
  );
};

// Form Parent component
import { TextField, Typography } from "@mui/material";

const UserProfileEditForm = () => {
  return (
    <UserEditFormProvider>
      <form>
        <Typography>Name</Typography>
        <MyOwesomeTextField />
        <Email />
        <Website />
      </form>
    </<UserEditFormProvider>
  );
};

// Form child components
const MyOwesomeTextField = ()=> <TextField/>
// we have a required condition that the email 
// and website domain should match
const Email = ()=> {
 const validateEmail = ()=> { ... }
 return <TextField/>
}
const Website = ()=> {
  const { website, email, setWebsite } = useContext(UserEditContext);
  const [isValid, IsValid] = useState(true);
  useEffect(() => {
    const validateWebsite = () => {
      try {
        const emailDomain = email.split("@")[1];
        const urlDomain = new URL(website).hostname;
        return emailDomain === urlDomain;
      } catch (e) {}
      return false;
    };
  return <TextField error={!isValid} value={website} 
          onChange={(e) => setWebsite(e.target.value)} />
}

В этом примере компоненты электронной почты и веб-сайта должны совместно использовать состояние для проверки домена веб-сайта и электронной почты. Однако это общее состояние является внутренним для компонента формы и не является обязательным в глобальном состоянии, поэтому сохранение его в контексте, привязанном к форме, имеет больше смысла. Другие примеры могут включать ThemeProvider или компонент Select из Material-UI.

С другой стороны, когда нам нужно получить данные, такие как пользователь или продукт, и использовать их во всем приложении, лучше использовать Redux. Это связано с тем, что такие данные не нужно изолировать в пределах определенного компонента или группы компонентов, а скорее совместно использовать многие компоненты, потенциально находящиеся на разных уровнях дерева компонентов.

Бонус

  • Если вы посмотрите на функцию setCity из хука useWeatherCity , вы заметите, что setCity — это просто оболочка для диспетчера setCityAction, нам нужно создавать эти ненужные функции-оболочки диспетчеризации каждый раз, когда код выглядит избыточным.
const useWeatherCity = () => {
  const { city } = useSelector((state: RootState) => state.weather);
  const dispatch = useAppDispatch();

  const setCity = (city: string) => {
    dispatch(setCityAction({ city }));
  };

  return {
    city,
    setCity,
  };
}

Чтобы избежать этого, вы можете использовать bindActionCreatorsиз redux, чтобы обернуть создателей действий с отправкой без написания конкретных функции с диспетчеризацией для них.

const userSlice = createSlice({
  ...
  reducers: {
    setUserName: (state, action: PayloadAction<string>) => {
      state.user.name = action.payload;
    },
    setUserEmail: (state, action: PayloadAction<string>) => {
      state.user.email = action.payload;
    },
  },
});

const userActions = userSlice.actions;

export const useUser = () => {
  const dispatch = useAppDispatch();
  ...
  return {
    ...,
    ...bindActionCreators(userActions, dispatch),
  };
};

Обратите внимание, что я не использовал постфикс Action для всех действий, потому что в том виде, в каком я их использую сейчас, они не являются создателями действий, а являются непосредственными диспетчерами этих действий. .

  • Не записывайте маршруты страниц в строку везде в вашем приложении. Если вы решите изменить имя маршрута, вам нужно будет найти и изменить его везде, вместо этого создайте объекты для всех маршрутов и используйте их для навигации.
const pages = {
  home: {
    title: "Home",
    route: "/",
    element: <Home />,
  },
  userProfile: {
    title: "Profile",
    route: "/userProfile",
    element: <UserProfile />,
  },
};

// map in layout
<Routes>
      {Object.values(pages).map((page, index) => (
        <Route key={index} path={page.route} element={page.element} />
      ))}
</Routes>

// usage
const navigate =useNavigate()
navigate(pages.home.route);
  • Используйте общий интерфейс ответа для всех вызовов API, чтобы у вас был стандартный способ работы с ответами API.
// standard interface for all api call responses
interface ApiResponse<T> {
    status: boolean;
    message: string;
    data?: T;
    error?: any;
}

// use like this
const getWeatherForecast = 
 async (): Promise<ApiResponse<WeatherForcastRaw>> => {
  try {
    const response = await backendApi.get(apiRoutes.weather.getForcast);
    return {
      status: true,
      message: "weather fetched sucessfully!",
      data: response.data,
    };
  } catch (e) {
    return {
      status: false,
      message: "error fetching weather forcast",
    };
  }
};
  • Я часто вижу, что нам нужно получить некоторое состояние сокращения в useEffect для некоторых функций, таких как userAuth, продукты, настройки и т. д. Все эти функции требуют, чтобы эти состояния были получены в useEffect с некоторыми изменениями deps. Поэтому я стараюсь делать все эти хуки для конкретных функций, иметь похожий интерфейс для выборки и загрузки, а затем использовать общий хук для извлечения данных. Поясню на примере
export interface GenericStateFetchHook {
  fetch: () => Promise<any> | void;
  loading: boolean;
  fetchDep: any[];
}

export const useGenericStateFetch = 
 (useGenericHook: () => GenericStateFetchHook) => {
  const values = useGenericHook();

  useEffect(() => {
    values.fetch && values.fetch();
  }, values.fetchDep);

  return values;
};

...
// In another component

// use this custom hook
useGenericStateFetch(useWeatherForcast);

// Instead of
const { fetch, fetchDeps } = useWeatherForcast()
useEffect(()=>{
  fetch()
},[fetchDeps])

// I am over engineering to save two lines I guess, but whetever.. 😂
  • MUI — это моя библиотека стилей goto для css, но последняя версия обесценила makeStyles и не предоставляет лучшей альтернативы стилям объектов CSS (написание css в объектах javascript), которые мне лично нравятся. Emotion предоставляет поддержку css, но она не такая, как в случае с makeStyles, поэтому я создал пользовательскую функцию makeStyles, которая работает так же, как и старая makeStyles, но совместима с ней. с МУИ v6.
import { Interpolation } from "@emotion/react";
import { Theme } from "@mui/material";
import { theme } from "../config/theme";

const makeStyles = <T extends string>(style: (theme: Theme) => 
   Record<T, Interpolation<Theme>>) => {
  return style(theme);
};

const styles = makeStyles((theme) => ({
  root: {
    display: "flex",
    gap: "10px",
    height: "100%",
    alignItems: "center",
  },
  span: {
    gap: "1.8rem",
    display: "flex",
    alignItems: "flex-start",
    width: "100%",
    paddingBottom: "1.5rem 0",
    flexDirection: "column",
  },
}));

// usage
<span css={styles.root}>
   <span css={styles.span}>
    ...
   </span>
</span>
  • Для приложения Next.js вы можете использовать аналогичную структуру папок. Переместите компоненты уровня страницы в основную папку «components» и создайте отдельную папку для компонентов каждой страницы. Кроме того, включите «общую» папку для организации общих компонентов.
/components
 /common
  /Button
   index.ts
   styles.ts
 /home
  /WeatherForecastDetail
   index.ts
   styles.ts

Заключение

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

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

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