Эпическая сага про маленький custom hook для React (генераторы, sagas, rxjs)

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

Прелюдия

Стояла задача реализовать прелоадер с обратным отсчетом на реакте. Т.к. гуглить не умею и очень люблю лепить велосипеды (хороший двухместный получился из двух велосипедов "Украина"), то я был обречен глубоко копать на полях асинхреньщины. Забегу наперёд и скажу, что реализовал этот прелоадер с помощью генераторов, redux-saga, rxjs. Очень интересный опыт и хотелось бы поделиться, учитывая, что в процессе разбирательств, кроме статей, описывающих очевидные вещи, и обрывков сугубо специфической информации на stackoverflow, не находил.

Итак: часть первая - создание кастомного хука.

Структура прелоадера

В качестве шкалы индикатора загрузки, я выбрал количество картинок на сайте (можно выбирать и другие ресурсы), т.к. в моём случае это были самые тяжёлые ресурсы страницы.

Т.к. сделать это нужно было в реакте, то конечно же оформил это в виде кастомного хука и назвал его usePreloader.

Код самого прелоадера
import React from "react";

export default function Preloader() {
  return (
    <div className="preloader__wrapper">
      <div className="preloader">
        <svg
          width="300"
          height="300"
          viewBox="0 0 300 300"
          fill="none"
          xmlns="http://www.w3.org/2000/svg"
        >
          <path
            d="M300 150C300 232.843 232.843 300 150 300C67.1573 300 0 232.843 0 150C0 67.1573 67.1573 0 150 0C232.843 0 300 67.1573 300 150ZM20.7679 150C20.7679 221.373 78.6271 279.232 150 279.232C221.373 279.232 279.232 221.373 279.232 150C279.232 78.6271 221.373 20.7679 150 20.7679C78.6271 20.7679 20.7679 78.6271 20.7679 150Z"
            fill="#F3F3F3"
          />
          <path
            d="M289.616 150C295.351 150 300.037 154.655 299.641 160.376C297.837 186.392 289.275 211.553 274.72 233.336C258.238 258.003 234.811 277.229 207.403 288.582C179.994 299.935 149.834 302.906 120.736 297.118C91.6393 291.33 64.9119 277.044 43.934 256.066C22.9561 235.088 8.66999 208.361 2.88221 179.264C-2.90558 150.166 0.0649254 120.006 11.4181 92.5975C22.7712 65.1886 41.9971 41.7618 66.6645 25.2796C88.4468 10.725 113.608 2.16293 139.624 0.359232C145.345 -0.0374269 150 4.64906 150 10.384C150 16.1189 145.343 20.7246 139.627 21.1849C117.723 22.9486 96.5686 30.2756 78.2025 42.5475C56.9504 56.7477 40.3864 76.931 30.6052 100.545C20.8239 124.159 18.2647 150.143 23.2511 175.212C28.2375 200.28 40.5457 223.307 58.6191 241.381C76.6926 259.454 99.7195 271.762 124.788 276.749C149.857 281.735 175.841 279.176 199.455 269.395C223.069 259.614 243.252 243.05 257.453 221.797C269.724 203.431 277.051 182.277 278.815 160.373C279.275 154.657 283.881 150 289.616 150Z"
            fill="#6CAE30"
          />
        </svg>
      </div>
      <div className="preloader__counter">0%</div>
    </div>
  );
}

Стили прелоадера
.App {
  font-family: sans-serif;
  text-align: center;
}

.preloader__wrapper {
  position: fixed;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: #ffffff;
  z-index: 2000;
}

.preloader {
  position: relative;
  width: 18.75rem;
  height: 18.75rem;
}
.preloader svg {
  width: 18.75rem;
  height: 18.75rem;
  -webkit-animation: spin 4s infinite linear;
  animation: spin 4s infinite linear;
}
.preloader__counter {
  margin-top: 1.625rem;
  padding-left: 1rem;
  font-family: sans-serif;
  font-size: 3.125rem;
  line-height: 1;
  color: #6cae30;
}

@media (max-width: 900px) {
  .preloader {
    width: 6.25rem;
    height: 6.25rem;
  }
  .preloader svg {
    width: 6.25rem;
    height: 6.25rem;
  }
  .preloader__counter {
    margin-top: 0.75rem;
    padding-left: 0.5rem;
    font-size: 2.125rem;
  }
}

@-moz-keyframes spin {
  from {
    -moz-transform: rotate(0deg);
  }
  to {
    -moz-transform: rotate(360deg);
  }
}
@-webkit-keyframes spin {
  from {
    -webkit-transform: rotate(0deg);
  }
  to {
    -webkit-transform: rotate(360deg);
  }
}
@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

Реализация хука

Логикой хука является поиск всех тэгов <img> на странице и подключение обработчиков на события load и error. Делается это с помощью следующего кода

const updateCounter = () => {
  dispatch({
    type: ACTIONS.SET_COUNTER,
    data: state.counter + state.counterStep
  });
};

const checkImageLoading = (url) => {
  const imageChecker = new Image();
  imageChecker.addEventListener("load", updateCounter);
  imageChecker.addEventListener("error", updateCounter);
  imageChecker.src = url;
};

У хука будет состояние (state), в котором будет хранится шаг индикатора и текущее состояние индикатора. В данной (первой) части прелоадер реализован только с помощью хуков react, но в нескольких вариантах. Подключать redux для хранения состояния не будем, т.к. это кастомный хук и в будущем хотелось бы его переиспользовать.

Исходный код state.js
export const SET_COUNTER = "SET_COUNTER";
export const SET_COUNTER_STEP = "SET_COUNTER_STEP";
export const initialState = {
  counter: 0,
  counterStep: 0,
};
export const reducer = (state, action) => {
  switch (action.type) {
    case SET_COUNTER:
      return { ...state, counter: action.data };
    case SET_COUNTER_STEP:
      return { ...state, counterStep: action.data };
    default:
      throw new Error("This action is not applicable to this component.");
  }
};

export const ACTIONS = {
  SET_COUNTER,
  SET_COUNTER_STEP,
};

Вариант 1 (не рабочий, для наглядности). useReducer

Этот вариант показан для наглядности, чтобы было понятно, в каких случаях без useRef не обойтись. Метод checkImageLoading выполняется в цикле при монтировании компонента и поэтому ссылка на state у всех обработчиков будет указывать на начальное состояние.

Исходный код usePreloader.js
import { useReducer, useEffect } from "react";
import { reducer, initialState, ACTIONS } from "./state";

const PRELOADER_SELECTOR = ".preloader__wrapper";
const PRELOADER_COUNTER_SELECTOR = ".preloader__counter";

const usePreloader = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  const preloaderEl = document.querySelector(PRELOADER_SELECTOR);
  const counterEl = document.querySelector(PRELOADER_COUNTER_SELECTOR);

  const updateCounter = () => {
    dispatch({
      type: ACTIONS.SET_COUNTER,
      data: state.counter + state.counterStep
    });
  };

  const checkImageLoading = (url) => {
    const imageChecker = new Image();
    imageChecker.addEventListener("load", updateCounter);
    imageChecker.addEventListener("error", updateCounter);
    imageChecker.src = url;
  };

  useEffect(() => {
    const imgArray = document.querySelectorAll("img");
    if (imgArray.length > 0) {
      dispatch({
        type: ACTIONS.SET_COUNTER_STEP,
        data: Math.floor(100 / imgArray.length) + 1
      });
      imgArray.forEach((img) => {
        checkImageLoading(img.src);
      });
    }
  }, []);

  useEffect(() => {
    if (counterEl) {
      state.counter < 100
        ? (counterEl.innerHTML = `${state.counter}%`)
        : hidePreloader(preloaderEl);
    }
  }, [state]);

  return;
};

const hidePreloader = (preloaderEl) => {
  preloaderEl.remove();
};

export default usePreloader;

Вариант 2. useReducer + useRef

Объект, создаваемый с помощью useRef существует на протяжении всего существования компонента. В него я буду записывать ссылку на обновленный state и таким образом из колбэков будет доступно актуальное состояние хука.

Для этого нужно создать этот объект:

//сохраняю ссылку на state
const stateRef = useRef(state);

В колбэке ссылаться на state через свойство current переменной stateRef:

const updateCounter = () => {
  dispatch({
    type: ACTIONS.SET_COUNTER,
    data: stateRef.current.counter + stateRef.current.counterStep //данные беру не из state, а из stateRef
  });
};

Также ссылаюсь на state при обновлении значения индикатора в разметке

stateRef.current.counter < 100
	? (counterEl.innerHTML = `${stateRef.current.counter}%`) //данные беру не из state, а из stateRef
	: hidePreloader(preloaderEl);

При каждом обновлении state, нужно обновить ссылку:

useEffect(() => {
  stateRef.current = state; //при каждом обновлении state, записываю ссылку на обновленный state
  ...
}, [state]);
Исходный код useReducer + useRef
import { useReducer, useEffect, useRef } from "react";
import { reducer, initialState, ACTIONS } from "./state";

const PRELOADER_SELECTOR = ".preloader__wrapper";
const PRELOADER_COUNTER_SELECTOR = ".preloader__counter";

const usePreloader = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  //сохраняю ссылку на state
  const stateRef = useRef(state);

  const preloaderEl = document.querySelector(PRELOADER_SELECTOR);
  const counterEl = document.querySelector(PRELOADER_COUNTER_SELECTOR);

  const updateCounter = () => {
    dispatch({
      type: ACTIONS.SET_COUNTER,
      data: stateRef.current.counter + stateRef.current.counterStep //данные беру не из state, а из stateRef
    });
  };

  const checkImageLoading = (url) => {
    const imageChecker = new Image();
    imageChecker.addEventListener("load", updateCounter);
    imageChecker.addEventListener("error", updateCounter);
    imageChecker.src = url;
  };

  useEffect(() => {
    const imgArray = document.querySelectorAll("img");
    if (imgArray.length > 0) {
      dispatch({
        type: ACTIONS.SET_COUNTER_STEP,
        data: Math.floor(100 / imgArray.length) + 1
      });
      imgArray.forEach((img) => {
        checkImageLoading(img.src);
      });
    }
  }, []);

  useEffect(() => {
    stateRef.current = state; //при каждом обновлении state, записываю ссылку на обновленный state
    if (counterEl) {
      stateRef.current.counter < 100
        ? (counterEl.innerHTML = `${stateRef.current.counter}%`) //данные беру не из state, а из stateRef
        : hidePreloader(preloaderEl);
    }
  }, [state]);

  return;
};

const hidePreloader = (preloaderEl) => {
  preloaderEl.remove();
};

export default usePreloader;

Но тут появляются ньюансы. Если принудительно ограничить скорость интернета (закладка network в devtools), то всё работает нормально. А если интернет скоростной, то прелоадер зависает на определенном значении. Т.е. хук

useEffect(() => {
  ...
}, [state]);

не отрабатывает каждый раз при изменении state. А соответственно ссылка stateRef.current не обновляется и остаётся неактуальной. Это происходит потому что реакт оптимизирует отрисовку.

Чтобы решить эту проблему, вместо useEffect нужно использовать useLayoutEffect:

  useLayoutEffect(() => {
    stateRef.current = state;
    if (counterEl) {
      stateRef.current.counter < 100
        ? (counterEl.innerHTML = `${stateRef.current.counter}%`)
        : hidePreloader(preloaderEl);
    }
  }, [state]);

После этого прелоадер стал работать стабильно.

useEffect - выполняется асинхронно. Т.е. пока он выполнится - state может поменяться несколько раз.

useLayoutEffect - выполняется синхронно. Т.е. при каждом изменении состояния компонента этот хук будет выполнен и отрисовка будет произведена.

Практическое отличие я бы сформулировал так: хотите, чтобы компонент перересовывался каждый раз при изменении состояния компонента, используйте useLayoutEffect; если для Вас не важно отображение пошагового состояния, а можно отрендерить последнюю версию состояния - useEffect.

Вариант 3. useState + useRef

Я использовал useReducer, т.к. состояние содержит два значения: шаг счетчика и текущее состояние. Но можно реализовать и с помощью useState. Например состояние couterStep будет реализовано так:

  const [counterStep, setCounterStep] = useState(0);
  const counterStepStateRef = useRef(counterStep);
  const setCounterStepState = (data) => {
    counterStepStateRef.current = data;
    setCounterStep(data);
  };
Исходный код useState + useRef
import { useEffect, useLayoutEffect, useRef, useState } from "react";

const PRELOADER_SELECTOR = ".preloader__wrapper";
const PRELOADER_COUNTER_SELECTOR = ".preloader__counter";

const usePreloader = () => {
  const [counter, setCounter] = useState(0);
  const counterStateRef = useRef(counter);
  const setCounterState = (data) => {
    counterStateRef.current = data;
    setCounter(data);
  };

  const [counterStep, setCounterStep] = useState(0);
  const counterStepStateRef = useRef(counterStep);
  const setCounterStepState = (data) => {
    counterStepStateRef.current = data;
    setCounterStep(data);
  };

  const preloaderEl = document.querySelector(PRELOADER_SELECTOR);
  const counterEl = document.querySelector(PRELOADER_COUNTER_SELECTOR);

  const updateCounter = () => {
    setCounterState(counterStateRef.current + counterStepStateRef.current);
  };

  const checkImageLoading = (url) => {
    const imageChecker = new Image();
    imageChecker.addEventListener("load", updateCounter);
    imageChecker.addEventListener("error", updateCounter);
    imageChecker.src = url;
  };

  useEffect(() => {
    const imgArray = document.querySelectorAll("img");
    if (imgArray.length > 0) {
      setCounterStepState(Math.floor(100 / imgArray.length) + 1);
      imgArray.forEach((img) => {
        checkImageLoading(img.src);
      });
    }
  }, []);

  useLayoutEffect(() => {
    if (counterEl) {
      counterStateRef.current < 100
        ? (counterEl.innerHTML = `${counterStateRef.current}%`)
        : hidePreloader(preloaderEl);
    }
  }, [counter, counterStep]);
  return;
};

const hidePreloader = (preloaderEl) => {
  preloaderEl.remove();
};

export default usePreloader;

Основное приложение

Для использования в приложении этого хука достаточно вызывать его

export default function App() {
  usePreloader();
  return (
    <div className="App">
      <Preloader />
      ...
    </div>
  );
}

Итого:

В этой части статьи показано:

  • как создать кастомный хук

  • как управлять состоянием хука с помощью хуков useReducer и useState

  • как использовать useRef для хранения ссылки на состояние

  • различие в поведении компонента при использовании хуков useEffect и useLayoutEffect

Ссылка на песочницу

Ссылка на репозиторий

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

Начнём, а вернее продолжим, с генераторами...

Источник: https://habr.com/ru/post/532440/


Интересные статьи

Интересные статьи

Есть несколько способов добавить водяной знак в Битрикс. Рассмотрим два способа.
Привет, Хабр! В не такие уж далёкие годы, на первом курсе «программистского» факультета, мне нравилось задавать товарищам по учёбе вопрос: «Зачем вы вообще пошли сюда учиться?» Точной статистики ...
Всем привет! Я — Сергей, R&D officer в Genesis. В этом тексте хочу поделиться своими знаниями и опытом по созданию привычки использования продукта, рассказать о том, как это влияет на прибыль...
Сегодня трудно кого-то удивить возможностью свайпать элементы списка в мобильных приложениях. В одном нашем react-native приложении тоже была такая функциональность, но недавно возникла необходим...
Эта статья посвящена одному из способов сделать в 1с-Битрикс форму в всплывающем окне. Достоинства метода: - можно использовать любые формы 1с-Битрикс, которые выводятся компонентом. Например, добавле...