Галоўная / Артыкулы

Створыце useState для іграшакоў, каб зрозумець снэпшоты, кялі і эфекты

Выявіце, як працююць хукі React, стварыўшы маленькі useState, а пасля выкарыстаце гэты модель для пояснення черг выканання, застарэлых копій дадзеных, закрыццяў, чысткі эфектаў, рэфаў і групавання операцый.

Выдавец: REACTAPP.TOP14 хв чытання3107 слоў

Крыніцы: 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 можа яго павтарыць або адкінуць.