Створыце useState для іграшакоў, каб зрозумець снэпшоты, кялі і эфекты
Выявіце, як працююць хукі React, стварыўшы маленькі useState, а пасля выкарыстаце гэты модель для пояснення черг выканання, застарэлых копій дадзеных, закрыццяў, чысткі эфектаў, рэфаў і групавання операцый.
Крыніцы: React Hooks Internally: How `useState`, `useEffect`& Other Hooks Actually Work
Адзін рядок коду хукава скрывае значны чыннікі, якія працуюць у спадку:
const [count, setCount] = useState(0);
React павінен вырашыць, дзе зберагаецца count, як ён застаёцца актуальным пасля кожнага вызову, які стан нарабатывае заданный сеттер, чаму вызовы хукава не можаць быць умовнымі, і як эфекты стежаць за залежнасцямі. У гідзе ствараецца маленькі працоўны модэль хукава і ён выкарыстоўваецца для пояснення явіс, якія ўскладнююць жыццё развіваўцам кожны дзень: застарэлыя значэнні пасля setState, функцыйнае апдэйтаванне, чыстка эфектаў і їх групаванне.
Стан не знаходзіцца ў функцыі
Возьмім самую маленькую можлівую компоненту з станам:
function Counter() {
const [count, setCount] = useState(0);
return <h1>{count}</h1>;
}
Хочацца думаць, што count знаходзіцца ўнутрь Counter, але React проста вызывае гэтую функцыю зноў пасля кожнага рэндарування:
Render 1
Counter()
count = 0
Render 2
Counter()
count = 1
Render 3
Counter()
count = 2
Локальныя зменныкі зникаюць, калі функцыя вяртаеся, таму React зберагае стан хуків за межамі компонента, прыўязваючы яго да сваёй елементарнай структуры Fiber, і вяртае зберажаны значэнне ў кожны раз, калі відбываецца рендерыраванне.
Fiber зберагае серію хуків
У спрасцаванай форме цяперошнія зв’язкі выглядаюць так:
Component
│
▼
Fiber
│
▼
Hook data
│
┌──┼───────────────┐
▼ ▼ ▼
State Ref Effect
Fiber — это едыніца роботы React, створаная як для компонентаў, так і для гаспадарскіх элементаў. Для функцыйнага компонента ён несе даны хуків, концэптуальна у виглядзе ланца:
Counter Fiber
│
▼
Hook #1
│
▼
Hook #2
│
▼
Hook #3
У рэальнай реалізацыі є ўжо болей шмат полей, але такога зображэння достатна, каб разумець основную працэзуру.
Хукі паспраўляюцца за порядкам вызову
Ось компонент з трыма хукамі:
function Component() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Salim");
useEffect(() => {
console.log(count);
}, [count]);
return ...;
}
У React няма імен для гэтых хуків; ён ведае толькі «першы», «другі» і «трэці», і прыпускае, што яны паспраўляюцца ў такім порядку ў кожны раз, калі відбываецца рендерыраванне:
Fiber
│
├── Hook #1 → useState
│
├── Hook #2 → useState
│
└── Hook #3 → useEffect
Гэта падзея ёсць восьпамінай для правіл хуков.
Што выходзіць з-пад контролю у умовнам хуку
Тепер рассмотрзім хук, які знаходзится за умовай:
function Component({ loggedIn }) {
if (loggedIn) {
useState("Salim"); // ❌
}
useState(0);
return ...;
}
Пад час атрыбутавання, калі корыстнік заўважаны, вызываюцца два хука:
loggedIn = true
Hook #1 → name
Hook #2 → count
Якщо loggedIn пазней стане false, вызываецца толькі адны хук:
loggedIn = false
Hook #1 → count
Памятка, якая раней выкорыстоўвалася для зберагчэння назвы, тепер прыме вызов для підрахунку, таму стан супараджваецца з некоректным хукам:
Before:
1 → name
2 → count
After:
1 → count
Таму порядак хуков ніколі не должен змянювацца пад час атрыбутавання, што выключае вызывы ў такіх месцах:
if / else
loops
nested functions
event handlers
Размешчайце іх на верхнім рэвэлі компанентаў і спецыяльных хуков; плагін React hooks lint стараваецца прымусіць дапэўніць гэта.
Уявны useState у двадзеці лініях
Стварэнне мінімальной версіі дапамагае уявіць механізм болей чытра. Наведжаны ў прыкладзе скэч утримвае масів слотаў для хуков і курсар. Кожны вызыв useState абярае слот, які знаходзіцца на пазычы курсара, ініцыялізуе яго толькі якщо ён порожні, вяртае функцыю-зменяльнік, прычалёваную да гэтага індексу, і прыступляе да наступнага пазычэння курсара:
let hooks = [];
let currentHook = 0;
function useState(initialValue) {
const index = currentHook;
if (hooks[index] === undefined) {
hooks[index] = {
state: initialValue
};
}
function setState(value) {
hooks[index].state =
typeof value === "function"
? value(hooks[index].state)
: value;
render();
}
const state = hooks[index].state;
currentHook++;
return [state, setState];
}
Есць два моменты, якія ўзначаны, але не паказаны: render() должен перазначыць currentHook на 0 пры новым вызыве компонента, а таксама прабавіць яго зноў. Для компонента, падобнага гэтаму:
function Counter() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Salim");
return ...;
}
сховішча будуць выглядаць так:
hooks[0]
state = 0
hooks[1]
state = "Salim"
Пры наступным выкананні render() кожны вызыв знаходзіць свой слот толькі за дапамою пазычэння.
Дзе заканчываецца гэты просты модель
Real React дадзеяе об’екты хукав на Файберах, калейкі для апдэйтаў, планавальнік з прыоритетнымі каналамі, механізмы запуску та апдэйта дыспетчароў, логіку супрацоўкі та затверджэння змян; глобальныя масівы не існуюць. Асновная ідея застаецца незменнай:
Stable Hook order
+
React-managed Hook state
+
Component Fiber
Як працюе useState у React
Для гэтага вызову:
const [count, setCount] = useState(0);
Для кожнага хука стану React патрэбны два элементы:
State
+
Update queue
У візуальнай форме хук зберагае свое текущая значэнне ў memoizedState, а таксама калейку з незавершанымі апдэйтамі:
Hook
├── memoizedState → 0
│
└── queue
└── pending updates
Першыя рэндары: запуск
Для гэтага компонента:
function Counter() {
const [count, setCount] = useState(0);
return <button>{count}</button>;
}
Падчас першага рэндару стварываецца хук, зберагаецца пачатковае значэнне, ствараецца порожняя калейка і вяртаецца пара:
Component renders
↓
useState(0)
↓
Create Hook
↓
Store initial state
↓
Create update queue
↓
Return [0, setCount]
Файбер тады выглядае працоўна так, і гэты этап называецца запускам:
Fiber
│
└── Hook
├── state: 0
└── queue
Пасляўшыя рэндары: апдэйт
Падазроўваючы, што працоўнік запуску вызывае:
setCount(5);
React плануе апдэйт. Пад час наступнага рэндарування useState нічога не стварае; ён знаходзіць існуючы хук, прыкладзея апдэйты з чергі і вяртае рэзультат:
Component renders
↓
useState(0)
↓
React finds existing Hook
↓
Process queued updates
↓
New state = 5
↓
Return [5, setCount]
Значэнне 0, якое вы пасуеце, ў такім разы ёсць пачатковым значэнням, а не способам перазначэння. Гэта мае значэнне толькі калі хук ствараецца першы раз.
setState — цэла просьба, а не прызначэнне
Такі вызов:
setCount(5);
не значыць „значэнне count трэба зараз задаць у 5“. Ён додае просьбу да чергі, якую React обрабоцвае па свайм графіку:
setCount(5)
↓
Create update
↓
Add update to queue
↓
Schedule React work
↓
Render component
↓
Process update queue
↓
Calculate new state
↓
Reconcile UI
↓
Commit changes
Апдэйты прыкладзяюцца па порядку
Разглядзім тры функцыйнальныя апдэйты паспелі:
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
React заставляе іх у черзі, начынаючы з ныявеснага значэння:
Update Queue
c => c + 1
c => c + 1
c => c + 1
If the current state is: 0
і прыкладзяе іх адны за другім:
0 → 1
1 → 2
2 → 3
Рэзультат — 3, і самэ гэта прычына, чаму функцыйнальныя апдэйты падходзяць для апдэйтаў, якія залежаць ад паканальнага значэння.
Чаму перадача значэння працуе інача
Порашчыце версію, якая перадае значэння:
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
Якщо ў текущай відрасці count дороўна 0, усе тры выразы ацэнююцца перад тым, як будзе обработана якая-небудзь апдэйт, таму ўсё гэта дае:
setCount(1)
setCount(1)
setCount(1)
Канечны стан — 1. За дапамогою функцый-апдэйтаў:
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
Кожная функцыя прыме рэзультат паканальнай, чым даюць 0, 1, 2, а ў канцы — 3.
Стан — это кадруванне адной відрасці
Гэты хендлер дзівуе багато разработчыкаў:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(1);
console.log(count);
}
return <button onClick={handleClick}>{count}</button>;
}
Лог выведае 0, а не 1. count — это стаянка, якая належыць відрасці, якая створыла хендлер; вызов сэтара яе не зменшвае:
Current render
count = 0
↓
setCount(1)
↓
Update scheduled
↓
Current render still has count = 0
↓
Next render gets count = 1
Клозуры пояснююць кадруванне
Гэта прыемка — это сэмантыка замкнення JavaScript. Паглядзіце:
function Counter() {
const [count, setCount] = useState(0);
function logCount() {
console.log(count);
}
return <button onClick={logCount}>Log</button>;
}
Кожны процес атрыбутавання задае новы logCount, і кожны з ягох фіксуе значэнне count сваго атрыбутавання:
Render #1
count = 0
└── logCount closes over 0
Render #2
count = 1
└── logCount closes over 1
Стары калебак всегда бачыць значэнні сваго атрыбутавання, што ёсць прычыной большасці бягоў з застарэлымі значэннямі.
Як useEffect запамячае своія залежнасці
Возьмім базавы эфект:
useEffect(() => {
console.log(count);
}, [count]);
Для цього React павінен стежыць за трома рэчамі:
Effect function
Dependencies
Cleanup function
Унутрана яны зберагаюцца ў хуку эфекта пад назвамі, схожымі на гэтыя:
Hook
├── create → effect function
├── deps → [count]
└── destroy → cleanup
Пры кожным атрыбутаванні React парабягае новы массив залежнасцей з тым, што ўжо зберагаецца.
Парабяганне залежнасцей
Іспользуючы той самы эфект:
useEffect(() => {
console.log(count);
}, [count]);
першае атрыбутаванне фіксуе:
count = 0
deps = [0]
а наступнае атрыбутаванне стварае:
count = 1
deps = [1]
React парабяруе масівы элемент за элементам:
Previous: [0]
New: [1]
Changed
↓
Effect needs to run
Осё змянілася, таму выконваецца эфект. Якщо наступныя рендеры даюць тыя ж значэння:
Previous: [1]
New: [1]
Same
↓
Effect can be skipped
эфект працягваецца. Кожны элемент парабяруецца за дапамою Object.is, што значыць, што об’ект або маса, створаныя пад час рендера, ўважаюцца змененымі ў кожны раз.
Чыстка
Эфект можа вернуць функцыю, якая скасоўвае його дзеяння:
useEffect(() => {
const id = setInterval(() => {
console.log("tick");
}, 1000);
return () => {
clearInterval(id);
};
}, []);
Гэтая вернутая функцыя ёсьць чыстка. Жыцёвы цикл інтэрвалу выглядае так:
Effect setup
↓
Interval created
↓
...
Cleanup
↓
Interval removed
Калі залежнасці можа змяніцца, чыстка становіцься ўсё важлівейшай:
useEffect(() => {
subscribe(userId);
return () => {
unsubscribe(userId);
};
}, [userId]);
Пры кожной змяне userId React разбірае паканальны эфект прычыму запуску новага, а таксама выконвае фінальную чыстку, калі компонент анмонтуецца:
Old effect
↓
Cleanup
↓
New effect
Эфекты сінхронізуюцься, але не ўжо методы жыцёвага циклу
Пашчотным спосабам ўваходзіць адносленне useEffect з порожнім масавым значэнням да componentDidMount. Болей корыстны падход – гэты эфект дапамагае React заставіць ся сынхронізаваць з якой-небудзь системай за межамі React, напрыклад:
Network requests
Timers
Subscriptions
Browser APIs
DOM APIs
Third-party libraries
WebSocket connections
Напрыклад, з’ўязак у чате:
useEffect(() => {
const connection = connect(roomId);
return () => {
connection.disconnect();
};
}, [roomId]);
Калі змянюецца roomId, эфект пакідае старую камнату і прыўязываецца да новай, тады з’ўязак завжды адпавядае текущым пропам.
useRef: адна стабільная об’ект
Реф ствараецца так:
const ref = useRef(0);
React вяртае абсолюютна той самы об’ект праз кожны рендер:
Render #1
ref → Object A
Render #2
ref → Object A
Render #3
ref → Object A
У результате прызначэнне значэння яму:
ref.current = 100;
не спрычыняе рендера, на вядзіманне з апдэйтом статаусу:
setCount(100);
які запланавае рендер. Контраст у краткасці:
useState
├── React-managed state
├── update schedules render
└── used for UI state
useRef
├── stable object
├── mutable .current
└── changing .current doesn't schedule render
Тыповая прычына ўжывання – зберагчы ID таймера:
function Timer() {
const timerRef = useRef(null);
function start() {
timerRef.current = setInterval(() => {
console.log("tick");
}, 1000);
}
return <button onClick={start}>Start</button>;
}
ID павінен застацца, але ніколі не паказвацца на экране — гэта класычная задача для ref.
useMemo і useCallback
Расчытак, які быў запам’ятаны, выглядае так:
const result = useMemo(
() => expensiveCalculation(data),
[data]
);
Hook концэптуальна зберагае паслядні результат і залежнасці, якія былі викорыстаны для його вычыслення:
Hook
├── memoizedValue
└── dependencies
Пад час першага атрыбутавання выконваецца расчытак:
data = A
calculate(A)
↓
result = X
Якщо пад час наступнага атрыбутавання прабіваюцца тыя ж данні:
data = A
залежнасці не зменшуюцца:
A → A
таму вяртаецца запам’ятаны результат X. Калі data стае B, расчытак выконваецца знову.
Спрыяйце гэтаму як оптымізацыі, а не як прабачэнню: React можа знехтаць кешам, таму код павінен застацца правильным, якщо значэнне будзе вычысляцца занова. Вярнуюце яго для дорогіх задач або калі важна стабільная рэферэнцыя, а не як стандарт.
useCallback прыменяе тую ж ідею да функцый:
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
Ён зберагае функцыю і яе залежнасці:
Hook
├── memoized function
└── dependencies
Залежнасці, які не зменіліся, вяртаюць тую ж рэферэнцю функцыі; змененыя вяртаюць новую. Гэта мае значэнне галоўная колькі калі калбэк направляецца да дзецявай компанентнай, абгортанай ў React.memo, дзе новая рэферэнцыя паспела б знесяць эфект мемуізацыі:
const Child = React.memo(ChildComponent);
Адзінакавыя хукі — гэта проста функцыі
Ось маленькі адзінакавы хук:
function useCounter() {
const [count, setCount] = useState(0);
function increment() {
setCount(c => c + 1);
}
return {
count,
increment
};
}
Які викорыстоўваецца компанентам:
function App() {
const { count, increment } = useCounter();
return (
<button onClick={increment}>
{count}
</button>
);
}
Адзінакавы хук — гэта не аднальны компанент. Це простая функцыя, створаная з іншых хукоў, а яе useState прыяўляецца да таго компаненту, які ў даны момент атрыбутуецца:
App Fiber
↓
useCounter()
↓
useState()
↓
Hook state
Два компаненты, якія вызываюць useCounter, таму атрыбутуюцца двума незалежнымі лямдамі.
Дыспэтчеры: адна API, два спосабу дзеяння
Калі вы вызываеце useState, гэта публічная функцыя перадае задачу таму, куды React установіў дыспэтчар для текущага адрасавання:
useState()
↓
Current Dispatcher
↓
mountState() / updateState()
Пад першым адрасаваннем это ўжо реалізацыя функцыі mount:
Mount
↓
mountState()
а пад наступнымі адрасаваннямі — реалізацыя функцыі update:
Update
↓
updateState()
Mount проты update для аднаго хука
Для гэтага вызову:
const [count, setCount] = useState(0);
функцыя mount стварае неабходныя структуры:
Create Hook
↓
Store initial state
↓
Create update queue
тады як функцыя update іх пераўтварае:
Find existing Hook
↓
Process pending updates
↓
Calculate new state
↓
Return state + dispatcher
У канцы:
FIRST RENDER
↓
Mount Hooks
SUBSEQUENT RENDERS
↓
Update Hooks
Этап адрасавання і этап застосоўвання
Хуки найбольш падходяць да дваэтапнага моделі адрасавання React:
State / Props Update
↓
Render Phase
↓
Reconciliation
↓
Commit Phase
↓
DOM
Пад час адрасавання React вызывае компоненты і вырахоўвае наступны візуальны фармат:
Components execute
Hooks are processed
New UI is calculated
Пад час застосоўвання рэзультатаў — ён іх фактычна застаўляе:
DOM mutations happen
Refs are updated
Layout effects are handled
Сучасны React можа націснуць, пачаць занова або адхіліцаваць роботу з відрасленням, тады як commit завжды выкаанаецца, і самэ гэта ўскладнівае логіку відраслення, якая павінна быць чыстай.
Зберэнне чыстасці відраслення
Утрымайцеся ад пасэктыўных эфектаў безпосередна ў тэле компонента:
function Component() {
fetch("/api/users"); // ❌
return <div>Hello</div>;
}
Відрасленне можа выканацца колькаразо або быць адхілічанам раней чым выкаанаецца commit, таму гэты запит можа быць выкананы часоўна. Узамест таго вжывайце адны з гэтых варыянтаў:
Event handler
useEffect
Framework data-loading mechanism
Відрасленне павінна вырахоўваць UI толькі на адной падставе вхідных даных:
UI = f(props, state)
Аб’еднанне: ад однаго кліку да канца
Ёсць компонент, які выкаранае на базе стата і эфекта:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
);
}
Клік праходзіць чераз усі вышэ пераказаны этапы:
Click
↓
setCount()
↓
Update added to Hook queue
↓
React schedules work
↓
Counter renders
↓
useState retrieves/processes state
↓
useEffect checks dependencies
↓
React reconciles new UI
↓
Commit DOM changes
↓
Effect runs
Групаванне і прычыны, чаму DOM не адкладзіцца негайна
Функцыя-обрабнік, якая выставляе кальколькі значэнняў стата:
function handleClick() {
setCount(c => c + 1);
setName("Salim");
setVisible(true);
}
гэта не спрычынае троха адзінольных атрыбутаванняў. Зачыненне React 18 апдэйты автаматычна групаваняцца, уключаючы тые, якія знаходзяцца ў таймаутах і прамесах:
setCount()
setName()
setVisible()
↓
Batch
↓
Render
↓
Commit
замест таго, каб:
Update → Render → Commit
Update → Render → Commit
Update → Render → Commit
Групаванне сама па сабе ёсць спосабом адхілення ад непатрэбной роботы.
Той жа логічны прынцып поясняе, чаму такі вызов, як:
setCount(10);
не значыць негайна:
state = 10
DOM = 10
У замест на гэта ён праходзіць через весь ланцюг обробкі:
setCount(10)
↓
Schedule update
↓
React render
↓
Reconciliation
↓
Commit
↓
DOM update
Відкладэнне дае React можлівасць групаваць, прыоритэтаваць і адхіляць зайвую роботу; flushSync з react-dom прымусова выконвае сінхронны апдэйт тады, калі гэта даскледзяецца.
Весь модэль у пераглядзе
Усё вышэйшае паслужыць часткай адной кантрэкстуальнай карты, ад компонента да пікселей на экране:
FUNCTION COMPONENT
│
▼
Fiber
│
▼
Hook sequence
│
┌───────────────┼───────────────┐
▼ ▼ ▼
useState useRef useEffect
│ │ │
▼ ▼ ▼
Update Queue Stable Object Dependencies
│
▼
setState()
│
▼
Schedule Update
│
▼
Render
│
▼
Reconciliation
│
▼
Commit
│
▼
DOM
│
▼
Browser
│
▼
Pixels
Галоўныя выводы
- Стан хука зберагаецца ў Fiber-компоненте і супарабатоўваецца з кожным вызывам па пазіцыі, таму порядак хукав не павінен ніколі змяняцца.
setStateдодае апдэйт у чергу; новая значэнне праказваецца пад час наступнага рэндара, а текущы рэндар застаецца зі своім скрапшотам.- Іспользуйце функцыональныя апдэйты, калі апдэйт залежыць ад пярэдней значэння.
- Эфекты сінхронізуюцца з зовнішнімі системамі; залежнасці парабялююцца за дапамою
Object.is, а процес чысткі выкананыя перад паўтарным запускам і праз анмонтаванне. - Refs — это стабільныя зменныя кантэйнеры, якія ніколі не спрычынаюць рэндараў; мемоізацыя — это оптымізацыя, а не гаранцыя.
- Адзінакавыя хукі дзелюцца логікай, а не станам, і рэндар павінен застацца чыстым, таму што React можа яго павтарыць або адкінуць.