Привет, фронтендеры! Если вы думали, что независимые архитектурные движки - это привилегия экосистемы React (сарказм), у меня для вас потрясающие новости. Сегодня мы разберем, как запустить независимое реактивное ядро @pravosleva/reactive-engine внутри Vue 3 и Nuxt 4 с полноценной поддержкой серверного рендеринга (SSR). Это логическое продолжение статьи про Реактивные мутации в JS.

🎙️ Podcast #3

Часть 1. Изоляция ядра и реактивный мост (DI / Vue 3 Wrappers)

Шаг 1. Установка зависимостей

Открываем терминал в корне вашего Nuxt 4 проекта и устанавливаем ядро движка:

yarn add @pravosleva/reactive-engine

Структура папок, которую нужно создать руками

Nuxt 3

По умолчанию свежий проект Nuxt 3 поставляется в максимально минималистичном виде — в корне лежит только файл app.vue. Чтобы развернуть архитектуру из нашей статьи, создайте в корне проекта три папки:

my-nuxt-app/
├── composables/          # Сюда кладем useEngine.ts
├── plugins/              # Сюда кладем reactiveEngine.ts
├── components/           # Сюда кладем CounterWidget.vue и DashboardClientUI.vue
├── pages/                # Сюда кладем dashboard.vue для Части 2
├── app.vue               # Главная точка входа
└── nuxt.config.ts        # Конфигурация Nuxt

Важнейшая фишка Nuxt 3 для новичков

Папки composables/ и components/ обладают механизмом Auto-imports (автоимпорт). Это значит, что внутри файлов страниц вам НЕ нужно писать что-то вроде этого:

import { useEngine } from '...'

или импортировать компоненты вручную. Nuxt сам увидит их и сделает доступными в коде рантайма!

Nuxt 4

В моем случае структура немного отличается, но суть остается:

my-nuxt-app/
├── app/                      # <--- Главная папка вашего исходного кода
│   ├── components/           # Сюда кладем CounterWidget.vue и DashboardClientUI.vue
│   ├── composables/          # Сюда создаем useEngine.ts
│   ├── plugins/              # Сюда создаем reactiveEngine.ts
│   ├── pages/                # Сюда создаем dashboard.vue
│   ├── app.config.ts         # Конфигурация приложения Nuxt 4
│   └── app.vue               # Главная точка входа
├── package.json
└── nuxt.config.ts            # Глобальный конфиг (лежит в самом корне проекта)

🛠️ Микро-справка для импортов (Новичку на заметку)

Поскольку в Nuxt 4 папка /app является корневым пространством имен, алиас ~ или # автоматически перенаправляется сборщиком внутрь этой папки. Когда вы будете писать код из нашей статьи, пути импорта останутся красивыми и короткими. Вместо длинных относительных путей внутри CounterWidget.vue вы сможете писать:

// NOTE: For example
import { useEngine } from '~/composables/useEngine'
// > Webpack/Vite сам найдет файл в app/composables/

На текущий момент файл app/app.vue выглядит так:

<template>
  <div style="background-color: #121214; min-height: 100vh; padding: 40px; display: flex; justify-content: center; align-items: center;">
    <!-- Nuxt автоматически найдет и отрендерит компонент из папки app/components/ -->
    <CounterWidget />
  </div>
</template>

Шаг 2. Изоляция ядра на сервере (Nuxt Plugin)

Поскольку в среде SSR Node.js-процесс обслуживает тысячи пользователей одновременно, создавать движок как глобальный синглтон нельзя — данные одного пользователя неизбежно утекут к другому.

Мы напишем Nuxt-плагин, который будет создавать изолированный инстанс движка под каждый конкретный HTTP-запрос (паттерн Request-Scoped State), используя нативный механизм внедрения зависимостей provide/inject.

Плагин в контексте Nuxt 4 — это...

Создаем файл app/plugins/reactiveEngine.ts:

// app/plugins/reactiveEngine.ts

import { ReactiveEngine as ReactiveEngine4Vue } from '@pravosleva/reactive-engine/vue'

export default defineNuxtPlugin((nuxtApp) => {
  // Инициализируем Vue-версию движка под текущую сессию/запрос
  const engine = new ReactiveEngine4Vue({
    logger: {
      isEnabled: import.meta.dev,
      instanceName: import.meta.server ? 'nuxt-server-engine' : 'nuxt-client-engine'
    }
  })

  // Прокидываем инстанс во Vue-контекст приложения (для inject)
  nuxtApp.vueApp.provide('RE_ENGINE', engine)

  // Делаем движок доступным через useNuxtApp().$reEngine
  return {
    provide: {
      reEngine: engine
    }
  }
})

Для удобного и строго типизированного доступа к движку внутри компонентов создадим простой composable в файле app/composables/useEngine.ts:

Composable в контексте Nuxt 4 — это...

🎓 Сравнение ментальных моделей: React Custom Hooks vs Vue Composables

КритерийCustom Hook (React / Next.js)Composable (Vue 3 / Nuxt 4)
ИмпортТребуется явный import вверху файла.Автоимпорт из папки app/composables/.
Физика вызоваВызывается на каждый ререндер компонента. Код внутри функции крутится циклически.Вызывается строго один раз при инициализации компонента (setup). Дальше рантайм следит за точечными прокси-мутациями.
ОптимизацияТребует ручной обертки в useCallback и useMemo для защиты от лишних перерисовок.Не требует оптимизаций зависимостей. Код изначально мелкозернистый и работает максимально быстро.
// app/composables/useEngine.ts

import { inject } from 'vue'

// Явное указание импорта типа для удовлетворения правил TS/ESLint
import type { ReactiveEngine as ReactiveEngine4Vue } from '@pravosleva/reactive-engine/vue'

export const useEngine = () => {
  const engine = inject<ReactiveEngine4Vue>('RE_ENGINE')
  if (!engine) {
    throw new Error('useEngine должен использоваться строго внутри плагина, где активирован provide')
  }
  return engine
}

Шаг 3. Создаем реактивный Vue-компонент

Теперь самое интересное — магия моста между независимым грахом бизнес-логики и рантаймом Vue 3. Метод engine.use(signal) автоматически оборачивает сигнал ядра в стандартный объект ShallowRef<T>, обеспечивая реактивную синхронизацию изменений.

Создаем компонент счетчика components/CounterWidget.vue:

<script setup lang="ts">
import { AbstractService } from '@pravosleva/reactive-engine'
import { useEngine } from '~/composables/useEngine'

// 1. Описываем изолированную бизнес-логику (Сервис) 
// Этот код на 100% идентичен тому, что мы пишем в React!
class CounterLogic extends AbstractService {
  public counter = this.engine.signal<number>(0, 'example:vue:counter')
  public doubledCounter = this.engine.computed<number>(() => this.counter.value * 2, 'example:vue:computed')

  public inc = () => {
    this.counter.value += 1
  }
}

// 2. Достаем изолированный инстанс движка из контекста Nuxt 3
const engine = useEngine()

// 3. Внедряем наш сервис из встроенного DI-контейнера ядра
const logic = engine.inject(CounterLogic)

// 4. Оборачиваем сигналы во Vue-реактивные примитивы.
// Метод .use() возвращает стандартный ShallowRef<T> объект.
const counter = engine.use(logic.counter)
const doubledCounter = engine.use(logic.doubledCounter)
</script>

<template>
  <div>
    <div>Vue 3 Signal Example</div>

    <!-- 
      ⚠️ Внимание новичкам! 
      В шаблонах Vue 3 объекты ref/shallowRef разворачиваются автоматически.
      Обращаться к .value внутри тегов {{ }} НЕ нужно — пишем просто имя переменной.
    -->
    <code>{{ counter }} | x2 = {{ doubledCounter }}</code>

    <div>
      <!-- Вешаем слушатель события клика через директиву @click -->
      <button @click="logic.inc">
        INC (Vue)
      </button>
    </div>
  </div>
</template>

🌊 Часть 2. Гидратация состояния (Dehydration / Hydration)

Чтобы полностью ликвидировать микро-мерцание контента при гидратации и убрать любые ложные сообщения об ошибках, мы применили изоморфный паттерн гарантированного фоллбэка: если внутреннее состояние resourceState.value.data движка находится в фазе секундного сброса (null), наше вычисляемое свойство metricsData на стороне Vue мгновенно и бесшовно подставляет данные напрямую из props.snapshot. Верстка застывает намертво, обеспечивая идеальный UX без Layout Shift.

Шаг 1. Универсальный асинхронный сервис

Сервис сам управляет своими зависимостями (computed) и асинхронным ресурсом (resource), принимая нативный токен отмены abortSignal.

Создаем файл app/services/DashboardMetricsService.ts:

// app/services/DashboardMetricsService.ts

import { AbstractService } from '@pravosleva/reactive-engine'

// Интерфейс сырых данных бэкенда
export interface IDashboardData {
  rawUsersCount: number
  rawServerLoad: number
}

// Контракт плоского слепка состояния (Snapshot) для передачи по сети
export interface IDashboardSnapshot {
  'ssr:metrics:users': number
  'ssr:metrics:load': number
}

export class DashboardMetricsService extends AbstractService {
  // 1. Сигналы-зависимости. snapshotSignal — изоморфный мост гидратации
  public endpoint = this.engine.signal<string>('dashboard-metrics', 'ssr:signal:endpoint')
  private snapshotSignal = this.engine.signal<IDashboardSnapshot | null>(null, 'ssr:signal:snapshot')

  // Объединяем сигналы в единый вычисляемый кортеж зависимостей для ресурса
  private requestDeps = this.engine.computed(() => {
    return [this.endpoint.value, this.snapshotSignal.value] as const
  }, 'ssr:computed:request-deps')

  // 2. Декларативный асинхронный ресурс (Сетевой слой)
  public metricsResource = this.engine.resource<IDashboardData, readonly [string, IDashboardSnapshot | null]>(
    async ([_endpoint, clientSnapshot]) => {
      // ГИДРАТАЦИЯ: Если на клиент прилетел готовый снапшот — мгновенно отдаем его без fetch
      if (clientSnapshot) {
        return {
          rawUsersCount: clientSnapshot['ssr:metrics:users'],
          rawServerLoad: clientSnapshot['ssr:metrics:load']
        }
      }

      // Вызывается на сервере Node.js при SSR ИЛИ при ручном refresh на клиенте
      if (import.meta.server) {
        return { rawUsersCount: 1420, rawServerLoad: 42 }
      }

      // Эмуляция сети для последующих ручных обновлений на клиенте
      await new Promise(resolve => setTimeout(resolve, 100))
      return {
        rawUsersCount: Math.floor(Math.random() * 200) + 1300,
        rawServerLoad: Math.floor(Math.random() * 30) + 20
      }
    },
    this.requestDeps,
    'ssr:resource:metrics'
  )

  // 3. Вычисляемое свойство статуса (Считываем поля .data строго по типам вашего ядра)
  public status = this.engine.computed<string>(() => {
    const data = this.metricsResource.data
    if (!data) return 'STABLE ✅' // Дефолтный статус для серверного кадра при гидратации
    return data.rawServerLoad > 80 ? 'CRITICAL 🔥' : 'STABLE ✅'
  }, 'ssr:computed:status')

  /**
   * Метод гидратации: Переводим сигналы на рельсы прилетевших данных
   */
  public initFromSnapshot(snapshot?: IDashboardSnapshot | null): void {
    if (!snapshot) return
    this.snapshotSignal.value = snapshot
  }

  /**
   * Метод генерации снимка для передачи по сети на сервере
   */
  public getSnapshot(): IDashboardSnapshot {
    const data = this.metricsResource.data
    return {
      'ssr:metrics:users': data?.rawUsersCount ?? 1420,
      'ssr:metrics:load': data?.rawServerLoad ?? 42
    }
  }

  /**
   * Бизнес-метод мутации (Инвалидация через изменение зависимостей ресурса)
   */
  public simulateUserInflow = (): void => {
    this.snapshotSignal.value = null // Снимаем заглушку, открывая шлюз для живой сети
    this.endpoint.value = `dashboard-metrics?refresh=${Date.now()}`
  }
}

Шаг 2. Серверная подготовка данных

На сервере Node.js мы запрашиваем сырые метрики из базы данных, внедряем наш новый сервис из DI-контейнера текущей сессии, наполняем его данными и генерируем плоский JSON-слепок через getSnapshot().

План А: ⚠️ Экспериментальный

Обновляем файл app/pages/dashboard.vue:

<!-- app/pages/dashboard.vue -->
<script setup lang="ts">
import { useEngine } from '~/composables/useEngine'
import { DashboardMetricsService, type IDashboardSnapshot } from '~/services/DashboardMetricsService'

const engine = useEngine()
const metricsService = engine.inject(DashboardMetricsService)

// На этапе SSR блокируем поток сервера Node.js до полного выполнения асинхронного ресурса движка
if (import.meta.server) {
  // Если у вашего ресурса есть promise-геттер загрузки, дожидаемся его выполнения
  // Либо Nuxt автоматически дождется топ-левел await тасок перед генерацией HTML-кадра
}

// Формируем плоский снимок состояния
const sharedSnapshot = useState<IDashboardSnapshot>(
  'engine-state-snapshot',
  () => metricsService.getSnapshot()
)
</script>

<template>
  <main style="max-width: 600px; margin: 0 auto; padding: 40px;">
    <h1 style="color: #ff8a53; font-size: 1.8rem; margin-bottom: 24px;">
      🌊 Мониторинг: Nuxt 4 + ReactiveEngine (Resource SSR)
    </h1>

    <DashboardClientUI :snapshot="sharedSnapshot" />
  </main>
</template>

План Б: Родной подход Nuxt через useAsyncData (рекомендуемый и самый стабильный)

Чтобы не пытаться вытащить скрытые промисы из вашего Webpack-эффекта внутри движка, мы можем поручить управление сетевым запросом встроенному в Nuxt инструменту useAsyncData. Мы выполним fetcher-функцию один раз средствами Nuxt, а затем передадим готовый результат в метод setup() сервиса прямо на сервере. Это гарантирует 0 секунд ожидания и идеальную SSR-генерацию. Полностью обновите файл app/pages/dashboard.vue:

<!-- app/pages/dashboard.vue -->
<script setup lang="ts">
import { useEngine } from '~/composables/useEngine'
import { DashboardMetricsService, type IDashboardSnapshot } from '~/services/DashboardMetricsService'

// 1. На сервере Nuxt сам выполняет асинхронный запрос и блокирует поток до его резолва
const { data: serverMetrics } = await useAsyncData('dashboard-ssr-fetch', async () => {
  // В реальном проекте здесь будет обращение к вашему бэкенду/базе
  return {
    rawUsersCount: 1420,
    rawServerLoad: 42
  }
})

const engine = useEngine()
const metricsService = engine.inject(DashboardMetricsService)

// 2. Превращаем результат запроса Nuxt в плоский слепок (Snapshot)
const initialSnapshot: IDashboardSnapshot = {
  'ssr:metrics:users': serverMetrics.value?.rawUsersCount ?? 1420,
  'ssr:metrics:load': serverMetrics.value?.rawServerLoad ?? 42
}

// 3. Записываем snapshot в кэш сервиса прямо на сервере, чтобы getSnapshot() отработал корректно
metricsService.initFromSnapshot(initialSnapshot)

// 4. Упаковываем в изоморфный буфер для передачи в браузер
const sharedSnapshot = useState<IDashboardSnapshot>('engine-state-snapshot', () => initialSnapshot)
</script>

<template>
  <main style="max-width: 600px; margin: 0 auto; padding: 40px;">
    <h1 style="color: #ff8a53; font-size: 1.8rem; margin-bottom: 24px;">
      🌊 Мониторинг: Nuxt 4 + ReactiveEngine (Resource SSR)
    </h1>
    
    <DashboardClientUI :snapshot="sharedSnapshot" />
  </main>
</template>

Шаг 3. Клиентская гидратация и интерактивный UI

Клиентский компонент принимает пропсы, внедряет этот же самый класс сервиса в рантайме браузера и гидрирует его метод initFromSnapshot(). Код становится кристально чистым, а логика — централизованной.

Обновляем файл app/components/DashboardClientUI.vue:

<!-- app/components/DashboardClientUI.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useEngine } from '~/composables/useEngine'
import { DashboardMetricsService, type IDashboardSnapshot, type IDashboardData } from '~/services/DashboardMetricsService'

interface IDashboardProps {
  snapshot: IDashboardSnapshot | null
}

const props = defineProps<IDashboardProps>()

const engine = useEngine()
const logic = engine.inject(DashboardMetricsService)

// Инициализируем мост гидратации до запуска эффектов
logic.initFromSnapshot(props.snapshot)

// Строим реактивный мост во Vue со строгими типами вашего ядра (без any)
const resourceState = engine.use(logic.metricsResource)
const status = engine.use(logic.status)

// Бесшовный прокси-фоллбэк (Ликвидация микро-мерцания верстки).
// Если ресурс сбросился в null в момент гидратации эффектов, мы подставляем данные из props.snapshot напрямую. Экран застывает намертво без морганий.
const metricsData = computed<IDashboardData | null>(() => {
  const currentResourceData = resourceState.value?.data

  if (currentResourceData) {
    return currentResourceData
  }

  if (props.snapshot) {
    return {
      rawUsersCount: props.snapshot['ssr:metrics:users'],
      rawServerLoad: props.snapshot['ssr:metrics:load']
    }
  }

  return null
})
</script>

<template>
  <div style="background: rgba(255,255,255,0.05); padding: 24px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); display: flex; flex-direction: column; gap: 16px;">
    <h3 style="margin: 0; color: #00b7ff;">
      Ресурс гидрирован на базе нативного fetcher-транзита:
    </h3>

    <!-- TypeScript строго проверяет наличие полей внутри объекта metricsData -->
    <div
      v-if="metricsData"
      style="font-size: 1.1rem; font-family: monospace;"
    >
      <div>Активных сессий: <code style="color: #00ffcc; font-weight: bold;">{{ metricsData.rawUsersCount }}</code></div>
      <div style="margin-top: 8px;">
        Нагрузка на CPU: <code style="color: #00ffcc; font-weight: bold;">{{ metricsData.rawServerLoad }}%</code>
      </div>
      <div style="margin-top: 8px;">
        Статус системы: <code style="color: #ffcb6b; font-weight: bold;">{{ status }}</code>
      </div>

      <!-- Аккуратный индикатор фонового рефреша сети -->
      <div
        v-if="resourceState && resourceState.loading"
        style="color: #ff8a53; font-size: 0.85rem; margin-top: 12px; font-style: italic;"
      >
        🔄 Обновление данных из сети...
      </div>
    </div>

    <div
      v-else
      style="color: #ff5370;"
    >
      Критическая ошибка: данные сервера потеряны
    </div>

    <div>
      <button
        style="padding: 10px 16px; border: none; background: #ff8a53; color: #fff; font-weight: bold; border-radius: 6px; cursor: pointer;"
        @click="logic.simulateUserInflow"
      >
        Обновить ресурс (Мутировать зависимость)
      </button>
    </div>
  </div>
</template>

Проверка работы изоморфного приложения в браузере

Перейдите в браузере на роут нашего дашборда:👉 http://localhost:3000/dashboard

На что обратить внимание (Суть SSR)

  1. Отсутствие мерцания: Страница мгновенно отображает цифры 1420 и 42%. Никаких пустых лоадеров или нулей — данные прилетели готовыми прямо в HTML-коде с сервера Node.js.
  2. Живой граф: Кликните по кнопке. Метрики в интерфейсе начнут синхронно увеличиваться. Это доказывает, что клиентский движок успешно подхватил слепок сервера и развернул на его основе полноценный реактивный граф в рантайме браузера.

🚨 Золотые правила безопасности (SSR Rules of Thumb)

  1. Request-Scoped инстансы: Категорически избегайте глобальных синглтонов классов логики в SSR-среде. Инстанс должен создаваться через Nuxt Plugin и жить только в рамках текущего HTTP-запроса, изолируя сессии пользователей.
  2. engine.use(signal) в рантайме: Метод выступает умным контекстным адаптером. На сервере Node.js он пассивно считывает текущее значение для генерации HTML-строки, а на клиенте в браузере — автоматически разворачивает полноценную реактивную подписку.
  3. Сериализация примитивов: В useState передавайте только плоские объекты (snapshots), а не сигналы целиком.

📊 Сводная матрица и глубокий анализ: React vs Vue 3 в SSR-окружении

Чтобы окончательно разложить по полочкам механику работы независимого реактивного ядра в изоморфных фреймворках, давайте взглянем на сводную матрицу состояний и жизненного цикла данных.

Несмотря на фундаментальные различия под капотом (React полагается на неизменяемость данных, виртуальное дерево и ручные ререндеры, в то время как Vue 3 использует нативную прокси-реактивность и мелкозернистое обновление DOM), движок @pravosleva/reactive-engine выступает для них универсальным общим знаменателем. Он полностью инкапсулирует бизнес-логику, предоставляя каждому фреймворку специализированные легковесные адаптеры-коннекторы (use для React и engine.use() для Vue 3), которые переводят сигналы ядра на нативный язык реактивности конкретного UI-движка.

Фаза / КонтекстReact + Next.js (Pages/App Router)Vue 3 + Nuxt 3/4 (Nitro Engine)Физика процесса под капотом движка
Изоляция на сервере (Node.js)Создание через DI-контейнер или Request-Scoped контекст внутри SSR-контроллера страницы.Регистрация Request-Scoped инстанса через Nuxt Plugin с помощью встроенного механизма provide/inject.Защита от утечки данных: Каждый HTTP-запрос порождает изолированный экземпляр графа вычислений. Память утилизируется сборщиком мусора (GC) сразу после отправки HTML.
Рендеринг кадра на сервереАдаптер считывает сырое .value сигнала, преобразуя его в статическую HTML-строку. Подписки (эффекты) блокируются.Метод engine.use() считывает текущее значение кадра на сервере, не запуская клиентские вотчеры.Нулевой оверхед на сервере: На этапе SSR Node.js не тратит ресурсы на подписки и отслеживание мутаций — граф работает как быстрый синхронный конвейер.
Изоморфный транспорт (Транзит)Сериализация через pageProps (в Pages Router) или пропсы серверных компонентов (в App Router).Сериализация плоского слепка (Snapshot) в JSON-строку и передача через встроенный изоморфный буфер useState().Dehydration (Дегидратация): Сигналы нельзя передавать целиком (они содержат функции и подписки). Передается только плоский стейт «ключ-значение».
Оживление в браузере (Hydration)Клиентский адаптер подхватывает пропсы и создает локальные подписки, связывая граф со стейтом React.Клиентский инстанс движка инициализирует сигналы данными из useState(), а engine.use() возвращает живой ShallowRef<T>.Синхронизация без мерцания: Интерфейс оживает в браузере на базе тех же самых данных, что были на сервере, полностью исключая ошибки Hydration Mismatch.
Мутации и апдейты в DOMИзменение сигнала триггерит внутренний хук, вызывая ререндер компонента React и его дочерних нод.Изменение значения сигнала мутирует связанный ShallowRef. Vue точечно обновляет строго текстовую ноду в DOM.Мелкозернистая реактивность: Граф вычислений движка идеально мэтчится со стратегией обновлений Vue 3, обеспечивая максимальную производительность (60 FPS).

Физика изоморфного моста: Разбор ментальной модели

Главное, что должен усвоить разработчик при работе с @pravosleva/reactive-engine в SSR — это дуализм поведения метода engine.use().

В момент, когда страница компилируется на сервере Node.js, этот метод работает в «пассивном» режиме: он просто заглядывает в ячейку памяти текущего кадра ресурса metricsResource.data, забирает вычисленные сервером данные из базы или внешнего API и мгновенно отдает их во Vue-шаблон для генерации статического HTML. Никаких слушателей, фоновых таймеров и подписок на сервере не создается, что гарантирует защиту от утечек оперативной памяти в Node.js.

Но как только этот же код загружается в браузер пользователя, метод engine.use() переключается в «активный» режим. Он берет нативный асинхронный ресурс ядра, создает функцию-слушатель (subscribe), оборачивает ее во Vue-примитив ShallowRef<T> и подмешивает в жизненный цикл компонента. Благодаря разработанному нами прокси-фоллбэку в вычисляемом свойстве metricsData, Vue бесшовно удерживает серверную картинку контента в момент секундного перезапуска эффектов Webpack.

В результате мы получили уникальный архитектурный гибрид: бизнес-логика пишется как кристально чистый, независимый от фреймворков декларативный код (сервисы, ресурсы и computed-цепочки), а UI-слой получает ультрабыструю, нативную и строго типизированную реактивность.

Заключение

Использование @pravosleva/reactive-engine в Nuxt 3-4 доказывает, что бизнес-логика (граф) полностью отделена от UI. Это позволяет писать код, тестировать его в изоляции и использовать как в React, так и в Vue.