Изображение, созданное DALL-E
Изображение, созданное DALL-E

Vue 3 не только добавил новый синтаксис (Composition API), но и серьёзно обновил движок реактивности. Теперь под капотом используются прокси-объекты (ES6 Proxy), а при отслеживании и изменении данных происходят события Track и Trigger. Эти детали могут быть неочевидны в простых демо-примерах, но становятся крайне важными, когда вы работаете с большими структурами данных или строите действительно масштабные приложения.

С обновлениями до версии 3.5 улучшения стали ещё более заметными: Vue научился лучше обрабатывать глубоко вложенные структуры данных, оптимизировал производительность реактивности и усилил поддержку асинхронных процессов.

В этой статье разберём:

  1. Реактивность в глубину: как Vue 3 следит за изменениями, что такое Track/Trigger, как оптимизировать работу с вложенными объектами, и какие инструменты для отладки могут помочь.

  2. Сложные сценарии с provide/inject и customRef: когда эти механизмы полезны, как управлять глубокой иерархией компонентов, и как customRef решает задачи с debounce.

  3. Suspense и асинхронные данные: что такое <Suspense>, как работает async setup(), какие преимущества даёт при работе с динамическими компонентами и загрузкой больших данных, а также как обрабатывать ошибки с помощью Error Boundaries.

Поехали!

Реактивность в глубину: Proxy, Track, Trigger

Как Vue 3 следит за изменениями

В Vue 2 механизм реактивности строился на Object.defineProperty, который перехватывал геттеры/сеттеры каждого свойства. Этот подход имел ограничения, например, не отслеживал динамически добавленные свойства и был менее гибким при работе со вложенными структурами.

В Vue 3 вместо этого используется Proxy. Когда вы создаёте реактивные данные (через reactive() или ref()), Vue оборачивает исходный объект в прокси, чтобы ловить все операции чтения/записи:

  • track срабатывает, когда мы читаем свойство (геттер).

  • trigger срабатывает, когда мы записываем (сеттер).

С обновлениями версии 3.5 Vue улучшил работу track и trigger для сложных структур, оптимизировав их производительность. Теперь изменения в глубоко вложенных объектах инициализируются только при необходимости, что снижает накладные расходы.

Простой пример с effect() (в реальном приложении Vue сама под капотом использует рендер-эффекты):

import { reactive, effect } from 'vue'

const state = reactive({ count: 0 })

effect(() => {
  console.log(`Count is: ${state.count}`)
})

// При изменении state.count (trigger) автоматически вызывается effect
state.count++
// console.log выведет: "Count is: 1"

В реальном приложении вместо effect() Vue под капотом использует собственные эффекты рендера, чтобы обновлять шаблон или virtual DOM.

Track/Trigger на практике

Под капотом всё выглядит так (упрощённо):

  • track(target, type, key) - Подпишись на изменения свойства key у объекта target, если при рендере мы читали это свойство.

  • trigger(target, type, key, newValue, oldValue) - Уведоми всех подписчиков, когда свойство key у объекта target меняется.

Если у вас есть глубокие вложенные структуры (например, state.user.profile.address), Vue 3 создаст прокси для каждого уровня, чтобы при доступе к address.city происходил track, а при изменении city - trigger.

Оптимизация при работе с большими объектами

Для работы с большими структурами данных в Vue 3.5 появились дополнительные оптимизации, которые стоит учитывать:

  • Ленивая инициализация: Прокси для вложенных объектов создаются только при обращении к этим объектам. Это уменьшает нагрузку на память и улучшает производительность.

  • Использовать shallowReactive и shallowRef.Эти функции поверхностно отслеживают только верхний уровень объекта и не спускаются глубже. Если вам нужно реактивно заменить весь объект целиком, но не важно, что происходит внутри, это может быть отличным решением.

  • Делить объект на логические модули. Вместо одного огромного store разбивайте данные на более мелкие подхранилища. В экосистеме Vue 3 для этого отлично подойдёт Pinia или несколько отдельных composable-функций.

Инструменты для отладки: Vue Devtools 6+

Когда вы работаете со сложной реактивностью, полезно следить, как Vue отслеживает изменения. В версии Vue Devtools 6 (и выше) есть расширенный вклад Timeline, где можно увидеть события component render, update, и другие - это упрощает понимание, какой именно фрагмент кода или объект триггерит повторный рендер. Также существуют экспериментальные плагины, показывающие track/trigger в реальном времени, но они могут меняться от релиза к релизу.

Сложные сценарии с provide/inject и customRef

provide/inject для больших деревьев

provide и inject позволяют протягивать данные через несколько уровней компонентов без явной передачи пропсов. Это особенно актуально, когда у вас глобальные данные или сервисы (например, тема приложения, текущий пользователь, WebSocket-соединение).

Упрощённый пример:

<!-- App.vue -->
<template>
  <div>
    <ThemeProvider>
      <ChildComponent />
    </ThemeProvider>
  </div>
</template>

<script setup>
// Никакой логики здесь - просто контейнер
</script>
<!-- ThemeProvider.vue -->
<template>
  <div>
    <slot />
  </div>
</template>

<script setup>
import { provide, reactive } from 'vue'

const themeState = reactive({ 
  color: 'blue',
  fontSize: '16px'
})

// Предоставляем (provide) themeState всем дочерним компонента
provide('themeState', themeState)
</script>
<!-- ChildComponent.vue -->
<template>
  <div :style="{ color: theme.color, fontSize: theme.fontSize }">
    Я потомок, но мне прилетела тема из ThemeProvider!
  </div>
</template>

<script setup>
import { inject } from 'vue'

const theme = inject('themeState')
</script>

Здесь нет необходимости пробрасывать theme через каждый уровень компонентов в виде пропсов. Однако, когда в коде много таких глобальных переменных (цвета, настройки, текущий пользователь и т.д.), будьте аккуратны с выбором ключей (provide('themeState', ...) и т.п.). Если вы используете TypeScript, применяйте символы (Symbol) вместо строк, чтобы избежать коллизий. Suspense и асинхронные данные

Проблемы производительности

Если у вас сложная логика, слушающая изменения в themeState (или другом глобальном объекте), и это приводит к каскадному ререндеру многих компонентов, подумайте о разделении:

  • Использовать несколько provide для разных сущностей (цвет, шрифт, лейаут).

  • Применять shallowReactive, если нужно менять объект целиком, а не отдельные поля.

  • Организовывать обновление только там, где оно действительно нужно, а в остальных местах (детях) использовать мемоизацию (в Composition API это может быть computed() или закешированные значения).

customRef для тонкой настройки рендера

Иногда нужно полностью контролировать момент, когда произойдёт перерисовка. customRef даёт возможность вручную описать логику при чтении (get) и записи (set). Типичный пример - debounce.

import { customRef } from 'vue'

function useDebouncedRef(value, delay = 500) {
  let timeout
  return customRef((track, trigger) => {
    return {
      get() {
        track() // подпишемся на чтение
        return value
      },
      set(newValue) {
        clearTimeout(timeout)
        timeout = setTimeout(() => {
          value = newValue
          trigger() // уведомим, что значение изменилось
        }, delay)
      }
    }
  })
}

export default useDebouncedRef

Как использовать:

<template>
  <input v-model="search" placeholder="Поиск..." />
  <p>Вы ввели: {{ search }}</p>
</template>

<script setup>
import { ref } from 'vue'
import useDebouncedRef from './useDebouncedRef.js'

const search = useDebouncedRef('', 500)
</script>

Теперь при вводе в <input>, ререндер срабатывает не на каждый символ, а только через 500 мс тишины. Это заметно улучшает производительность, если, скажем, мы вызываем тяжелый запрос по мере ввода.

Suspense и асинхронные данные

Что такое ?

<Suspense> - это специальный компонент в Vue 3, который позволяет показывать заглушку (fallback), пока внутри него происходит асинхронная загрузка данных. Идея пришла из React (React Suspense), но Vue адаптировал её под свою модель.

Пример:

<template>
  <Suspense>
    <template #default>
      <AsyncDataComponent />
    </template>
    <template #fallback>
      Загрузка...
    </template>
  </Suspense>
</template>

<script setup>
import AsyncDataComponent from './AsyncDataComponent.vue'
</script>

Пока AsyncDataComponent не загрузит данные (например, делает запрос на сервер в setup()), <Suspense> будет отображать блок <template #fallback>. Как только данные будут получены, рендерится нормальный контент.

async setup() в компонентах

С приходом Composition API мы можем сделать setup() асинхронным. Упрощённый пример:

<template>
  <div>
    <h1>Пользователь: {{ user.name }}</h1>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// Эмулируем запрос
async function fetchUser(id) {
  const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
  return await res.json()
}

const user = ref(null)

const props = defineProps({
  userId: {
    type: Number,
    required: true
  }
})

onBeforeMount(async () => {
  user.value = await fetchUser(props.userId)
})
</script>

Но чтобы <Suspense> понимал, что внутри происходит асинхронщина, Vue проверяет, есть ли в setup() Promise (или onBeforeMount(async () => ...) возвращает Promise). Если да, <Suspense> будет ждать, пока этот Promise не завершится.

Prefetching и graceful fallback

  1. Prefetching. Если вы знаете, что компонент с асинхронной логикой скоро понадобится (пользователь, например, наводит курсор на ссылку), можно заранее инициировать запрос. К моменту рендера <Suspense> будет ждать намного меньше, а пользователь увидит готовые данные практически моментально.

  2. Graceful fallback и Error Boundaries. Если при загрузке данных произошла ошибка, можно показать не только "Загрузка…", но и "Ошибка загрузки данных" или другой альтернативный контент. Для этого во Vue 3 можно использовать Error Boundaries, чтобы перехватывать ошибки в асинхронных компонентах и показывать соответствующий интерфейс. Примерно это может выглядеть так:

    <template>
      <ErrorBoundary>
        <Suspense>
          <template #default>
            <AsyncDataComponent />
          </template>
          <template #fallback>
            Загрузка...
          </template>
        </Suspense>
      </ErrorBoundary>
    </template>
    

    Где ErrorBoundary - компонент, который внутри использует Vue-хуки errorCaptured, onErrorCaptured или специальную логику для перехвата и отображения ошибок.

Заключение

Итоги

Мы рассмотрели:

  • Реактивность во Vue 3: как прокси-объекты помогают трекать изменения, что такое Track/Trigger и почему это эффективно.

  • provide/inject и customRef: удобный способ передавать данные между компонентами без пропсов, и тонкая настройка рендера с помощью customRef.

  • <Suspense>: как он упрощает работу с асинхронными компонентами и даёт пользователям дружелюбный loading вместо мгновенного белого экрана.

Когда это применять?

  • Глубокую реактивность - в большинстве проектов по умолчанию. Но следите за производительностью, избегайте реактивного монстра.

  • provide/inject - если у вас сложная архитектура, где пропсы становятся громоздкими. Это особенно актуально для глобальных зависимостей (тема, текущий пользователь, глобальные сервисы).

  • customRef - в точечных случаях, когда нужно особое поведение при записи значения (debounce, throttle, сериализация и т.д.).

  • <Suspense> - при работе с асинхронным кодом, чтобы дать пользователю плавный опыт загрузки, особенно если данные тяжёлые.

Надеюсь, этот материал поможет глубже понять механику Vue 3. Экспериментируйте с реактивностью, используйте провайдеры и асинхронные компоненты для оптимизации рабочих процессов - и ваш код станет чище, а приложения - быстрее!

Если вы хотите глубже погрузиться в тему оптимизации JavaScript и TypeScript, советую также ознакомиться с моими другими статьями на Habr, где я делюсь опытом написания продуктивного кода и улучшения пользовательского опыта :)

Шина между Веб-воркерами и основным потоком. Ускоряем работу JavaScript
В этой статье я рассказываю о созданном npm-пакете web-worker-bus, который упрощает взаимодействие веб-воркеров с основным потоком. Пакет помогает повысить производительность и разгрузить UI-поток, особенно актуально для крупных и ресурсоёмких приложений — будь то на Vue, React или любом другом фреймворке.

Мощь декораторов TypeScript на живых примерах
Здесь я показываю, как декораторы в TypeScript упрощают код, устраняя дублирование и улучшая читаемость. Рассматривается несколько реальных примеров, где сквозная функциональность (логирование, кеширование, валидация и т.д.) оформлена при помощи декораторов без изменения основной бизнес-логики приложения.

Комментарии (0)