Новые возможности Vue 3.4 — 3.5

This entry is part 3 of 3 in the series Обзор обновлений Vue 3

Статьи серии
  1. Новые возможности Vue 3.1 — 3.2
  2. Новые возможности Vue 3.3
  3. Новые возможности Vue 3.4 — 3.5

Завершаем обзор возможностей, которые появились во Vue. Рассматриваем версии 3.4. и 3.5

Версия 3.4

На версии 3.4 подробно останаливаться не будем. Список изменений выглядит так:

  • Новый, более быстрый парсер шаблонов;
  • Существенно ускорена система реактивности;
  • defineModel() стал стабильным;
  • Вычисляемые (computed) свойства обновляются только при реальном изменении основных значений;
  • Сокращённая запись v-bind (:id вместо :id="id").

Если совсем коротко, то здесь мы видим улучшения производительности, переход defineModel() в статус стабильной функции и одно сокращение синтаксиса.

Что касается сокращения синтасиса v-bind. Если имя атрибута совпадает с именем переменной JavaScript, к которой привязывается значение, синтаксис можно дополнительно сократить, опустив значение атрибута:

<!-- так же, как :id="id" -->
<div :id></div>

<!-- это также работает -->
<div v-bind:id></div>

Это похоже на синтаксис сокращения свойств при объявлении объектов в JavaScript.

Версия 3.5

В новой версии полностью переработана внутренняя система реактивности. Она стала быстрее и экономнее по памяти.

Но также были из изменения в функциональности, которые будут разобраны ниже.

Реактивный деструктуринг defineProps()

Одно из самых приятных изменений в Vue 3.5 — Reactive Props Destructure стал стандартным поведением. Если раньше эту возможность нужно было включать как экспериментальную, то теперь она работает «из коробки».

В чем была проблема раньше?

До Vue 3.5 многие писали так:

const props = defineProps<{
  title: string
  count: number
}>()

console.log(props.title)

Это было немного многословно, поэтому хотелось написать что-то типа:

const { title, count } = defineProps<{
  title: string
  count: number
}>()

Но здесь была проблема: реактивность терялась.

Например вот здесь:

const { count } = defineProps<{ count: number }>()

watchEffect(() => {
  console.log(count) // Выполнится лишь один раз во Vue 3.4
})

Во Vue 3.4 watchEffect() выполнится только один раз. При изменении count он уже не запустится, потому что count стал обычной переменной JavaScript.


Что изменилось в Vue 3.5?

Теперь этот же код полностью реактивен:

const { count } = defineProps<{ count: number }>()

watchEffect(() => {
  console.log(count) // Выполнится при любом изменении свойства count
})

Хотя выглядит так, будто используется обычная переменная, компилятор Vue автоматически преобразует обращения к count в props.count.

То есть фактически он генерирует примерно такой код:

const props = defineProps()

watchEffect(() => {
  console.log(props.count)
})

Разработчик этого не видит — это полностью работа компилятора.

Можно использовать значения по-умолчанию

Это, пожалуй, самое приятное следствие нового поведения.

Раньше было так:

interface Props {
  title?: string
}

const props = withDefaults(defineProps<Props>(), {
  title: 'Vue'
})

То есть приходилось прибегать к withDefaults().

Теперь так:

interface Props {
  title?: string
}

const { title = 'Vue' } = defineProps<Props>()

Используется обычный синтаксис JavaScript, без withDefaults(). Код становится значительно чище.

Можно переименовывать свойства

Теперь также работает обычный синтаксис деструктуризации:

const {
  title: pageTitle,
  count: total
} = defineProps<Props>()

Компилятор и здесь сохранит реактивность.

Важное ограничение

Но есть ограничение — деструктурированные свойства нельзя напрямую передавать в watch().

watch(count, () => {}) // Так неправильно. Ведь watch() ожидает Ref или getter.
watch(() => count, () => {}) // Так верно

То же касается передачи в composable-функции.

useCounter(count) // Неправильно
useCounter(() => count) // Правильно

Стоит ли теперь всегда использовать деструктуризацию?

Теперь это считается современным стилем кода Vue 3.5:

const {
  user,
  page,
  loading = false,
  disabled = false
} = defineProps<Props>()

Такой код:

  • короче;
  • его легче читать;
  • не требует постоянно писать props.;
  • позволяет естественно задавать значения по умолчанию;
  • полностью сохраняет реактивность.

Единственное, о чем нужно помнить, — при использовании watch() и при передаче пропсов в composable следует передавать getter, а не саму переменную. Это, пожалуй, единственный нюанс новой возможности.

onWatcherCleanup()

onWatcherCleanup() позволяет зарегистрировать функцию очистки в любом месте внутри watch() или watchEffect(), не передавая специальный аргумент onCleanup.

До появления этого API очистка выглядела так:

watch(id, async (newId, oldId, onCleanup) => {
  const controller = new AbortController()

  onCleanup(() => {
    controller.abort()
  })

  const response = await fetch(`/api/${newId}`, {
    signal: controller.signal
  })
})

Работает, но есть несколько недостатков:

  • нужно помнить о третьем параметре onCleanup;
  • если логика вынесена в отдельную функцию, приходится передавать onCleanup дальше;
  • код становится менее читаемым.

Теперь же можно импортировать специальную функцию и использовать ее прямо внутри watcher’а:

import { watch, onWatcherCleanup } from 'vue'

watch(id, async (newId) => {
  const controller = new AbortController()

  onWatcherCleanup(() => {
    controller.abort()
  })

  await fetch(`/api/${newId}`, {
    signal: controller.signal
  })
})

Функция, зарегистрированная через onWatcherCleanup(), вызывается в двух случаях:

  1. Перед следующим запуском watcher’а.
  2. При остановке watcher’а. Например, когда компонент размонтируется или будет вызван stop()

Типичный пример — отмена HTTP-запросов. Самый распространенный сценарий:

watch(search, async (query) => {
  const controller = new AbortController()

  onWatcherCleanup(() => {
    controller.abort()
  })

  const response = await fetch(`/search?q=${query}`, {
    signal: controller.signal
  })

  results.value = await response.json()
})

Если пользователь быстро введет:

v
vu
vue

то предыдущие запросы будут отменены, и останется только последний.

Еще один распространенный сценарий использования — очистка таймеров:

watch(delay, (value) => {
  const timer = setTimeout(() => {
    console.log(value)
  }, 1000)

  onWatcherCleanup(() => {
    clearTimeout(timer)
  })
})

Возможность pause() / resume() для watch

До Vue 3.5 watch() возвращал только функцию остановки:

const stop = watch(source, callback)

После вызова stop() watcher прекращал работу навсегда. Если нужно было снова начать отслеживание, приходилось создавать новый watcher.

Теперь watch() возвращает объект с методами управления:

const {
  pause,
  resume,
  stop
} = watch(source, callback)

Аналогично работает и watchEffect().

Пример использования:

const count = ref(0)

const { pause, resume } = watch(count, value => {
  console.log(value)
})

count.value++ // 1

pause()

count.value++ // ничего
count.value++ // ничего

resume()

count.value++ // 4

Обратите внимание: после возобновления не будут воспроизводиться все пропущенные изменения. Watcher просто продолжит работу с актуального состояния.

Во время паузы Vue продолжает отслеживать изменения реактивных данных, но не вызывает callback.

Когда вызывается resume(), watcher синхронизируется с текущим состоянием и затем продолжает работать как обычно.

Это означает, что если значение изменилось несколько раз, вот так:

pause()

count.value = 1
count.value = 2
count.value = 3

resume()

то callback не получит значения 1 и 2. После возобновления он увидит только актуальное состояние (3) при следующем срабатывании согласно логике watcher’а.

Зачем это нужно?

  1. Массовое обновление данных

Например, нужно изменить множество реактивных свойств, но выполнять дорогостоящие вычисления после каждого изменения не хочется. Вместо множества срабатываний watcher будет молчать во время обновления.

  1. Временное отключение синхронизации

Например, watcher автоматически сохраняет изменения на сервер. Это позволит избежать лишних запросов.

Функции pause() и resume() особенно полезны, когда нужно временно отключить реакцию на изменения, но не хочется уничтожать watcher и создавать его заново. Это упрощает код и может заметно снизить количество лишних вычислений или побочных эффектов при пакетных обновлениях данных.

Это небольшое по объему изменение API, но оно закрывает давнее ограничение Vue и делает управление жизненным циклом watcher’ов гораздо более гибким.

Числовой deep у watch

Еще одно небольшое, но полезное нововведение Vue 3.5 — параметр deep у watch() теперь может быть не только логическим значением, но и числом. Это позволяет ограничить глубину обхода объекта при глубоком отслеживании.

До Vue 3.5 было всего два варианта:

watch(state, callback)

или

watch(state, callback, {
  deep: true
})

Если указывался deep: true, Vue рекурсивно обходил весь объект целиком, независимо от его размера и глубины вложенности.

Например:

const state = ref({
  user: {
    profile: {
      settings: {
        theme: 'dark'
      }
    }
  }
})

Изменение даже такого свойства state.value.user.profile.settings.theme = 'light' вызывало watcher.

Для небольших объектов это нормально, но для крупных структур (деревья, формы, конфигурации) глубокий обход может быть довольно дорогим.

Теперь можно указать максимальную глубину:

watch(state, callback, {
  deep: 2
})

Число означает, на сколько уровней вложенности Vue будет обходить объект при отслеживании изменений.

Пусть есть объект:

const state = ref({
  user: {
    profile: {
      settings: {
        theme: 'dark'
      }
    }
  }
})

Если теперь задать deep = 2:

watch(state, callback, {
  deep: 2
})

то изменения будут отслеживаться примерно так:

  • state.value.user = ... — отслеживается
  • state.value.user.profile = ... — отслеживается
  • state.value.user.profile.settings.theme = ... — не отслеживается

Свойство theme находится глубже второго уровня.

В большинстве компонентов по-прежнему достаточно deep: true или вовсе обычного watch() без глубокого отслеживания.

Но если вы работаете с крупными вложенными объектами и знаете, что изменения важны только до определенного уровня, deep: 1, deep: 2 или deep: 3 позволяют снизить стоимость наблюдения без изменения логики приложения.

Это небольшое изменение API, но оно дает разработчику больше контроля над балансом между функциональностью и производительностью.

useTemplateRef()

useTemplateRef() — делает работу с template refs более простой и особенно полезно при создании composables.

Раньше, чтобы получить ссылку на элемент или компонент из шаблона, нужно было создать ref в JavaScript (TypeScript) коде с тем же именем, что и значение одноименного атрибута в шаблоне:

<script setup lang="ts">
import { ref, onMounted } from 'vue'

/**
 * Имя "inputElement" для переменной должно совпадать со значением атрибута ref в шаблоне
 */
const inputElement = ref<HTMLInputElement | null>(null)

onMounted(() => {
  inputElement.value?.focus()
})
</script>

<template>
  <input ref="inputElement">
</template>

Теперь можно использовать useTemplateRef() для установления связи:

<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'

/**
 * Имя "inputElement" - выбрано произвольно. Связь устанавливается через значение useTemplateRef
 */
const inputElement = useTemplateRef<HTMLInputElement>('searchInput')

onMounted(() => {
  inputElement.value?.focus()
})
</script>

<template>
  <input ref="searchInput">
</template>

Это особенно полезно, если нам нужно вынести в composable код, использующий useTemplateRef().

Подход работает и с компонентами, не только с DOM-элементами:

<script setup lang="ts">
const dialog = useTemplateRef('dialog')
</script>

<template>
  <MyDialog ref="dialog" />
</template>

В итоге dialog.value будет содержать экземпляр компонента (точнее, то, что он предоставляет через defineExpose()).

Типизация теперь стала проще. Благодаря дженерикам можно сразу указать тип:

const canvas = useTemplateRef<HTMLCanvasElement>('canvas')

После этого TypeScript корректно подсказывает все свойства:

canvas.value?.getContext('2d')

Teleport defer

<Teleport defer> — небольшое, но полезное изменение Vue 3.5. Сначала важно понять обычный Teleport.

Обычный Teleport

Допустим, у нас есть компонент:

<template>
  <div class="page">
    <MyModal />
  </div>
</template>

А внутри MyModal такой код:

<Teleport to="#modals">
  <div class="modal">
    ...
  </div>
</Teleport>

Vue логически оставляет MyModal на своём месте в компонентном дереве, но его HTML переносит в отдельный блок (элемент с id="modals"):

<div id="modals">
  <div class="modal">...</div>
</div>

Это удобно для модалок, dropdown, tooltip и т.п., чтобы они не зависели от overflow, z-index, transform родительских элементов.

И тут была проблема. #modals должен существовать в момент монтирования Teleport.

Рассмотрим такой код:

<Teleport to="#modals">
  <MyModal />
</Teleport>

<!-- где-то дальше -->
<div id="modals"></div>

Раньше это могло вызвать:

Failed to locate Teleport target with selector «#modals»

Потому что Vue дошёл до Teleport, попытался найти #modals, а его в DOM ещё нет.

В новой версии Vue c defer теперь можно так:

<Teleport defer to="#modals">
  <Modal />
</Teleport>

<div id="modals"></div>

Vue подождёт монтирования остальных элементов и затем найдёт #modals. Но target должен появиться в том же mount/update tick.

То есть defer — небольшое удобство для случаев, когда Teleport и его контейнер создаются одним Vue-приложением, но target находится позже в дереве.

Улучшения для SSR

В версии 3.5 есть также ряд улучшений для Server Side Rendering (SSR).

Если совсем кратко:

  • Lazy Hydration — Когда HTML уже пришёл от сервера, Vue не делает его интерактивным сразу. Например, комментарии или сложный виджет ниже первого экрана можно гидрировать только при прокрутке/клике.
  • data-allow-mismatch — Когда сервер и клиент намеренно показывают немного разный HTML, например локальное время, можно подавить предупреждение о hydration mismatch. Это не исправляет расхождение, а говорит Vue, что оно ожидаемое.
  • useId() — фактически не только для SSR, но особенно полезен там: генерирует уникальный ID для label/input, aria-* и т. п., причём ID гарантированно одинаковый на сервере и клиенте, поэтому не возникает hydration mismatch.

Немного подробнее про useId()

В первую очередь он нужен, когда компоненту надо связать несколько DOM-элементов через id / for / aria-*, не придумывая ID вручную. Vue гарантирует уникальность ID между экземплярами компонентов; стабильность между SSR и клиентом — дополнительное преимущество, но в SPA это тоже совершенно нормально использовать.

<script setup lang="ts">
import { useId } from 'vue'

const id = useId()
</script>

<template>
  <label :for="id">Email</label>
  <input :id="id" type="email">
</template>

Если на странице будет 10 таких компонентов, каждый получит свой ID:

v-0
v-1
v-2
...

То есть не возникнет ситуации, когда два input имеют одинаковый id.

Особенно полезно в переиспользуемых компонентах. Например, у тебя есть универсальный FormField:

<script setup lang="ts">
import { useId } from 'vue'

defineProps<{
  label: string
}>()

const id = useId()
</script>

<template>
  <label :for="id">{{ label }}</label>
  <input :id="id">
</template>

Теперь можно сколько угодно раз использовать:

<FormField label="Имя" />
<FormField label="Email" />
<FormField label="Телефон" />

и не нужно передавать id из родителя и следить за его уникальностью

0 0 голоса
Рейтинг статьи
guest
0 комментариев
Старые
Новые Популярные