Патерни реактивності у сучасному JavaScript

 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 дозволяє досягати реактивності безліччю різних способів. Ми можемо комбінувати ці патерни для реактивного рендерингу, логування, анімації, обробки подій користувача та інших речей, що відбуваються в браузері.

Джерело