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

Формулировка

Есть реализация компонента, от которого требуется 2 вещи:

1) выводить текущее значение вертикального скролла окна (window.scrollY)

2) после монтирования асинхронно получить число и вывести его

Нужно найти, объяснить и исправить как можно больше проблем в реализации

import React, { useState, useEffect } from 'react';

// имитация запроса к серверу. просто получаем число асинхронно
const fetchRandomNumber = () => Promise.resolve(Math.random());

const NumberAndScroll = () => {
  const [number, setNumber] = useState();
  const [scroll, setScroll] = useState();
  
  useEffect(async () => {
    setNumber(await fetchRandomNumber());
    
    window.addEventListener('scroll', () => setScroll(window.scrollY));

    return () => window.removeEventListener('scroll', () => setScroll(window.scrollY));
  });
  
  return (
    <div>
      <div> Number: { number } </div>
      <div> Scroll: { scroll } </div>
    </div>  
  )
}

Настоятельно рекомендую самостоятельно исправить всё что найдёте, прежде чем читать дальше.

Большие проблемы

Первое, что все правильно замечают - отсутствие второго аргумента у useEffect. Там должен быть пустой массив зависимостей, потому что число нам нужно получить с "сервера" только 1 раз при монтировании. Да и неоднократно переподписываться на событие скролла не имеет смысла.

import React, { useState, useEffect } from 'react';

const fetchRandomNumber = () => Promise.resolve(Math.random());

const NumberAndScroll = () => {
	const [number, setNumber] = useState();
  const [scroll, setScroll] = useState();
  
  useEffect(async () => {
    setNumber(await fetchRandomNumber());
    
    window.addEventListener('scroll', () => setScroll(window.scrollY));
    
    return () => window.removeEventListener('scroll', () => setScroll(window.scrollY));
  }, []); // добавили вторым аргументом массив зависимостей
  
  return (
  	<div>
      <div> Number: { number } </div>
      <div> Scroll: { scroll } </div>
    </div>	
  )
}

Частой ошибкой бывает передача такого массива зависимостей: [scroll]. Если вы тоже совершили эту ошибку, то вернитесь к коду и убедитесь, что нам не нужно текущее значение скролла, чтобы обновлять его.

Второе, на что обычно обращают внимание, это ключевое слово async у колбека. Многие имели свой личный опыт писать так и знают, что так делать нельзя. Но почему? Этот вопрос ставит некоторых в тупик. Согласно документации реакта, колбек должен либо ничего не возвращать (читай возвращать undefined) либо возвращать функцию очистки. То есть только 2 валидных значения есть для возврата: undefined и функция. Но вы же знали, что функции объявленные async всегда возвращают промис? То есть в текущей реализации колбек возвращает не функцию очистки, а промис этой функции. Нужно убрать ключевое слово async и переделать колбек, чтобы работало. С этим тоже бывали проблемы у новичков. Самое простое - написать через промис:

import React, { useState, useEffect } from 'react';

const fetchRandomNumber = () => Promise.resolve(Math.random());

const NumberAndScroll = () => {
 	const [number, setNumber] = useState();
  const [scroll, setScroll] = useState();
 
  // убрали async
  useEffect(() => {
    // переписали через промис
    fetchRandomNumber().then(setNumber);
    
    window.addEventListener('scroll', () => setScroll(window.scrollY));
    
    return () => window.removeEventListener('scroll', () => setScroll(window.scrollY));
  }, []);
  
  return (
  	<div>
      <div> Number: { number } </div>
      <div> Scroll: { scroll } </div>
    </div>	
  )
}

Если вы написали fetchRandomNumber().then(num => setNumber(num)), то уделите минуту, чтобы осознать, что fetchRandomNumber().then(setNumber) делает то же самое, только короче и оптимальнее.

Последняя грубая ошибка в том, что очистка листенера всё равно не происходит. Я попытаюсь привести свою аналогию для объяснения ошибки. Допустим вы купили новый телефон. Вы ставите будильник на нём на 5 утра, чтобы встать для пробежки по набережной. А потом вы поняли, что бег это дичь и передумали. Но вместо того, чтобы выключить будильник на своём новом телефоне, вы идёте снова в магазин, покупаете ещё один телефон той же самой модели, открываете в нём будильники и выключаете все на 5 утра (впрочем там и выключать нечего). Оба телефона абсолютно идентичны и видимых различий практически нет, но это всё же разные телефоны. В реализованном коде написана примерно такая же бессмыслица. Каждый раз, когда мы пишем выражение () => setScroll(window.scrollY), мы создаём новую функцию. Часто это проблема решалась несколько странно:

import React, { useState, useEffect } from 'react';

const fetchRandomNumber = () => Promise.resolve(Math.random());

const NumberAndScroll = () => {
  const [number, setNumber] = useState();
  const [scroll, setScroll] = useState();
  
  // вынесли создание функции сюда
  const handleScroll = () => setScroll(window.scrollY);
  
  useEffect(() => {
    fetchRandomNumber().then(setNumber);
    
    window.addEventListener('scroll', handleScroll);
    
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);
  
  return (
    <div>
      <div> Number: { number } </div>
      <div> Scroll: { scroll } </div>
    </div>    
  )
}

Это работает, но проблема тут в том, что handleScroll создаётся на каждый рендер компонента, а используется только в колбеке useEffect'а. То есть только при первом рендере. То есть выполняется лишняя работа и код более разбросан. Правильней так:

import React, { useState, useEffect } from 'react';

const fetchRandomNumber = () => Promise.resolve(Math.random());  

const NumberAndScroll = () => {      
  const [number, setNumber] = useState();   
  const [scroll, setScroll] = useState();        
  
  useEffect(() => {     
    fetchRandomNumber().then(setNumber);      
    
    // перенес создание в useEffect
    const handleScroll = () => setScroll(window.scrollY);
    
    window.addEventListener('scroll', handleScroll);   
    
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);  
  
  return (      
    <div>       
      <div> Number: { number } </div> 
      <div> Scroll: { scroll } </div>    
    </div>  
)}

Менее важные проблемы

Ещё один момент, на который я прошу обратить внимание кандидатов - начальные значения состояний. Новички часто пишут так:

const [number, setNumber] = useState(0);
const [scroll, setScroll] = useState(0);

Есть люди, которым кажется, что начальное значение всегда обязательно должно быть задано. И если это число, то наверняка 0. Но в этой задаче мы не можем дать переменной number адекватного начального значения и лучше оставить его undefined. Для переменной scroll начальное значение 0 неверно, потому что наш компонент может появиться когда страничка браузера уже прокручена вниз. Ожидаемый код такой:

import React, { useState, useEffect } from 'react';

const fetchRandomNumber = () => Promise.resolve(Math.random());

const NumberAndScroll = () => {
 	const [number, setNumber] = useState();
  // добавил начальное значение
  const [scroll, setScroll] = useState(window.scrollY); 
  
  useEffect(() => {
    fetchRandomNumber().then(setNumber);

	  const handleScroll = () => setScroll(window.scrollY);
    
    window.addEventListener('scroll', handleScroll);
    
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);
  
  return (
  	<div>
      <div> Number: { number } </div>
      <div> Scroll: { scroll } </div>
    </div>	
  )
}

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

Плюсом идёт кандидату, если он знает что такое тротлинг и правильно его применяет. Например так:

import React, { useState, useEffect } from 'react';
import throttle from 'lodash/throttle';

const fetchRandomNumber = () => Promise.resolve(Math.random());

const NumberAndScroll = () => {
  const [number, setNumber] = useState();
 	const [scroll, setScroll] = useState(window.scrollY);
  
  useEffect(() => {
    fetchRandomNumber().then(setNumber);
    
    // обернули в throttle
    const handleScroll = throttle(() => setScroll(window.scrollY), 37);

    window.addEventListener('scroll', handleScroll);
    
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);
  
  return (
  	<div>
      <div> Number: { number } </div>
      <div> Scroll: { scroll } </div>
    </div>	
  )
}

Ещё одна из вещей, которые я бы хотел увидеть - применение Принципа единственной ответственности к useEffect. Сейчас один useEffect отвечает и за логику асинхронного получения числа и за логику скролла. Результат может выглядеть так:

import React, { useState, useEffect } from 'react';
import throttle from 'lodash/throttle';

const fetchRandomNumber = () => Promise.resolve(Math.random());

const NumberAndScroll = () => {
 	const [number, setNumber] = useState();
  const [scroll, setScroll] = useState(window.scrollY);
  
  // отдельно логика получения числа
  useEffect(() => {
    fetchRandomNumber().then(setNumber);
  }, []);

  // отдельно логика скролла
  useEffect(() => {
	  const handleScroll = throttle(() => setScroll(window.scrollY), 37);
    
    window.addEventListener('scroll', handleScroll);
    
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);
  
  return (
  	<div>
      <div> Number: { number } </div>
      <div> Scroll: { scroll } </div>
    </div>	
  )
}

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

import React, { useState, useEffect } from 'react';
import throttle from 'lodash/throttle';

const fetchRandomNumber = () => Promise.resolve(Math.random());

// вынесли работу со скроллом в кастомный хук
const useWindowScrollY = () => {
  const [scroll, setScroll] = useState(window.scrollY);

  useEffect(() => {
	  const handleScroll = throttle(() => setScroll(window.scrollY), 37);
    
    window.addEventListener('scroll', handleScroll);
    
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

	return scroll;
}

const NumberAndScroll = () => {
 	const [number, setNumber] = useState();
  const scroll = useWindowScrollY();
  
  // логику получения числа, возможно, тоже стоило вынести
  // в отдельный кастомный хук
  useEffect(() => {
    fetchRandomNumber().then(setNumber);
  }, []);
  
  return (
  	<div>
      <div> Number: { number } </div>
      <div> Scroll: { scroll } </div>
    </div>	
  )
}

Неочевидное

Есть в JS такая нечасто применяемая в повседневной работе штука как дескрипторы. Можно сделать так, что когда мы достаём из объекта свойство, будет вызвана геттер функция и возвращён результат этого вызова. Функция Object.getOwnPropertyDescriptor может показать нам, является ли свойство объекта обычным или хитро определено дескрипторами. И так, если вы в консоли запустите Object.getOwnPropertyDescriptor(window, 'scrollY');, то увидите, что у свойства scrollY заданы гетер и сетер. То есть когда мы пишем window.scrollY, мы добавляем в стек вызовов эту гетер функцию. То есть если мы напишем useState(() => window.scrollY), то на каждый рендер в стек вызовов будет на одну функцию меньше идти (если передать в useState функцию, то она вызовется только во время первого рендера и начальным значением будет результат этого вызова). Оптимизация! На самом деле такая оптимизация никакой практической пользы не даёт. Даже экономией на спичках не хочется её называть. Но важно тут, как мне кажется, осознание этих процессов, сознательное написание каждой строчки кода. Окончательный вариант кода будет выглядеть примерно так:

import React, { useState, useEffect } from 'react';
import throttle from 'lodash/throttle';

const fetchRandomNumber = () => Promise.resolve(Math.random());

// получение window.scrollY вынесли в функцию
const getWindowScrollY = () => window.scrollY;
const useWindowScrollY = () => {
  const [scroll, setScroll] = useState(getWindowScrollY);

  useEffect(() => {
	  const handleScroll = throttle(() => setScroll(window.scrollY), 37);
    
    window.addEventListener('scroll', handleScroll);
    
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

	return scroll;
}

const NumberAndScroll = () => {
 	const [number, setNumber] = useState();
  const scroll = useWindowScrollY();
  
  useEffect(() => {
    fetchRandomNumber().then(setNumber);
  }, []);
  
  return (
  	<div>
      <div> Number: { number } </div>
      <div> Scroll: { scroll } </div>
    </div>	
  )
}

Бонус

Тем, кто любит лишний раз пошевелить мозгами, предлагаю придумать как забенчмаркать последнюю "оптимизацию". Я сам никогда не занимался написанием таких синтетических тестов, но специально для полноты этого поста я задался такой целью и вот что вышло у меня: https://codesandbox.io/s/descriptors-perfomance-test-g34e1?file=/src/usePerfomance.js. 20000 рендеров компонента на моём ноутбуке выполняются за ~3500мс (для useState(window.scrollY)) и ~3300мс (для useState(() => window.scrollY))

Заключение

Спасибо всем, кто дочитал

Комментарии (27)


  1. oWeRQ
    22.10.2021 12:01
    +3

    Чтениеwindow.scrollY должно вызывать reflow, на простой разметке это не скажется, а на сложной это может сильно тормозить, особенно когда после reflow вносятся изменения в dom.


    1. EdgarAbgaryan Автор
      22.10.2021 12:11

      хорошее дополнение


    1. GCU
      24.10.2021 00:32

      Почему чтение window.scrollY именно должно, а не просто может вызывать reflow? По логике даже изменение положения скролла окна часто не требует reflow.


      1. oWeRQ
        25.10.2021 13:11

        Возможно это надуманная проблема, я не работал с хуками и файбером(он ведь может отложить изменения), да и с реактом в общем давно не работал, так что недостаточно хорошо представляю когда реакт вносит изменения в дом и насколько реально, что после скрола будут внесены изменения до выполнения хука, после внесения изменений в дом window.scrollY тригерит reflow, в данном случае изменение текста на странице - уже изменение требующее пересчета и reflow в любом случае произойдет, другой вопрос может ли чтение при вызове useState вызвать лишний reflow, я бы перестраховался и не читал каждый раз для дефолтного значения.


  1. Drag13
    22.10.2021 12:24
    +3

    Еще использование window напрямую может принести сюрпризы когда вы решите перейти на SSR, — его там просто не будет.


    1. EdgarAbgaryan Автор
      22.10.2021 12:29

      Тоже верно подмечено, спасибо


  1. halfcupgreentea
    22.10.2021 12:57
    +6

    На throttle функции хорошо бы вызывать `cancel()` в cleanup callback.

    useEffect(() => {
        fetchRandomNumber().then(setNumber);
    }, []);

    лучше переписать так

    useEffect(() => {
        let mounted = true
        fetchRandomNumber().then(value => {
            if (mounted) {
                setNumber(value)
            }
        });
        return () => mounted = false
    }, []);

    Пока там просто асинхронный запрос чего-то, самое худшее, что может случиться — warning в консоли. Но вот если там происходит, например, отправление формы и по успеху делается роутинг типа такого

    submitForm().then(() => router.push('/success'))

    то можно поломать UX.


    1. EdgarAbgaryan Автор
      22.10.2021 13:06

      Отличное внимание к деталям, чувствуется опыт, побольше бы таких на собесы приходило)


    1. faiwer
      22.10.2021 13:48
      +2

      Не соглашусь. Можно смело вызывать setNumber на уже мёртвом компоненте. Ничего не случится. Кажется в свежей версии React убрали (или вот вот уберут) этот warning, из-за бесконечных ложно-позитивных результатов.


      Но вот если там происходит, например, отправление формы и по успеху делается роутинг типа такого

      ИМХО, только в таком случае и стоит так поступать. А более простой код калечить не стоит. YAGNI.


      P.S. мы написали простенький useIsUnmounted: () => (): boolean для таких целей. Компактнее и нагляднее получается. Плюс можно переиспользовать для нескольких callback-ов и effect-ов.


      const isUnmountFn = useIsUnmounted();
      
      useEffect(() => {
        whatever().then(() => {
          if (isUnmountFn()) return;
          // do staff
        });
      }, [/**/);

      Но избегать обыкновенный useState этим — из пушки по воробьям. Лаконичность и простота кодовой базы сильно важнее.


      1. faiwer
        29.10.2021 19:30
        +1

        Кажется в свежей версии React убрали (или вот вот уберут) этот warning, из-за бесконечных ложно-позитивных результатов.

        update: я кажется что-то напутал. В @latest версии (17.02) warning всё ещё на месте. А того issue найти не удалось. Возможно я что-то напутал. Но выглядит всё так, словно все эти isUnmount и AbortController штуки по любому поводу всё ещё higly recommended. На мой взгляд (может быть наши особенности проекта просто такие), это false positive почти всегда.


  1. faiwer
    22.10.2021 13:41
    +2

    На месте интервьювера я бы спросил что такое 37. Ожидал бы, что человек после этого вопроса скажет, что magic numbers это плохо и вынесет это в константу. А заодно даст внятный комментарий. И тут самое интересное — почему 37? Может лучше 16.7 (1/60 секунды).


    P.S. забыл дописать — нет обработки ошибок асинхронного запроса (хотя бы console.error)


    1. Drag13
      22.10.2021 13:55

      Раньше вроде бы была история что, все что ниже 25ms для браузера(ов) едино, может быть для ИЕ только, не помню уже. Но вот я проверил в свежем хроме, вроде бы для него это работает почти корректно


      var t1 = Date.now();
      setTimeout(()=> console.log(Date.now()-t1), 10); //12

      Вообще можно попробовать переписать это вместо throttle на requestAnimationFrame


      1. faiwer
        22.10.2021 13:58

        C requestAnimationFrame результат может быть даже лучше.


        А если сделать Promise.resolve().then(fn) то вообще ноль покажет (это кстати довольно хардкорный JS вопрос на собесах). Но это уже немного не в тему, да :)


        1. halfcupgreentea
          22.10.2021 14:29
          +1

          Ну на самом деле же там не 0. Если мерять через Performance API, а не Date.now(), все видно.


          1. faiwer
            22.10.2021 14:35

            Всё так. Но давайте мыслить дискретно позитивно! :-)


        1. Drag13
          22.10.2021 20:30
          +1

          Не очень понял почему там должен быть не 0.
          Вот если бы 0 был в таком коде, я бы удивился:

          var t1 = Date.now();
          function f() { console.log('diff: ',Date.now() - t1) };
          Promise.resolve(0).then(f);
          var x = 0;
          while (x < 1000_000_000) { x++ }

          Если я не правильно понял вас, поправьте пожалуйста.


          1. faiwer
            22.10.2021 20:40
            +1

            Не очень понял почему там должен быть не 0

            Суть в том что у promise-ов своя приоритетная (над обычной) асинхронная event-loop очередь. Поэтому там 0. Разгадка в этом. В то время как у setTimeout(,0) и даже у requestAnimationFrame это не так. По сути аналог, ЕМНИП, есть в nodejsprocess.nextTick.


            ЕМНИП, то с её помощью можно даже повешать браузер\nodejs-процесс.


            1. Drag13
              22.10.2021 20:57

              Разве у Promise именно "своя" очередь? Мануалы вроде говорят что они используют очередь микротасок.

              Собственно именно поэтому в моем примере у нас выводится не 0, т.к. мы сначала выполняем текущую макротаску, в ее процессе создаем микротаску (.then) которая ждет завершения текущей макротаски, а та не завершается т.к. выполняет очень длинный цикл. Когда цикл закончится, макротаска выполнится, , мы начнем выполнять микротаску и выведем в консоль время.

              А в вашем примере макротаска заканчивается выделением микротаски и микротаска сразу стартует, давая условный 0.

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


              1. faiwer
                22.10.2021 21:51

                Разве у Promise именно "своя" очередь? Мануалы вроде говорят что они используют очередь микротасок.

                Не совсем своя. Я просто не стал вдаваться в детали. Количество и состав очередей зависит от платформы. На мой взгляд эти нюансы мало кому важны, главное просто знать, что эта очередь "микротасок" есть, и с чем её едят. Чтобы, например, не уйти в вечный loop.


                А в вашем примере макротаска заканчивается выделением микротаски и микротаска сразу стартует, давая условный 0.

                Всё правильно.


                1. Drag13
                  22.10.2021 23:25
                  +1

                  Значит все-таки мы об одном и том же. Ну и чудно, спасибо за диалог)


      1. JustDont
        22.10.2021 14:16

        Я бы сказал, что не "можно", а "нужно".
        Вообще по тексту статьи не прослеживается главного — а зачем вообще появился throttle? setState можно фигачить десятками тысяч раз в секунду без каких-либо проблем, это я вам точно говорю. Там тормозить попросту нечему. Что будет тормозить — так это вовсе не setState, а последующий render.


        Но да это проблемы архитектурного уровня. Если мы говорим про реальность, а не выдуманный пример — тут надо бы начинать с вопросов типа "вы одним компонентом делаете 2 разные вещи, почему тут не два компонента?"; "задачи вида "получить значение с сервера" должны решаться не компонентами (не презентационным слоем приложения), а стейт-менеджментом, либо же вообще в своем отдельном I/O слое, если у нас проект достаточно масштабен, чтоб его выделить отдельно"; "почему реальность получения значения с сервера замазана гораздо более упрощенной абстракцией, что произойдет, если запрос упадёт, будете вечно показывать Number: undefined?"


        1. faiwer
          22.10.2021 14:21

          а зачем вообще появился throttle?

          Нуууу… Скролл бывает очень дробным. Скажем проскроллил тачпадом на 15px, браузер вызвал 15 событий. Из которых 10 попало в один кадр. 10 раз был изменён textContent, а он, к примеру 3 раза поменял layout страницы (не моноширинный шрифт). Где-то плачет Грета и считает углеродный след. Но в целом согласен.


  1. kemsky
    22.10.2021 22:48
    +2

    Если это скролл, то имеет смысл добавлять листенер с `{passive: true}`.


    1. EdgarAbgaryan Автор
      22.10.2021 23:00

      1. kemsky
        23.10.2021 01:55

        Да, любопытно, видимо это было сделано одновременно с установкой дефолта на passive: true. Во всех ли браузерах это так?


  1. WFF
    23.10.2021 00:14
    -1

    del


  1. Racheengel
    04.11.2021 01:11
    +1

    Прочитал абзац как

    Плюсом идёт кандидату, если он знает что такое ТРОЛЛИНГ и правильно его применяет.

    и реально задумался, почему это умение важно для компании...