React: решение интересной практической задачи28.11.2022 18:32
Привет, друзья!
В данном туториале я хочу поделиться с вами опытом решения одной интересной практической задачи.
Предположим, что у нас имеется страница сравнения товаров. На этой странице отображается слайдер с карточками товаров и таблица с их характеристиками. Задача состоит в том, чтобы синхронизировать переключение слайдов и прокрутку таблицы. Условия следующие:
ширина таблицы должна соответствовать ширине слайдера;
ширина колонки таблицы должна соответствовать ширине слайда;
слайды можно переключать с помощью перетаскивания, нажатия на кнопки управления и элементы пагинации;
таблицу можно прокручивать с помощью колесика мыши (на десктопе) и перемещения указателя (на телефоне);
при взаимодействии пользователя с одним компонентом второй должен реагировать соответствующим образом: при переключении слайда должна выполняться прокрутка таблицы, при прокрутке таблицы — переключение слайдов.
Репозиторий с кодом проекта.
Если вам это интересно, прошу под кат.
Подготовка и настройка проекта
Для работы с зависимостями я буду использовать Yarn. Проект будет реализован на React и TypeScript.
Создаем шаблон проекта с помощью Vite:
# react-slider-table - название проекта
# react-ts - используемый шаблон
yarn create vite react-slider-table --template react-ts
Переходим в созданную директорию, устанавливаем зависимости и запускаем сервер для разработки:
cd react-slider-table
yarn
yarn dev
Для реализации слайдера будет использоваться библиотека Swiper (для синхронизации слайдера и таблицы мы будем использовать некоторые возможности, предоставляемые Swiper, поэтому в рамках туториала рекомендую использовать именно эту библиотеку). Устанавливаем ее:
yarn add swiper
yarn add -D @types/swiper
Импортируем стили слайдера в файле main.tsx:
import "swiper/css";
// для модулей навигации и пагинации
import "swiper/css/navigation";
import "swiper/css/pagination";
Определяем минимальные стили в файле index.css (файл App.css можно удалить):
Обратите внимание, что мы фиксируем ширину основного контейнера приложения (.app), поскольку хотим сосредоточится на синхронизации слайдера и таблицы (реализация отзывчивого дизайна потребует некоторых дополнительных вычислений).
У нас имеется массив, содержащий 6 объектов с информацией о товарах. Каждый объект товара содержит массив, состоящий из 6 объектов с характеристиками товара.
Создаем директорию components.
Начнем с разработки слайдера. Создаем файл components/Slider.tsx следующего содержания:
// модули
import { Navigation, Pagination } from "swiper";
// компоненты
import { Swiper, SwiperSlide } from "swiper/react";
import { Items } from "../types";
type Props = {
items: Items;
};
// количество отображаемых слайдов
const SLIDES_PER_VIEW = 3;
function Slider({ items }: Props) {
return (
{items.map((item) => (
{item.title}
{item.price} ₽
))}
);
}
export default Slider;
Импортируем и рендерим слайдер в файле App.tsx:
import Slider from "./components/Slider";
import data from "./data";
function App() {
return (
);
}
export default App;
Результат:
Теперь реализуем компонент таблицы. Создаем файл components/Table.tsx следующего содержания:
import { Items } from "../types";
type Props = {
items: Items;
};
// названия характеристик
const FEATURE_NAMES = [
"Title",
"Title2",
"Title3",
"Title4",
"Title5",
"Title6",
];
function Table({ items }: Props) {
return (
мы оборачиваем таблицу с overflow: hidden в контейнер с overflow: scroll (.table-wrapper);
колонка с названием характеристики растягивается на всю ширину таблицы по количеству товаров (атрибут colspan), а само название оборачивается в элемент span: при прокрутке таблицы название характеристики должно оставаться видимым.
Импортируем и рендерим таблицу в App.tsx:
import Slider from "./components/Slider";
import Table from "./components/Table";
import data from "./data";
function App() {
return (
);
}
export default App;
Результат:
Отлично, у нас есть все необходимые компоненты, можно приступать к их синхронизации.
Синхронизация ширины слайда и колонки таблицы
Определяем состояние ширины слайда в App.tsx:
const [slideWidth, setSlideWidth] = useState(0);
Данное состояние будет обновляться в слайдере, а использоваться — в таблице:
Определяем переменную для хранения ссылки на экземпляр Swiper в Slider.tsx:
const swiperRef = useRef();
Тип TSwiper выглядит так:
// types.ts
import type Swiper from "swiper";
export type TSwiper = Swiper & {
slides: {
swiperSlideSize: number;
}[];
};
Одним из пропов, принимаемых компонентом Swiper, является onSwiper. В качестве аргумента коллбэку этого пропа передается экземпляр Swiper:
Интересующее нас значение ширины слайда содержится в свойстве slides[0].swiperSlideSize:
Проп onImageReady компонента Swiper принимает коллбэк для выполнения операций после загрузки всех изображений, используемых в слайдере, что в ряде случаев является критически важным для определения правильной ширины слайда:
Применяем проп slideWidth в таблице с помощью встроенных стилей (в реальном приложении для этого, скорее всего, будет использоваться одно из решений CSS-in-JS, например, styled-jsx — см. конец статьи):
Синхронизация переключения слайдов и прокрутки таблицы: обработка переключения слайдов
Определяем состояние прокрутки в App.tsx:
const [scrollLeft, setScrollLeft] = useState(0);
Данное состояние, как и состояние ширины слайда, будет обновляться в слайдере, а использоваться — в таблице:
Проп onSlideChange компонента Swiper принимает коллбэк, позволяющий выполнять операции после переключения слайдов (любым способом):
Прежде чем определять функцию onSlideChange, взглянем на то, что происходит с элементом div с классом swiper-wrapper при переключении слайдов:
Видим, что к данному элементу применяется встроенный стиль transform: translate3d(x, y, z), где x — интересующее нас значение прокрутки.
Функция onSlideChange выглядит следующим образом:
const onSlideChange = () => {
if (!swiperRef.current) return;
// извлекаем значение свойства `transform`
const { transform } = swiperRef.current.wrapperEl.style;
// извлекаем значение координаты `x`
const match = transform.match(/-?\d+(\.\d+)?px/);
if (!match) return;
// извлекаем положительное (!) число из значения координаты `x`
// с числами работать удобнее, чем со строками
const scrollLeft = Math.abs(Number(match[0].replace("px", "")));
setScrollLeft(scrollLeft);
};
Для того, чтобы применить проп scrollLeft в таблице, необходимо сделать несколько вещей.
Определяем переменные для хранения ссылок на контейнер для таблицы и саму таблицу, а также переменную для хранения ссылок на элементы с названиями характеристик:
Видим, что переключение слайдов перетаскиванием, нажатием кнопок управления и элементов пагинации приводит к прокрутке таблицы и сдвигу названий характеристик на правильные позиции.
Синхронизация переключения слайдов и прокрутки таблицы: обработка прокрутки таблицы
Определяем состояние отступа по оси x в App.tsx:
const [offsetX, setOffsetX] = useState(0);
Данное состояние будет обновляться в таблице, а использоваться — в слайдере:
Как при прокрутке таблицы с помощью колесика мыши, так и с помощью перемещения указателя, на обертке для таблицы возникает событие scroll:
Определяем функцию onScroll:
const onScroll: React.UIEventHandler = useCallback(() => {
if (!tableRef.current) return;
// извлекаем позицию левого края таблицы по оси `x`
const { x } = tableRef.current.getBoundingClientRect();
// делаем число положительным
setOffsetX(Math.abs(x));
}, []);
Обратите внимание: обработка прокрутки должна выполняться с задержкой, поскольку установка свойства scrollLeft приводит к возникновению события scroll, что может заблокировать переключение слайдов и прокрутку таблицы:
offsetX передается в слайдер и используется для переключения слайдов;
в обработчике переключения слайдов происходит обновление scrollLeft;
scrollLeft используется для выполнения прокрутки таблицы — возникает событие scroll, в обработчике которого обновляется offsetX.
Также обратите внимание, что прокрутка должна выполняться мгновенно: установка стиля scroll-behavior: smooth или выполнение прокрутки с помощью метода scrollTo({ left: scrollLeft, behavior: 'smooth' }) сделает поведение прокрутки непредсказуемым.
Создаем файл hooks/useDebounce.ts следующего содержания:
Применение пропа offsetX в слайдере предполагает знание количества элементов пагинации, определение ближайшего к offsetX элемента и его программное нажатие.
Определяем переменные для хранения ссылок на элементы пагинации и их позиции по оси x:
Ссылки на элементы пагинации хранятся в свойстве pagination.bullets экземпляра Swiper. Для определения позиций элементов по оси x достаточно умножить индекс элемента на ширину слайда. Расширяем функцию onImagesReady:
const bullets = swiperRef.current.pagination
.bullets as unknown as HTMLSpanElement[];
if (!bullets.length) return;
paginationBulletRefs.current = bullets;
for (const i in bullets) {
paginationBulletXCoords.current.push(slideWidth * Number(i));
}
Определяем эффект для выполнения программного нажатия на соответствующий элемент пагинации при изменении offsetX:
useEffect(() => {
// переменная для минимальной разницы между позицией элемента и отступом
let min = 0;
let i = 0;
for (const j in paginationBulletXCoords.current) {
// вычисляем текущую разницу
const dif = Math.abs(paginationBulletXCoords.current[j] - offsetX);
// текущая разница равна `0`
if (dif === 0) {
min = 0;
i = 0;
break;
}
// текущая разница не равна `0` и минимальная разница равна `0` или текущая разница меньше минимальной разницы
if (dif !== 0 && (min === 0 || dif < min)) {
min = dif;
i = Number(j);
}
}
// выполняем программное нажатие на соответствующий элемент
if (paginationBulletRefs.current[i]) {
paginationBulletRefs.current[i].click();
}
}, [offsetX]);
Обратите внимание: программное нажатие на элемент пагинации приводит к вызову onSlideChange, который обновляет scrollLeft, что приводит к выравниванию таблицы и названий характеристик.
Результат:
Видим, что прокрутка таблицы с помощью колесика мыши или перемещения указателя приводит сначала к переключению слайда, а затем — к выравниванию таблицы и названий характеристик.
Обратите внимание: отсутствие задержки вызова onScroll сделает прокрутку более чем на один слайд за раз невозможной, т.е. прокрутка станет последовательной и пошаговой.
Обновление стилей в таблице можно упростить с помощью одного из решений CSS-in-JS, а именно: styled-jsx. Устанавливаем эту библиотеку:
Мы также можем отрефакторить код слайдера, упростив процесс переключения слайдов в ответ на прокрутку таблицы. Экземпляр Swiper предоставляет метод slideTo, позволяющий программно прокручивать слайдер к указанному слайду. Следовательно, вместо позиций элементов пагинации по оси x нам необходимо знать позиции слайдов. Редактируем файл Slider.tsx:
import { useEffect, useRef } from "react";
import { Navigation, Pagination } from "swiper";
import { Swiper, SwiperSlide } from "swiper/react";
import { Items, TSwiper } from "../types";
type Props = {
items: Items;
setSlideWidth: React.Dispatch>;
setScrollLeft: React.Dispatch>;
offsetX: number;
};
const SLIDES_PER_VIEW = 3;
function Slider({ items, setSlideWidth, setScrollLeft, offsetX }: Props) {
const swiperRef = useRef();
// !
const slideXPositions = useRef([]);
const onImagesReady = () => {
if (!swiperRef.current) return;
const slideWidth = swiperRef.current.slides[0].swiperSlideSize;
// !
for (const i in items) {
slideXPositions.current.push(slideWidth * Number(i));
}
setSlideWidth(slideWidth);
};
const onSlideChange = () => {
if (!swiperRef.current) return;
const { transform } = swiperRef.current.wrapperEl.style;
const match = transform.match(/-?\d+(\.\d+)?px/);
if (!match) return;
const scrollLeft = Math.abs(Number(match[0].replace("px", "")));
setScrollLeft(scrollLeft);
};
useEffect(() => {
if (!swiperRef.current) return;
let min = 0;
let i = 0;
for (const j in slideXPositions.current) {
const dif = Math.abs(slideXPositions.current[j] - offsetX);
if (dif === 0) {
min = 0;
i = 0;
break;
}
if (dif !== 0 && (min === 0 || dif < min)) {
min = dif;
i = Number(j);
}
}
// !
if (items[i]) {
swiperRef.current.slideTo(i);
}
}, [offsetX]);
return (
{
console.log(swiper);
swiperRef.current = swiper as TSwiper;
}}
modules={[Navigation, Pagination]}
navigation={SLIDES_PER_VIEW < items.length}
onImagesReady={onImagesReady}
onSlideChange={onSlideChange}
pagination={
SLIDES_PER_VIEW < items.length
? {
clickable: true,
}
: undefined
}
slidesPerView={SLIDES_PER_VIEW}
>
{items.map((item) => (
{item.title}
{item.price} ₽
))}
);
}
export default Slider;
Уверен, что существуют и другие, возможно, даже более простые способы реализации синхронизации между слайдером и таблицей. Если у вас есть какие-то идеи на этот счет, делитесь ими в комментариях.
Надеюсь, вы узнали что-то новое и не зря потратили время.