В этой статье я хочу затронуть еще одну задачу, с которой вы можете столкнуться на собеседовании на позицию 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)


  1. lam0x86
    25.07.2022 10:42

    Зачем здесь используется useContext? В официальной документации по этому хуку написано, что его следует использовать для хранения глобального состояния.


    1. Alexandroppolus
      25.07.2022 10:59
      +1

      Контекст можно использовать и локально внутри компонентов, например чтобы не пропс-дриллить (как в данном случае).

      Хотя, если автор хочет в оптимизацию, то можно было бы попробовать обернуть каждый Star в React.memo и передавать ему проп "isSelected", чтобы, например, при ховере обновлялись не все. Тогда контекст не нужен. Впрочем, эти оптимизации будут копеечные. А вот обернуть весь компонент в React.memo надо бы.


      1. yantishko Автор
        26.07.2022 03:11

        спасибо за комментарий, учту и дополню в статье


  1. roelberg
    25.07.2022 11:22

    Интересная статья, как раз хотел с каким-нибудь js фреймворком поработать, возможно начну с Реакта.


    1. dopusteam
      25.07.2022 17:30

      <zanudamode>

      React либа, а не фреймворк)


  1. Sway
    26.07.2022 01:02
    +1

    Я, если честно, не понял зачем так сильно усложнять практически всё. Разве что ради того чтобы показать что ты умеешь в контексты. Только если контексты так использовать на каждый чих, то не выльется ли это в проблемы в итоге? Как по мне так в данном случае использование контекста - это misuse. Например компонент Star в каком-то другом месте использовать будет невозможно. Что совсем не круто.
    Почему не используется TypeScript?
    И, кстати, самое тормозное никак не оптимизировано - иконка. Представьте страницу на которой очень много звёздочек, а иконка каждой звездочки - отдельный svg элемент с одинаковым path. Это прямо очень тормозное дело, особенно если иконка в каждом блоке со компонентом рейтинга не одна. А ведь далеко не все иконки имеют такой короткий path. Я напоролся на такую проблему и скажу что абсолютно все остальные оптимизации - экономия на спичках. У меня в одном блоке было всего 4 разных иконки (разные, с длинным path), но блоков было много - от сотни до нескольких тысяч. В каждом блоке было от 4 до 12 иконок (чекбоксов могло быть от 1 до 8 в разном виде) Так вот - браузер очень туго переваривал весь этот зоопарк иконок. На довольно мощном компе.
    Решение: в svg есть возможность клонирования/переиспользования элементов тэгом <use>.
    Т.е. нужно:

    1. Вынести иконку в отдельный компонент

    2. При первом создании компонента пробрасывать полный svg в <body> или в другое место откуда будет максимально удобный доступ (Пример полного svg: <svg><circle cx="50" cy="50" r="10" fill="red" id="primcirc" /></svg>, обратите внимание на id). Тут особенность в том что нельзя чтобы полный SVG уничтожался при последующих перерисовках, иначе пропадут все иконки-клоны.

    3. При следующих рендерах компонента выводить не полный 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 штук на каждую иконку).


    1. yantishko Автор
      26.07.2022 03:17

      Задачей в данной статье не было реализовать npm пакет с данным виджетом для общего пользования, я хотел показать как решить данную задачу за минут 40 и какие требования могут быть. Ну и как оптимизировать можно.

      Как ответили выше, контекст здесь ради того, чтоб не тянуть сторы в данный пример и избежать прокидывания пропсов, а показать реализацию на чистом React.

      За замечание по SVG спасибо, очень полезно, попробую реализовать.


      1. Sway
        26.07.2022 04:05

        Давайте тогда представим что я тот кто оценивает Ваше решение =)
        У меня возникают следующие вопросы:

        1. Почему использовался контекст? (Ответ уже знаем: чтобы не прокидывать пропсы)

        2. Чем Вас не устраивает прокидывание пропсов в данной задаче? Ведь это было бы проще и понятнее. Да и компонент же довольно простой, а не конструктор типа Dropdown Menu где контент передается извне и нужно иметь возможность взаимодействия с верхним уровнем.

        3. В проекте где есть рейтинг чаще всего будет и избранное, которое идеально ложится на функционал компонента Star, что будете делать?

        4. Почему не используется TypeScript? Он бы почти никак не повлиял на скорость решения задачи, зато была бы типизация хорошо прописана и соблюдена.

        Посудите сами - данное тестовое задание дается не только чтобы проверить что Вы можете написать компонент или знаете какие-то особенности технологии/инструмента/языка, но и то как Вы это сделаете, какие возможности и как примените, и как Вы оцениваете ситуацию вне задачи (вопрос 3).
        Я ничего не имею против контекстов, прекрасная штука, но тащить их только ради того чтобы не прокидывать пропсы - это как-то неэффективно ни по времени ни по результату. Более того контекст обязывает все компоненты его использующие быть зависимыми друг от друга. Уже нельзя будет для вопроса 3 взять готовое решение. Нужно будет переделывать. А это время и деньги. И не нужно говорить что этого небыло в задании. В реальности далеко не всегда заранее известно какой компонент будет переиспользоваться в будущем. Т.е. я как наниматель увидел что Вы смогли решили задачу, но не подумали о том что некоторые компоненты можно будет переиспользовать в будущем. Ведь у компонента Star очень широкое применение. Его и в like можно было бы превратить всего-лишь поменяв иконку. Но увы, придется делать отдельный компонент...


        1. yantishko Автор
          26.07.2022 06:08

          У вас хорошо получается раскрыть тему, но будем реалистами, на интервью даже такого решение врятли будет, там все будет в одном js файле, а не разбито по папочкам и вылизано. Иногда приходилось ещё и подключать все самому, потому что было просто ничего в редакторе :)

          Для меня на TypeScript заняло бы больше времени на описание типов и интерфейсов, но если вы успеете на лету это все продумать и описать, то почему нет)

          Я как то больше сфокусировался на требованиях и простой реализации, что забыл о моменте переиспользования в статье, это важный момент, в этом случае архитектура будет совершенно другой и без контекстов.

          P.S. судя по количеству комментариев и их размеру вам нужно писать статьи )


          1. Alexandroppolus
            26.07.2022 09:28

            Для меня на TypeScript заняло бы больше времени на описание типов и интерфейсов

            Смотря где пишется код. В блокноте - само собой, а в вебшторме за счёт более лучшего автокомплита и оперативной проверки опечаток могло быть и быстрее.


            1. yantishko Автор
              26.07.2022 09:31

              Глючный codesandbox или ещё лучше whiteboard, речи об IDE даже не идет)

              Понятно в рабочей обстановке TypeScript, IDE, автокомплит и гугл лучшие друзья :)


              1. Sway
                26.07.2022 13:21

                Как по мне, то если на собеседовании загоняют в такие рамки и не дают использовать привычный или хотябы схожий набор инструментов, то возникает вопрос об адекватности собеседующих. Отнимите в ответ у собеседующего бумажку/планшет/ноут по которым они вопросы задают. Пусть почувствуют себя на Вашем месте =)))


                1. yantishko Автор
                  27.07.2022 04:40

                  К сожалению таковы правила игры :)


          1. Sway
            26.07.2022 13:22

            Мне лень статьи писать =) Я даже комментирую редко.