Год назад моя компания впервые попросила меня провести собеседование для фронтендера. Тогда я и придумал эту задачу на свою злобу дня. Задачка простая, на базовые знания, но, как оказалось, в ней можно сделать много интересных ошибок. Также в ней оказались неочевидные детали, которые могли бы оказаться интересны и опытным разработчикам. Я решил достаточно подробно разобрать задачу в этом посте в педагогических целях. Данный пост нацелен в первую очередь на начинающих реакт разработчиков.
Формулировка
Есть реализация компонента, от которого требуется 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)
Drag13
22.10.2021 12:24+3Еще использование window напрямую может принести сюрпризы когда вы решите перейти на SSR, — его там просто не будет.
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.
EdgarAbgaryan Автор
22.10.2021 13:06Отличное внимание к деталям, чувствуется опыт, побольше бы таких на собесы приходило)
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
этим — из пушки по воробьям. Лаконичность и простота кодовой базы сильно важнее.faiwer
29.10.2021 19:30+1Кажется в свежей версии React убрали (или вот вот уберут) этот warning, из-за бесконечных ложно-позитивных результатов.
update: я кажется что-то напутал. В
@latest
версии (17.02) warning всё ещё на месте. А того issue найти не удалось. Возможно я что-то напутал. Но выглядит всё так, словно все этиisUnmount
иAbortController
штуки по любому поводу всё ещё higly recommended. На мой взгляд (может быть наши особенности проекта просто такие), это false positive почти всегда.
faiwer
22.10.2021 13:41+2На месте интервьювера я бы спросил что такое
37
. Ожидал бы, что человек после этого вопроса скажет, что magic numbers это плохо и вынесет это в константу. А заодно даст внятный комментарий. И тут самое интересное — почему 37? Может лучше 16.7 (1/60 секунды).P.S. забыл дописать — нет обработки ошибок асинхронного запроса (хотя бы console.error)
Drag13
22.10.2021 13:55Раньше вроде бы была история что, все что ниже 25ms для браузера(ов) едино, может быть для ИЕ только, не помню уже. Но вот я проверил в свежем хроме, вроде бы для него это работает почти корректно
var t1 = Date.now(); setTimeout(()=> console.log(Date.now()-t1), 10); //12
Вообще можно попробовать переписать это вместо
throttle
наrequestAnimationFrame
faiwer
22.10.2021 13:58C
requestAnimationFrame
результат может быть даже лучше.А если сделать
Promise.resolve().then(fn)
то вообще ноль покажет (это кстати довольно хардкорный JS вопрос на собесах). Но это уже немного не в тему, да :)halfcupgreentea
22.10.2021 14:29+1Ну на самом деле же там не 0. Если мерять через Performance API, а не Date.now(), все видно.
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++ }
Если я не правильно понял вас, поправьте пожалуйста.
faiwer
22.10.2021 20:40+1Не очень понял почему там должен быть не 0
Суть в том что у promise-ов своя приоритетная (над обычной) асинхронная event-loop очередь. Поэтому там 0. Разгадка в этом. В то время как у
setTimeout(,0)
и даже уrequestAnimationFrame
это не так. По сути аналог, ЕМНИП, есть вnodejs
—process.nextTick
.ЕМНИП, то с её помощью можно даже повешать браузер\nodejs-процесс.
Drag13
22.10.2021 20:57Разве у Promise именно "своя" очередь? Мануалы вроде говорят что они используют очередь микротасок.
Собственно именно поэтому в моем примере у нас выводится не 0, т.к. мы сначала выполняем текущую макротаску, в ее процессе создаем микротаску (.then) которая ждет завершения текущей макротаски, а та не завершается т.к. выполняет очень длинный цикл. Когда цикл закончится, макротаска выполнится, , мы начнем выполнять микротаску и выведем в консоль время.
А в вашем примере макротаска заканчивается выделением микротаски и микротаска сразу стартует, давая условный 0.
Ну и да, т.к. микротаски выполняются всегда перед выполнением макротаски мы можем заблокировать потом от выполнения любой макрозадачи.
faiwer
22.10.2021 21:51Разве у Promise именно "своя" очередь? Мануалы вроде говорят что они используют очередь микротасок.
Не совсем своя. Я просто не стал вдаваться в детали. Количество и состав очередей зависит от платформы. На мой взгляд эти нюансы мало кому важны, главное просто знать, что эта очередь "микротасок" есть, и с чем её едят. Чтобы, например, не уйти в вечный loop.
А в вашем примере макротаска заканчивается выделением микротаски и микротаска сразу стартует, давая условный 0.
Всё правильно.
JustDont
22.10.2021 14:16Я бы сказал, что не "можно", а "нужно".
Вообще по тексту статьи не прослеживается главного — а зачем вообще появился throttle? setState можно фигачить десятками тысяч раз в секунду без каких-либо проблем, это я вам точно говорю. Там тормозить попросту нечему. Что будет тормозить — так это вовсе не setState, а последующий render.Но да это проблемы архитектурного уровня. Если мы говорим про реальность, а не выдуманный пример — тут надо бы начинать с вопросов типа "вы одним компонентом делаете 2 разные вещи, почему тут не два компонента?"; "задачи вида "получить значение с сервера" должны решаться не компонентами (не презентационным слоем приложения), а стейт-менеджментом, либо же вообще в своем отдельном I/O слое, если у нас проект достаточно масштабен, чтоб его выделить отдельно"; "почему реальность получения значения с сервера замазана гораздо более упрощенной абстракцией, что произойдет, если запрос упадёт, будете вечно показывать Number: undefined?"
faiwer
22.10.2021 14:21а зачем вообще появился throttle?
Нуууу… Скролл бывает очень дробным. Скажем проскроллил тачпадом на 15px, браузер вызвал 15 событий. Из которых 10 попало в один кадр. 10 раз был изменён
textContent
, а он, к примеру 3 раза поменял layout страницы (не моноширинный шрифт). Где-то плачет Грета и считает углеродный след. Но в целом согласен.
kemsky
22.10.2021 22:48+2Если это скролл, то имеет смысл добавлять листенер с `{passive: true}`.
EdgarAbgaryan Автор
22.10.2021 23:00я про
passive
не знал. сейчас почитал, узнал что-то новое, спасибо. но ваш комментарий неверенkemsky
23.10.2021 01:55Да, любопытно, видимо это было сделано одновременно с установкой дефолта на passive: true. Во всех ли браузерах это так?
Racheengel
04.11.2021 01:11+1Прочитал абзац как
Плюсом идёт кандидату, если он знает что такое ТРОЛЛИНГ и правильно его применяет.
и реально задумался, почему это умение важно для компании...
oWeRQ
Чтение
window.scrollY
должно вызывать reflow, на простой разметке это не скажется, а на сложной это может сильно тормозить, особенно когда после reflow вносятся изменения в dom.EdgarAbgaryan Автор
хорошее дополнение
GCU
Почему чтение window.scrollY именно должно, а не просто может вызывать reflow? По логике даже изменение положения скролла окна часто не требует reflow.
oWeRQ
Возможно это надуманная проблема, я не работал с хуками и файбером(он ведь может отложить изменения), да и с реактом в общем давно не работал, так что недостаточно хорошо представляю когда реакт вносит изменения в дом и насколько реально, что после скрола будут внесены изменения до выполнения хука, после внесения изменений в дом window.scrollY тригерит reflow, в данном случае изменение текста на странице - уже изменение требующее пересчета и reflow в любом случае произойдет, другой вопрос может ли чтение при вызове useState вызвать лишний reflow, я бы перестраховался и не читал каждый раз для дефолтного значения.