Паттерны реактивности в современном 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)
      })
    }
  },
  // Прим. пер.: автор почему-то считает, что у 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 позволяет достигать реактивности множеством различных способов. Мы можем комбинировать эти паттерны для реактивного рендеринга, логгирования, анимирования, обработки пользовательских событий и других вещей, происходящих в браузере.

Источник: https://habr.com/ru/articles/757770/


Интересные статьи

Интересные статьи

JSON стал де-факто форматом обмена данными в современном мире. Он широко используется в различных приложениях, таких как API, конфигурационные файлы и хранилища данных. При работе с JSON нам часто тре...
Нехватка специалистов, особенно высокого уровня, — привычная ситуация для рынка IT. Однако сейчас в России проблема еще более актуальна в связи с тем, что немалое число специалистов релоцировалось из ...
Привет, Хабр! Представляю вашему вниманию перевод статьи «Database: Anti-Patterns» автора Sergey Kozlov. Если вы храните данные, это критически важная часть вашего приложения. Можно легко и бы...
Используете для создания приложений Vue, React, Angular или Svelte? Я использую, и если вы тоже, и уверен, что вам уже давно не приходилось писать приложение, которое выводит информацию без эти...
Меня зовут Валерий Шавель, я из команды разработки векторного движка Яндекс.Карт. Недавно мы внедряли в движок технологию WebAssembly. Ниже я расскажу, почему мы её выбрали, какие результаты полу...