Патерни реактивності у сучасному JavaScript
“Реактивність” – це те, як системи реагують на оновлення даних. Існують різні типи реактивності, але в рамках цієї статті реактивність — це коли ми щось робимо у відповідь на зміну даних.
Патерни реактивності є ключовими для веб-розробки
Ми працюємо з великою кількістю JS на сайтах та у веб-додатках, оскільки браузер – це повністю асинхронне середовище. Ми повинні реагувати на дії користувача, взаємодіяти з сервером, надсилати звіти, моніторити продуктивність тощо. Це включає оновлення UI, мережеві запити, зміни навігації та URL в браузері, що робить каскадне оновлення даних ключовим аспектом веб-розробки.
Реактивність зазвичай асоціюється з фреймворками, але можна багато чому навчитися, реалізуючи реактивність на чистому JS. Ми можемо змішувати та грати з цими патернами для кращої обробки оновлення даних.
Вивчення патернів призводить до зменшення кількості коду та підвищення продуктивності веб-застосунків, незалежно від використовуваного фреймворку.
Мені подобається вивчати патерни, оскільки вони застосовні до будь-якої мови та системи. Паттерни можуть комбінуватися для вирішення завдань конкретної програми, часто приводячи до більш продуктивного і підтримуваного коду.
Видавець/передплатник
Видавець/передплатник (Publisher/Subscriber, PubSub) — один із основних патернів реактивності. Виклик події за допомогою publish()
дозволяє передплатникам (підписалися на подію за допомогою subscribe()
) реагувати на зміну даних:
const pubSub = {
events: {},
subscribe(event, callback) {
if (!this.events[event]) {
this.events[event] = []
}
this.events[event].push(callback)
},
publish(event, data) {
if (this.events[event]) {
this.events[event].forEach((callback) => {
callback(data)
})
}
},
unsubscribe(event, callback) {
if (this.events[event]) {
this.events[event] = this.events[event].filter((cb) => cb !== callback)
}
}
}
const handleUpdate = (data) => {
console.log(data)
}
pubSub.subscribe('update', handleUpdate)
pubSub.publish('update', 'Some update') // Some update
pubSub.unsubscribe('update', handleUpdate)
pubSub.publish('update', 'Some update') // nothing
Кастомні події – нативний браузерний інтерфейс для PubSub
Браузер надає API для виклику та передплати кастомних подій (custom events). Метод dispatchEvent() дозволяє не тільки викликати подію, але й прикріплювати до неї дані:
const pizzaEvent = new CustomEvent('pizzaDelivery', {
detail: {
name: 'Supreme',
},
})
const handlePizzaEvent = (e) => {
console.log(e.detail.name)
}
window.addEventListener('pizzaDelivery', handlePizzaEvent)
window.dispatchEvent(pizzaEvent) // Supreme
Ми можемо обмежити область видимості (scope) кастомної події будь-яким вузлом DOM. У наведеному прикладі ми використовували глобальний об’єкт window
, який також відомий як глобальна шина подій (event bus).
<div id="pizza-store"></div>
const pizzaEvent = new CustomEvent('pizzaDelivery', {
detail: {
name: 'Supreme',
},
})
const pizzaStore = document.getElementById('pizza-store')
const handlePizzaEvent = (e) => {
console.log(e.detail.name)
}
pizzaStore.addEventListener('pizzaDelivery', handlePizzaEvent)
pizzaStore.dispatchEvent(pizzaEvent) // Supreme
Примірники кастомних подій – створення підкласів EventTarget
Ми можемо створювати підкласи мети події (event target) для надсилання подій до екземпляра класу:
class PizzaStore extends EventTarget {
constructor() {
super()
}
addPizza(flavor) {
this.dispatchEvent(
new CustomEvent('pizzaAdded', {
detail: {
pizza: flavor,
},
}),
)
}
}
const Pizzas = new PizzaStore()
const handleAddPizza = (e) => {
console.log('Added pizza:', e.detail.pizza)
}
Pizzas.addEventListener('pizzaAdded', handleAddPizza)
Pizzas.addPizza('Supreme') // Added pizza: Supreme
Наші події викликаються на класі, а не глобально window
. Обробники можуть безпосередньо підключатися до цього примірника.
Спостерігач
Паттерн “Спостерігач” (Observer) схожий на PubSub. Він дозволяє підписуватись на суб’єкта (Subject). Для повідомлення передплатників про зміну даних суб’єкт викликає метод notify()
:
class Subject {
constructor() {
this.observers = []
}
addObserver(observer) {
this.observers.push(observer)
}
removeObserver(observer) {
this.observers = this.observers.filter((o) => o !== observer)
}
notify(data) {
this.observers.forEach((observer) => {
observer.update(data)
})
}
}
class Observer {
update(data) {
console.log(data)
}
}
const subject = new Subject()
const observer = new Observer()
subject.addObserver(observer)
subject.notify('Hi, observer!') // Hi, observer!
subject.removeObserver(observer)
subject.notify('Are you still here?') // nothing
Реактивні властивості об’єкта – проксі
Proxy дозволяє забезпечити реактивність при встановленні/отриманні значень властивостей об’єкта:
const handler = {
get(target, property) {
console.log(`Getting property ${property}`)
return target[property]
},
set(target, property, value) {
console.log(`Setting property ${property} to value ${value}`)
target[property] = value
return true
},
}
const pizza = {
name: 'Margherita',
toppings: ['mozzarella', 'tomato sauce'],
}
const proxiedPizza = new Proxy(pizza, handler)
console.log(proxiedPizza.name) // 'Getting property name' и 'Margherita'
proxiedPizza.name = 'Pepperoni' // Setting property name to value Pepperoni
Реактивність окремих властивостей об’єкту
Object.defineProperty() дозволяє визначати аксесори (гетери та сеттери) при визначенні властивості об’єкта:
const pizza = {
_name: 'Margherita',
}
Object.defineProperty(pizza, 'name', {
get() {
console.log('Getting property name')
return this._name
},
set(value) {
console.log(`Setting property name to value ${value}`)
this._name = value
},
})
console.log(pizza.name) // 'Getting property name' и 'Margherita'
pizza.name = 'Pepperoni' // Setting property name to value Pepperoni
Object.defineProperties() дозволяє визначити аксесори для кількох властивостей об’єкта одночасно.
Асинхронні реактивні дані – проміси
Давайте зробимо наших спостерігачів асинхронними! Це дозволить оновлювати дані та запускати спостерігачів асинхронно:
class AsyncData {
constructor(initialData) {
this.data = initialData
this.subscribers = []
}
subscribe(callback) {
if (typeof callback !== 'function') {
throw new Error('Callback must be a function')
}
this.subscribers.push(callback)
}
async set(key, value) {
this.data[key] = value
const updates = this.subscribers.map(async (callback) => {
await callback(key, value)
})
await Promise.allSettled(updates)
}
}
const data = new AsyncData({ pizza: 'Pepperoni' })
data.subscribe(async (key, value) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
console.log(`Updated UI for ${key}: ${value}`)
})
data.subscribe(async (key, value) => {
await new Promise((resolve) => setTimeout(resolve, 500))
console.log(`Logged change for ${key}: ${value}`)
})
async function updateData() {
await data.set('pizza', 'Supreme')
console.log('All updates complete.')
}
updateData()
/**
через 500 мс
Logged change for pizza: Supreme
через 1000 мс
Updated UI for pizza: Supreme
All updates complete.
*/
Реактивні системи
В основі багатьох популярних бібліотек і фреймворків лежать складні реактивні системи: хуки (Hooks) в React, сигнали (Signals) в SolidJS, сутності (Observables) в Rx.js і т.д. Як правило, їх основним завданням є повторний рендеринг компонентів або фрагментів DOM при зміні даних.
Observables (Rx.js)
Паттерн “Спостерігач” і Observables (що можна умовно перекласти як “сутні, що спостерігаються”) – це не одне і те ж, як може здатися на перший погляд.
Observables дозволяють генерувати (produce) послідовність (sequence) значень протягом часу. Розглянемо простий примітив Observable, що відправляє послідовність значень передплатникам, дозволяючи їм реагувати на значення, що генеруються:
class Observable {
constructor(producer) {
this.producer = producer
}
subscribe(observer) {
if (typeof observer !== 'object' || observer === null) {
throw new Error('Observer must be an object with next, error, and complete methods')
}
if (typeof observer.next !== 'function') {
throw new Error('Observer must have a next method')
}
if (typeof observer.error !== 'function') {
throw new Error('Observer must have an error method')
}
if (typeof observer.complete !== 'function') {
throw new Error('Observer must have a complete method')
}
const unsubscribe = this.producer(observer)
return {
unsubscribe: () => {
if (unsubscribe && typeof unsubscribe === 'function') {
unsubscribe()
}
},
}
}
}
Приклад використання:
const observable = new Observable(observer => {
observer.next(1)
observer.next(2)
observer.next(3)
observer.complete()
return () => {
console.log('Observer unsubscribed')
}
})
const observer = {
next: value => console.log('Received value:', value),
error: err => console.log('Error:', err),
complete: () => console.log('Completed'),
}
const subscription = observable.subscribe(observer)
subscription.unsubscribe()
Метод next()
надсилає дані спостерігачам. Метод complete()
закриває потік даних (stream). Метод error()
призначений обробки помилок. subscribe()
дозволяє передплатити дані, а unsubscribe()
— відписатися від них.
Найпопулярнішими бібліотеками, в яких використовується цей патерн, є Rx.js та MobX .
Signals (SolidJS)
Погляньте на курс реактивності з SolidJS від Ryan Carniato.
const context = []
export function createSignal(value) {
const subscriptions = new Set()
const read = () => {
const observer = context[context.length - 1]
if (observer) {
subscriptions.add(observer)
}
return value
}
const write = (newValue) => {
value = newValue
for (const observer of subscriptions) {
observer.execute()
}
}
return [read, write]
}
export function createEffect(fn) {
const effect = {
execute() {
context.push(effect)
fn()
context.pop()
},
}
effect.execute()
}
Приклад використання:
import { createSignal, createEffect } from './reactive'
const [count, setCount] = createSignal(0)
createEffect(() => {
console.log(count())
}) // 0
setCount(10) // 10
Повний код прикладу можна знайти тут.
Значення, що спостерігаються (Frontend Masters)
Наш відеоплеєр має багато налаштувань, які можуть змінюватись у будь-який час для модифікації відтворення відео. Kai з нашої команди розробив значення, що спостерігаються (observable-ish values), що являє собою ще один приклад реактивної системи на чистому JS.
Значення, що спостерігаються – це поєднання PubSub з обчислюваними значеннями (computed values), що дозволяють складати результати декількох видавців.
Приклад повідомлення передплатника про зміну значення:
const fn = function (current, previous) {}
const obsValue = ov('initial')
obsValue.subscribe(fn)
obsValue() // 'initial'
obsValue('initial') // 'initial',
obsValue('new') // fn('new', 'initial')
obsValue.value = 'silent' //
Модифікація масивів та об’єктів не публікує зміни, а замінює їх:
const obsArray = ov([1, 2, 3])
obsArray.subscribe(fn)
obsArray().push(4) //
obsArray.publish() // fn([1, 2, 3, 4]);
obsArray([4, 5]) // fn([4, 5], [1, 2, 3]);
Передача функції кешує результат як значення. Додаткові аргументи передаються функції. Спостережені сутності, викликані функції, є передплатниками, оновлення цих сутностей призводить до повторного обчислення значення.
Якщо функція повертає проміс, значення надається асинхронно після його вирішення.
const a = ov(1)
const b = ov(2)
const computed = ov((arg) => {
a() + b() + arg
}, 3)
computed.subscribe(fn)
computed() // fn(6)
a(2) // fn(7, 6)
Реактивний рендеринг UI
Розглянемо деякі патерни читання та записи в DOM та CSS.
Рендеринг даних за допомогою шаблонних літералів
Шаблонні літерали (template literals) дозволяють виконувати інтерполяцію змінних, що полегшує генерацію шаблонів HTML:
function PizzaRecipe(pizza) {
return `<div class="pizza-recipe">
<h1>${pizza.name}</h1>
<h3>Toppings: ${pizza.toppings.join(', ')}</h3>
<p>${pizza.description}</p>
</div>`
}
function PizzaRecipeList(pizzas) {
return `<div class="pizza-recipe-list">
${pizzas.map(PizzaRecipe).join('')}
</div>`
}
const allPizzas = [
{
name: 'Margherita',
toppings: ['tomato sauce', 'mozzarella'],
description: 'A classic pizza with fresh ingredients.',
},
{
name: 'Pepperoni',
toppings: ['tomato sauce', 'mozzarella', 'pepperoni'],
description: 'A favorite among many, topped with delicious pepperoni.',
},
{
name: 'Veggie Supreme',
toppings: [
'tomato sauce',
'mozzarella',
'bell peppers',
'onions',
'mushrooms',
],
description: 'A delightful vegetable-packed pizza.',
},
]
function renderPizzas() {
document.querySelector('body').innerHTML = PizzaRecipeList(allPizzas)
}
renderPizzas() /
function addPizza() {
allPizzas.push({
name: 'Hawaiian',
toppings: ['tomato sauce', 'mozzarella', 'ham', 'pineapple'],
description: 'A tropical twist with ham and pineapple.',
})
renderPizzas()
}
addPizza()
Основним недоліком цього підходу є модифікація всього DOM при кожному рендерингу. Такі бібліотеки, як lit-html , дозволяють оновлювати DOM інтелектуальніше, коли оновлюються лише модифіковані частини.
Реактивні атрибути DOM – MutationObserver
Одним із способів забезпечення реактивності DOM є маніпулювання атрибутами HTML-елементів. MutationObserver API дозволяє спостерігати за зміною атрибутів і реагувати на них певним чином:
const mutationCallback = (mutationsList) => {
for (const mutation of mutationsList) {
if (
mutation.type !== 'attributes' ||
mutation.attributeName !== 'pizza-type'
)
return
console.log('Old:', mutation.oldValue)
console.log('New:', mutation.target.getAttribute('pizza-type'))
}
}
const observer = new MutationObserver(mutationCallback)
observer.observe(document.getElementById('pizza-store'), { attributes: true })
Прим. пер.: MutationObserver
дозволяє спостерігати за зміною не лише атрибутів, але також за зміною тексту цільового елемента та його дочірніх елементів.
Реактивні атрибути у веб-компонентах
Веб-компоненти (Web Components) надають нативний спосіб спостереження за оновленнями атрибутів:
class PizzaStoreComponent extends HTMLElement {
static get observedAttributes() {
return ['pizza-type']
}
constructor() {
super()
const shadowRoot = this.attachShadow({ mode: 'open' })
shadowRoot.innerHTML = `<p>${
this.getAttribute('pizza-type') || 'Default content'
}</p>`
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'pizza-type') {
this.shadowRoot.querySelector('div').textContent = newValue
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`)
}
}
}
customElements.define('pizza-store', PizzaStoreComponent)
<pizza-store pizza-type="Supreme"></pizza-store>
document.querySelector('pizza-store').setAttribute('pizza-type', 'BBQ Chicken');
Реактивне прокручування — IntersectionObserver
IntersectionObserver API дозволяє реагувати на перетин цільового елемента з іншим елементом або областю перегляду (viewport):
const pizzaStoreElement = document.getElementById('pizza-store')
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in')
} else {
entry.target.classList.remove('animate-in')
}
})
})
observer.observe(pizzaStoreElement)
Дивіться приклад анімації під час прокручування на CodePen .
Прим. пер.: Крім MutationObserver
і IntersectionObserver
існує ще один нативний спостерігач – ResizeObserver .
Зациклювання анімації – requestAnimationFrame
При розробці ігор, при роботі з Canvas або WebGL анімації часто вимагають запису в буфер і наступного запису результатів у циклі, коли потік рендерингу стає доступним. Зазвичай, ми реалізуємо це за допомогою requestAnimationFrame:
function drawStuff() {
// Логіка рендерингу гри чи анімації
}
// Функция обробки анімації
function animate() {
drawStuff()
requestAnimationFrame(animate) // Продолжаем вызывать `animate` на каждом кадре рендеринга
}
// Запускаєм анімацію
animate()
Реактивні анімації – Web Animations
Web Animations API дозволяє створювати реактивні гранульовані анімації. Приклад використання цього інтерфейсу для анімації масштабу, положення та кольору елемента:
const el = document.getElementById('animated-element')
// Визначаємо властивості анімації
const animation = el.animate(
[
// Ключові кадри (keyframes)
{
transform: 'scale(1)',
backgroundColor: 'blue',
left: '50px',
top: '50px',
},
{
transform: 'scale(1.5)',
backgroundColor: 'red',
left: '200px',
top: '200px',
},
],
{
// Налаштування часу
// Тривалість
duration: 1000,
// Напрямок
fill: 'forwards',
},
)
// Встановлюємо швидкість відтворення у значення `0`
// для призупинення анімації
animation.playbackRate = 0
// Реєструємо оброблювач кліку
el.addEventListener('click', () => {
// Якщо анімацію припинено, відновлюємо її
if (animation.playbackRate === 0) {
animation.playbackRate = 1
} else {
// Якщо анімація відтворюється, змінюємо її напрямок
animation.reverse()
}
})
Реактивність такої анімації полягає в тому, що вона може відтворюватись щодо поточного положення в момент взаємодії (як у разі зміни напряму в наведеному прикладі). Анімації та переходи CSS такого робити не дозволяють.
Реактивний CSS – кастомні властивості та calc
Ми можемо писати реактивний CSS за допомогою кастомних властивостей та calc
:
barElement.style.setProperty('--percentage', newPercentage)
Ми встановлюємо значення кастомного властивості JS.
.bar {
width: calc(100% / 4 - 10px);
height: calc(var(--percentage) * 1%);
background-color: blue;
margin-right: 10px;
position: relative;
}
І робимо обчислення на основі цього значення в CSS. Таким чином, за стилізацію елемента повністю відповідає CSS, як і має бути.
Прочитати поточне значення кастомної властивості можна так:
getComputedStyle(barElement).getPropertyValue('--percentage')
Як бачите, сучасний JS дозволяє досягати реактивності безліччю різних способів. Ми можемо комбінувати ці патерни для реактивного рендерингу, логування, анімації, обробки подій користувача та інших речей, що відбуваються в браузері.