Сигнали в JavaScript, уявлені DALL·E
Минуло більше десятка років від моменту випуску React, і більше 5 років з того часу, як команда React представила функціональні компоненти, що стало великим зсувом парадигми. Насправді, вони не лише замінили класи компонентів, але й надихнули на створення багатьох бібліотек, таких як SolidJS.
Існує безліч статей, які пояснюють, як працюють функціональні компоненти React, і це досить легко зрозуміти. Сьогодні ми зосередимося виключно на хуках стану та ефектів, як на точці входу до розуміння Сигналів. Давайте подивимося на приклад нижче:
const SimpleComponent = () => {
const [count, setCount] = useState(0); // значення стану встановлено на 0
useEffect(() => {
const intervalId = setInterval(
() => setCount(prev => prev + 1),
1000
);
return () => clearInterval(intervalId)
}, [])
useEffect(() => {
console.log('Лічильник змінено!', count);
}, [count])
// ...
}
/*
Лічильник змінено! 0
Лічильник змінено! 1 // після секунди
Лічильник змінено! 2 // після ще однієї секунди
...і так далі...
*/
Очевидно, що ми підтримуємо стан під назвою count
, який можна оновлювати за допомогою методу setCount
. Тут є два хуки useEffect
— один ініціалізує setInterval
для збільшення count
на 1 кожну секунду, а інший виводить значення count
, коли воно змінюється. Обидва хука виконуються при першому рендері компонента. Потім, через секунду, стан змінюється, і React перерендерює компонент, виконавши весь код компонента ще раз з новим станом. Ось чому так важливо, щоб у useEffect
був масив залежностей [count]
, щоб зрозуміти, коли потрібно повторно виконати код, порівнюючи старе значення з новим. Ніякої магії, все до чіткого результату.
І ця парадигма — головна причина, чому я був так здивований, коли вперше побачив Сигнали, де ефекти автоматично повторно виконуються, коли значення, від яких вони залежать, змінюються. Без масиву залежностей.
Сигнали
Давайте перейдемо до іншого прикладу та подивимося на подібний приклад із сигналами:
const [count, setCount] = signal(0); // значення стану встановлено на 0
setInterval(
() => setCount(prev => prev + 1),
1000
);
effect(() => {
console.log('Лічильник змінено!', count());
})
/*
Лічильник змінено! 0
Лічильник змінено! 1 // після секунди
Лічильник змінено! 2 // після ще однієї секунди
...і так далі...
*/
Зверніть увагу на різницю, крім зміни назв? Боюсь, що ви помітили. Давайте розберемо це:
- Тут немає визначеного компонента, оскільки Сигнали не обов'язково є частиною життєвого циклу компонента, хоча на практиці вони мають бути там, щоб уникнути витоків пам'яті та інших проблем.
count
тепер є функцією, а не змінною, яка зберігає значення стану.- Функція ефекту не має масиву залежностей, але все одно працює як очікується!
Ключ до розуміння того, як це працює, — це функція count
і однонитковість JavaScript. Давайте спочатку подивимося, як можна реалізувати базову функцію signal
:
const signal = (value) => {
let _value = value
function read() {
return _value
}
function write(value) {
_value = value
}
return [read, write]
}
Добре, ви можете сказати, що це було легко. Ми зберігаємо змінне значення всередині функції signal
і взаємодіємо з ним через функції read
і write
. Це має сенс.
Але де ж та магія з автоматичним відслідковуванням залежностей ефектів? Добре, давайте визначимо базову функцію effect
:
const effect = (cb) => {
let _externalCleanup // визначено явно користувачем
function execute() {
dispose()
_externalCleanup = cb()
}
function dispose() {
if (typeof _externalCleanup === "function") {
_externalCleanup()
}
}
execute()
return dispose
}
Добре, у нас є ефект, який з якоїсь причини трохи складніший, ніж міг би бути, але все одно зрозумілий, чи не так? Він приймає зворотний виклик, який може або не може повернути якусь явну функцію очищення (схоже на useEffect
в React), але де ж магія?
Магія за Сигналами
Давайте зробимо паузу і трохи поміркуємо. JavaScript — це однозадачна мова, і це означає, що він може виконувати лише одну операцію одночасно в основному потоці. Є багато корисних відео на YouTube, які чудово пояснюють це поняття і те, як асинхронний код обробляється в такому середовищі. Але наразі давайте просто приймемо це як факт і подивимося, що відбувається в прикладі коду вище:
- Ми визначаємо сигнал з початковим значенням, яке встановлено в 0
- Ми запускаємо ефект, використовуючи його функцію
execute
, яка зчитує значення нашого сигналу через виклик функціїcount
- Коли ми викликаємо
count
, ефект все ще працює, це важливо - Наш стек викликів виглядатиме ось так:
effect -> execute -> dispose -> cb -> count -> console.log
- Ефект завершує своє виконання
Якщо ви замислитесь на хвилину над послідовністю цих операцій, ви помітите, що ефект все ще працює, коли ми зчитуємо значення сигналу. Давайте трохи змінимо функцію execute
, щоб побачити, що я маю на увазі:
function execute() {
dispose()
console.log('Привіт!')
_externalCleanup = cb()
console.log('Бувай!')
}
/*
Привіт!
Лічильник змінено! 0
Бувай!
*/
І здогадайтеся що? Виведення в консолі буде відповідати точній послідовності, показаній у фрагменті коду вище. JavaScript — це однозадачна мова, і цей код виконується синхронно. Тож, що якби ми могли використати це, щоб перехоплювати всі сигнали, до яких звертаються під час виконання ефекту? Тоді ми точно знали б, від яких сигналів залежить наш ефект! Ну що ж, ми наближаємось до розкриття магії за Сигналами. Давайте зробимо це!
let activeObserver = null
const signal = (value) => {
let _value = value
const _subscribers = new Set()
function read() {
if (activeObserver && !_subscribers.has(activeObserver)) {
_subscribers.add(activeObserver)
}
return _value
}
function write(value) {
_value = value
}
return [read, write]
}
const effect = (cb) => {
let _externalCleanup // визначено явно користувачем
const effectInstance = {}
function execute() {
dispose()
activeObserver = effectInstance
_externalCleanup = cb()
activeObserver = null
}
function dispose() {
if (typeof _externalCleanup === "function") {
_externalCleanup()
}
}
execute()
return dispose
}
Тепер стає зрозуміло, як ми відслідковуємо залежності ефектів. Але зачекайте, ефект все ще не має жодного уявлення про свої залежності, але сигнал, з іншого боку, знає, який ефект виконується, коли його значення зчитується. І так, це правда. Ефект, насправді, не потребує знати, від чого він залежить щоб повторно виконатися, тому що повторні виконання ефектів ініціюються сигналами, а не самим ефектом. Хоча ефекту все одно потрібно знати свої залежності, щоб правильно очистити себе перед повторним виконанням.
Давайте перейдемо до наступної частини коду, цього разу ми охопимо більше компонентів і, зрештою, все запрацює, обіцяю!
let activeObserver = null
const signal = (value) => {
let _value = value
const _subscribers = new Set()
function unlink(dep) {
_subscribers.delete(dep)
}
function read() {
if (activeObserver && !_subscribers.has(activeObserver)) {
_subscribers.add(activeObserver)
activeObserver.link(unlink)
}
return _value
}
function write(valueOrFn) {
const newValue = typeof valueOrFn === "function" ? valueOrFn(_value) : valueOrFn
if (newValue === _value) return
_value = newValue
for (const subscriber of [..._subscribers]) {
subscriber.notify()
}
}
return [read, write]
}
const effect = (cb) => {
let _externalCleanup // визначено явно користувачем
let _unlinkSubscriptions = new Set() // відслідковуємо активні сигнали (щоб відключити на повторному запуску)
const effectInstance = { notify: execute, link }
function link(unlink) {
_unlinkSubscriptions.add(unlink)
}
function execute() {
dispose()
activeObserver = effectInstance
_externalCleanup = cb()
activeObserver = null
}
function dispose() {
for (const unlink of _unlinkSubscriptions) {
unlink(effectInstance)
}
_unlinkSubscriptions.clear()
if (typeof _externalCleanup === "function") {
_externalCleanup()
}
}
execute()
return dispose
}
Давайте розберемо і виокремимо основні зміни:
- Сигнал перехоплює виконуваний ефект через глобальну змінну
activeObserver
і додає його до набору_subscribers
- Сигнал також пов’язує себе (насправді свою функцію
unlink
) з ефектом, щоб ефект міг очистити себе перед наступним повторним запуском - Коли значення сигналу змінюється, він сповіщає всі підписані ефекти про цю зміну, щоб вони могли повторно виконатися
- Функція
write
сигналу тепер приймає або примітивне значення, або зворотний виклик, наприкладprev => prev + 1
, для обробки оновлень на основі попереднього значення - Ефект повідомляє сигналам, що він працює, призначаючи свій екземпляр глобальній змінній
activeObserver
- Екземпляр ефекту надає сигналам 2 функції:
link
іnotify
, щоб відслідковувати залежні сигнали та отримувати сповіщення про їх зміни - Ефект відслідковує свої залежності сигналів у наборі
_unlinkSubscriptions
- Функція
dispose
ефекту очищає всі залежні сигнали, щоб уникнути витоків пам’яті та зберігати лише активні залежності (якщо деякі сигнали зчитуються умовно, ви розумієте)
Ви можете запитати, чому ми перетворюємо набір _subscribers
в масив перед його ітерацією в функції signal
. Уявіть ситуацію, коли ефект не тільки зчитує значення сигналу, але й змінює його. Оскільки ці операції виконуються синхронно, це може створити нескінченний цикл. Якщо ефект зчитує сигнал і змінює його значення, ця зміна призведе до повторного виконання ефекту, що постійно змінюватиме _subscribers
. Цикл буде повторюватися нескінченно. Використання [..._subscribers]
забезпечує, що ми працюємо зі знімком _subscribers
на момент виконання, і таким чином зміни в _subscribers
не впливатимуть на цикл.
Візуальна діаграма, що представляє взаємозв’язки Signal / Effect. Функція "Execute" — це точка старту
Демонстрація
Підсумки
Ось і все. Магія за Сигналами пояснюється за допомогою функцій отримувачів для зчитування значень та однозадачної природи JavaScript. Так просто і потужно! Але, звісно, приклад коду вище надто спрощений і має достатньо простору для подальшого вдосконалення.
В наступній статті ми обговоримо, як:
- Дозволити сигналам приймати початкове значення як зворотний виклик
- Підтримувати обчислювальні значення, наприклад,
fullName
, які залежить від сигналівfirstName
іlastName
і зберігає ту ж реактивність - Підтримувати виконання кількох ефектів без переривання один одного (коли один ефект змінює сигнал і викликає виконання іншого ефекту)
Наразі вони будуть перезаписувати один одного, оскільки зміннаactiveObserver
утримує лише один екземпляр ефекту. - Підтримка пакетних оновлень, щоб ефекти виконувалися рівно один раз, коли кілька значень залежних сигналів змінюються послідовно (наприклад,
firstName
іlastName
змінюються одне за одним)
А як вишенка на торті, ми створимо просту бібліотеку на JavaScript для опису структури DOM, подібно до React, і зробимо її реактивною за допомогою сигналів! Побачите, це не так важко, як може здатися.
Сподіваюся, вам сподобалася моя стаття, і до зустрічі в наступній!
Перекладено з: How Signals in JavaScript work? Let’s build one and find out!