Паттерны реактивности в современном 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *