Статьи серии
- Новые возможности Vue 3.1 — 3.2
- Новые возможности Vue 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(), вызывается в двух случаях:
- Перед следующим запуском watcher’а.
- При остановке 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’а.
Зачем это нужно?
- Массовое обновление данных
Например, нужно изменить множество реактивных свойств, но выполнять дорогостоящие вычисления после каждого изменения не хочется. Вместо множества срабатываний watcher будет молчать во время обновления.
- Временное отключение синхронизации
Например, 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 из родителя и следить за его уникальностью