"Реактивность" — это то, как системы реагируют на обновление данных. Существуют разные типы реактивности, но в рамках этой статьи, реактивность — это когда мы что-то делаем в ответ на изменение данных.
Паттерны реактивности являются ключевыми для веб-разработки
Мы работаем с большим количеством 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)
})
}
},
// Прим. пер.: автор почему-то считает, что у PubSub не должно быть этого метода
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') // Ничего
Кастомные события — нативный браузерный интерфейс для 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?') // Ничего
Реактивные свойства объекта — прокси
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()
}
},
}
}
}
Пример использования:
// Создаем новый observable, который генерирует три значения и завершается
const observable = new Observable(observer => {
observer.next(1)
observer.next(2)
observer.next(3)
observer.complete()
// Опционально: возвращаем функцию очистки
return () => {
console.log('Observer unsubscribed')
}
})
// Определяем observer с методами next, error и complete
const observer = {
next: value => console.log('Received value:', value),
error: err => console.log('Error:', err),
complete: () => console.log('Completed'),
}
// Подписываемся на observable
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) предоставляют нативный способ наблюдения за обновлениями атрибутов:
// Определяем кастомный элемент HTML
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>
// Модифицируем атрибут `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 анимации часто требуют записи в буфер и последующей записи результатов в цикле, когда поток рендеринга (rendering thread) становится доступным. Обычно, мы реализуем это с помощью 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 позволяет достигать реактивности множеством различных способов. Мы можем комбинировать эти паттерны для реактивного рендеринга, логгирования, анимирования, обработки пользовательских событий и других вещей, происходящих в браузере.
Комментарии (5)
Zenitchik
30.08.2023 07:21А мы отписчик возвращаем из подписчика.
sub: function(event, callback) { var me = this, callbacks = me._callbacks, list = callbacks[event], uid = me._uid++; if(list === undefined) { callbacks[event] = list = {}; } list[uid] = callback; return function() { delete list[uid]; } },
aughing
30.08.2023 07:21Реактивное программирование — программирование на асинхронных потоках данных. Поток данных можно создать из чего угодно, не только из событий DOM браузера. Потоки универсальны.
В дополнение к заметкам автора оригинала, хочу добавить пару слов о функциональном реактивном программировании на javascript. В библиотеке flyd поток — это функция, которую можно использовать везде, где ожидается функция. Потоки можно связывать, объединять, комбинировать, фильтровать получая новый поток. К сожалению, ФРП в javascript не очень популярная тема.
black1277
Благодарю Вас за хорошую подборку, всё интересное в одном месте. Добавил в закладки.
swordgna
Будьте любезны, здесь перечислены все паттерны реактивности? Или что-то осталось за рамками статьи?
black1277
Конечно, не все. Но автор и не претендовал на охват всего.