Доброго времени суток, друзья!

Представляю вашему вниманию первую десятку пользовательских хуков.

Оглавление



useMemoCompare


Данный хук похож на useMemo, но вместо массива зависимостей ему передается функция, сравнивающая предыдущее и новое значения. Функция может сравнивать вложенные свойства, вызывать методы объектов или делать что-то еще в целях сравнения. Если функция возвращает true, хук возвращает ссылку на старый объект. Стоит отметить, что этот хук, в отличие от useMemo, не предполагает отсутствия повторных сложных вычислений. Ему необходимо передавать вычисленное значение для сравнения. Это может пригодится в случае, когда вы хотите поделиться библиотекой с другими разработчиками, и не хотите заставлять их запоминать объект перед отправкой. Если объект создается в теле компонента (в случае, когда он зависит от пропсов), тогда он будет новым при каждом рендеринге. Если объект является зависимостью useEffect, тогда эффект будет срабатывать при каждом рендеринге, что может привести к проблемам, вплоть до бесконечного цикла. Данный хук позволяет избежать такого развития событий, используя старую ссылку на объект вместо новой, если функция признала объекты одинаковыми.

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

// использование
function MyComponent({ obj }) {
  const [state, setState] = useState();

  // возвращаем старый объект, если свойство "id" не изменилось
  const objFinal = useMemoCompare(obj, (prev, next) => {
    return prev && prev.id === next.id;
  });

  // мы хотим запускать эффект при изменении objFinal
  // если мы используем obj напрямую, без нашего хука, и obj технически будет
  // новым объектом при каждом рендеринге, тогда эффект также будет срабатывать при каждом рендеринге
  // что еще хуже, если наш эффект приводит к изменению состояния, это может закончиться бесконечным циклом
  // запускается эффект -> изменение состояния влечет повторный рендеринг -> снова запускается эффект -> и т.д.
  useEffect(() => {
    // вызываем метод объекта и присваиваем результат состоянию
    return objFinal.someMethod().then((value) => setState(value));
  }, [objFinal]);

  // почему нам не передать [obj.id] в качестве зависимости?
  useEffect(() => {
    // eslint-plugin-hooks справедливо решит, что obj не указан в массиве зависимостей
    // и нам придется использовать eslint-disable-next-line для решения этой проблемы
    // лучше просто получить ссылку на старый объект с помощью нашего хука
    return obj.someMethod().then((value) => setState(value));
  }, [obj.id]);
}

// хук
function useMemoCompare(next, compare) {
  // ref для хранения предыдущего значения
  const prevRef = useRef();
  const prev = prevRef.current;

  // передаем предыдущее и новое значения в функцию
  // для определения их идентичности
  const isEqual = compare(prev, next);

  // если значения не равны, обновляем prevRef
  // обновление осуществляется только в случае неравенства значений
  // поэтому, если функция вернула true, хук возвращает старое значение
  useEffect(() => {
    if (!isEqual) {
      prevRef.current = next;
    }
  });

  // если значения равны, возвращаем старое значение
  return isEqual ? prev : next;
}

useAsync


Хорошей практикой считается отображение статуса асинхронного запроса. Примером
может служить получение данных из API и отображение индикатора загрузки перед рендерингом результатов. Другим примером является отключение кнопки на время отправки формы и последующее отображение результата. Вместо того, чтобы загрязнять компонент большим количеством вызовов useState для отслеживания состояния асинхронной функции, мы можем использовать данный хук, принимающий асинхронную функцию и возвращающий значения «value», «error» и «status», необходимые для обновления пользовательского интерфейса. Возможными значениями свойства «status» являются «idle», «pending», «success» и «error». Наш хук позволяет выполнять функцию как сразу, так и с задежкой с помощью функции «execute».

import React, { useState, useEffect, useCallback } from 'react'

// использование
function App() {
  const {execute, status, value, error } = useAsync(myFunction, false)

  return (
    <div>
      {status === 'idle' && <div>Начните ваше путешествие с нажатия кнопки</div>}
      {status === 'success' && <div>{value}</div>}
      {status === 'error' && <div>{error}</div>}
      <button onClick={execute} disabled={status === 'pending'}>
        {status !== 'pending' ? 'Нажми меня' : 'Загрузка...'}
      </button>
    </div>
  )
}

// асинхронная функция для тестирования хука
// успешно выполняется в 50% случаев
const myFunction = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const random = Math.random() * 10
      random <=5
        ? resolve('Выполнено успешно')
        : reject('Произошла ошибка')
    }, 2000)
  })
}

// хук
const useAsync = (asyncFunction, immediate = true) => {
  const [status, setStatus] = useState('idle')
  const [value, setValue] = useState(null)
  const [error, setError] = useState(null)

  // функция "execute" оборачивает asyncFunction и
  // обрабатывает настройку состояний для pending, value и error
  // useCallback предотвращает вызов useEffect при каждом рендеринге
  // useEffect вызывается только при изменении asyncFunction
  const execute = useCallback(() => {
    setStatus('pending')
    setValue(null)
    setError(null)

    return asyncFunction()
      .then(response => {
        setValue(response)
        setStatus('success')
      })
      .catch(error => {
        setError(error)
        setStatus('error')
      })
  }, [asyncFunction])

  // вызываем execute для немедленного выполнения
  // с другой стороны, execute может быть вызвана позже
  // например, как обработчик нажатия кнопки
  useEffect(() => {
    if (immediate) {
      execute()
    }
  }, [execute, immediate])

  return { execute, status, value, error }
}

useRequireAuth


Назначением данного хука является перенаправление пользователя на страницу авторизации при выходе из учетной записи. Наш хук представляет собой композицию хуков «useAuth» и «useRouter». Разумеется, мы можем реализовать необходимый фукнционал в хуке «useAuth», но тогда нам придется включить его с схему маршрутизации. С помощью композиции мы можем сохранить простоту useAuth и useRouter, реализовав перенаправление с помощью кастомного хука.

import Dashboard from "./Dahsboard.js";
import Loading from "./Loading.js";
import { useRequireAuth } from "./use-require-auth.js";

function DashboardPage(props) {
  const auth = useRequireAuth();

  // если значением auth является null (данные еще не получены)
  // или false (пользователь вышел из учетной записи)
  // показываем индикатор загрузки
  if (!auth) {
    return <Loading />;
  }

  return <Dashboard auth={auth} />;
}

// хук (use-require-auth.js)
import { useEffect } from "react";
import { useAuth } from "./use-auth.js";
import { useRouter } from "./use-router.js";

function useRequireAuth(redirectUrl = "./signup") {
  const auth = useAuth();
  const router = useRouter();

  // если значением auth.user является false,
  // значит, вход не выполнен, осуществляем перенаправление
  useEffect(() => {
    if (auth.user === false) {
      router.push(redirectUrl);
    }
  }, [auth, router]);

  return auth;
}

useRouter


Если вы используете в своей работе React Router, то могли заметить, что недавно появилось несколько полезных хуков, таких как «useParams», «useLocation», «useHistory» и «useRouterMatch». Давайте попробуем обернуть их в один хук, возвращающий данные и методы, которые нам нужны. Мы покажем, как скомбинировать несколько хуков и вернуть один объект, содержащий их состояния. Для библиотек вроде React Router имеет смысл предоставлять выбор нужного хука. Это позволяет избежать ненужного рендеринга. Но иногда нам требуются все или большинство названных хуков.

import { useMemo } from "react";
import {
  useParams,
  useLocation,
  useHistory,
  useRouterMatch,
} from "react-router-dom";
import queryString from "query-string";

// использование
function MyComponent() {
  // получаем объект роутера
  const router = useRouter();

  // получаем значение строки запроса (?postId=123) или параметров запроса (/:postId)
  console.log(router.query.postId);

  // получаем название текущего пути
  console.log(router.pathname);

  // реализуем навигацию с помощью router.push()
  return <button onClick={(e) => router.push("./about")}>About</button>;
}

// хук
export function useRouter() {
  const params = useParams();
  const location = useLocation();
  const history = useHistory();
  const match = useRouterMatch();

  // возвращаем наш объект роутера
  // запоминаем его для того, чтобы новый объект возвращался только при наличии изменений
  return useMemo(() => {
    return {
      // для удобства определяем push(), replace() и pathname на верхнем уровне
      push: history.push,
      replace: history.replace,
      pathname: location.pathname,
      // объединяем параметры и преобразуем строку запроса в простой объект "query"
      // для того, чтобы они были взаимозаменяемыми
      // пример: /:topic?sort=popular -> { topic: 'react', sort: 'popular' }
      query: {
        ...queryString.parse(location.search), // преобразуем строку в объект
        ...params,
      },
      // добавляем объекты "match", "location" и "history"
      // в качестве дополнительного функционала React Router
      match,
      location,
      history,
    };
  }, [params, match, location, history]);
}

useAuth


Обычным делом является наличие нескольких компонентов, которые рендерятся в зависимости от того, выполнил ли пользователь вход в учетную запись. Некоторые их этих компонентов вызывают методы аутентификации, такие как signin, signout, sendPasswordResetEmail и т.д. Для этого прекрасно подходит хук «useAuth», который обеспечивает получение компонентом состояния аутентификации и перерисовку компонента при наличии изменений. Вместо создания экземпляра useAuth для каждого пользователя, наш хук вызывает useContext для получение данных от родительского компонента. Настоящая магия происходит в компоненте «ProvideAuth», где все методы аутентификации (в примере мы используем Firebase) оборачиваются в хук «useProvideAuth». Затем используется контекст для передачи текущего объекта аутентификации дочерним компонентам, вызывающим useAuth. В сказанном будет больше смысла после ознакомлением с примером. Еще одной причиной, по которой мне нравится этот хук, является абстрагирование реального провайдера аутентификации (Firebase), что облегчает внесение изменений.

// глобальный компонент App
import React from "react";
import { ProvideAuth } from "./use-auth.js";

function App(props) {
  return (
    <ProvideAuth>
      {/*
        здесь находятся компоненты роутера, которые зависят от структуры приложения
        при использовании Next.js, это будет выглядеть так: /pages/_app.js
      */}
    </ProvideAuth>
  );
}

// любой компонент, которому требуется состояние аутентификации
import React from "react";
import { useAuth } from "./use-auth.js";

function NavBar(props) {
  // получаем состояние auth и осуществляем перерисовку при его изменении
  const auth = useAuth();

  return (
    <NavbarContainer>
      <Logo />
      <Menu>
        <Link to="/about">About</Link>
        <Link to="/contact">Contact</Link>
        {auth.user ? (
          <Fragment>
            <Link to="/account">Account ({auth.user.email})</Link>
            <Button onClick={() => auth.signout()}>Signout</Button>
          </Fragment>
        ) : (
          <Link to="/signin">Signin</Link>
        )}
      </Menu>
    </NavbarContainer>
  );
}

// хук (use-auth.js)
import React, { useState, useEffect, useContext, createContext } from "react";
import * as firebase from "firebase/app";
import "firebase/auth";

// добавлем данные для Firebase
firebase.initializeApp({
  apiKey: "",
  authDomain: "",
  projectId: "",
  appID: "",
});

const authContext = createContext();

// компонент Provider, оборачивающий приложение и делающий объект "auth"
// доступным для любого дочернего компонента, вызывающего useAuth
export const useAuth = () => {
  return useContext(authContext);
};

// хук для дочерних компонентов для получения объекта "auth"
// и повторного рендеринга при его изменении
export const useAuth = () => {
  return useContext(authContext);
};

// хук провайдера, создающий объект "auth" и обрабатывающий его состояние
function useProviderAuth() {
  const [user, setUser] = useState(null);

  // оборачиваем любые методы Firebase, неоходимые для сохранения
  // состояния пользователя
  const signin = (email, password) => {
    return firebase
      .auth()
      .signInWithEmailAndPassword(email, password)
      .then((response) => {
        setUser(response.user);
        return response.user;
      });
  };

  const signup = (email, password) => {
    return firebase
      .auth()
      .createUserWithEmailAndPassword(email, password)
      .then((response) => {
        setUser(response.user);
        return response.user;
      });
  };

  const signout = () => {
    return firebase
      .auth()
      .signOut()
      .then(() => {
        setUser(false);
      });
  };

  const sendPasswordResetEmail = (email) => {
    return firebase
      .auth()
      .sendPasswordResetEmail(email)
      .then(() => true);
  };

  const confirmPasswordReset = (code, password) => {
    return firebase
      .auth()
      .confirmPasswordReset(code, password)
      .then(() => true);
  };

  // регистрируем пользователя при монтировании
  // установка состояния в колбэке приводит к тому
  // что любой компонент, использующий хук
  // перерисовывается с учетом последнего объекта "auth"
  useEffect(() => {
    const unsubscribe = firebase.auth().onAuthStateChange((user) => {
      if (user) {
        setUser(user);
      } else {
        setUser(false);
      }
    });

    // отписываемся от пользователя
    return () => unsubscribe();
  }, []);

  // возвращаем объект "user" и методы аутентификации
  return {
    user,
    signin,
    signup,
    signout,
    sendPasswordResetEmail,
    confirmPasswordReset,
  };
}

useEventListener


Если вам приходиться иметь дело с большим количеством обработчиков событий, которые регистрируются в useEffect, у вас может появиться желание вынести их в отдельных хук. В представленном ниже примере мы создаем хук «useEventListener», которые проверяет поддержку «addEventListener», добавляет обработчики и удаляет их на выходе.
import { useState, useRef, useEffect, useCallback } from "react";

// использование
function App() {
  // состояние для хранения координат курсора
  const [coords, setCoords] = useState({ x: 0, y: 0 });

  // обработчик событий обернут в useCallback,
  // поэтому ссылка никогда не изменится
  const handler = useCallback(
    ({ clientX, clientY }) => {
      // обновляем координаты
      setCoords({ x: clientX, y: clientY });
    },
    [setCoords]
  );

  // добавляем обработчик с помощью нашего хука
  useEventListener("mousemove", handler);

  return <h1>Позиция курсора: ({(coords.x, coords.y)})</h1>;
}

// хук
function useEventListener(eventName, handler, element = window) {
  // создаем ссылку, хранящую обработчик
  const saveHandler = useRef();

  // обновляем ref.current при изменении обработчика
  // это позволяет нашему эффекту всегда иметь дело с последним обработчиком
  // без необходимости передавать ему массив зависимостей
  // что запускает эффект при каждом рендеринге
  useEffect(() => {
    saveHandler.current = handler;
  }, [handler]);

  useEffect(
    () => {
      // проверяем поддержку addEventListener
      const isSupported = element && element.addEventListener;
      if (!isSupported) return;

      // создаем обработчик событий, который вызывает обработчик, сохраненный в ref
      const eventListener = (event) => saveHandler.current(event);

      // добавляем обработчик событий
      element.addEventListener(eventName, eventListener);

      // удаляем обработчик событий на выходе
      return () => {
        element.removeEventListener(eventName, eventListener);
      };
    },
    [eventName, element] // перезапускаем только при изменении элемента
  );
}

useWhyDidYouUpdate


Данный хук позволяет определить, изменения каких пропсов приводят к повторному рендерингу. Если функция является «сложной» и вы уверены, что она является чистой, т.е. возвращает одинаковые результаты для одинаковых пропсов, вы можете использовать компонент высшего порядка «React.memo», как мы делаем в примере ниже. Если после этого ненужные рендеринги не прекратились, вы можете использовать useWhyDidYouUpdate, который выводит в консоль изменяющиеся при рендеринге пропсы с указанием предыдущего и текущего значений.

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

// представим, что <Counter> является дорогим для повторного рендеринга
// поэтому мы обернули его в React.memo, но проблемы остались
// мы добавили useWhyDidYouUpdate для прояснения ситуации
const Counter = React.memo((props) => {
  useWhyDidYouUpdate("Counter", props);
  return <div style={props.style}>{props.count}</div>;
});

function App() {
  const [count, setCount] = useState(0);
  const [userId, setUserId] = useState(0);

  // хук показывает, что объект, предназначенный для стилизации <Counter>
  // меняется при каждом рендеринге, даже когда мы меняем только состояние userId
  // нажимая кнопку "switch user". Разумеется, это происходит потому
  // что объект заново создается при каждом рендеринге
  // благодаря хуку мы поняли, что нам следует поместить этот объект
  // за пределами тела компонента
  const counterStyle = {
    fontSize: "3rem",
    color: "red",
  };
}

return (
  <div>
    <div className="counter">
      <Counter count={count} style={counterStyle} />
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
    <div className="user">
      <img src={`http://i.pravatar.cc/80?img=${userId}`} />
      <button onClick={() => setUserId(userId + 1)}>Switch User</button>
    </div>
  </div>
);

// хук
function useWhyDidYouUpdate(name, props) {
  // создаем неизменяемый объект "ref" для хранения пропсов
  // чтобы иметь возможность сравнить пропсы при следующем запуске
  const prevProps = useRef();

  useEffect(() => {
    if (prevProps.current) {
      // получаем ключи предыдущего и текущего пропсов
      const allKeys = Object.keys({ ...prevProps.current, ...props });
      // используем этот объект для отслеживания изменений пропсов
      const changesObj = {};
      // перебираем ключи
      allKeys.forEach((key) => {
        // если предыдущий отличается от текущего
        if (prevProps.current[key] !== props[key]) {
          // добавлем его в changesObj
          changesObj[key] = {
            from: prevProps.current[key],
            to: props[key],
          };
        }
      });

      // если в changesObj что-то есть, выводим сообщение в консоль
      if (object.keys(changesObj).length) {
        console.log("why-did-you-update", name, changesObj);
      }
    }

    // наконец, обновляем prevProps текущими пропсами для следующего вызова хука
    prevProps.current = props;
  });
}

useDarkMode


Данный хук реализует логику переключения цветовой схемы сайта (светлой и темной). В нем используется локальное хранилище для хранения схемы, выбранной пользователем, режима по умолчанию, установленного в браузере с помощью медиа-запроса «prefers-color-scheme». Для включения темного режима используется класс «dark-mode» элемента «body». Хук также демонстрирует силу композиции. Синхронизация состояния с localStorage реализована с помощью хука «useLocalStorage», а определение предпочитаемой пользователем схемы — с помощью хука «useMedia», которые спроектированы для других целей. Однако, композиция этих хуков приводит к еще более мощному хуку размером всего в несколько строк кода. Это почти тоже самое, что «композиционная» мощь хуков по отношению к состоянию компонентов.

function App() {
  const [darkMode, setDarkMode] = useDarkMode();

  return (
    <div>
      <div className="navbar">
        <Toggle darkMode={darkMode} setDarkMode={setDarkMode} />
      </div>
      <Content />
    </div>
  );
}

// хук
function useDarkMode() {
  // используем хук "useLocalStorage" для сохранения состояния
  const [enabledState, setEnableState] = useLocalStorage("dark-mode-enabled");

  // проверяем предпочтения пользователя относительно цветовой схемы
  // в хуке "usePrefersDarkMode" используется хук "useMedia"
  const prefersDarkMode = usePrefersDarkMode();

  // если enabledState определена, используем ее, иначе, используем prefersDarkMode
  const enabled =
    typeof enabledState !== "undefined" ? enabledState : prefersDarkMode;

  // запускаем эффект добавления/удаления класса
  useEffect(
    () => {
      const className = "dark-mode";
      const element = window.document.body;
      if (enabled) {
        element.classList.add(className);
      } else {
        element.classList.remove(className);
      }
    },
    [enabled] // перезапускаем эффект только при изменении enabled
  );

  // возвращаем установленный режим и сеттер
  return [enabled, setEnableState];
}

// используем хук "useMedia" для определения пользовательской схемы
// интерфейс этого хука выглядит немного странно, но это объясняется тем,
// что он предназначен для поддержки нескольких медиа-запросов и возвращаемых значений
// благодаря композиции мы можем скрыть сложные детали реализации
function usePrefersDarkMode() {
  return useMedia(["(prefers-color-scheme: dark)"], [true], false);
}

useMedia


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

import { useState, useEffect } from "react";

function App() {
  const columnCount = useMedia(
    // медиа-запросы
    ["(min-width: 1500px)", "(min-width: 1000px)", "(min-width: 600px)"],
    // количество колонок зависит от запроса
    [5, 4, 3],
    // количество колонок по умолчанию
    2
  );

  // создаем массив с высотой колонок (начиная с 0)
  let columnHeight = new Array(columnCount).fill(0);

  // создаем массив массивов, содержащих каждую колонку
  let columns = new Array(columnCount).fill().map(() => []);

  data.forEach((item) => {
    // получает индекс самой короткой колонки
    const shortColumntIndex = columnHeight.indexOf(Math.min(...columnHeight));
    // добавляем элемент
    columns[shortColumntIndex].push(item);
    // обновляем высоту
    columnHeight[shortColumntIndex] += item.height;
  });

  // рендерим колонки и элементы
  return (
    <div className="App">
      <div className="columns is-mobile">
        {columns.map((column) => (
          <div className="column">
            {column.map((item) => (
              <div
                className="image-container"
                style={{
                  // размер изображения определяется его aspect ratio
                  paddingTop: (item.height / item.width) * 100 + "%",
                }}
              >
                <img src={item.image} alt="" />
              </div>
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}

// хук
function useMedia(queries, values, defaultValue) {
  // массив с медиа-запросами
  const mediaQueryList = queries.map((q) => window.matchMedia(q));

  // функция получения значения на основе запроса
  const getValue = () => {
    // получаем индекс первого совпавшего запроса
    const index = mediaQueryList.findIndex((mql) => mql.matches);
    // возвращаем соответствующее значение или значение по умолчанию
    return typeof values[index] !== "undefined"
      ? values[index]
      : defaultValue;
  };

  // состояние и сеттер для совпавшего значения
  const [value, setValue] = useState(getValue);

  useEffect(
    () => {
      // колбэк обработчика событий
      // обратите внимание: определяя getValue за пределами useEffect, мы обеспечиваем
      // соответствие между текущими значениями и аргументами хука
      // поскольку колбэк создается только один раз при монтировании
      const handler = () => setValue(getValue);
      // регистрируем обработчик для каждого медиа-запроса
      mediaQueryList.forEach((mql) => mql.addEventListener(handler));
      // удаляем обработчики на выходе
      return () =>
        mediaQueryList.forEach((mql) => mql.removeEventListener(handler));
    },
    [] // пустой массив обеспечивает запуск эффекта только при монтировании и размонтировании
  );

  return value;
}

useLocalStorage


Данный хук предназначен для синхронизации состояния с локальным хранилищем для сохранения состояния между перезагрузками страницы. Использование этого хука похоже на использование useState, за исключением того, что мы передаем ключ локального хранилища в качестве значения по умолчанию при загрузке страницы вместо определения начального значения.

import { useState } from "react";

// использование
function App() {
  // аналогично useState, но первым аргументом является ключ значения, хранящегося в локальном хранилище
  const [name, setName] = useLocalStorage("name", "Igor");

  return (
    <div>
      <input
        type="text"
        placeholder="Enter your name"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
    </div>
  );
}

// хук
function useLocalStorage(key, initialValue) {
  // состояние для хранения значения
  // передаем функцию инициализации useState для однократного выполнения
  const [storedValue, setStoredValue] = useState(() => {
    try {
      // получаем значение из локального хранилища по ключу
      const item = window.localStorage.getItem(key);
      // разбираем полученное значение или возвращаем initialValue
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      // если возникла ошибка, также возвращаем начальное значение
      console.error(error);
      return initialValue;
    }
  });

  // возвращаем обернутую версию сеттера useState,
  // которая помещает новое значение в локальное хранилище
  const setValue = (value) => {
    try {
      // значение может быть функцией
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      // сохраняем состояние
      setStoredValue(valueToStore);
      // помещаем его в локальное хранилище
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      // более продвинутая реализация может предполагать обработку ошибок в зависимости от вида ошибки
      console.error(error);
    }
  };

  return [storedValue, setValue];
}

На сегодня это все. Надеюсь, вы нашли для себя что-то полезное. Благодарю за внимание.