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

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

За последние 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 компонента: SlideList, Arrows и 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.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Yan Tsishko: Implementing Image and Text Slider With React.js And Optimizations

Предыдущая статьяMito: быстрый анализ данных на Python
Следующая статья3 эффективные новинки Swift с WWDC 2022