В данной статье мы разберем задание, которое может встретиться в рамках собеседования на должность фронтенд-разработчика, а именно реализацию слайдера изображений.
За последние 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 варианта оптимизации.
- Одновременный показ 3-х слайдов.
- Показ только 1 слайда за раз.
Рассмотрим их.
Оптимизация с одновременным показом 3-х слайдов
Вы можете выбрать данный вариант оптимизации при желании использовать transform
для смены слайдов.
В этом случае мы отображаем одновременно только 3 слайда: активный в середине, предыдущий и следующий. Объясняется это тем, что пользователь чаще всего нажимает на стрелки для перемещения на один слайд назад или вперед. При автоматической смене слайдов мы каждый раз перемещаемся вперед.
При переходе к предыдущему или следующему слайду мы определяем 3 новых слайда и отображаем их.
Оптимизация с показом 1 слайда
При намерении задействовать анимацию в CSS вы можете каждый раз отображать один слайд с информацией.
Пример возможных эффектов анимации:
Этот вариант оптимизации требует корректировки решения.
Теперь нет необходимости в компоненте 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;
}
}
}
Дополнительные предложения по оптимизации
- Скорректировать размер изображения. Нет необходимости в разрешении Full HD для слайдера с ограниченным размером.
- Выбрать формат WebP для уменьшения размера изображений. Он сжимает на 20–30% лучше, чем
jpg
иpng
. - Избегать изображений со 100% качеством. 70–85% качества выглядят также, как и 100%, но при этом размер изображений меньше.
- Уменьшить размеры исходного кода JavaScript и стилей.
- Использовать CDN для хранения изображений.
- Задействовать Brotli для сжатия.
Окончательный размер данных при Brotli-сжатии на 14–21% меньше, чем в случае Gzip.
Заключение
Реализация собственного слайдера изображений не требует специальных знаний. Потренируйтесь в его создании без опоры на интернет и за ограниченное время.
Весь код предоставлен на GitHub.
Читайте также:
- Версионирование независимых UI-компонентов
- 7 основных навыков, необходимых для фронтенд-разработчика
- Что делает сайты медленнее?
Читайте нас в Telegram, VK и Дзен
Перевод статьи Yan Tsishko: Implementing Image and Text Slider With React.js And Optimizations