Скедуляры React і цикл здарэнняў: хто насправды адначыяе, калі будзе выканана робота
Пазіце, як кооператыўны планавальнік React працуе ўнутрь цыклу змаганняў JavaScript, чаму пераходы можаць дахоўваць ресурсы, і чаму жадны планавальнік не можа вываліць заблокаваны потак.
Крыніцы: How Does React’s Scheduler Work With the JavaScript Event Loop?
React адзначае не толькі, што павінна змяніцца на экране, але і калі гэта будзе здарыцца, выкарыстоўваючы прыорітэты, канвеі і можлівасць перарыву процэса атрыбутавання. Аднак React — гэта проста JavaScript, а JavaScript ужо мае цыкл змаганняў, який адзначае, калі код павінен выконвацца. Тады чаму React выкарыстоўвае свой сабскрэйнер, і калі з гэтых двух насправды ёсць керуючы элемент? У гэтым артыкуле раз’яснюецца цяперашняя звязь, паказваецца, дзе месца такім функцыям, як пераходы і useDeferredValue, а таксама пояснюецца жорсткі ліміт, якога не можа падбіцца ніякая хытрая схема раскладзу задач, як у вебе, так і ў React Native.
Пачніце з стака вызываў, а не з React
Звычны JavaScript выконвае код у стаку вызываў, по аднаму кадру за раз. У прыкладе нижэй нічога не ўяўляе сабою асінхроннае выкананне: логаванне, вызыв функцыі, якая логуе, і ўжо апошнее логаванне.
console.log("A");
function calculate() {
console.log("B");
}
calculate();
console.log("C");
Рэзультат выканання коду адпавядае яго порядку абсалютна точна:
A
↓
B
↓
C
Тепер заменіце вызов функцыі на таймераў з адлегчэнням нуля:
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
console.log("C");
Порядак зменшыцца:
A
C
B
Это дзівіць людзей, таму што адлегчэння буквальна:
0
Паясненне такое: setTimeout() ніколі не значыць «заўсёды выконваць гэта зараз». Ён значыць, што функцыя-вызыв стане гатовай да выконання пасля адлегчэння, і яй все рава трэба чакаць, пакуль завершыцца код, які зараз выконваецца, і рэангіментарабельная сістэма даспеў да яе. Адліквідацыя пытання, калі гэта адбудзецца, — задача цыклу змаганняў.
Цыкл змаганняў у стислай форме
Для аналізу React дапаможае спроставаная карцінка:
┌─────────────┐
│ Call Stack │
└──────┬──────┘
│
↓
JavaScript Runs
│
↓
Stack becomes free
│
↓
Event Loop
│
┌──────┴──────┐
↓ ↓
Microtasks Tasks
Код выконваецца на стаке пакуль стак не будзе порожні. Ляч тады цикл змаганняў берае на сябе задачы, якія знаходзяцца у черге: спачатку адбываюцься мікрозадачы (такія як калебуры, якія выкаананы пасля рэштавання праграмы), а потым — наступная задача (такая як калебура таймера або запуск змаганняў на вхідных дадзенах). Рэальныя браузеры і среды выканання дадаюць больш дакладнасці, уклjuчаючы крокі рэндарування между задачамі, але ўсюды важная ўмова: сінхронны JavaScript, які вже выканваецца, не можа быць перарваны іншым JavaScript. Якщо нитка зайнята, усе інша чакае. Для большых вяскавасцей пра чергі адзірніце закрыціі і цикл змаганняў.
Навмесна заморожаныя прыклады
Ёнколі хочаце зразка, калі гэта правіла паўтараюцца ў React, узьмім компонент, якій пры натысканні вызывае цикл, які працуе прыблізна пяць секунд, перш чым апডейтаваць стан. У прыкладзе выкарыстоўваецца Button з React Native, але парадакт дзейства той самы ў React DOM:
function App() {
const [count, setCount] = useState(0);
function handlePress() {
const start = Date.now();
while (Date.now() - start < 5000) {
// intentionally blocking JavaScript
}
setCount(c => c + 1);
}
return (
<Button
title={`Count: ${count}`}
onPress={handlePress}
/>
);
}
У гэтыя пяць секундадзейнасць JavaScript-ниткі:
JavaScript Thread
выглядае так:
██████████████████████████████ BLOCKED FOR 5 SEC
Апডейты, якія знаходзяцца у черге, прапытна прыорітэт, дрэва Fiber, гатовае да обрабоцкі — нічга з гэтага не мае значэння, таму што сам React — это JavaScript і яму трэба час на выкананне ў нітцы. Скеджуляр не можа захопіць CPU званоўна ад рантайму.
Хто контролюе
Лёгка ўявіць сабе, што React знаходзіцца вышэй і керуе JavaScript:
React Scheduler
↓
Controls JavaScript
Але на самай працоўнай картыны все адваротна. Рантайм выдае час на выкананне, а скеджуляр React вяршыць, як ім распаўзіцца гэты час:
JavaScript Runtime
↓
Provides execution opportunity
↓
React Scheduler
↓
Coordinates React work
Распрацоўчык запускаецца выключна ў мераях средовышча, якое з’являе JavaScript. Ён можа розумна арганізаваць власную работу React, але дзейна толькі тады, калі яму даюць поток.
Тады чаму взагалі патрэбен распрацоўчык?
Якшо React не можа перакрыць цыкл запуска, чыя ж польза ад яго распрацоўчыка? Уявім сабе экран з тысячамі элементаў:
App
├── Header
├── Search
├── Filters
├── ProductList
│ ├── Product
│ ├── Product
│ ├── Product
│ ├── ...
│ └── Product × 5000
└── Footer
У чыста сінхронным модэле атрыбутаванне гэтага дрэва ёст толькі адны, дужы неперарваны блок:
Start Render
↓
████████████████████████████
↓
Finish Entire Render
Тым часам корыстнік продовжае взаімаць з інтерфейсам:
Type
Tap
Scroll
Якшо атрыбутаванне ёст адной вялікай задачай, усі этыя взаімадзеі стояць у черге пакуль вона не завершыцца, і інтерфейс здаецца застойным. React патрэбна можлівасць раздзеліць атрыбутаванне на часткі і саўместна працаваць з хостам, рэгулярна вяртаючы поток, каб можліва была робота з вхіднымі дадзеннямі та атрыбутаванне.
Саўместная распрацоўка запуска
Внутрэній планавальнік React — гэта самэў такія саўместнае распрацоўванне. У працоўнасці з адной старой інструкцыі:
Render everything NOW
яны постаўляюць серію запытанняў пра застойную роботу:
There is React work pending.What should we work on first?How urgent is it?Should we continue?Should we yield?Did something more important arrive?
З точкі зору прайходжання роботы, цей цикл выглядае так:
Pending React Work
↓
Scheduler
↓
Check Priority
↓
Perform Some Work
↓
Should We Yield?
↙ ↘
YES NO
↓ ↓
Give Host Continue
a Chance Rendering
React адрабоўвае частку роботы, пераканальваецца, чы не трэба яе завершыць, і як толькі гэта стаёцца, вяртае кантроль хосту, ўжо каб браўзер (או натыўны рантайм) могаў обрабаваць вхідныя даны, запускаць іншыя задачы та атрыбутаваць элементы. Пасля чаго ён прыемлівае роботу з таго месца, дзе застаў, або перэглядае, над чым працаваць далей.
Ключовая слова — саўместнае распрацоўванне. React нічога не забірае у JavaScript; ён структуруець свою роботу так, каб ніколі не утримваў поток адночасна дыяльнасці дольш чым трэба. Аднак саўместнае распрацоўванне можа працаваць толькі, калі всі учаснікі будуць саработаць разам.
Две гэтавы, два запытання
Дапамагае сформулюваць разлік як два окремыя запытання.
- Event loop спытаецца: калі JavaScript можа выконваць свою адражаную роботу?
- Scheduler у React спытаецца: тепер, калі у React є адзін поток, яка з робоць React павінна быть выконана першая?
Калі ўсе шары разам, яны выглядаюць так:
Browser / Native Runtime
↓
JavaScript Runtime
↓
Event Loop
↓
JavaScript gets to run
↓
React Scheduler
↓
Choose React Work
↓
Fiber
↓
Render / Reconcile
↓
Commit
Кожны шар працуе ў межах шара, які знаходзится вышэй.
Калі введенне тексту конкуруе з масовым атрыбутаваннем
Уявіце сабе, калі React ўжо часткова атрыбутавае вялікі список товароў:
Rendering Products...
██████████████░░░░░░░░
Потым корыстнік пачынае вводзіць запит на пошук, адну клавішу за раз:
R
Re
Rea
React
У React тепер є два відзелы роботы, якія конкуруюць за той самы поток:
Large List Rendering
+
User Input Update
Корыстніку значна важлівейша можлівасць введення тексту: кожны симвал павінен з’являцца мгновэна, нават якщо список адстае на трохі. Прыоритэтаванне клавішных натысканняў і перарыв атрыбутавання списку дазволяе це здзейсніць.
Як Fiber забезпечвае пункты перерыву
Планаванне дапамагае толькі тады, калі роботу можна фактычна разбіць. Гэта і ўжо ролю Fiber: кожны файбер представляе роботу для адной часткі дрэва, таму праця над відображенням стае серыёю маленькіх крокаў, а не адной монолітнай операцыяй:
App Fiber
↓
Header Fiber
↓
Search Fiber
↓
List Fiber
↓
Product Fiber
↓
Product Fiber
↓
...
За дапамогою паралельнага відображэння React можа обрабатваць дзеякі з гэтых крокаў, зупініцца, займіцца чым-то болей нягледзячым, а потым продаць далей чы прачынаць занова:
Start Rendering
↓
Perform Some Fiber Work
↓
Pause
↓
Handle More Urgent Work
↓
Continue / Restart
У дакументацыі React паралельнае відображэння описваецца як можна перарываць самэлькі гэтым спосабам: праця над відображенням можа быць запусканая, павернутая на час, пракранутая пазней чы абанаваная, якщо ёй стала непатрэбная, усё гэта перш чым будзе здабыты ўсталяванне. Гэта абоўсумна іншая тэза, чым стверджэнне, што React запускае JavaScript паралельна, чаго ён не робіць.
Паралельнае — гэта не суміжнае
Фраза:
Concurrent React
Частаючы, гэта вызывае адразу прыемку пра спецыяльныя ніткі для кожнай задачы:
Thread 1 → InputThread 2 → Product ListThread 3 → React Rendering
Гэтыя моделі некоректны. Канкуранцыя ў React адбываецца праз перакрыстанне задач на адной нітцы, больш схожае на гэта:
Work A
██████░░░░░░ ↓ pauseWork B
████ ↓Work A
██████
Роба А выконваецца павольна, зупіняецца, потым выконваецца роба Б, а пасля знову роба А. Разны элементы відрасунку координуюцца з часам, але код вашага компонента ніколі не выкананы на кальколькох нітках адночасна.
Трывожная роба, якая прыходзіць пад час пераходу
Предпазім, што React адрабатвае дорогі апдэйт, які быў заглушаны ў пераход:
startTransition(() => {
setResults(expensiveResults);
});
Калі гэты адрабатка яшчэ трывае, корыстнік вводзіць іншы симвал. Прыгляд за гэтым процесам выглядае прыблізна так:
Transition Render
██████████░░░░░░░
↓ User Types ↓Urgent Update
████████ ↓React can reconsider
lower-priority work
Пакалі пераход не ў прыоритэце, React можа адкласіць яго, спачатку обработаць нажатыя клавішу, а пасля вернуцца да робы з нижэйшым прыорітэтом, часта пачынаючы яе занова з чыстым статусам.
Адзірванне значэння замест апдэйта
useDeferredValue прыменяе тую ж ідэю з баку чытання. Замест тагавання апдэйта стану як неняхіднага, вы отрымваеце запазджаная копію значэння:
const deferredQuery = useDeferredValue(query);
Вхід продовжвае выкарыстоўваць рэальны, актуальны стан:
<TextInput
value={query}
onChangeText={setQuery}
/>
тады калі дорогі компонент отрымвае адзірваную копію:
<ExpensiveList query={deferredQuery} />
У результате поле вядома ўжо можа паказваць:
React
тады калі спіс на час яшчэ адбівае пракануную запытку:
Reac
У тым часа React готавіць новы список у шчылку, і кожны новы натиск клавішы проста вымусвае яго абандонаваць тую спробу і пачынаць знова з апошнім запитам. Це не робіць дорогі компонент шырэйшым; мета проста — не дазволіць менш важлівым процесам атрыбутавання блакануць взаімадзею, на якую зосераджаны корыстнік. Выбор между гэтымі двума інструментамі зазвычай прост: выкорыстоўваеце startTransition, калі самі керуеце апдэйтом стану, а useDeferredValue — калі проста отрымляеце значэнне, напрыклад як проп.
Ліміт: планаванне не можа перакрыць ваш код
Усё вышэйзгаданае залежыць ад таго, чым React рэгулярна вяртае нитку. Тепер разглянем функцыю, якая выканае вельмі большую сінхронную обработку:
function calculateEverything() {
for (let i = 0; i < 10_000_000_000; i++) {
// huge synchronous calculation
}
}
Калі яна працуе, нітка JavaScript:
JS THREAD
зайнята абсалютна:
████████████████████████████Scheduler waiting...React waiting...Input waiting...
Скедуляр, React і чакаючыя данні чакаюць адночасна. Кооператыўны скедуляр можа толькі пераводзіць контроль межы елементамі роботы самога React; ён не може перерваць цикл, які вы напісалі. Таму канкурэнтна рендарызацыя не ўсуне проблему нэфарткавага JavaScript. Складныя вычысленні все рава трэба скарматаваць, разбіць на часткі, зберагчы ў памяці або перанесці з галоўнай вядкі, напрыклад, у рабочы процес.
Чаму рендарызацыя і зберагчыце данні застаюцца разнымі
Той самы прынцып поясняе, чаму React настаівае на роздзеленні апдэйта на два этапы:
Render
і:
Commit
Рендарызацыя — гэта момент, калі React вырашае, яким павінен стаць інтерфейс, а ў режыме канкурэнціі гэтае падготавленне можа быць перарвана. React адмовляецца показваць незавершаны результат. Таму пад час фазы рендарызацыі:
Render
React можа завершыць дзеянне некалькіх фібраў, быць перарваным і пракратыць або запусціць занова, і толькі калі весь «дрэво» будзе гатова, ён выканае каміт:
Fiber A ✓
Fiber B ✓
Fiber C ...
↓
Interrupted
↓
Continue / Restart
↓
Entire Tree Ready
↓
COMMIT
↓
Visible UI
Самэй таго перарваны процес рендараўння ніколі не створые экрана, яны ў яком разы сумешчаюць розныя статусы:
Half old UI
+
Half new UI
Каміт завжды застосоўвае толькі адносна стабільны, завершаны рэзултат.
Чаму рендараўння павінна быць чыстым
«Храніце компаненты чыстымі падчас рендараўння» — гэта знаёмы савет, а можлівасць перарыву рендараўння чытка паказвае, чаму гэта важна. Паглядзіце на компанент, які з выкарыстоўванням свайго корпуса запускае вызов для аналізу:
function Product() {
analytics.track("Product Rendered");
return <View />;
}
Пад адночасным рендараўнем React можа праходзіць па такой сэрыі дзеяння:
Starts Render
↓
Pauses
↓
Abandons Work
↓
Renders Again
Кожна з гэтых спроб запускае функцыю компанента, таму гэты вызов:
analytics.track(...)
Можа выконвацца для рэндароў, якія ніколи не былі запісаны і ніхто не бачыў. Вашы показнікі будуць урахоўваць фантомныя перагляды продукту. React патрэбуе свабоды:
Render
Pause
Retry
Abandon
Restart
без таго, каб якія-небудзь з гэтых спроб зменялі зовнішній свет. Пабочныя эфекты патрабуюць размешчэння ў функціях effects або обробніках змаганняў, якія выконваюцца толькі для запісаных дзеянняў. Чытайце практычны кіявод па выбору месца, дзе должна знаходзіцца логіка, у выборы правильнага інструмента React: derive, handle, fetch, defer або effect.
Аспект React Native
У дапытку React Native шлях ад натыску клавішы да экрана праходзіць через тыя ж шары, з натыўным рэндарерам у канцы:
User Types
↓
JS Event Handler
↓
setState()
↓
React Scheduler
↓
Fiber Work
↓
React Native Renderer
↓
Native UI Update
Заблокіруйце нитку JavaScript дорогай сінхронной працэй, і ланцюг зупініцца на другым кроку:
User Types
↓
JS Thread Busy
████████████████
↓
React can't process JS work yet
Дзеянне прыстрыю выглядае заціснутым, навет якшчына сама натыўная галочка UI можа быць абсалютна справнай. Пры діаганазе сповольнення цікава разлучыць можлівыя прычыны, таму што кожная з іх трэба ліквідаваць адначасна інакшым спосабам:
Is React rendering too much?ORIs JavaScript itself blocked?ORIs expensive native work happening?ORIs the UI thread overloaded?
Занадто многія вызовы для атрыбутаў візуалізацыі выклекаюць патрэбу ў мемаізацыі, вярчыналізацыі або переходах. Заблокаваная галочка JavaScript выклекае патрэбу перасунуць або раздзеліць важкую работу. Дорогая натыўная работа або перавантажаная галочка UI вядуць да натыўнага прафілювання.
Полны лянцюг
Складаючы ўсе часткі разам, адна взаімадзейнасць праходзіць через гэтыя стадыі:
USER INTERACTION
↓
JavaScript Runtime
↓
Event Handler
↓
setState()
↓
Update Created
↓
Lane / Priority
↓
React Scheduler
↓
Choose Work
↓
Fiber Render
↓
Can Yield / Restart
↓
Reconciliation
↓
Completed Work
↓
Commit
↓
UI Updated
І всё гэта выконваецца ў межах рантайму JavaScript:
┌─────────────────────────────┐
│ JavaScript Runtime │
│ │
│ Event Loop gives JS a chance│
│ ↓ │
│ React coordinates │
│ its own work │
└─────────────────────────────┘
Цыкл адбытка дае JavaScript можлівасць выконання; у кожны момент часу React адлучае, як ім яго выкарыстаць.
Пытанні, на якія варта знаты адпаведзі
Чы рэжымар React — гэта тое самае, што і цікл адбывання змаганняў?
Ня. Цікл адбывання змаганняў належыць до средовыя выконвальнай сістэмы. Рэжымар React координуе роботу React у часовым працэсе, які надае выконвальная сістэма.
Чы React, які працуе паралельна, запускае компоненты на калькуляцыйных нітках?
Ня. Функцыя паралельнага атрыбутавання дозволяе React паставіць на паузу, адноваць, перазапускаць чы прыняць рашэнне аб абмове атрыбутавання на адной нітцы. Код компонентаў не стае паралельным.
Чы React можа перарваць атрыбутавання?
Так, у режыме паралельнага атрыбутавання. React можа паставіць тыповыя адкладзеныя на першае месца і абмовіць старыя процесы атрыбутавання перш чым яны будуць зафіксаваны.
Чы рэжымар можа вываліць з блокування нітку?
Ня. Дзейнік сінхронныя JavaScript-праграмы змушаюць React чакаць, як і весь іншы код, пакуль нітка не стане вольная.
Чаму компоненты павінны застацца чыстымі?
Таму што процес атрыбутавання можа выконвацца больш чым раз і можа быць перарваны або знішчэны. Паслядкі такога процесу могу выйсці за межы роботы, якая ніколі не падае на экран.
Як эвалююцья ментальная модель
Большасць разработчыкаў пачынают з уявлення, што зміна стану безпосередзьва вялічае патрэбу ў новым атрыбутаванні:
setState()
↓
React renders
↓
Screen updates
Вивчэнне механізмаў планавання дадае кальколька дапаможных этапаў:
setState()
↓
Update
↓
Lane
↓
Scheduler
↓
Render
↓
Commit
Пашчотны ўровень — сама часавая среда, якая знаходзіцца пад усім, што робіць React:
JavaScript Runtime
↓
Event Loop
↓
React Scheduler
↓
Fiber
↓
Render
↓
Commit
React інтэлігентна плануе свою роботу, але завжды з ўсероўні JavaScript-среды. Якща адносіцца да гэтага падходу, пераходы, можлівасць перарыву атрыбутавання і блаканне JS-ніты складаюць адно цэласнае явішча.
Куды працюе цяпер канвейр
У гэтым артыкуле была праглядная дорага ад змены стану да цыклу запуску падзеяў:
State Update
↓
Update Queue
↓
Fiber
↓
Lanes
↓
Scheduler
↓
JavaScript Event Loop
У React Native гэты пацек продовжваецца даўжэй, ад каміту ў React да рэальных відвораў платформы. Такі просты «дрэва»:
<View>
<Text>Hello React Native</Text>
</View>
яно ўсё равно праходзіць через бандлер, двыжак Hermes, JSI і рэндэрар Fabric прытаму, калі стане частюю натыўнага UI Android або iOS. Гэтыя шары маюць своія скары і проблемы з планаваннем, і іх варта розумець окрема, калі стане ясна ситуацыя з частюю JavaScript.
Ключовыя выводы
- Цыкл змаганняяў вядомае, калі запускаецца JavaScript; планавальнік React вядомае, якую роботу ў React адрабатыць у той час.
- Планаванне ў React яе кооператыўнае: ён перадае кантроль между елементамі роботы Fiber, каб можна было адрабатваць вхідныя даны і выкананне айтэмаў на экране.
- Канкурэнція ў React значыць перакрытуюе выкананне на аднам скары, а не паралельнае.
startTransition пазначае апдэты як неадчынныя; useDeferredValue дазваляе компаненту адрадзіваць на основе з запаздалым значэнням.