
"Реактивность" — это то, как системы реагируют на обновление данных. Существуют разные типы реактивности, но в рамках этой статьи, реактивность — это когда мы что-то делаем в ответ на изменение данных.
Паттерны реактивности являются ключевыми для веб-разработки
Мы работаем с большим количеством 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 позволяет достигать реактивности множеством различных способов. Мы можем комбинировать эти паттерны для реактивного рендеринга, логгирования, анимирования, обработки пользовательских событий и других вещей, происходящих в браузере.
