Разделение ответственности в React. Как использовать контейнерные и презентационные компоненты.

2 года назад·6 мин. на чтение

Многие новички в React объединяют логику и представление в одном и том же компоненте. И они могут не знать, почему важно разделять эти две вещи.

В таком случае может обнаружиться, что нужно внести большие изменения в файл. Затем придется вносить много переделок, чтобы разделить логику и представление. Это происходит из-за того, что разработчик может не знать о разделении ответственности и таком шаблоне как презентационные и контейнерные компоненты (presentational and container components). В этой статье рассмотрим этот паттерн, чтобы смягчить эту проблему на ранних этапах жизненного цикла разработки проекта.

Что такое разделение ответственности?

Разделение ответственности — это концепция, которая широко используется в программировании. В нем говорится, что логика, выполняющая разные действия, не должна группироваться или объединяться вместе. Например, то, что мы обсуждали во вводной части, нарушает разделение задач, потому что мы поместили логику выборки данных и представления данных в один и тот же компонент.
Чтобы решить эту проблему и придерживаться разделения ответственности, мы должны разделить эти две части — то есть запрос данных и их представление в пользовательском интерфейсе — на два разных компонента. Шаблон контейнеры и презентационные компоненты (smart/dummy components) поможет нам решить эту проблему.

Что такое контейнерные и презентационные компоненты?

Контейнерные компоненты

Это компоненты, которые предоставляют, создают или хранят данные для дочерних компонентов. Единственная работа компонента-контейнера — обработка данных. Он не состоит из собственного пользовательского интерфейса. Скорее, он состоит из презентационных компонентов в качестве своих дочерних элементов, которые используют эти данные. Простым примером может быть компонент с именем FetchUserContainer, который состоит из некоторой логики, которая извлекает данные всех пользователей.

Презентационные компоненты

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

Зачем нам нужны эти компоненты?

Чтобы понять это, возьмем простой пример. Мы хотим отобразить список сообщений, которые мы получаем из JSON placeholder API.
// DisplayPosts.tsx

import { useEffect, useState } from "react";

interface ISinglePost {
  userId: number;
  id: number;
  title: string;
  body: string;
}

/* Пример того как НЕ нужно объединять логику и отображение */
export default function DisplayPosts() {
  const [posts, setPosts] = useState<ISinglePost[] | null>(null);
  const [isLoading, setIsLoading] = useState<Boolean>(false);
  const [error, setError] = useState<unknown>();

  // Логика
  useEffect(() => {
    (async () => {
      try {
        setIsLoading(true);
        const resp = await fetch("https://jsonplaceholder.typicode.com/posts");
        const data = await resp.json();
        setPosts(data);
        setIsLoading(false);
      } catch (err) {
        setError(err);
        setIsLoading(false);
      }
    })();
  }, []);

  // Представление
  return isLoading ? (
    <span>Loading... </span>
  ) : posts ? (
    <ul>
      {posts.map((post: ISinglePost) => (
        <li key={`item-${post.id}`}>
          <span>{post.title}</span>
        </li>
      ))}
    </ul>
  ) : (
    <span>{JSON.stringify(error)}</span>
  );
}
Вот что делает этот компонент:
  • Он имеет 3 переменные состояния: posts, isLoading и error.
  • У нас есть хук useEffect, который состоит из бизнес-логики. Здесь мы извлекаем данные из jsonplaceholder API с помощью fetch.
  • Когда данные извлекаются, мы сохраняем их в переменной состояния posts, используя setPosts.
  • Мы также гарантируем, что переключаем значения isLoading и error во время соответствующих сценариев.
  • Мы поместили всю эту логику в асинхронную функцию.
  • Возвращаем посты в виде списка и отображаем их.
Проблема заключается в том, что логика получения данных и отображения данных находится в одном компоненте. Можно сказать, что компонент теперь тесно связан с логикой. Это именно то, чего мы не хотим.
Ниже приведены некоторые причины, по которым нам требуются контейнерные и презентационные компоненты:
  • Они помогают нам создавать слабосвязанные компоненты.
  • Они помогают нам поддерживать разделение ответственности.
  • Рефакторинг кода становится намного проще.
  • Код становится более организованным и удобным в сопровождении
  • Это значительно упрощает тестирование.

Пример компонента-представления и контейнера

Для примера будем использовать тот же пример, что и выше — получение данных из JSON placeholder API. Разберемся со структурой файлов. Нашим контейнерным компонентом будет PostContainer. У нас будет два презентационных компонента:
  • Posts: компонент с неупорядоченным списком.
  • SinglePost: компонент, отображающий элемент списка.
Мы собираемся хранить все вышеперечисленные компоненты в отдельной папке с именем components. Теперь, когда мы знаем, что куда помещать, давайте начнем с компонента-контейнера: PostContainer.

Компонент PostContainer

// components/PostContainer.tsx

import { useEffect, useState } from "react";
import { ISinglePost } from "../Definitions";
import Posts from "./Posts";

export default function PostContainer() {
  const [posts, setPosts] = useState<ISinglePost[] | null>(null);
  const [isLoading, setIsLoading] = useState<Boolean>(false);
  const [error, setError] = useState<unknown>();

  useEffect(() => {
    (async () => {
      try {
        setIsLoading(true);
        const resp = await fetch("https://jsonplaceholder.typicode.com/posts");
        const data = await resp.json();
        setPosts(data.filter((post: ISinglePost) => post.userId === 1));
        setIsLoading(false);
      } catch (err) {
        setError(err);
        setIsLoading(false);
      }
    })();
  }, []);

  return isLoading ? (
    <span>Loading... </span>
  ) : posts ? (
    <Posts posts={posts} />
  ) : (
    <span>{JSON.stringify(error)}</span>
  );
}
Файл с типами.
components/Definitions.ts

export interface SinglePost {
  userId: number;
  id: number;
  title: string;
  body: string;
}
Приведенный выше код просто содержит логику выборки данных. Эта логика присутствует в хуке useEffect. Этот компонент-контейнер передает эти данные презентационному компоненту Posts. Давайте взглянем на презентационный компонент Posts.

Компонент Posts

// components/Posts.tsx

import { ISinglePost } from "../Definitions";
import SinglePost from "./SinglePost";

export default function Posts(props: { posts: ISinglePost[] }) {
  return (
    <ul
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center"
      }}
    >
      {props.posts.map((post: ISinglePost) => (
        <SinglePost {...post} />
      ))}
    </ul>
  );
}
Как видите, это простой файл, состоящий из тега ul — неупорядоченного списка. Затем этот компонент отображает посты, которые передаются в качестве пропса. Мы передаем каждый объект поста в компонент SinglePost. Существует еще один презентационный компонент, который отображает элемент списка, это тег li. Он отображает заголовок и тело сообщения.

Компонент SinglePost

// components/SinglePost.tsx

import { ISinglePost } from "../Definitions";

export default function SinglePost(props: ISinglePost) {
  const { userId, id, title, body } = props;

  return (
    <li key={`item-${userId}-${id}`} style={{ width: 400 }}>
      <h4>
        <strong>{title}</strong>
      </h4>
      <span>{body}</span>
    </li>
  );
}
Эти презентационные компоненты просто отображают данные на экране. Вот и все. Они не делают ничего другого. Поскольку здесь они просто отображают данные, они также будут иметь собственные стили. Теперь, когда мы настроили компоненты, давайте посмотрим, что удалось достичь:
  • Концепция разделения ответственности в этом примере не нарушается.
  • Написание модульных тестов для каждого компонента становится проще.
  • Сопровождаемость и читабельность кода намного лучше. Таким образом, наша кодовая база стала намного более организованной.
Здесь мы добились того, чего хотели, но мы можем еще больше улучшить этот паттерн с помощью React хуков.

Как заменить контейнерные компоненты на React хуки

Начиная с React 16.8 стало намного проще создавать и разрабатывать компоненты с помощью функциональных компонентов и хуков. Здесь мы воспользуемся этими возможностями и заменим компонент-контейнер хуком.
// hooks/usePosts.ts

import { useEffect, useState } from "react";
import { ISinglePost } from "../Definitions";

export default function usePosts() {
  const [posts, setPosts] = useState<ISinglePost[] | null>(null);
  const [isLoading, setIsLoading] = useState<Boolean>(false);
  const [error, setError] = useState<unknown>();

  useEffect(() => {
    (async () => {
      try {
        setIsLoading(true);
        const resp = await fetch("https://jsonplaceholder.typicode.com/posts");
        const data = await resp.json();
        setPosts(data.filter((post: ISinglePost) => post.userId === 1));
        setIsLoading(false);
      } catch (err) {
        setError(err);
        setIsLoading(false);
      }
    })();
  }, []);

  return {
    isLoading,
    posts,
    error
  };
}
Что дает это улучшение:
  • Извлечена логика, которая присутствовала в компоненте PostContainer, в хук.
  • Этот хук вернет объект, содержащий значения isLoading, posts и error.
Теперь мы можем просто удалить компонент-контейнер PostContainer. Затем, вместо того, чтобы передавать данные контейнера презентационным компонентам в качестве пропса, мы можем напрямую использовать этот хук внутри презентационного компонента Posts. Внесем следующие изменения в компонент Posts.
// components/Posts.tsx

import { ISinglePost } from "../Definitions";
import usePosts from "../hooks/usePosts";
import SinglePost from "./SinglePost";

export default function Posts(props: { posts: ISinglePost[] }) {
  const { isLoading, posts, error } = usePosts();

  return (
    <ul
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center"
      }}
    >
      {isLoading ? (
        <span>Loading...</span>
      ) : posts ? (
        posts.map((post: ISinglePost) => <SinglePost {...post} />)
      ) : (
        <span>{JSON.stringify(error)}</span>
      )}
    </ul>
  );
}
Используя хуки, мы устранили дополнительный слой компонента, который присутствовал поверх этих презентационных компонентов. С хуками мы достигли тех же результатов, что и с шаблоном контейнерные/презентационные компоненты.

Итоги

Итак, в этой статье мы рассмотрели:
  • Разделение ответственности.
  • Контейнерные и презентационные компоненты
  • Зачем нам нужны эти компоненты
  • Как хуки могут заменить компоненты-контейнеры

Вопросы и ответы React собеседования 2023 - Часть 2

год назад·6 мин. на чтение

Актуальный список вопросов и ответов по ReactJS на интервью 2023 - Часть 2

1. Что такое JSX?

  • JSX — это расширение синтаксиса для JavaScript, обладающее всеми возможностями JavaScript.
  • Вы можете внедрить любое выражение JavaScript в JSX, заключив его в фигурные скобки. После компиляции выражения JSX становятся обычными объектами JavaScript.
  • Это означает, что вы можете использовать JSX внутри операторов if и циклов for, назначать его переменным, принимать в качестве аргументов и возвращать из функций.

2. Каков эквивалент следующего кода с использованием React.createElement?

const element = <h1 className="greeting">Hello, world!</h1>;
Эквивалент с использованием React.createElement будет выглядеть следующим образом:
const element = React.createElement(
  "h1",
  { className: "greeting" },
  "Hello, world!"
);

3. Что такое Redux?

  • Основная идея redux заключается в том, что все состояние приложения хранится в одном хранилище. Store (хранилище) - это просто JavaScript объект.
  • Единственный способ изменить состояние — отправить действие (action) из вашего приложения, а затем написать редьюсеры для этих действий, которые изменяют состояние.
  • Весь переход состояния хранится внутри редьюсеров и не должен иметь никаких побочных эффектов.
Подробнее о Redux можно узнать в бесплатном видео-курсе о Redux.

4. Что такое store в Redux?

Store — это JavaScript объект, который содержит состояние приложения. Наряду с этим он также имеет следующие обязанности:
  • Разрешает доступ к состоянию через getState().
  • Позволяет обновлять состояние с помощью отправки действия dispatch(action).
  • Регистрирует слушателей через subscribe(listener).
  • Обрабатывает отмену регистрации слушателей с помощью функции, возвращаемой из subscribe(listener).

5. Разница между action и reducer.

  • Action (действие) — это простые JavaScript объекты.
  • Они должны иметь тип, указывающий тип выполняемого действия.
  • По сути, действия — это некоторые данные, которые отправляются из вашего приложения в хранилище.
  • Редьюсер — это просто чистая функция, которая принимает предыдущее состояние и действие и возвращает обновленное состояние.

6. Для чего используется Redux Thunk?

  • Redux Thunk — это промежуточное программное обеспечение (middleware), которое позволяет вам писать создателей действий (action creator), которые возвращают функцию вместо действия (action). Что такое action creators?
  • Затем thunk можно использовать для задержки отправки действия, если выполняется определенное условие. Это позволяет вам обрабатывать асинхронную диспетчеризацию действий.
  • Подробнее о Redux Thunk можно узнать в полном видео-курсе о Redux Thunk.

7. Напишите кастомный хук, который можно использовать для debounce’а ввода.

// Хук useDebounce

const useDebounce = (value, delay) => {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timeout = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(timeout);
    };
  }, [value]);

  return debouncedValue;
};

// Использование

const Counter = () => {
  const [value, setValue] = useState(0);
  const lastValue = useDebounce(value, 1000);

  return (
    <div>
      <p>
        Current Value: {value} | Debounced Value: {lastValue}
      </p>
      <button onClick={() => setValue(value + 1)}>Increment</button>
    </div>
  );
};

8. Напишите кастомный хук для копирования текста в буфер обмена.

// Хук useCopyToClipboard

function useCopyToClipboard(content) {
  const [isCopied, setIsCopied] = useState(false);

  const copy = useCallback(() => {
    navigator.clipboard
      .writeText(content)
      .then(() => setIsCopied(true))
      .then(() => setTimeout(() => setIsCopied(false), 1250))
      .catch((err) => alert(err));
  }, [content]);
  return [isCopied, copy];
}
// Использование
export default function App() {
  const [isCopied, copy] = useCopyToClipboard("Text to copy!");
  return <button onClick={copy}>{isCopied ? "Copied!" : "Copy"}</button>;
}

9. Как использовать хук useId для создания уникальных идентификаторов?

  • useId не принимает никаких параметров.
  • useId возвращает уникальную строку идентификатора, связанную с этим конкретным вызовом useId в этом конкретном компоненте.
// Использование

import { useId } from "react";

const App = () => {
  const id = useId();

  return (
    <form>
      <label htmlFor={`email-${id}`}>Email</label>
      <input type="text" id={`email-${id}`} name="email" />

      <label htmlFor={`password-${id}`}>Password</label>
      <input type="password" id={`password-${id}`} name="password" />
    </form>
  );
};

// Плохая практика - не стоит использовать в качестве key
const id = useId();

return posts.map((post) => <article key={id}>...</article>);
Подробности можно найти в статье Что за хук useId в React?.

10. Как проверить/валидировать пропсы в React?

Мы можем использовать пакет prop-types Раньше, до React v15.5, это было частью самого React.
import PropTypes from "prop-types";

function MyComponent({ name }) {
  return <div>Hello, {name}</div>;
}

MyComponent.propTypes = {
  name: PropTypes.string,
};

export default MyComponent;
Еще один вариант - это добавить к проекту TypeScript.

11. Приведите практический пример компонента высшего порядка в React.

// Компонент высшего порядка
function WithLoading(Component) {
  return function WihLoadingComponent({ isLoading, ...props }) {
    if (!isLoading) return <Component {...props} />;
    return <p>Please wait, fetching your data in no time...</p>;
  };
}
export default WithLoading;
// Использование

import UserListComponent from "./UserListComponent.js"; // импорт компонента
import WithLoading from "./withLoading.js"; // импорт HOC

const ListWithLoading = WithLoading(UserListComponent); // обернем компонент в HOC

const App = () => {
  const [loading, setLoading] = useState(true);
  const [users, setUsers] = useState([]);

  useEffect(() => {
    // запрос данных
    const dataFromApi = ["this is coming from API call", "don't show loader"];
    
    // в это время загрузчик будет показан в HOC
    // данные получены
    setUsers([...dataFromApi]);
    setLoading(false);
  }, []);

  return <ListWithLoading isLoading={loading} users={users} />;
};

12. Чем полезен хук useDeferredValue?

  • useDeferredValue — это React хук, который позволяет вам отложить обновление части пользовательского интерфейса.
  • По сути, это позволяет вам выполнять технику debounce с меньшим количеством кода.
  • Подробный разбор хука useDeferredValue можно прочитать в статье Хуки useTransition и useDeferredValue в ReactJS 18.
// Использование

import { useState, useDeferredValue } from "react";
// Компонент userList получает searchText для запроса списка пользователей
import UserList from "./UserList.js";

export default function App() {
  const [searchText, setSearchText] = useState("");
  // отправить searchText по умолчанию
  const deferredQuery = useDeferredValue(searchText);

  return (
    <>
      <label>
        Search user:
        <input
          value={searchText}
          onChange={(e) => setSearchText(e.target.value)}
        />
      </label>
      <div>
        <UserList searchText={deferredQuery} />
      </div>
    </>
  );
}

13. Как отследить клик за пределами компонента React?

export default function OutsideAlerter() {
  const clickMeDivRef = useRef(null);

  useEffect(() => {
    const handleClickOutside = (event) => {
      if (!ref?.current?.contains(event.target)) {
        alert("You clicked outside of me!");
      }
    };

    // Добавим прослушивание событий
    document.addEventListener("mousedown", handleClickOutside);

    return () => {
      // Удалим прослушивание событий
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [clickMeDivRef]);

  return <div ref={clickMeDivRef}>Clicked me?</div>;
}

14. Почему имена компонентов React должны начинаться с заглавных букв?

В JSX имена тегов нижнего регистра считаются тегами HTML. Однако имена тегов в нижнем регистре с точкой (аксессор свойства) не являются таковыми.
  • <person /> компилируется в React.createElement('person') (тег html)
  • компилируется в React.createElement(Person)
  • <obj.person /> компилируется в React.createElement(obj.person)
// Ошибка! Это компонент и должен начинаться с заглавной буквы
function person(props) {
  // Верно! Это использование <div> верно, т.к. div это валидный элемент
  return <div>{props.isLearning ? "Great!" : "Hello!"}</div>;
}

function App() {
  // Ошибка! React считает <person /> тэгом HTML, т.к. Не с заглавной буквы
  return <person isLearning={true} />;
}

// Верно! Это компонент и должен начинаться с заглавной буквы
function Person(props) {
  // Верно! Это использование <div> верно, т.к. div это валидный элемент
  return <div>{props.isLearning ? "Great!" : "Hello!"}</div>;
}

function App() {
  // Верно! React знает, что <Person /> - это компонент, с заглавной буквы
  return <Person isLearning={true} />;
}

15. В чем разница между npx и npm?

  • npm — это менеджер пакетов, который можно использовать для установки пакетов node.js. npM - Manager.
  • npx— это инструмент для выполнения пакетов node.js. npX - Execute.
Неважно, установили ли вы этот пакет глобально или локально. npx временно установит его и запустит. npm также может запускать пакеты, если вы настроите файл package.json. Поэтому, если вы хотите быстро проверить/запустить пакет без его установки - используйте npx. create-react-app — это npm пакет, который должен запускаться только один раз в жизненном цикле проекта. Следовательно, предпочтительнее использовать npx для установки и запуска за один шаг.
> npx create-react-app my-app

16. Как установить фокус на поле ввода после монтирования компонента в UI?

import React, { useEffect, useRef } from "react";

const SearchPage = () => {
  const textInput = useRef(null);

  useEffect(() => {
    textInput.current.focus();
  }, []);

  return (
    <div>
      <input ref={textInput} type="text" />
    </div>
  );
};
Еще больше вопросов с собеседований