
В этой статье я хочу затронуть еще одну задачу, с которой вы можете столкнуться на собеседовании на позицию Front-End — создание Star Rating виджета.
За последние пять месяцев у меня было 15 онсайт собеседований, а также офферы от Google, Roku, Microsoft и других компаний.
Вы должны уточнить требования и реализовать этот виджет в течение ~ 45–50 минут.
Требования
Начнем с требований нашего виджета.
- Отображение заданного количества звезд из конфига. 
- Пользователи могут выбрать рейтинг. 
- Поддержка режима только для чтения, для отображения рейтингов других пользователей без редактирования. 
- Кастомизация текста для виджета. 
- Кастомизация цвета для выбранного и невыбранного состояний. 
- Расширить события выбора рейтинга и наведения на него. 
- Кастомизация размера звезд. 
Макет

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

Код компонентов
Начнем с пропсов нашего компонента. С помощью этих проспов мы можем конфигурировать наш слайдер.
В компоненте Star Rating нам нужно реализовать следующее:
- Расширение методов выбора и наведения на рейтинг для кастомизации пользователем. 
- Прокинуть всю конфигурацию виджета в контекст. 
- Рендеринг компонентов Label и StarsList. 
Текущий рейтинг и значения наведения будем хранить в локальном состоянии компонента. Чтобы избежать большого количества прокидывания пропсов, предлагаю использовать Context для прокидывания конфигурации и текущего состояния виджета.
setRatingFn и setHoverFn расширяют события пользовательскими функциями и предоставляют текущие состояния виджета. В режиме только для чтения эти методы отключены.
Для текста виджета у нас есть метод по умолчанию с дефолтным текстом. Если пользователь хочет использовать другой текст, мы разрешаем ему переопределить этот метод. Этот метод используется в компоненте StarRatingLabel при рендеринге.
Компонент StarsList отображает заданное в конфиге количество звезд. Значение по умолчанию - пять.
Каждый компонент Star знает свое собственное значение, и мы можем использовать это значение для сохранения состояния рейтинга или отображения изменений при наведении. Использование SVG позволяет управлять цветом, размером и формой иконок. Если вы хотите предоставить возможность использовать другие иконки, пользователь может установить собственный SVG в конфигурации. Также SVG позволяет изменять размер без потери качества изображения.
Другой подход может заключаться в использовании пользовательских шрифтов, если вы предоставляете разные иконки для виджета.
Оптимизация
Как вы можете видеть в компоненте Star, каждый раз создается анонимная функция, мы можем этого избежать.
Давайте изменим код компонента Star. Необходимо добавить data атрибут со значением для звезды и вызывать методы onClick, onMouseEnter и onMouseLeave без анонимных функций.
В компоненте Star Rating изменены два метода: setRatingFn и setHoverFn. Здесь мы можем получить значение из data-атрибута и использовать его.
При событии onMouseLeave нам нужно установить состояние наведения в значение null. Мы можем понять это, добавив проверку на тип события или создать отдельный метод и вызвать его.
Еще один подход может заключаться в использовании radio-button и получении текущего значения из него.
Заключение
Теперь вы знаете, как создать собственный виджет звездного рейтинга и какие требования вы можете уточнить у интервьюера.
Весь код вы можете найти на GitHub.
Другие статьи, которые вы можете прочитать:
Более подробно о процессе поиска работы в США на позицию Front-End в моем Telegram-канале.
Удачи на собеседованиях!
Комментарии (14)
 - Sway26.07.2022 01:02+1- Я, если честно, не понял зачем так сильно усложнять практически всё. Разве что ради того чтобы показать что ты умеешь в контексты. Только если контексты так использовать на каждый чих, то не выльется ли это в проблемы в итоге? Как по мне так в данном случае использование контекста - это misuse. Например компонент Star в каком-то другом месте использовать будет невозможно. Что совсем не круто. 
 Почему не используется TypeScript?
 И, кстати, самое тормозное никак не оптимизировано - иконка. Представьте страницу на которой очень много звёздочек, а иконка каждой звездочки - отдельный svg элемент с одинаковым path. Это прямо очень тормозное дело, особенно если иконка в каждом блоке со компонентом рейтинга не одна. А ведь далеко не все иконки имеют такой короткий path. Я напоролся на такую проблему и скажу что абсолютно все остальные оптимизации - экономия на спичках. У меня в одном блоке было всего 4 разных иконки (разные, с длинным path), но блоков было много - от сотни до нескольких тысяч. В каждом блоке было от 4 до 12 иконок (чекбоксов могло быть от 1 до 8 в разном виде) Так вот - браузер очень туго переваривал весь этот зоопарк иконок. На довольно мощном компе.
 Решение: в svg есть возможность клонирования/переиспользования элементов тэгом- <use>.
 Т.е. нужно:- Вынести иконку в отдельный компонент 
- При первом создании компонента пробрасывать полный svg в - <body>или в другое место откуда будет максимально удобный доступ (Пример полного svg:- <svg><circle cx="50" cy="50" r="10" fill="red" id="primcirc" /></svg>, обратите внимание на- id). Тут особенность в том что нельзя чтобы полный SVG уничтожался при последующих перерисовках, иначе пропадут все иконки-клоны.
- При следующих рендерах компонента выводить не полный svg, а - <use xlink:href="#primcirc" />Подробнее гуглите: Cloning SVG Elements: Using use
 - Вот полный работающий код компонента иконки на основе - MDIIcon(Material Design Community Icons):- import * as React from "react"; import {AllHTMLAttributes, CSSProperties, useEffect, useRef} from "react"; let idCounter: number = 0; let reusableIcons: { [key: string]: boolean } = {}; export interface IconProps extends Omit<AllHTMLAttributes<SVGSVGElement>, 'size' | 'label'> { id?: string, path: string, ref?: React.RefObject<SVGSVGElement>, title?: string, description?: string | null, horizontal?: boolean, vertical?: boolean, rotate?: number, spin?: boolean | number, style?: CSSProperties, size?: number | null, reuse?: string, //< icon id to be reused reusableItemsContainerJquerySelector?: string | 'body' } export default function MDIIcon(props: IconProps) { const { id, path, title, description, size, horizontal, vertical, rotate, spin, reuse, reusableItemsContainerJquerySelector, style: customStyle = {}, ...rest } = props; const ref = useRef<SVGSVGElement>(null); useEffect(() => { if (reuse && ref.current && ref.current.getAttribute('data-use') === '0') { // copy original svg element to <body> so that it will persist there even after rerender // on rerender original svg element will be replaced by <svg><use href="#{reuse}"/></svg> and if // there are no clone inside <body> - all <use> tag will target nothing and icons will not be rerendered reusableIcons[reuse] = true; const existing = document.getElementById(reuse); if (existing) { window.$(existing).parent().remove(); } const $container = window.$('<div style="display: none;" class="react-app"></div>') .append(window.$(ref.current.cloneNode(true) as any).attr('id', reuse)); if (reusableItemsContainerJquerySelector === 'body') { window.$(document.body).append($container); } else { const $supercontainer = window.$(document.body).find(reusableItemsContainerJquerySelector as string); if ($supercontainer.length !== 0) { $supercontainer.append($container); } else { const message = '[MDIIcon] failed to find element for reusableItemsContainerJquerySelector = ' + reusableItemsContainerJquerySelector; console.error(message); window.$(document.body).append($container); } } } }, [ref.current, reuse]) const index = ++idCounter; const pathStyle: any = {}; const transform = []; const style = Object.assign({}, customStyle || {}); if (size !== null) { style.height = style.width = size + 'px'; } if (horizontal) { transform.push("scaleX(-1)"); } if (vertical) { transform.push("scaleY(-1)"); } if (rotate !== 0) { transform.push(`rotate(${rotate}deg)`); } let transformElement = ( <path d={path} style={pathStyle} /> ); if (transform.length > 0) { style.transform = transform.join(' '); style.transformOrigin = 'center'; } let spinElement = transformElement; const spinSec = spin || typeof spin !== 'number' ? 2 : spin; let inverse = horizontal || vertical; if (spinSec < 0) { inverse = !inverse } if (spin) { spinElement = ( <g style={{ animation: `spin${inverse ? '-inverse' : ''} linear ${Math.abs(spinSec)}s infinite`, transformOrigin: 'center' }} > {transformElement} {!(horizontal || vertical || rotate !== 0) && ( <rect width="24" height="24" fill="transparent" /> )} </g> ) } let ariaLabelledby; let labelledById = `icon_labelledby_${index}`; let describedById = `icon_describedby_${index}`; let role; if (title) { ariaLabelledby = description ? `${labelledById} ${describedById}` : labelledById; } else { role = 'presentation'; if (description) { throw new Error("title attribute required when description is set"); } } if (reuse && reusableIcons[reuse]) { return ( <svg ref={ref} viewBox="0 0 24 24" style={style} role={role} aria-labelledby={ariaLabelledby} data-use="1" {...rest as any} > <use href={'#' + reuse}/> </svg> ); } else { if (reuse) { reusableIcons[reuse] = true; } return ( <svg ref={ref} viewBox="0 0 24 24" style={style} role={role} aria-labelledby={ariaLabelledby} id={id} data-use="0" {...rest as any} > {title && <title id={labelledById}>{title}</title>} {description && <desc id={describedById}>{description}</desc>} {spin && ( inverse ? <style>{"@keyframes spin-inverse { to { transform: rotate(-360deg) } }"}</style> : <style>{"@keyframes spin { to { transform: rotate(360deg) } }"}</style> )} {spinElement} </svg> ); } } MDIIcon.defaultProps = { horizontal: false, vertical: false, rotate: 0, spin: false, size: 24, style: {}, reusableItemsContainerJquerySelector: 'body' } as Partial<IconProps>;- Извините за использование jQuery. Проект был на него завязан, а я не настолько ханжа чтобы этим не воспользоваться. 
 Я не претендую на идеальность кода или решения, но это единственный вариант который я нашел и который достаточно хорошо работает имея минимальные недостатки (по сути только лишний код в- <body>). Не стоит пользоваться этой функцией везде - оно имеет смысл только если одинаковых иконок очень много (примерно от 50-100 штук на каждую иконку). - yantishko Автор26.07.2022 03:17- Задачей в данной статье не было реализовать npm пакет с данным виджетом для общего пользования, я хотел показать как решить данную задачу за минут 40 и какие требования могут быть. Ну и как оптимизировать можно. - Как ответили выше, контекст здесь ради того, чтоб не тянуть сторы в данный пример и избежать прокидывания пропсов, а показать реализацию на чистом React. - За замечание по SVG спасибо, очень полезно, попробую реализовать.  - Sway26.07.2022 04:05- Давайте тогда представим что я тот кто оценивает Ваше решение =) 
 У меня возникают следующие вопросы:- Почему использовался контекст? (Ответ уже знаем: чтобы не прокидывать пропсы) 
- Чем Вас не устраивает прокидывание пропсов в данной задаче? Ведь это было бы проще и понятнее. Да и компонент же довольно простой, а не конструктор типа Dropdown Menu где контент передается извне и нужно иметь возможность взаимодействия с верхним уровнем. 
- В проекте где есть рейтинг чаще всего будет и избранное, которое идеально ложится на функционал компонента Star, что будете делать? 
- Почему не используется TypeScript? Он бы почти никак не повлиял на скорость решения задачи, зато была бы типизация хорошо прописана и соблюдена. 
 - Посудите сами - данное тестовое задание дается не только чтобы проверить что Вы можете написать компонент или знаете какие-то особенности технологии/инструмента/языка, но и то как Вы это сделаете, какие возможности и как примените, и как Вы оцениваете ситуацию вне задачи (вопрос 3). 
 Я ничего не имею против контекстов, прекрасная штука, но тащить их только ради того чтобы не прокидывать пропсы - это как-то неэффективно ни по времени ни по результату. Более того контекст обязывает все компоненты его использующие быть зависимыми друг от друга. Уже нельзя будет для вопроса 3 взять готовое решение. Нужно будет переделывать. А это время и деньги. И не нужно говорить что этого небыло в задании. В реальности далеко не всегда заранее известно какой компонент будет переиспользоваться в будущем. Т.е. я как наниматель увидел что Вы смогли решили задачу, но не подумали о том что некоторые компоненты можно будет переиспользовать в будущем. Ведь у компонента Star очень широкое применение. Его и в like можно было бы превратить всего-лишь поменяв иконку. Но увы, придется делать отдельный компонент... - yantishko Автор26.07.2022 06:08- У вас хорошо получается раскрыть тему, но будем реалистами, на интервью даже такого решение врятли будет, там все будет в одном js файле, а не разбито по папочкам и вылизано. Иногда приходилось ещё и подключать все самому, потому что было просто ничего в редакторе :) - Для меня на TypeScript заняло бы больше времени на описание типов и интерфейсов, но если вы успеете на лету это все продумать и описать, то почему нет) - Я как то больше сфокусировался на требованиях и простой реализации, что забыл о моменте переиспользования в статье, это важный момент, в этом случае архитектура будет совершенно другой и без контекстов. - P.S. судя по количеству комментариев и их размеру вам нужно писать статьи )  - Alexandroppolus26.07.2022 09:28- Для меня на TypeScript заняло бы больше времени на описание типов и интерфейсов - Смотря где пишется код. В блокноте - само собой, а в вебшторме за счёт более лучшего автокомплита и оперативной проверки опечаток могло быть и быстрее.  - yantishko Автор26.07.2022 09:31- Глючный codesandbox или ещё лучше whiteboard, речи об IDE даже не идет) - Понятно в рабочей обстановке TypeScript, IDE, автокомплит и гугл лучшие друзья :)  - Sway26.07.2022 13:21- Как по мне, то если на собеседовании загоняют в такие рамки и не дают использовать привычный или хотябы схожий набор инструментов, то возникает вопрос об адекватности собеседующих. Отнимите в ответ у собеседующего бумажку/планшет/ноут по которым они вопросы задают. Пусть почувствуют себя на Вашем месте =))) 
 
 
 
 
 
 
 
           
 

lam0x86
Зачем здесь используется useContext? В официальной документации по этому хуку написано, что его следует использовать для хранения глобального состояния.
Alexandroppolus
Контекст можно использовать и локально внутри компонентов, например чтобы не пропс-дриллить (как в данном случае).
Хотя, если автор хочет в оптимизацию, то можно было бы попробовать обернуть каждый Star в React.memo и передавать ему проп "isSelected", чтобы, например, при ховере обновлялись не все. Тогда контекст не нужен. Впрочем, эти оптимизации будут копеечные. А вот обернуть весь компонент в React.memo надо бы.
yantishko Автор
спасибо за комментарий, учту и дополню в статье