Реализация слайдера изображений и текста на React.js с вариантами оптимизации

Реализация слайдера изображений и текста на React.js с вариантами оптимизации

https://t.me/javascriptv

В данной статье мы разберем задание, которое может встретиться в рамках собеседования на должность фронтенд-разработчика, а именно реализацию слайдера изображений. 

За последние 5 месяцев на моем счету 15 очных собеседований и предложения от Google, Roku, Microsoft и других компаний. 

Суть задачи — реализовать виджет за 45–50 минут и рассказать о вариантах оптимизации. Ее решением мы займемся в данном руководстве. Главная цель не в том, чтобы реализовать слайдер изображений со множеством функциональностей, а в том, чтобы объяснить, как именно его реализовать и оптимизировать. 

Требования

Виджет должен: 

  • показывать изображения кошек из API с ограничением размеров; 
  • отображать описание или название каждого изображения; 
  • обеспечивать перемещение между изображениями с помощью стрелок; 
  • допускать смену слайда при касании на мобильных устройствах; 
  • осуществлять переход на любой слайд; 
  • включать автоматическую смену слайдов (Autoplay); 
  • предоставлять возможность настройки ширины и высоты слайдера; 
  • гарантировать отзывчивость слайдера; 
  • обеспечивать эффективную загрузку и быстрое отображение изображений слайдера. 

Макет 

Макет слайдера

Рендеринг в браузере 

В первой и простой реализации мы выполняем рендеринг всех слайдов в браузере и отображаем только части в области просмотра (англ. viewport) или в элементе слайдера (когда задаем ширину или высоту). Эти решения загружают все изображения для всех слайдов и имеют N элементов DOM, где N — количество слайдов. 

Рендеринг в браузере 

Архитектура компонентов 

Архитектура компонентов слайдера 

Реализация компонентов 

Начнем с пропсов (англ. props) компонента слайдера, с помощью которых мы настраиваем слайдер:

{   
autoPlay: boolean,   
autoPlayTime: number,   
width: '%' | 'px',   
height:  '%' | 'px',
}

В компоненте слайдера реализуем: 

  • загрузку изображений; 
  • метод для навигации по стрелкам (англ. arrows); 
  • метод для навигации по точкам (англ. dots);
  • навигацию касанием; 
  • функциональность Autoplay;
  • рендеринг слайдов, стрелок и точек. 

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

Компонент слайдера: 

import React, { useEffect, useState, createContext } from "react";
import PropTypes from "prop-types";
import { getImages } from "../../../imagesApi";

import Arrows from "./components/Controls/Arrows";
import Dots from "./components/Controls/Dots";

import SlidesList from "./components/SlidesList";

export const SliderContext = createContext();

const Slider = function ({ width, height, autoPlay, autoPlayTime }) {
  const [items, setItems] = useState([]);
  const [slide, setSlide] = useState(0);
  const [touchPosition, setTouchPosition] = useState(null)

  useEffect(() => {
    const loadData = async () => {
      const images = await getImages();
      setItems(images);
    };
    loadData();
  }, []);

  const changeSlide = (direction = 1) => {
    let slideNumber = 0;

    if (slide + direction < 0) {
      slideNumber = items.length - 1;
    } else {
      slideNumber = (slide + direction) % items.length;
    }

    setSlide(slideNumber);
  };

  const goToSlide = (number) => {
    setSlide(number % items.length);
  };

  const handleTouchStart = (e) => {
    const touchDown = e.touches[0].clientX;

    setTouchPosition(touchDown);
  }

  const handleTouchMove = (e) => {
    if (touchPosition === null) {
      return;
    }

    const currentPosition = e.touches[0].clientX;
    const direction = touchPosition - currentPosition;

    if (direction > 10) {
      changeSlide(1);
    }

    if (direction < -10) {
      changeSlide(-1);
    }

    setTouchPosition(null);
  }

  useEffect(() => {
    if (!autoPlay) return;

    const interval = setInterval(() => {
      changeSlide(1);
    }, autoPlayTime);

    return () => {
      clearInterval(interval);
    };
  }, [items.length, slide]); // when images uploaded or slide changed manually we start timer

  return (
    <div
      style={{ width, height }}
      className="slider"
      onTouchStart={handleTouchStart}
      onTouchMove={handleTouchMove}
    >
      <SliderContext.Provider
        value={{
          goToSlide,
          changeSlide,
          slidesCount: items.length,
          slideNumber: slide,
          items,
        }}
      >
        <Arrows />
        <SlidesList />
        <Dots />
      </SliderContext.Provider>
    </div>
  );
};

Slider.propTypes = {
  autoPlay: PropTypes.bool,
  autoPlayTime: PropTypes.number,
  width: PropTypes.string,
  height: PropTypes.string
};

Slider.defaultProps = {
  autoPlay: false,
  autoPlayTime: 5000,
  width: "100%",
  height: "100%"
};

export default Slider;

Стили компонента слайдера: 

.slider {
  overflow: hidden;
  position: relative;

  & .slide-list {
    display: flex;
    height: 100%;
    transition: transform 0.5s ease-in-out;
    width: 100%;

    & .slide {
      flex: 1 0 100%;
      position: relative;

      & .slide-image {
        display: flex;
        margin: 0 auto;
        max-height: 300px;
        width: 100%;
        object-fit: contain;
      }

      & .slide-title {
        text-align: center;
        margin-top: 10px;
      }
    }
  }
}

Как видно, архитектура слайдера содержит 3 компонента: SlideListArrows и Dots.

Имеются 2 стрелки: слева и справа. 

Компонент Arrows:

import React, { useContext } from "react";
import { SliderContext } from "../../Slider";

import "../../styles.scss";

export default function Arrows() {
  const { changeSlide } = useContext(SliderContext);

  return (
    <div className="arrows">
      <div className="arrow left" onClick={() => changeSlide(-1)} />
      <div className="arrow right" onClick={() => changeSlide(1)} />
    </div>
  );
}

Для стилизации стрелок с обеих сторон используем CSS. 

Стили компонента Arrows:

/* ARROWS */

  & .arrows {
    color: white;
    display: flex;
    font-size: 30px;
    justify-content: space-between;
    height: 100%;
    position: absolute;
    top: 30%;
    width: 100%;
    z-index: 1;

    & .arrow {
      height: 30px;
      width: 30px;

      &:hover {
        cursor: pointer;
      }

      &.left {
        background-image: url(../../../assets/icons/arrow.png);
        background-repeat: no-repeat;
        background-size: contain;
        margin-left: 5px;
        transform: rotate(
180deg);
      }

      &.right {
        background-image: url(../../../assets/icons/arrow.png);
        background-repeat: no-repeat;
        background-size: contain;
        margin-right: 5px;
      }
    }
  }

По количеству слайдов отображаем нужное количество точек. 

Компонент Dots

import React, { useContext } from "react";
import { SliderContext } from "../../Slider";
import Dot from "./Dot";

import "../../styles.scss";

export default function Dots() {
  const { slidesCount } = useContext(SliderContext);

  const renderDots = () => {
    const dots = [];
    for (let i = 0; i < slidesCount; i++) {
      dots.push(<Dot key={`dot-${i}`} number={i} />);
    }

    return dots;
  };

  return <div className="dots">{renderDots()}</div>;
}

Каждая точка выглядит так: 

import React, { useContext } from "react";
import { SliderContext } from "../../Slider";

import "../../styles.scss";

export default function Dot({ number }) {
  const { goToSlide, slideNumber } = useContext(SliderContext);

  return (
    <div
      className={`dot ${slideNumber === number ? "selected" : ""}`}
      onClick={() => goToSlide(number)}
    />
  );
}

Для отображения слайдов в SlideList можно получить элементы из контекста и выполнить рендеринг компонента Slide с ключами и данными о слайде. 

Компонент SlideList

import React, { useContext } from "react";
import Slide from "./Slide";
import { SliderContext } from "../Slider";

import "../styles.scss";

export default function SlidesList() {
  const { slideNumber, items } = useContext(SliderContext);

  return (
    <div
      className="slide-list"
      style={{ transform: `translateX(-${slideNumber * 100}%)` }}
    >
      {items.map((slide, index) => (
        <Slide key={index} data={slide} />
      ))}
    </div>
  );
}

Применяя в стилях transform и translateX, получаем нижепредставленную анимацию. От одного изображения слайда к другому перемещаемся по номеру слайда в массиве:

Компонент Slide включает 2 компонента: SlideImage и SlideTitle. Данная архитектура в перспективе позволяет добавлять новые функциональности для каждого слайда. 

Компонент Slide:

import React from "react";
import SlideTitle from "./SlideTitle";
import SlideImage from "./SlideImage";

import "./../styles.scss";

export default function Slide({ data: { url, title } }) {
  return (
    <div className="slide">
      <SlideImage src={url} alt={title} />
      <SlideTitle title={title} />
    </div>
  );
}

Компонент SlideImage:

import React from "react";

import "../styles.scss";

export default function SlideImage({ src, alt }) {
  return <img src={src} alt={alt} className="slide-image" />;
}

Компонент SlideTitle:

import React from "react";

import "../styles.scss";

export default function SlideTitle({ title }) {
  return <div className="slide-title">{title}</div>;
}

Варианты оптимизации

Предположим, что слайдер содержит много изображений и нуждается в оптимизации. Ее суть состоит в изменении анимации слайдов.

Возможны 2 варианта оптимизации.

  1. Одновременный показ 3-х слайдов. 
  2. Показ только 1 слайда за раз. 

Рассмотрим их.

Оптимизация с одновременным показом 3-х слайдов

Вы можете выбрать данный вариант оптимизации при желании использовать transform для смены слайдов.

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

При переходе к предыдущему или следующему слайду мы определяем 3 новых слайда и отображаем их. 

Одновременный рендеринг 3-х слайдов 

Оптимизация с показом 1 слайда 

При намерении задействовать анимацию в CSS вы можете каждый раз отображать один слайд с информацией. 

Рендеринг одного слайда за раз 

Пример возможных эффектов анимации: 

Анимация с эффектом прозрачности (англ. opacity) 

Этот вариант оптимизации требует корректировки решения. 

Теперь нет необходимости в компоненте SlidesList, и мы отображаем только один компонент Slide (строка 99).

Кроме того, устанавливаем управление эффектом анимации и применяем его только при изменении содержимого слайда (строка 41). 

В качестве последнего изменения для улучшения пользовательского опыта предварительно загружаем изображения: предшествующее текущему слайду и следующее за ним (строка 25). 

Компонент Slide для оптимизации с показом одного слайда: 

import React, { useEffect, useState, createContext } from "react";
import PropTypes from "prop-types";
import { getImages } from "../../../imagesApi";

import Arrows from "./components/Controls/Arrows";
import Dots from "./components/Controls/Dots";

import Slide from "./components/Slide";

export const SliderContext = createContext();

const Slider = function ({ width, height, autoPlay, autoPlayTime }) {
  const [items, setItems] = useState([]);
  const [slide, setSlide] = useState(0);
  const [animation, setAnimation] = useState(true);

  useEffect(() => {
    const loadData = async () => {
      const images = await getImages();
      setItems(images);
    };
    loadData();
  }, []);

  const preloadImages = () => {
    const prevItemIndex = slide - 1 < 0 ? items.length - 1 : slide - 1;
    const nextItemIndex = (slide + 1) % items.length;

    new Image().src = items[slide].url;
    new Image().src = items[prevItemIndex].url;
    new Image().src = items[nextItemIndex].url;
  }

  useEffect(() => {
    if (items.length) {
      preloadImages();
    }
  }, [slide, items])

  const changeSlide = (direction = 1) => {
    setAnimation(false);
    let slideNumber = 0;

    if (slide + direction < 0) {
      slideNumber = items.length - 1;
    } else {
      slideNumber = (slide + direction) % items.length;
    }

    setSlide(slideNumber);

    const timeout = setTimeout(() => {
      setAnimation(true);
    }, 0);

    return () => {
      clearTimeout(timeout)
    }
  };

  const goToSlide = (number) => {
    setAnimation(false);
    setSlide(number % items.length);

    const timeout = setTimeout(() => {
      setAnimation(true);
    }, 0);

    return () => {
      clearTimeout(timeout)
    }
  };

  useEffect(() => {
    if (!autoPlay) return;

    const interval = setInterval(() => {
      changeSlide(1);
    }, autoPlayTime);

    return () => {
      clearInterval(interval);
    };
  }, [items.length, slide]); // when images uploaded or slide changed manually we start timer

  return (
    <div style={{ width, height }} className="slider">
      <SliderContext.Provider
        value={{
          goToSlide,
          changeSlide,
          slidesCount: items.length,
          slideNumber: slide,
        }}
      >
        <Arrows />
        {
          items.length ? (
            <Slide data={items[slide]} animation={animation} />
          ) : null
        }
        <Dots />
      </SliderContext.Provider>
    </div>
  );
};

Slider.propTypes = {
  autoPlay: PropTypes.bool,
  autoPlayTime: PropTypes.number,
  width: PropTypes.string,
  height: PropTypes.string
};

Slider.defaultProps = {
  autoPlay: false,
  autoPlayTime: 5000,
  width: "100%",
  height: "100%"
};

export default Slider;

В Slide вносится только одно изменение: он задействует класс с анимацией при смене слайда: 

import React from "react";
import SlideTitle from "./SlideTitle";
import SlideImage from "./SlideImage";

import "./../styles.scss";

export default function Slide({ data: { url, title }, animation }) {
  return (
    <div className={`slide ${animation && 'fadeInAnimation'}`}>
      <SlideImage src={url} alt={title} />
      <SlideTitle title={title} />
    </div>
  );
}

Реализация анимации fadeIn в стилях: 

.slider {
  overflow: hidden;
  position: relative;

  & .slide {
    flex: 1 0 100%;
    position: relative;

    &.fadeInAnimation {
      animation: fadeIn 1.5s;
    }

    @keyframes fadeIn {
      0% { opacity: 0; }
      100% { opacity: 1; }
    }

    & .slide-image {
      display: flex;
      margin: 0 auto;
      max-height: 300px;
      width: 100%;
      object-fit: contain;
    }

    & .slide-title {
      text-align: center;
      margin-top: 10px;
    }
  }
}

Дополнительные предложения по оптимизации 

  1. Скорректировать размер изображения. Нет необходимости в разрешении Full HD для слайдера с ограниченным размером. 
  2. Выбрать формат WebP для уменьшения размера изображений. Он сжимает на 20–30% лучше, чем jpg и png
  3. Избегать изображений со 100% качеством. 70–85% качества выглядят также, как и 100%, но при этом размер изображений меньше. 
  4. Уменьшить размеры исходного кода JavaScript и стилей.
  5. Использовать CDN для хранения изображений.
  6. Задействовать Brotli для сжатия.

Окончательный размер данных при Brotli-сжатии на 14–21% меньше, чем в случае Gzip. 

Заключение 

Реализация собственного слайдера изображений не требует специальных знаний. Потренируйтесь в его создании без опоры на интернет и за ограниченное время. 

Весь код предоставлен на GitHub.

https://t.me/react_tg


Report Page