入门
Hello World 与注释
Vue 3 应用通过 createApp() 和 mount() 挂载到 DOM 元素来启动。插值使用双花括号 {{ }} 来渲染响应式数据。HTML 注释会出现在渲染后的 DOM 中;要避免注释泄漏,可在 <script> 块中使用 /* */ 注释。
<!-- index.html -->
<div id="app">
<p>{{ message }}</p>
<!-- This is an HTML comment, visible in DOM -->
</div>
<script type="module">
import { createApp } from 'vue'
createApp({
data() {
return { message: 'Hello Vue 3!' }
}
}).mount('#app')
</script>SFC 结构(单文件组件)
SFC 将模板、脚本和样式打包在一个 .vue 文件中。<script setup> 是组合式 API 的推荐语法——简洁且零样板。scoped 样式只应用于当前组件,防止泄漏。每个 SFC 编译为一个 JavaScript 模块。
<template>
<button @click="count++">Count: {{ count }}</button>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<style scoped>
button { color: #42B883; }
</style>应用实例与挂载
createApp 返回一个自包含的应用实例——没有全局 Vue 状态,因此您可以在一个页面上挂载多个独立的应用。在调用 mount() 之前注册插件、提供全局值和设置配置。挂载后,进一步的配置更改将不会生效。
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// global config / plugins before mounting
app.config.errorHandler = (err) => console.error(err)
app.provide('apiKey', '12345')
app.mount('#app')项目脚手架(Vite)
npm create vue@latest 是官方脚手架工具,提供可选的 TypeScript、Router、Pinia 和 Vitest 设置。它使用 Vite 实现即时开发服务器启动和 HMR。如果您想要一个没有 Vue 主张的最小模板,create-vite 是更底层的工具。
# scaffold a new Vue project with Vite
npm create vue@latest my-app
cd my-app
npm install
npm run dev # start dev server (HMR)
npm run build # production build to /dist
# or with create-vite directly
npm create vite@latest my-app -- --template vue-tsdefineOptions 与 defineProps
defineProps、defineEmits 和 defineOptions 是编译时宏——它们不需要导入。defineProps 通过运行时验证声明组件的 prop 契约。defineOptions 让您设置 name、inheritAttrs 和其他无法直接在 <script setup> 中表达的选项。
<script setup>
// declare props with types and defaults
const props = defineProps({
title: String,
count: { type: Number, default: 0 }
})
// declare emits
const emit = defineEmits(['update', 'delete'])
// additional non-reactive options
defineOptions({ name: 'MyButton', inheritAttrs: false })
</script>模板语法
文本插值
{{ }} 安全地渲染文本(HTML 转义)。v-once 在首次渲染后冻结绑定,适用于静态内容。v-html 注入原始 HTML——只用于可信内容以避免 XSS。在 {{ }} 内可以使用任何单个 JavaScript 表达式,但不能是赋值或 var 声明等语句。
<template>
<p>{{ message }}</p>
<!-- renders the value of message -->
<!-- one-time binding (no updates after render) -->
<span v-once>{{ initialCount }}</span>
<!-- raw HTML (sanitize to avoid XSS!) -->
<div v-html="rawHtmlContent"></div>
<!-- expressions are supported -->
<p>{{ count * 2 }}</p>
<p>{{ isReady ? 'Yes' : 'No' }}</p>
<p>{{ message.split('').reverse().join('') }}</p>
</template>属性绑定
v-bind(简写 :)响应式地绑定属性。对于 class,可以传递对象(真值键生效)、数组或字符串。style 接受带有 camelCase 或 kebab-case 键的对象。动态参数语法 :[expr] 让属性名本身也是响应式的——对于事件名或本地化属性很有用。
<template>
<!-- bind an attribute -->
<img v-bind:src="imageUrl" />
<img :src="imageUrl" /> <!-- shorthand -->
<!-- bind class -->
<div :class="{ active: isActive, error: hasError }"></div>
<div :class="['btn', isActive && 'active']"></div>
<!-- bind style -->
<div :style="{ color: textColor, fontSize: size + 'px' }"></div>
<!-- dynamic attribute name -->
<button :[eventName]="handler">Click</button>
</template>布尔与多值属性
对于 disabled 或 checked 等布尔属性,Vue 仅在值为真时才渲染该属性。class 和 style 是特殊的:Vue 合并静态值和绑定值而不是替换它们。对于表单输入,优先使用 v-model 而不是手动绑定 :value 和 @input。
<template>
<!-- boolean attribute: presence depends on value -->
<input :disabled="isDisabled" />
<button :disabled="!canSubmit">Submit</button>
<!-- class merging: bound class merges with static class -->
<div class="card" :class="{ highlighted: isFeatured }"></div>
<!-- style merging: bound style merges with static style -->
<div style="color: red" :style="{ fontSize: '14px' }"></div>
<!-- form attributes: value is bound with v-model instead -->
<input v-model="text" />
</template>修饰符与表达式
修饰符是以点表示的后缀,指示特殊处理。事件修饰符(.prevent、.stop、.once、.self)用对应的 DOM 方法包装处理程序。键修饰符(.enter、.esc、.ctrl)按键过滤事件。v-model 修饰符(.trim、.number、.lazy)在赋值前转换输入值。
<template>
<!-- .prevent calls event.preventDefault() -->
<form @submit.prevent="onSubmit">...</form>
<!-- .stop calls event.stopPropagation() -->
<button @click.stop="doStuff">Click</button>
<!-- .once runs handler only one time -->
<button @click.once="init">Init</button>
<!-- .trim and .lazy modifiers on v-model -->
<input v-model.trim="email" />
<input v-model.lazy="text" />
<!-- chain multiple modifiers -->
<input @keyup.enter.ctrl="onCombo" />
</template>模板 ref
模板 ref 提供对 DOM 元素的直接访问。声明一个与 ref 属性同名的 ref()——Vue 在挂载后分配 DOM 节点。在 onMounted(或之后)访问;挂载前 ref 为 null。对于 v-for,ref 变为元素数组。当响应式数据流足够时,避免使用 ref。
<script setup>
import { ref, onMounted } from 'vue'
// declare a ref matching the template ref attribute
const inputEl = ref(null)
onMounted(() => {
// access the DOM node after mount
inputEl.value.focus()
})
</script>
<template>
<input ref="inputEl" />
</template>指令
v-if / v-else-if / v-else
v-if 条件渲染元素——它们被添加/从 DOM 中移除。v-else-if 和 v-else 与前面的 v-if 链接。当您不想要额外的包装元素时,将多个元素包装在 <template v-if> 中。v-if 的切换成本较高(DOM 创建),但当条件为 false 时初始成本比 v-show 低。
<template>
<div v-if="type === 'A'">Type A</div>
<div v-else-if="type === 'B'">Type B</div>
<div v-else-if="type === 'C'">Type C</div>
<div v-else>Unknown type</div>
<!-- use <template> for invisible wrapper -->
<template v-if="showHeader">
<h1>Title</h1>
<p>Subtitle</p>
</template>
</template>v-show 与 v-if
当条件很少改变时使用 v-if——元素被销毁和重建,隐藏时节省渲染成本。对于频繁切换的元素(选项卡、下拉菜单)使用 v-show——元素保留在 DOM 中,只切换 display:none,因此切换很便宜。v-show 不能与 <template> 或 v-else 一起使用。
<template>
<!-- v-if removes from DOM when false -->
<p v-if="isVisible">I'm created/destroyed</p>
<!-- v-show just toggles display:none -->
<p v-show="isVisible">I'm always in DOM, just hidden</p>
</template>
<script setup>
import { ref } from 'vue'
const isVisible = ref(true)
</script>v-for 与 :key
v-for 迭代数组、对象或数字范围。:key 属性是按身份跟踪所必需的——它让 Vue 在重新排序时高效复用 DOM 节点。使用稳定的唯一 id 作为 key;永远不要使用数组索引(当项目移动时会出问题)。v-for 在同一元素上的优先级高于 v-if——避免在同一元素上同时使用两者。
<template>
<!-- iterate over array -->
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
<!-- iterate with index -->
<li v-for="(item, index) in items" :key="item.id">
{{ index }}: {{ item.name }}
</li>
<!-- iterate over object -->
<li v-for="(value, key) in user" :key="key">
{{ key }}: {{ value }}
</li>
<!-- range (1 to n) -->
<span v-for="n in 10" :key="n">{{ n }}</span>
</template>v-model 绑定
v-model 是表单输入的双向绑定:它同步输入值与响应式状态。单个复选框绑定到布尔值;共享相同 v-model 的多个复选框绑定到选中值的数组。使用修饰符:.lazy(在 change 而不是 input 时同步)、.number(转换为数字)、.trim(去除空白)。
<template>
<!-- text input -->
<input v-model="text" />
<!-- multi-line textarea -->
<textarea v-model="message"></textarea>
<!-- checkbox (single = boolean, multiple = array) -->
<input type="checkbox" v-model="isChecked" />
<input type="checkbox" value="apple" v-model="fruits" />
<input type="checkbox" value="banana" v-model="fruits" />
<!-- radio -->
<input type="radio" value="A" v-model="picked" />
<!-- select -->
<select v-model="selected">
<option value="a">A</option>
<option value="b">B</option>
</select>
</template>自定义指令
自定义指令让您直接操作 DOM 元素。在 <script setup> 中,任何名为 vSomething(camelCase)的变量都自动在模板中作为 v-something 可用。指令对象有生命周期钩子:mounted、updated、unmounted 等。当需要响应式时优先使用组件而不是指令——指令用于低级 DOM 工作如焦点、工具提示或拖拽。
<script setup>
// local directive: function form (mounted + updated)
const vFocus = {
mounted: (el) => el.focus()
}
// global registration
// app.directive('focus', { mounted: el => el.focus() })
</script>
<template>
<input v-focus />
</template>计算属性
基础计算属性
computed 创建一个缓存的 getter,仅在其依赖项改变时重新求值。与方法调用不同,重复访问返回缓存值直到依赖项更新。在 <script> 中通过 .value 访问;在模板中 Vue 自动解包。当值依赖于响应式状态时优先使用 computed 而非方法——它更快且是声明式的。
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// computed caches based on dependencies
const fullName = computed(() => {
return firstName.value + ' ' + lastName.value
})
// access via .value in script
console.log(fullName.value) // 'John Doe'
</script>
<template>
<!-- use without .value in template -->
<p>{{ fullName }}</p>
</template>可写计算属性
默认情况下 computed 是只读的,但您可以提供 getter 和 setter。当您赋值给 .value 时调用 setter。这对于派生状态的双向绑定很有用,例如将全名输入同步回姓/名字段。注意避免无限循环——setter 不应以重新触发自身的方式直接设置 computed 的自身依赖。
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
const fullName = computed({
get() {
return firstName.value + ' ' + lastName.value
},
set(newValue) {
[firstName.value, lastName.value] = newValue.split(' ')
}
})
// assignment triggers the setter
fullName.value = 'Jane Smith'
</script>计算属性 vs 方法
计算属性根据依赖项缓存其结果——它们仅在 items 改变时重新运行。方法在每次渲染时重新运行,无论改变了什么。对于从响应式状态派生的值使用计算属性;对于接受参数或具有副作用的操作使用方法。计算属性还使意图更清晰:'此值是派生的' vs '执行此操作'。
<script setup>
import { ref, computed } from 'vue'
const items = ref([1, 2, 3, 4, 5])
// computed: cached, re-runs only if items changes
const doubled = computed(() =>
items.value.map(x => x * 2)
)
// method: re-runs on every render
function getDoubled() {
return items.value.map(x => x * 2)
}
</script>
<template>
<!-- cached, no recompute on unrelated re-renders -->
<p>{{ doubled }}</p>
<!-- re-invoked on every render -->
<p>{{ getDoubled() }}</p>
</template>计算属性的 getter 陷阱
计算属性 getter 必须是纯函数:无副作用(无变异、无异步、无 fetch)。不要调用 Date.now() 或 Math.random()——它们不是响应式的,因此计算属性不会更新。如果需要副作用,请使用侦听器。如果需要异步,computed 不会 await——使用 watch + ref,或使用 async setup 与 Suspense。
<script setup>
import { ref, computed } from 'vue'
const date = ref(new Date())
// BAD: non-reactive Date.now() — won't update
const badTime = computed(() => Date.now())
// BAD: side effect inside computed
const count = ref(0)
const bad = computed(() => {
count.value++ // never mutate state in computed!
return count.value
})
// GOOD: pure function of reactive state
const formatted = computed(() =>
date.value.toLocaleDateString()
)
</script>Options API 中的计算属性
在 Options API 中,在 computed 选项中声明计算属性。它们通过 this.total 访问(无 .value、无括号)——Vue 自动解包。可写计算属性使用 get/set 函数。计算属性与 data 和 methods 一起暴露在组件实例上,都可通过 this 访问。
<script>
export default {
data() {
return { price: 100, quantity: 2 }
},
computed: {
total() {
return this.price * this.quantity
},
discounted: {
get() { return this.total * 0.9 },
set(v) { this.price = v / 0.9 / this.quantity }
}
},
methods: {
checkout() { console.log(this.total) }
}
}
</script>侦听器
基础 watch
watch 在侦听的源改变时运行回调。源可以是 ref、响应式对象属性(通过 getter)或源数组。回调接收 (newValue, oldValue)。使用 getter () => x.y 侦听嵌套响应式属性。侦听器用于副作用(fetch、日志、持久化)——不用于派生状态(使用 computed)。
<script setup>
import { ref, watch } from 'vue'
const question = ref('')
// watch a ref
watch(question, (newValue, oldValue) => {
console.log('changed from', oldValue, 'to', newValue)
})
// watch a getter (computed source)
watch(
() => question.value.length,
(newLen) => {
if (newLen > 100) console.warn('Too long!')
}
)
</script>深度侦听与立即执行
deep: true 使 watch 在对象任何嵌套属性改变时触发——有用但对大型结构代价高昂。immediate: true 在 setup 时立即触发回调(首次调用 oldValue 为 undefined)。避免对大型对象深度侦听——优先侦听特定 getter () => obj.specificField 以获得更好的性能。
<script setup>
import { reactive, watch } from 'vue'
const user = reactive({
name: 'Alice',
address: { city: 'NYC', zip: '10001' }
})
// deep: watch nested property changes
watch(
() => user.address,
(newAddr) => console.log('address changed', newAddr),
{ deep: true }
)
// immediate: run callback right away
watch(
user,
(val) => saveToServer(val),
{ deep: true, immediate: true }
)
</script>watchEffect(自动追踪)
watchEffect 立即运行回调并自动追踪其中访问的任何响应式依赖。当任何追踪的依赖改变时,回调重新运行。与 watch 不同,您不指定源——它是推断的。当您不需要 oldValue 并想要自动依赖追踪时使用 watchEffect。对于设置读取多个响应式源的订阅很有用。
<script setup>
import { ref, watchEffect } from 'vue'
const a = ref(1)
const b = ref(2)
// runs immediately and tracks dependencies automatically
watchEffect(() => {
console.log('a + b =', a.value + b.value)
})
// logs: a + b = 3
a.value = 10 // logs: a + b = 12
// stop the watcher
const stop = watchEffect(() => { /* ... */ })
stop() // cleanup
</script>watch vs watchEffect
当您需要:oldValue、延迟执行(跳过初始运行)或显式侦听特定源时选择 watch。当您想要:立即执行、不需要 oldValue 或有涉及多个响应式值的副作用并想要自动追踪时选择 watchEffect。watch 与数组源在回调中给出新/旧值数组。
<script setup>
import { ref, watch, watchEffect } from 'vue'
const count = ref(0)
// watch: lazy, explicit source, has oldValue
watch(count, (newVal, oldVal) => {
console.log('count changed:', oldVal, '->', newVal)
})
// watchEffect: eager, auto-tracked, no oldValue
watchEffect(() => {
console.log('count is', count.value)
})
// logs immediately: count is 0
// watch multiple sources
const a = ref(1), b = ref(2)
watch([a, b], ([newA, newB], [oldA, oldB]) => {
console.log('a or b changed')
})
</script>侦听器清理与 flush
onCleanup 回调(第 3 个参数)注册一个在下一次侦听器调用前运行的清理函数——非常适合取消进行中的请求、清除计时器或取消订阅。flush: 'post' 在 Vue 更新 DOM 后运行侦听器(当您需要读取更新后的 DOM 时有用)。'sync' 在依赖改变时同步运行——很少需要且可能导致性能问题。
<script setup>
import { ref, watch } from 'vue'
const id = ref(1)
// cleanup function: runs before next callback
watch(id, (newId, oldId, onCleanup) => {
const controller = new AbortController()
fetch(`/api/user/${newId}`, { signal: controller.signal })
.then(r => r.json())
// cancel previous request when id changes again
onCleanup(() => controller.abort())
}, { flush: 'post' })
// flush: 'pre' (default) | 'post' (after DOM update) | 'sync'
</script>条件渲染
v-if vs v-show
v-if 真正地从 DOM 添加/移除元素——初始成本较高但隐藏时零成本。v-show 总是渲染元素并切换 display:none——切换更便宜但即使隐藏元素也存在。对于很少改变的条件(或隐藏元素根本不应存在时)使用 v-if。对于频繁切换的 UI 如选项卡、下拉菜单、模态框使用 v-show。
<template>
<!-- v-if: element removed from DOM when false -->
<div v-if="isLoggedIn">Welcome back</div>
<!-- v-show: element stays, display toggles -->
<div v-show="isLoggedIn">Welcome back</div>
</template>
<!-- v-show compiles to: -->
<!-- <div style="display: none;">Welcome back</div> when false -->
<!-- v-if does not render the element at all when false -->v-if 与 v-else 链
v-else-if 和 v-else 必须紧跟在 v-if(或另一个 v-else-if)元素之后——它们之间不能有其他元素。链从上到下求值并在第一个真值条件处停止。最后的 v-else 捕获所有剩余情况。此模式非常适合基于状态的 UI(加载、错误、空、成功)。
<template>
<div v-if="status === 'loading'">Loading...</div>
<div v-else-if="status === 'error'">Error occurred</div>
<div v-else-if="status === 'empty'">No items</div>
<div v-else>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
</div>
</template><template> 包装 v-if
<template> 是一个不渲染到 DOM 的逻辑包装器——非常适合在不添加额外 HTML 的情况下将多个元素分组到一个 v-if 或 v-for 下。这保持您的标记语义化(无多余的 div)。对于 template 上的 v-for,您仍需要在 template 元素本身上使用 :key。
<template>
<!-- group elements under one condition, no wrapper div -->
<template v-if="showProfile">
<h2>{{ user.name }}</h2>
<p>{{ user.bio }}</p>
<button @click="edit">Edit</button>
</template>
<!-- combine v-if with v-for on template -->
<template v-for="item in items" :key="item.id">
<dt>{{ item.term }}</dt>
<dd>{{ item.def }}</dd>
</template>
</template>v-if vs v-for 优先级
永远不要在同一元素上使用 v-if 和 v-for。在 Vue 3 中,v-if 优先级更高,因此它在 v-for 之前运行——循环变量尚不可用,导致错误。相反,使用计算属性过滤数组(首选)或内联 .filter()。如果必须,使用包装的 <template v-for> 加内部 v-if。
<template>
<!-- AVOID: v-if and v-for on same element -->
<!-- In Vue 3, v-if has higher priority than v-for -->
<!-- so 'item' is not defined when v-if evaluates -->
<li v-for="item in items" v-if="item.active" :key="item.id">
{{ item.name }}
</li>
<!-- GOOD: filter first, then iterate -->
<li
v-for="item in items.filter(i => i.active)"
:key="item.id"
>
{{ item.name }}
</li>
<!-- BEST: use computed for the filtered list -->
</template>v-if 与过渡
将 v-if(或 v-show)元素包装在 <Transition> 中以动画化进入/离开。Vue 在正确的时机添加 6 个类:v-enter-from、v-enter-active、v-enter-to(以及 v-leave-*)。在这些类上定义 CSS 过渡/动画。name='fade' 将 'v-' 前缀替换为 'fade-'。Transition 只支持单个子元素——列表使用 TransitionGroup。
<template>
<Transition name="fade">
<p v-if="show">Hello</p>
</Transition>
</template>
<style>
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>列表渲染
v-for 与数组
v-for 迭代数组,暴露每个项目和(可选)索引。始终将 :key 绑定到稳定的唯一值如 item.id——这让 Vue 在重新排序时高效匹配旧节点和新节点,保留组件状态和 DOM。使用索引作为 key 在项目插入/移除/重新排序时会出问题,导致微妙的 bug。
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Cherry' }
])
</script>
<template>
<!-- always provide :key with stable unique id -->
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
<!-- with index (avoid as :key) -->
<li v-for="(item, index) in items" :key="item.id">
{{ index + 1 }}. {{ item.name }}
</li>
</template>v-for 与对象和范围
v-for 遍历对象默认迭代值;第二个参数是 key,第三个是索引。对象迭代顺序遵循 Object.keys()(字符串键的插入顺序)。v-for 与数字 n 从 1 迭代到 n(包含,不是 0 到 n-1)。对于过滤或排序列表,优先使用计算属性而不是内联逻辑以提高可读性和缓存。
<template>
<!-- iterate object: (value, key, index) -->
<div v-for="(value, key, index) in user" :key="key">
{{ index }}. {{ key }}: {{ value }}
</div>
<!-- iterate a number range (1 to n inclusive) -->
<span v-for="n in 10" :key="n">{{ n }} </span>
<!-- 1 2 3 4 5 6 7 8 9 10 -->
<!-- use v-for of computed for derived lists -->
<li v-for="todo in doneTodos" :key="todo.id">
{{ todo.text }}
</li>
</template>列表变更检测
Vue 3 使用基于 Proxy 的响应式,因此用 push、pop、splice、sort、reverse 变异数组都会触发更新——没有特殊注意事项(与 Vue 2 不同)。替换整个数组(items.value = newArray)也有效,因为 ref 的 value setter 触发响应式。filter/map 产生新数组——将它们赋值回去以更新。
<script setup>
import { ref } from 'vue'
const items = ref([1, 2, 3])
// MUTATION methods (Vue 3 detects these via Proxy)
items.value.push(4) // add
items.value.pop() // remove last
items.value.splice(1, 1) // remove at index
items.value.sort() // sort
// REPLACEMENT (always reactive)
items.value = items.value.filter(n => n > 1)
items.value = [...items.value, 5]
</script>列表的 TransitionGroup
TransitionGroup 为 v-for 列表动画化三种行为:enter(新项目)、leave(移除项目)和 move(现有项目改变位置)。与 Transition 不同,它渲染一个真实元素(通过 tag 设置)。每个子元素必须有唯一的 :key。.list-move 类在列表重新排序时动画化项目滑动到新位置——普通 CSS 无法实现的精致效果。leave-active 需要 position: absolute 以实现平滑移动。
<template>
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.text }}
</li>
</TransitionGroup>
</template>
<style>
.list-enter-active, .list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from, .list-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* animate items moving to new positions */
.list-move {
transition: transform 0.5s ease;
}
</style>性能:v-memo
v-memo 是一个优化提示。除非依赖数组中的一个值改变,否则 Vue 跳过该元素的重新渲染。对于大型列表中大多数项目在每次更新时不变的情况,这可以大幅减少重新渲染。仅当您测量到性能问题时才使用它——过早优化会增加复杂性和过时 UI 的风险。
<template>
<!-- skip re-render of this item unless 'id' or 'selected' changes -->
<div
v-for="item in hugeList"
:key="item.id"
v-memo="[item.id, item.id === selectedId]"
>
{{ item.name }} — {{ item.id === selectedId ? '★' : '' }}
</div>
</template>事件处理
内联处理程序
@click(v-on:click 的简写)绑定点击处理程序。内联语句(count++)适用于简单情况。方法名(greet)自动接收原生事件作为第一个参数。要传递自定义参数和事件,用 $event 显式调用方法:greet('hi', $event)。
<script setup>
import { ref } from 'vue'
const count = ref(0)
const name = ref('Vue')
function greet(event) {
alert('Hello ' + name.value)
// event is the native DOM event
console.log(event.target.tagName)
}
</script>
<template>
<!-- inline statement -->
<button @click="count++">Add</button>
<!-- method reference (auto-receives event) -->
<button @click="greet">Greet</button>
<!-- call with custom args (use $event for native event) -->
<button @click="greet($event)">Greet</button>
</template>事件修饰符
事件修饰符以点链在事件名后。.prevent 和 .stop 最常见(表单提交、事件冒泡)。.self 确保仅在元素本身(而非子元素)被点击时触发处理程序。.passive 告诉浏览器处理程序不会调用 preventDefault,从而实现更平滑的滚动——对触摸/滚动处理程序很重要。
<template>
<!-- .prevent: call preventDefault() -->
<form @submit.prevent="onSubmit">...</form>
<!-- .stop: call stopPropagation() -->
<div @click="outer">
<button @click.stop="inner">Won't bubble</button>
</div>
<!-- .self: only trigger if event.target === current element -->
<div @click.self="onSelf">Only direct clicks</div>
<!-- .once: handler removed after first invocation -->
<button @click.once="init">Init once</button>
<!-- .capture: use capture phase instead of bubble -->
<div @click.capture="onCapture">...</div>
<!-- .passive: improve scroll perf (no preventDefault) -->
<div @scroll.passive="onScroll">...</div>
</template>键与系统修饰符
键修饰符按键名过滤键盘事件(.enter、.esc、.delete、.space、.tab、.up、.down、.left、.right)。系统修饰符(.ctrl、.alt、.shift、.meta)要求按住该键。.exact 确保没有按住其他系统修饰符——@click.ctrl.exact 仅在 Ctrl+Click 时触发,而非 Ctrl+Shift+Click。对 Vue 不别名的键使用 kebab-case(page-down)。
<template>
<!-- key modifiers: only fire on specific keys -->
<input @keyup.enter="submit" />
<input @keyup.esc="cancel" />
<input @keyup.delete="remove" />
<!-- system modifiers: ctrl, alt, shift, meta -->
<button @click.ctrl="openInNewTab">Ctrl+Click</button>
<input @keyup.alt.enter="altEnter" />
<!-- .exact: no other modifiers can be pressed -->
<button @click.ctrl.exact="onCtrlOnly">Ctrl only</button>
<!-- any key code via kebab-case (deprecated but works) -->
<input @keyup.page-down="onPageDown" />
</template>使用 emit 的自定义事件
子组件 emit 事件以向上通信。defineEmits 声明事件名(用于验证和 IDE 提示)。父组件用 @event-name 侦听。有效载荷作为额外参数传递给 emit 并由父组件的处理程序接收。按照约定,在模板中使用 kebab-case 事件名(my-event),在 defineEmits 中使用 camelCase(myEvent)——Vue 自动转换。
<!-- ChildButton.vue -->
<script setup>
const emit = defineEmits(['increment', 'delete'])
function onClick() {
// emit with payload
emit('increment', 1)
}
</script>
<template>
<button @click="onClick">+1</button>
</template>
<!-- Parent.vue -->
<template>
<ChildButton @increment="onIncrement" @delete="remove" />
</template>
<script setup>
function onIncrement(amount) {
console.log('incremented by', amount)
}
</script>组件上的 v-model
defineModel() 是现代(Vue 3.4+)宏,用于组件上的双向绑定——它返回一个自动与父组件 v-model 同步的 ref。对于多个绑定,使用命名 model:v-model:fieldName。在底层这 emit 'update:modelValue'(或 'update:fieldName'),父组件同步绑定的 ref。较旧的代码手动使用 defineProps + defineEmits。
<!-- CustomInput.vue -->
<script setup>
const model = defineModel()
</script>
<template>
<input :value="model" @input="model = $event.target.value" />
</template>
<!-- Parent.vue -->
<template>
<CustomInput v-model="text" />
</template>
<!-- multiple v-models (Vue 3.4+) -->
<template>
<UserForm v-model:name="name" v-model:email="email" />
</template>表单绑定
文本与 Textarea
文本输入上的 v-model 在每次 input 事件时同步。对于 textarea,将 v-model 直接放在元素上(而不是 :value——属性中的多行内容不起作用)。修饰符:.lazy 在 blur/change 时同步(更少更新),.trim 去除空白,.number 将值转换为 Number。可链式使用:v-model.lazy.trim。
<script setup>
import { ref } from 'vue'
const text = ref('')
const message = ref('')
</script>
<template>
<!-- text input: binds on every keystroke -->
<input v-model="text" placeholder="Type here" />
<p>You typed: {{ text }}</p>
<!-- textarea: insert tags inside, not :value -->
<textarea v-model="message" rows="4"></textarea>
<p>Preview: {{ message }}</p>
<!-- .lazy: sync on 'change' (blur) instead of 'input' -->
<input v-model.lazy="text" />
<!-- .trim: strip leading/trailing whitespace -->
<input v-model.trim="text" />
<!-- .number: cast to Number (NaN if invalid) -->
<input v-model.number="age" type="number" />
</template>复选框与单选
单个复选框绑定到布尔值(选中 = true)。共享相同 v-model 的多个复选框绑定到其 value 属性的数组——选中添加值,取消选中移除值。共享 v-model 的单选按钮将选中选项的 value 绑定到 ref。使用 true-value / false-value 属性自定义布尔映射。
<script setup>
import { ref } from 'vue'
const agreed = ref(false)
const fruits = ref([]) // array for multi-checkbox
const picked = ref('') // string for radio
</script>
<template>
<!-- single checkbox -> boolean -->
<input type="checkbox" v-model="agreed" /> I agree
<!-- multiple checkboxes with same v-model -> array -->
<input type="checkbox" value="apple" v-model="fruits" /> Apple
<input type="checkbox" value="banana" v-model="fruits" /> Banana
<input type="checkbox" value="cherry" v-model="fruits" /> Cherry
<p>Selected: {{ fruits }}</p>
<!-- radio: value binds to the chosen option -->
<input type="radio" value="A" v-model="picked" /> A
<input type="radio" value="B" v-model="picked" /> B
</template>Select
在 <select> 上,v-model 放在 select 元素上(而非 options)并与选中选项的 value 同步。使用空值的禁用占位符选项实现'请选择'UX。对于动态选项,用 v-for 渲染并绑定 :value。添加 multiple 属性允许多选——v-model 然后绑定到选中值的数组。
<script setup>
import { ref } from 'vue'
const selected = ref('')
const multi = ref([])
const options = [
{ value: 'a', label: 'Option A' },
{ value: 'b', label: 'Option B' }
]
</script>
<template>
<!-- single select -->
<select v-model="selected">
<option disabled value="">Please select</option>
<option value="a">A</option>
<option value="b">B</option>
</select>
<!-- dynamic options -->
<select v-model="selected">
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<!-- multi-select (hold Ctrl/Cmd) -> array -->
<select v-model="multi" multiple>
<option value="x">X</option>
<option value="y">Y</option>
</select>
</template>表单提交与验证
使用 @submit.prevent 处理表单提交同时防止默认页面重新加载。响应式表单对象将相关字段分组。对于验证,VeeValidate 或 Zod + composable 等库在复杂表单中很受欢迎,但对于小型表单,返回错误对象的简单 validate() 函数效果很好。始终防止默认以启用 SPA 风格的提交。
<script setup>
import { reactive, ref } from 'vue'
const form = reactive({
email: '',
password: ''
})
const errors = ref({})
function validate() {
errors.value = {}
if (!form.email.includes('@')) errors.value.email = 'Invalid email'
if (form.password.length < 6) errors.value.password = 'Too short'
return Object.keys(errors.value).length === 0
}
function onSubmit() {
if (!validate()) return
// submit form...
}
</script>
<template>
<form @submit.prevent="onSubmit">
<input v-model.trim="form.email" />
<span v-if="errors.email">{{ errors.email }}</span>
<input type="password" v-model="form.password" />
<span v-if="errors.password">{{ errors.password }}</span>
<button type="submit">Submit</button>
</form>
</template>自定义组件的 v-model
将表单字段包装在自定义组件中以实现重用。defineModel() 返回一个读取父组件值并通过 'update:modelValue' emit 写回的 ref。组件将 :value 绑定到 model,@input 更新它。此模式让您构建一致的设计系统表单输入(BaseInput、BaseSelect、BaseCheckbox),可像原生元素一样使用 v-model。
<!-- BaseInput.vue -->
<script setup>
const model = defineModel()
defineProps({
label: String,
type: { type: String, default: 'text' }
})
</script>
<template>
<label>
{{ label }}
<input
:type="type"
:value="model"
@input="model = $event.target.value"
/>
</label>
</template>
<!-- Usage -->
<template>
<BaseInput v-model="email" label="Email" type="email" />
</template>组件
定义与使用组件
组件是一个带有模板、脚本和可选样式的 .vue 文件。导入它(在 <script setup> 中自动本地注册)并将其用作自定义 HTML 标签。脚本中的组件名是 PascalCase,模板中可以是 kebab-case(my-button)——Vue 规范化它们。全局注册的组件(app.component)无需导入即可在任何地方使用。
<!-- MyButton.vue -->
<template>
<button class="btn"><slot /></button>
</template>
<!-- App.vue -->
<script setup>
import MyButton from './MyButton.vue'
</script>
<template>
<MyButton>Click me</MyButton>
</template>动态组件
<component :is='...'> 动态渲染组件——值可以是注册的名称字符串、导入的组件对象或 defineAsyncComponent。将其包装在 <keep-alive> 中以在切换选项卡时保留组件 状态(表单输入、滚动位置)。对组件对象使用 shallowRef(而非 ref)以避免深度响应式开销。
<script setup>
import { ref, shallowRef } from 'vue'
import HomeTab from './HomeTab.vue'
import PostsTab from './PostsTab.vue'
import ArchiveTab from './ArchiveTab.vue'
const tabs = { HomeTab, PostsTab, ArchiveTab }
const current = shallowRef('HomeTab')
// keep component state when switching (e.g. form input)
import { keep-alive } from 'vue' // not real — use the tag
</script>
<template>
<button v-for="(comp, name) in tabs" :key="name" @click="current = name">
{{ name }}
</button>
<keep-alive>
<component :is="tabs[current]" />
</keep-alive>
</template>异步组件
defineAsyncComponent 在首次渲染时延迟加载组件——按需获取 chunk,减少初始包大小。配置加载/错误状态以获得流畅的 UX。与 Suspense 结合以协调多个异步组件。loader 返回组件模块的 Promise(Vite/Webpack 基于动态 import() 自动拆分)。
<script setup>
import { defineAsyncComponent } from 'vue'
// lazy-load on first use (code-split)
const HeavyChart = defineAsyncComponent(() =>
import('./HeavyChart.vue')
)
// with loading, error, delay, timeout states
const AdminPanel = defineAsyncComponent({
loader: () => import('./AdminPanel.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorMsg,
delay: 200, // show loading after 200ms
timeout: 5000 // error if not loaded in 5s
})
</script>
<template>
<HeavyChart />
<AdminPanel />
</template>组件 v-model(多个)
命名 model 让单个组件暴露多个双向绑定。defineModel('name') 声明每一个。父组件使用 v-model:name='...'。这对于捆绑多个字段的表单组件(例如带有街道、城市、邮编的地址组件)很有用。默认的 v-model(无名称)映射到 'modelValue'。
<!-- UserForm.vue -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>
<template>
<input v-model="firstName" />
<input v-model="lastName" />
</template>
<!-- Parent.vue -->
<template>
<UserForm
v-model:firstName="form.first"
v-model:lastName="form.last"
/>
</template>递归与循环组件
组件可以通过其文件名引用自身(自动自引用)。这对于树结构(文件树、评论线程、菜单)很完美。对于两个组件之间的循环引用(A 导入 B,B 导入 A),对其中一个使用 defineAsyncComponent,或在 beforeCreate 中导入。Vue 处理递归组件但要注意无限循环——始终有基本情况(无子节点)。
<!-- TreeNode.vue -->
<script setup>
defineProps({
node: Object
})
// a component can reference itself by its filename
// TreeNode.vue can use <TreeNode /> recursively
</script>
<template>
<div>
{{ node.label }}
<ul v-if="node.children?.length">
<li v-for="child in node.children" :key="child.id">
<TreeNode :node="child" />
</li>
</ul>
</div>
</template>Props
声明 Props
defineProps 声明组件的输入契约。运行时声明支持 type、default、required 和 validator。Vue 3 基于类型的声明(仅 TS)提供类型安全但失去运行时验证(与 volar + 验证库一起使用)。对象/数组的默认值必须使用工厂函数:default: () => []。
<script setup>
// runtime declaration (validates types)
const props = defineProps({
title: String,
count: { type: Number, default: 0 },
items: { type: Array, required: true },
callback: { type: Function, default: () => {} },
status: {
type: String,
validator: (v) => ['active', 'idle'].includes(v)
}
})
// type-based declaration (TS only, no runtime validation)
const props2 = defineProps<{
title: string
count?: number
}>()
</script>Prop 命名与传递
Prop 在 JS 中应为 camelCase(greetingMessage),在模板中为 kebab-case(greeting-message)——HTML 不区分大小写,因此 kebab-case 更安全。Vue 在两者之间自动转换。静态字符串 prop 使用普通属性语法;动态值使用 :prop。传递数字/布尔值/对象始终使用 : 以避免它们被视为字符串。
<!-- Child.vue -->
<script setup>
defineProps({
greetingMessage: String,
userId: Number
})
</script>
<!-- Parent.vue -->
<template>
<!-- kebab-case in templates (HTML-friendly) -->
<Child greeting-message="Hello" :user-id="42" />
<!-- camelCase also works in SFC templates -->
<Child greetingMessage="Hello" :userId="42" />
</template>单向数据流
Prop 是只读的——永远不要直接修改它们。Vue 在开发模式下会警告。要更改 prop 的值,emit 一个事件并让父组件更新它(单向数据流)。对于本地工作副本,从 prop 初始化一个 ref 并通过 watch 同步。最干净 的双向模式是使用 defineModel 的 v-model,它抽象了 emit-and-update 的舞蹈。
<!-- BAD: mutating a prop -->
<script setup>
const props = defineProps(['count'])
function inc() {
props.count++ // WARNING: don't mutate props!
}
</script>
<!-- GOOD: emit to parent, or use local copy -->
<script setup>
import { ref, watch } from 'vue'
const props = defineProps(['count'])
const emit = defineEmits(['update:count'])
// local copy that syncs with prop
const local = ref(props.count)
watch(() => props.count, v => local.value = v)
function inc() {
local.value++
emit('update:count', local.value)
}
</script>Prop 验证与默认值
Vue 在开发模式下验证 prop 并在失败时警告(生产环境不警告)。type 接受构造函数(String、Number、Boolean、Array、Object、Function、Symbol)或它们的数组。对象和数组默认值必须是返回新实例的函数——否则所有组件将共享相同的默认引用(变异 bug)。验证器在类型检查后运行。
<script setup>
const props = defineProps({
// type can be an array of allowed types
value: { type: [String, Number], required: true },
// object/array default MUST be a factory function
list: {
type: Array,
default: () => []
},
// custom validator
age: {
type: Number,
validator: (v) => v >= 0 && v <= 150
},
// multiple checks combined
email: {
type: String,
required: true,
validator: (v) => v.includes('@')
}
})
</script>布尔转换与属性透传
布尔 prop 有特殊转换:存在无值即为 true。属性透传将非 prop 属性(class、style、id、data-*)自动传递给根元素。多个根需要在所需元素上显式 v-bind='$attrs'。使用 defineOptions({ inheritAttrs: false }) 禁用透传以获得完全控制。
<!-- prop declared as Boolean -->
<script setup>
defineProps({
disabled: Boolean,
active: Boolean
})
</script>
<!-- Parent -->
<template>
<MyButton disabled /> <!-- disabled = true -->
<MyButton :disabled="false" /> <!-- disabled = false -->
<MyButton /> <!-- disabled = undefined -->
</template>
<!-- Attribute fallthrough: non-prop attributes
land on the root element automatically -->
<template>
<!-- <MyButton data-id="5" class="large" /> -->
<!-- the data-id and class merge onto the <button> root -->
<button>...</button>
</template>Emits
声明与 Emit
defineEmits 声明组件可以 emit 的事件。对象形式允许验证器接收有效载荷并返回 false 以警告(开发模式)。emit 向上传播——父组件用 @event-name 侦听。始终显式声明 emit:它记录 API、启用 attr 透传排除并支持验证。emit() 接受事件名加可选的有效载荷参数。
<script setup>
// declare emits (with optional validation)
const emit = defineEmits({
// null-ish = no validation
click: null,
// function = validate payload
submit: (payload) => {
if (!payload.email) {
console.warn('submit needs email')
return false
}
return true
}
})
function handleSubmit() {
emit('submit', { email: '[email protected]', name: 'Alice' })
}
</script>侦听 Emit
父组件用 @event-name(或 v-on:event-name)侦听。处理程序接收任何 emit 的有效载荷。内联箭头函数便于简单转换(@increment='n => count += n')。方法处理程序自动接收有效载荷作为第一个参数。组件根元素上的原生 DOM 事件会透传,除非在 emits 中声明——声明 'click' 使其成为自定义事件。
<!-- Child.vue -->
<script setup>
const emit = defineEmits(['increment', 'delete'])
function click() {
emit('increment', 5)
}
</script>
<template>
<button @click="click">+5</button>
</template>
<!-- Parent.vue -->
<template>
<!-- inline handler -->
<Child @increment="n => count += n" />
<!-- method handler -->
<Child @increment="onIncrement" @delete="onDelete" />
</template>
<script setup>
function onIncrement(amount) {
console.log('incremented by', amount)
}
</script>v-model 实现
组件上的 v-model 是 :modelValue + @update:modelValue 的语法糖。子组件通过 modelValue prop 接收值并 emit update:modelValue 以同步。这是手动模式;defineModel()(Vue 3.4+)包装了它。对于命名 v-model(v-model:foo),使用 prop 名 foo 和事件 update:foo。这使构建像原生输入一样工作的表单组件成为可能。
<!-- CustomInput.vue (manual implementation) -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
function onInput(e) {
emit('update:modelValue', e.target.value)
}
</script>
<template>
<input
:value="modelValue"
@input="onInput"
/>
</template>
<!-- Parent -->
<template>
<CustomInput v-model="text" />
</template>Emit 验证
Emit 验证器接收有效载荷参数并返回 false 以触发开发模式警告。它们不阻止 emit——只是警告。这在开发早期捕获契约违规。使用它们记录预期的有效载荷形状。在生产环境中,验证器被剥离,因此不要依赖它们进行安全或控制流。
<script setup>
const emit = defineEmits({
// no validation
click: null,
// validate payload; return false to warn
change: (value) => {
if (typeof value !== 'string') {
console.warn('change payload must be string')
return false
}
return true
},
// validate multiple args
update: (id, value) => {
if (!id || value === undefined) return false
return true
}
})
// validation runs on emit
emit('change', 123) // warns: must be string
</script>事件 vs Props:何时使用
遵循单向数据流:prop 向下,事件向上。对于双向绑定,使用 v-model(prop + emit 语法糖)。对于深层嵌套通信,prop 传递变得痛苦——使用 provide/inject 进行依赖注入或使用 store(Pinia)进行共享状态。事件用于'发生了某事'的通知;prop 用于'这是您的数据'。
<!-- Parent-to-child: props (down) -->
<template>
<Child :data="parentData" :config="config" />
</template>
<!-- Child-to-parent: emits (up) -->
<template>
<Child @change="onChange" @submit="onSubmit" />
</template>
<!-- Two-way: v-model (combines both) -->
<template>
<Child v-model="value" />
</template>
<!-- For complex state, prefer provide/inject or a store -->插槽
默认插槽
插槽让父组件将内容注入子组件的模板。<slot> 元素是占位符。父组件的子内容填充默认插槽。插槽实现组合:Card 处理包装器样式,父组件控制内部内容。没有插槽,您需要为每个可能的内部元素设置一个 prop——插槽灵活得多。
<!-- Card.vue -->
<template>
<div class="card">
<slot />
</div>
</template>
<!-- Usage -->
<template>
<Card>
<p>This goes into the default slot</p>
<button>Action</button>
</Card>
</template>具名插槽
具名插槽让组件有多个内容占位符。用 <slot name='foo' /> 声明,用 <template #foo> 填充。# 是 v-slot: 的简写。未命名的插槽是'默认'插槽——没有 template 包装的内容放在那里。具名插槽非常适合布局(header、sidebar、main、footer),父组件填充每个区域。
<!-- Layout.vue -->
<template>
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>
</template>
<!-- Usage with # shorthand -->
<template>
<Layout>
<template #header>
<h1>My Page Title</h1>
</template>
<!-- default slot (no #name) -->
<p>Main content here</p>
<template #footer>
<p>© 2025</p>
</template>
</Layout>
</template>插槽 Prop(作用域插槽)
作用域插槽让子组件将数据传回父组件的插槽内容。子组件在 <slot :item='item'> 上绑定数据;父组件通过 v-slot='{ item }'(或 #default='{ item }')接收。这是可重用列表组件(数据表、虚拟列表)的基础:子组件处理迭代,父组件控制每个项目的渲染。
<!-- List.vue -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot :item="item" :index="index" />
</li>
</ul>
</template>
<script setup>
defineProps(['items'])
</script>
<!-- Usage: receive slot props via v-slot -->
<template>
<List :items="todos">
<template #default="{ item, index }">
<input type="checkbox" v-model="item.done" />
<span :class="{ done: item.done }">{{ index }}: {{ item.text }}</span>
</template>
</List>
</template>默认插槽内容
插槽回退内容放在 <slot> 和 </slot> 之间。仅在父组件未提供插槽内容时渲染。这对于合理的默认值很棒——按钮标签、空状态消息、占位符。如果父组件甚至传递空字符串,回退将被覆盖。
<!-- Button.vue -->
<template>
<button>
<slot>Click here</slot> <!-- fallback content -->
</button>
</template>
<!-- Usage -->
<template>
<Button /> <!-- renders "Click here" -->
<Button>Save</Button> <!-- renders "Save" -->
</template>渲染作用域与使用插槽
插槽内容在父组件的作用域中渲染——它可以访问父组件状态但不能直接访问子组件状态(除非通过插槽 prop)。这种分离让子组件处理机制(分页、虚拟化、获取),父组件控制表示。作用域插槽模式(FancyList + UserCard)是一种强大的组合技术——子组件拥有数据流,父组件拥有渲染。
<!-- FancyList.vue (scoped slot pattern) -->
<script setup>
const props = defineProps(['items', 'pageSize'])
import { ref, computed } from 'vue'
const page = ref(1)
const paged = computed(() =>
props.items.slice((page.value - 1) * props.pageSize, page.value * props.pageSize)
)
</script>
<template>
<div v-for="item in paged" :key="item.id">
<slot :item="item" :page="page" />
</div>
</template>
<!-- Usage: parent fully controls rendering -->
<FancyList :items="users" :pageSize="10">
<template #default="{ item, page }">
<UserCard :user="item" />
<small>Page {{ page }}</small>
</template>
</FancyList>生命周期钩子
组合式 API 钩子
生命周期钩子在特定阶段运行。onMounted 在 DOM 渲染后触发(安全访问元素、运行 canvas 库、获取数据)。onUpdated 在每次响应式更新后触发——避免在此处修改状态(无限循环)。onUnmounted 用于清理(计时器、侦听器、订阅)。钩子必须在 setup 中同步调用,但可以多次调用。
<script setup>
import {
onBeforeMount, onMounted,
onBeforeUpdate, onUpdated,
onBeforeUnmount, onUnmounted,
onActivated, onDeactivated,
onErrorCaptured
} from 'vue'
onBeforeMount(() => console.log('before mount'))
onMounted(() => console.log('mounted — DOM ready'))
onBeforeUpdate(() => console.log('before re-render'))
onUpdated(() => console.log('after re-render'))
onBeforeUnmount(() => console.log('before unmount'))
onUnmounted(() => console.log('unmounted — cleanup'))
</script>Setup vs Created
在 <script setup> 中,整个脚本体在组件初始化期间运行——等同于 Options API 中的 beforeCreate + created。没有单独的 created() 钩子。顶层代码(声明、函数调用,甚至异步工作)每个实例运行一次。对于数据获取,这是开始的好地方(尽管 onMounted 也可以——fetch 无论如何都开始)。
<script setup>
// <script setup> runs BEFORE the component is mounted
// (equivalent to beforeCreate + created in Options API)
import { ref } from 'vue'
// this code runs once per component instance,
// before reactive state is exposed to the template
const count = ref(0)
console.log('component created, count =', count.value)
// no need for created() hook — just run code here
fetch('/api/data')
.then(r => r.json())
.then(data => { /* ... */ })
</script>挂载与清理
设置/清理配对至关重要:在 onMounted 中添加的每个侦听器、计时器、订阅或观察者必须在 onUnmounted 中移除。不清理会导致内存泄漏和僵尸处理程序在已销毁组件上触发。模式:onMounted 中 addEventListener,onUnmounted 中 removeEventListener。对于侦听器,使用返回的 stop 函数或它们在卸载时自动清理。
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const windowWidth = ref(window.innerWidth)
function onResize() {
windowWidth.value = window.innerWidth
}
onMounted(() => {
// setup side effects after DOM is ready
window.addEventListener('resize', onResize)
console.log('mounted')
})
onUnmounted(() => {
// ALWAYS clean up to avoid memory leaks
window.removeEventListener('resize', onResize)
})
</script>KeepAlive 钩子
当组件包装在 <KeepAlive> 中时,切换离开不会卸载它——它被停用(缓存)。onActivated 和 onDeactivated 在显示/隐藏时触发。使用它们暂停/恢复工作(轮询、视频播放)而无需完整的设置/拆卸。onMounted 仍只触发一次(首次激活);缓存时 onUnmounted 从不触发。这些钩子让您优化缓存选项卡的资源使用。
<script setup>
import { onActivated, onDeactivated } from 'vue'
// these fire when the component is wrapped in <KeepAlive>
// and is being shown (activated) or hidden (deactivated)
onActivated(() => {
console.log('component shown again — resume polling')
startPolling()
})
onDeactivated(() => {
console.log('component hidden but not unmounted — pause')
stopPolling()
})
// onMounted fires only once (first show)
// onActivated fires every time it's re-shown
</script>错误捕获
onErrorCaptured 捕获后代组件的错误(在模板、渲染、生命周期、侦听器中)。它接收 (error, instance, info)。返回 false 以阻止错误传播到父组件的 errorCaptured 处理程序和全局 errorHandler。将它用于优雅处理子组件错误的边界组件(如 React Error Boundaries)。与 Suspense 结合处理异步错误状态。
<script setup>
import { onErrorCaptured, ref } from 'vue'
const error = ref(null)
onErrorCaptured((err, instance, info) => {
// err: the error thrown
// instance: component that threw
// info: lifecycle hook or event name where it occurred
error.value = err.message
console.error('Captured:', err, info)
// return false to stop propagation to parent
return false
})
</script>
<template>
<div v-if="error" class="error">{{ error }}</div>
<slot v-else />
</template>组合式 API
ref vs reactive
ref() 适用于任何值(原始值、对象)。响应式访问在 JS 中需要 .value,但在模板中自动解包。reactive() 只适用于对象/数组——不需要 .value。原始值和可替换值使用 ref;分组状态使用 reactive。经验法则:单个值优先使用 ref(),状态对象使用 reactive()。混合使用也可以——ref 在 reactive 对象内自动解包。
<script setup>
import { ref, reactive } from 'vue'
// ref: works with any value, access via .value
const count = ref(0)
count.value++
console.log(count.value) // 1
// reactive: only for objects/arrays, no .value needed
const state = reactive({ count: 0, user: { name: 'A' } })
state.count++
console.log(state.count) // 1
// refs auto-unwrap in templates and reactive objects
const wrapper = reactive({ count }) // wrapper.count === count.value
</script>reactive 陷阱
reactive 对象有两个陷阱:您不能重新赋值变量(state = newObj)——而是修改属性(state.list = [])。解构会失去响应式,因为解构的变量是快照。使用 toRefs() 将 reactive 属性转换为保持链接的 ref。这些陷阱是许多开发者更喜欢 ref() 的原因——它可替换(只需设置 .value)且解构友好。
<script setup>
import { reactive, ref } from 'vue'
const state = reactive({ list: [1, 2, 3] })
// PITFALL 1: replacing the whole object loses reactivity
// state = reactive({ list: [4, 5] }) // ERROR: reassign
// GOOD: mutate properties
state.list = [4, 5]
// PITFALL 2: destructuring loses reactivity
let { list } = state // 'list' is now plain
// list.push(4) won't trigger updates
// FIX: use toRefs to keep reactivity
import { toRefs } from 'vue'
const { list } = toRefs(state) // now list is a ref
</script>toRef 与 toRefs
toRef() 将单个 reactive 属性转换为保持链接的 ref(修改任一者会影响另一者)。toRefs() 转换所有属性——用于从 composable 返回响应式状态同时保留解构 。没有 toRefs,解构的值失去响应式。模式:composable 返回 toRefs(state),以便消费者可以解构而不破坏响应式。
<script setup>
import { reactive, toRef, toRefs } from 'vue'
const state = reactive({
count: 0,
name: 'Alice'
})
// toRef: single property -> ref (stays linked to state)
const countRef = toRef(state, 'count')
// toRefs: all properties -> object of refs
const { count, name } = toRefs(state)
countRef.value++ // updates state.count
count.value++ // also updates state.count
console.log(state.count) // 2
</script>Setup 中的 computed 与 watch
在 <script setup> 中,computed() 和 watch() 被导入并直接调用。computed 创建派生 ref;watch 在源改变时运行回调。watch 可以接受单个 ref、getter、源数组或 reactive 对象。这些是组合式 API 的核心原语——将它们组合以在 composable 中封装逻辑。
<script setup>
import { ref, computed, watch } from 'vue'
const a = ref(1)
const b = ref(2)
// computed: derived value, cached
const sum = computed(() => a.value + b.value)
// watch: side effect on change
watch(sum, (newSum) => {
console.log('sum changed to', newSum)
})
// watch multiple sources
watch([a, b], ([newA, newB]) => {
console.log('a or b changed:', newA, newB)
})
</script>shallowRef 与 shallowReactive
shallowRef 和 shallowReactive 退出深度响应式——只有顶级引用/属性被追踪。这极大地提高了大型数据结构(例如图表的数据数组、3D 场景图)的性能,其中深度追踪是浪费的。使用 triggerRef() 在深度修改后手动通知 Vue。仅当您测量到性能问题时才使用它们。
<script setup>
import { shallowRef, shallowReactive, triggerRef } from 'vue'
// shallowRef: only .value changes trigger updates
// (deep changes to .value properties do NOT)
const big = shallowRef({ items: [1, 2, 3] })
big.value.items.push(4) // NO update triggered
big.value = { items: [1,2,3,4] } // update triggered
// force update after deep mutation
big.value.items.push(5)
triggerRef(big) // tells Vue to re-render
// shallowReactive: only root-level props are reactive
const state = shallowReactive({ user: { name: 'A' } })
state.count = 1 // reactive
state.user.name = 'B' // NOT reactive
</script>组合式函数
编写组合式函数
组合式函数是封装可重用响应式逻辑的函数。按照约定,命名为 useSomething。它使用组合式 API 钩子(onMounted 等),这些钩子绑定到使用它的组件的生命周期。返回 ref 和方法。组件解构它们。组合式函数是 React hooks 的 Vue 等价物——它们让您共享有状态逻辑而无需 render props 或 HOC。
<!-- useMousePosition.js -->
import { ref, onMounted, onUnmounted } from 'vue'
export function useMousePosition() {
const x = ref(0)
const y = ref(0)
function update(e) {
x.value = e.pageX
y.value = e.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
<!-- In component -->
<script setup>
import { useMousePosition } from './useMousePosition'
const { x, y } = useMousePosition()
</script>有状态组合式函数(共享状态)
当状态在模块作用域声明(函数外)时,它是单例——所有使用该 composable 的组件共享相同状态。这是 Pinia 对于简单全局状态的轻量级替代方案。使用 readonly() 暴露状态而不允许直接修改——组件必须调用 login/logout 来更改它。对于复杂应用,优先使用 Pinia(更好的 devtools、持久化、SSR 支持)。
<!-- useUser.js -->
import { reactive, readonly } from 'vue'
// module-scoped state — shared across all consumers
const state = reactive({
user: null,
loading: false
})
export function useUser() {
async function login(credentials) {
state.loading = true
state.user = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
}).then(r => r.json())
state.loading = false
}
function logout() {
state.user = null
}
return {
user: readonly(state.user), // expose read-only
loading: readonly(state.loading),
login,
logout
}
}useFetch 组合式函数
此 composable 包装 fetch 带有 loading/error 状态,并在响应式 url 改变时重新获取。isRef() 检查输入是否为 ref;unref() 解包它(对 ref 返回值,否则返回本身)。返回 refresh 让消费者手动重新获取。此模式(响应式输入 + 自动重新运行 + 暴露状态)是数据获取 composable 的基 础。
<!-- useFetch.js -->
import { ref, watchEffect, isRef, unref } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(true)
async function doFetch() {
loading.value = true
error.value = null
try {
const res = await fetch(unref(url))
if (!res.ok) throw new Error('HTTP ' + res.status)
data.value = await res.json()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
// re-fetch when url ref changes
if (isRef(url)) {
watchEffect(doFetch)
} else {
doFetch()
}
return { data, error, loading, refresh: doFetch }
}带清理的组合式函数
注册副作用(侦听器、计时器、订阅)的 composable 必须清理——通常通过 composable 内的 onUnmounted。这将清理绑定到使用该 composable 的任何组件,因此消费者不必记住。这是 composable 相对于实用工具函数的主要优势:它们参与组件生命周期。
<!-- useEventListener.js -->
import { onUnmounted } from 'vue'
export function useEventListener(target, event, callback) {
target.addEventListener(event, callback)
// auto-cleanup when the consuming component unmounts
onUnmounted(() => {
target.removeEventListener(event, callback)
})
}
<!-- Usage -->
<script setup>
import { useEventListener } from './useEventListener'
import { ref } from 'vue'
const key = ref('')
useEventListener(window, 'keydown', (e) => {
key.value = e.key
})
// listener is removed automatically on unmount
</script>组合式函数约定
遵循这些约定:(1) 用 use 前缀命名 composable;(2) 返回 ref(非普通值——普通值失去响应式);(3) 接受 ref 或 getter 作为输入以获得灵活性;(4) 使用 onUnmounted 进行清理;(5) 保持专注(每个 composable 一个关注点)。这些约定使 composable 相互组合且可预测使用。
<!-- DO: name with 'use' prefix, return refs -->
export function useCounter(initial = 0) {
const count = ref(initial)
const inc = () => count.value++
const dec = () => count.value--
return { count, inc, dec }
}
<!-- AVOID: returning plain values (loses reactivity) -->
export function badCounter() {
let count = 0 // plain variable — NOT reactive!
return { count, inc: () => count++ }
}
<!-- DO: accept refs/getters for flexible inputs -->
export function useSearch(query) {
// query can be a ref, a getter, or a plain value
return watchEffect(() => fetch('/api?q=' + unref(query)))
}响应式深入
响应式 Proxy 机制
Vue 3 响应式使用 Proxy(ES6)。reactive() 返回一个拦截 get(追踪依赖)和 set(触发更新)的 Proxy。原始对象不被修改——您得到一个包装版本。isReactive() 检查某物是否为响应式 proxy。这比 Vue 2 的 Object.defineProperty 方法更强大,后者无法检测新属性添加。
<script setup>
import { reactive, isReactive, isProxy } from 'vue'
const state = reactive({ count: 0 })
// reactive() wraps the object in a Proxy
// reads/writes are tracked and trigger updates
state.count++ // triggers re-render
// the original object is NOT reactive
const raw = { count: 0 }
const wrapped = reactive(raw)
console.log(raw === wrapped) // false
console.log(isReactive(wrapped)) // true
console.log(isReactive(raw)) // false
console.log(isProxy(wrapped)) // true
</script>ref 解包规则
ref 解包规则:在 <script> 中通过 .value 访问;在模板和 reactive 对象内,ref 自动解包为 .value。这就是为什么模板中的 {{ count }} 无需 .value 即可工作。自动解包是浅层的——数组/Map 中的嵌套 ref 不解包。包含 ref 的 reactive 对象在赋值时修改 ref 的值:state.count = 5 设置 count.value = 5。
<script setup>
import { ref, reactive } from 'vue'
const count = ref(0)
const state = reactive({ count })
// in JS: ref needs .value
count.value = 1
// in reactive object: ref auto-unwraps
console.log(state.count) // 1 (not count.value)
state.count = 5 // updates count.value
// in templates: ref auto-unwraps
// {{ count }} renders count.value
</script>
<template>
<p>{{ count }}</p> <!-- 5, not [object Object] -->
</template>Effect 与 track/trigger(内部)
effect() 是响应式背后的低级原语——它运行一个函数,追踪其中的每个响应式读取,并在任何追踪的值改变时重新运行。computed、watch、watchEffect 和组件渲染都基于 effect 构建。您很少在应用代码中直接调用 effect()——优先使用 computed/watch/watchEffect——但它是引擎。理解它有助于推理重新排序和时序。
<script setup>
import { ref, effect } from 'vue'
const count = ref(0)
// effect runs immediately, then re-runs on dep change
effect(() => {
console.log('count is', count.value)
})
// logs: count is 0
count.value = 10
// logs: count is 10
// internally:
// - effect tracks dependencies accessed inside it
// - setting count.value triggers all tracked effects
</script>markRaw 与 readonly
markRaw() 永久地将对象退出响应式——适用于有自己的更新机制且不应被包装在 Proxy 中的第三方类实例(图表、地图、游戏状态)。readonly() 创建响应式对象的只读视图——尝试修改在开发模式下会警告。使用 readonly 暴露 composable/store 的状态同时防止直接修改。
<script setup>
import { reactive, markRaw, readonly, isReadonly } from 'vue'
// markRaw: opt a value OUT of reactivity forever
const bigChart = markRaw(new HeavyChartLib())
const state = reactive({ chart: bigChart })
// state.chart mutations won't trigger Vue updates
// (use chart's own API to update it)
// readonly: make a reactive object read-only
const original = reactive({ count: 0 })
const frozen = readonly(original)
frozen.count = 5
// warning: target is readonly (dev mode)
console.log(isReadonly(frozen)) // true
</script>自定义 Ref(工厂)
customRef() 让您显式控制依赖追踪和触发。工厂接收 track() 和 trigger()——在 get() 中调用 track() 注册依赖,在 set() 中调用 trigger() 通知更新。这实现了防抖 ref、惰性 ref 或由存储支持的 ref 等模式。功能强大但谨慎使用——通常 watchEffect + 普通 ref 更清晰。
<script setup>
import { customRef } from 'vue'
// debounce a ref's updates
function debouncedRef(value, delay = 200) {
let timeout
return customRef((track, trigger) => ({
get() {
track() // tell Vue this value is a dependency
return value
},
set(newValue) {
clearTimeout(timeout)
timeout = setTimeout(() => {
value = newValue
trigger() // notify Vue of the change
}, delay)
}
}))
}
const text = debouncedRef('', 300)
// input only triggers re-render 300ms after typing stops
</script>Vue Router
路由设置
createRouter 设置路由。createWebHistory 使用 HTML5 history 模式(干净的 URL,需要服务器回退到 index.html)。使用动态 import() 延迟加载路由组件以进行代码拆分——每个路由成为自己的 bundle。动态段(:id)通过 route.params 访问。使用 app.use(router) 将路由器安装为插件。
<!-- router/index.js -->
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
const routes = [
{ path: '/', name: 'home', component: Home },
{
path: '/about',
name: 'about',
// lazy-load for code-splitting
component: () => import('../views/About.vue')
},
{ path: '/user/:id', name: 'user', component: () => import('../views/User.vue') }
]
export const router = createRouter({
history: createWebHistory(),
routes
})
<!-- main.js -->
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'
createApp(App).use(router).mount('#app')RouterView 与 RouterLink
<RouterView> 是匹配路由组件渲染的占位符。<RouterLink> 渲染 <a> 标签但拦截点击进行客户端导航(无完整页面重新加载)。to prop 接受字符串路径或对象({ name, params, query })。RouterLink 添加 active/exact-active 类用于样式化当前链接。
<!-- App.vue -->
<template>
<nav>
<!-- RouterLink renders <a>, handles navigation -->
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
<RouterLink :to="{ name: 'user', params: { id: 42 } }">
User 42
</RouterLink>
</nav>
<!-- matched route's component renders here -->
<RouterView />
</template>导航与编程式路由
useRouter() 给出路由器实例进行导航;useRoute() 给出当前路由(响应式)。push 添加历史记录条目(后退按钮有效);replace 不添加。路由属性(path、params、query、hash、name、matched)是响应式的——侦听器和计算属性在导航时重新运行。始终使用 router.push 进行应用内导航——永远不要 window.location 以保持 SPA。
<script setup>
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// push to a new URL (adds to history)
function goHome() {
router.push('/')
router.push({ name: 'user', params: { id: 1 } })
router.push({ path: '/search', query: { q: 'vue' } })
}
// replace current entry (no back button)
router.replace('/login')
// go back/forward
router.back()
router.forward()
router.go(-2)
// access current route info
console.log(route.path, route.params.id, route.query.q)
</script>路由守卫
守卫控制导航流程。全局 beforeEach 适合身份验证检查。每路由 beforeEnter 守卫特定路由。组件内守卫(onBeforeRouteLeave、onBeforeRouteUpdate)对于未保存更改警告很有用。返回 false 或路由以取消/重定向;返回 true/undefined 确认。异步守卫可以返回 Promise。使用 route.meta 附加 requiresAuth 等标志。
<!-- router/index.js -->
const router = createRouter({ history, routes })
// global guard: runs on every navigation
router.beforeEach((to, from) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
return '/login' // redirect
}
// return true or undefined to confirm
})
// per-route guard
{ path: '/admin', component: Admin, beforeEnter: (to, from) => {
if (!isAdmin()) return '/403'
}}
// in-component guard
<script setup>
import { onBeforeRouteLeave } from 'vue-router'
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges()) {
return confirm('Discard changes?')
}
})
</script>懒加载与嵌套路由
嵌套路由在父组件的 <RouterView> 内渲染子组件。用相对于父组件的路径定义子路由。空路径('')精确匹配父 URL。动态 import() 将每个路由代码拆分为自己的 bundle。使用 route.meta 附加数据(身份验证要求、标题)——在守卫中通过 to.meta 访问。用 chunk 名注释捆绑相关路由。
const routes = [
{
path: '/dashboard',
component: () => import('../views/Dashboard.vue'),
children: [
{ path: '', component: () => import('../views/Overview.vue') },
{ path: 'stats', component: () => import('../views/Stats.vue') },
{ path: 'settings', component: () => import('../views/Settings.vue') }
]
},
// group routes into one chunk
{
path: '/admin',
component: () => import(/* webpackChunkName: "admin" */ '../views/Admin.vue'),
meta: { requiresAuth: true, role: 'admin' }
}
]状态管理(Pinia)
定义 Store
Pinia 是 Vue 的官方状态管理(Vuex 继任者)。defineStore 带有 setup 函数提供组合式 API 语法——ref/computed 是 state/getter,返回的函数是 action。第一个参数是唯一的 store id。Setup store 灵活(您可以在内部使用任何 composable)。或者,使用 Options 语法(state/getters/actions 对象)获得更像 Vuex 的感觉。
<!-- stores/counter.js -->
import { defineStore } from 'pinia'
// setup syntax (composition API style)
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const double = computed(() => count.value * 2)
function increment() {
count.value++
}
function asyncIncrement() {
setTimeout(() => count.value++, 1000)
}
return { count, double, increment, asyncIncrement }
})使用 Store
在 setup 中调用 useStore() 访问 store。对于响应式解构,使用 storeToRefs()(类似 toRefs 但跳过 action)。Action 可以直接解构——它们绑定到 store。Pinia 允许直接修改(counter.count = 5)和 $patch 进行批量更新。Store 是单例——所有组件间相同实例。使用 app.use(createPinia()) 安装 Pinia。
<script setup>
import { useCounterStore } from '../stores/counter'
import { storeToRefs } from 'pinia'
const counter = useCounterStore()
// state and getters lose reactivity if destructured directly
// use storeToRefs to preserve it
const { count, double } = storeToRefs(counter)
// actions can be destructured directly (they're functions)
const { increment } = counter
// mutate state directly (Pinia allows it, unlike Vuex)
counter.count = 5
counter.$patch({ count: 10 })
counter.$patch(state => { state.count++ })
</script>
<template>
<p>{{ count }} x 2 = {{ double }}</p>
<button @click="increment">+</button>
</template>Getter 与组合 Store
Getter 是从 state 派生的计算值。要在一个 store 内使用另一个,只需调用其 useStore()——Pinia 处理依赖。这使得组合 store 变得简单(与 Vuex 模块不同)。Getter 也可以引用其他 getter。对于异步操作,只需在 store 中编写异步函数——没有像 Vuex 那样的特殊 'action' 上下文。
<!-- stores/cart.js -->
import { defineStore } from 'pinia'
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', () => {
const items = ref([])
// getter (computed)
const total = computed(() =>
items.value.reduce((sum, i) => sum + i.price * i.qty, 0)
)
const itemCount = computed(() =>
items.value.reduce((n, i) => n + i.qty, 0)
)
// compose other stores
const userStore = useUserStore()
const canCheckout = computed(() =>
userStore.isLoggedIn && items.value.length > 0
)
function addItem(item) {
items.value.push(item)
}
return { items, total, itemCount, canCheckout, addItem }
})订阅与持久化
$subscribe 在任何状态改变时触发(带有变更信息和新状态)。将其用于持久化(localStorage、sessionStorage)或同步到外部系统。$onAction 钩入 action 调用——对于日志记录、分析或测量 action 持续时间很有用。after/onError 回调让您对 action 完成或失败做出反应。组件卸载时自动清理。
<script setup>
import { useCartStore } from '../stores/cart'
const cart = useCartStore()
// subscribe to any state change
cart.$subscribe((mutation, state) => {
console.log('cart changed', mutation.type, state)
// persist to localStorage
localStorage.setItem('cart', JSON.stringify(state.items))
})
// subscribe to actions
cart.$onAction(({ name, after, onError }) => {
console.log('action started:', name)
after(result => console.log('action done:', name, result))
onError(err => console.error('action failed:', name, err))
})
</script>重置与 Store 模块
Options 语法的 store 有内置 $reset();setup store 没有——通过重新赋值每个 ref 手动实现 reset()。Pinia 设计上是模块化的——每个 store 独立,没有像 Vuex 那样的嵌套模块。通过在一个 store 内调用 useOtherStore() 组合 store。使用 createPinia() 全局安装 Pinia。Store 可 tree-shake 并有出色的 TypeScript 支持和 devtools 集成。
<!-- stores/user.js -->
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
const name = ref('')
const isLoggedIn = ref(false)
function login(n) { name.value = n; isLoggedIn.value = true }
function logout() { name.value = ''; isLoggedIn.value = false }
// Pinia setup stores don't have a built-in $reset
// implement it manually
function reset() {
name.value = ''
isLoggedIn.value = false
}
return { name, isLoggedIn, login, logout, reset }
})
<!-- main.js -->
import { createPinia } from 'pinia'
const pinia = createPinia()
app.use(pinia)过渡与动画
Transition 组件
将 v-if/v-show 元素包装在 <Transition> 中以动画化进入/离开。Vue 在正确的时机添加 6 个类:*-enter-from(开始状态)、*-enter-active(过渡期间)、*-enter-to(结束状态)以及 *-leave-*。在这些类上定义 CSS 过渡/动画。name prop 将 'v-' 前缀替换为指定名称。只允许一个子元素;列表使用 TransitionGroup。
<template>
<Transition name="fade">
<p v-if="show">Hello</p>
</Transition>
</template>
<style>
/* 6 classes: {name}-enter-{from,active,to}, {name}-leave-{...} */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>过渡模式与钩子
mode='out-in' 在进入元素开始之前完全动画化离开元素——非常适合无重叠地交换组件(选项卡)。mode='in-out' 很少使用。JavaScript 钩子(@before-enter、@enter、@leave)让您用 GSAP 等库驱动动画。设置 :css='false' 告诉 Vue 跳过 CSS 检测并完全依赖 JS 钩子(轻微性能提升)。
<template>
<!-- 'out-in': current leaves, then new enters -->
<Transition mode="out-in" name="fade">
<component :is="currentTab" />
</Transition>
<!-- JavaScript hooks for GSAP/anime.js -->
<Transition
@before-enter="beforeEnter"
@enter="enter"
@leave="leave"
:css="false"
>
<div v-if="show">Animated</div>
</Transition>
</template>列表的 TransitionGroup
TransitionGroup 为 v-for 列表动画化三种行为:enter(新项目)、leave(移除项目)和 move(现有项目改 变位置)。*-move 类动画化 FLIP 技术——Vue 计算位置增量并应用 transform 过渡。在 leave-active 上设置 position: absolute 使离开项目脱离流,让兄弟项目平滑滑动到位。
<template>
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">{{ item.text }}</li>
</TransitionGroup>
</template>
<style>
.list-enter-active, .list-leave-active { transition: all 0.5s; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(30px); }
/* the magic class for reordering */
.list-leave-active { position: absolute; }
.list-move { transition: transform 0.5s ease; }
</style>用 <Transition> 包装器动画化状态
您可以用自定义类名覆盖 Vue 的默认类名——enter-active-class 和 leave-active-class。这让您插入现有动画系统(Animate.css、Tailwind 的动画实用程序、自定义 keyframes)。模式:在类上定义动画,将 Vue 的 transition 指向它。这将 Vue 的生命周期与任何 CSS 动画框架桥接。
<template>
<button @click="show = !show">Toggle</button>
<Transition
enter-active-class="animate__animated animate__fadeIn"
leave-active-class="animate__animated animate__fadeOut"
>
<div v-if="show" class="box">Content</div>
</Transition>
</template>
<!-- combine with third-party animation libraries -->
<!-- Animate.css, Tailwind animations, custom keyframes -->异步的 Suspense
Suspense 协调异步 setup——当子组件使用顶层 await 时,它挂起,Suspense 显示 #fallback。所有异步子组件解析后,默认插槽渲染。这对于数据获取组件很优雅。注意:Suspense 在 Vue 3 中仍是实验性的——错误处理需要 onErrorCaptured。对于生产,大多数团队仍使用 composable + loading 状态。
<template>
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<div>Loading...</div>
</template>
</Suspense>
</template>
<!-- AsyncComponent uses top-level await -->
<script setup>
import { ref } from 'vue'
const data = ref(null)
// top-level await suspends the component
const res = await fetch('/api/data').then(r => r.json())
data.value = res
</script>Teleport 与 Suspense
Teleport 基础
Teleport 将内容渲染到文档中其他位置的 DOM 元素(通常在 body 级别),但组件保留其父组件的响应式上下文、prop、emit 等。这解决了模态框、工具提示和下拉菜单的 z-index/overflow 问题,这些问题会被父组件 overflow:hidden 或堆叠上下文裁剪。'to' 选择器指向目标元素。
<!-- Modal.vue -->
<template>
<!-- the rendered content is moved to #modals in the DOM,
but keeps the component's reactive context -->
<Teleport to="#modals">
<div class="modal">
<h2>{{ title }}</h2>
<slot />
<button @click="$emit('close')">Close</button>
</div>
</Teleport>
</template>
<!-- index.html must have -->
<!-- <div id="app"></div> -->
<!-- <div id="modals"></div> -->条件 Teleport
disabled prop 让您有条件地 teleport——为 true 时,内容就地渲染(不 teleport)。当同一组件在不同上下文中使用时(例如工具提示在正常布局中应被 teleport 但在另一个模态框内应内联)很有用。多个 Teleport 针对相同元素按渲染顺序追加。Teleport 与其中的 v-if 一起工作。
<template>
<!-- disabled: render in place (not teleported) -->
<Teleport :to="target" :disabled="inline">
<div class="popup" v-if="show">...</div>
</Teleport>
</template>
<script setup>
const props = defineProps({
inline: Boolean // if true, render inline instead of teleporting
})
const target = '#popups'
</script>
<!-- Multiple Teleports to same target: appended in order -->模态框与通知的 Teleport
Teleport 到 body 是模态框的标准模式——模态框逃脱任何父组件 overflow/transform/z-index 约束。与 Transition 结合用于进入/离开动画。backdrop 上的 @click.self 在点击外部时关闭(但不在内容上)。插槽让父组件完全自定义模态框的 header、body 和 footer,模态框组件处理定位和动画。
<!-- components/Modal.vue -->
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="modal-backdrop" @click.self="$emit('close')">
<div class="modal-content">
<header><slot name="header" /></header>
<slot />
<footer><slot name="footer" /></footer>
</div>
</div>
</Transition>
</Teleport>
</template>异步 Setup 的 Suspense
当子组件的 setup 使用顶层 await 时,它返回一个 Promise。Suspense 捕获它并显示 #fallback 直到解析。挂起组件的 setup 结果然后用于渲染。多个异步子组件被协调——Suspense 等待所有。onErrorCaptured 捕获 setup 错误。Suspense 仍是实验性的——对于生产,优先在 composable 中手动 loading 状态。
<!-- Parent.vue -->
<template>
<Suspense>
<AsyncProfile :id="userId" />
<template #fallback>
<Spinner />
</template>
</Suspense>
</template>
<!-- AsyncProfile.vue -->
<script setup>
const props = defineProps(['id'])
// top-level await suspends this component
const profile = await fetch(`/api/users/${props.id}`).then(r => r.json())
// component only renders after this resolves
</script>嵌套 Suspense 与异步组件
Suspense 可以嵌套——每个级别处理自己的异步边界。外部 Suspense 在 Dashboard(及其所需的一切)加载时显示 PageSkeleton;内部一个专门为图表显示 ChartSkeleton。这种粒度加载改善了 UX:页面外壳先出现,然后各个部分填充。记住 Suspense 是实验性的——彻底测试并通过 onErrorCaptured 处理错误。
<template>
<Suspense>
<template #default>
<Dashboard>
<Suspense>
<template #default>
<AsyncChart :data="data" />
</template>
<template #fallback>
<ChartSkeleton />
</template>
</Suspense>
</Dashboard>
</template>
<template #fallback>
<PageSkeleton />
</template>
</Suspense>
</template>依赖注入
基础 Provide 与 Inject
provide() 使值对所有后代组件可用;inject() 在树的任何 位置检索它。这避免了深层嵌套组件的 prop 传递。inject 的第二个参数是默认值(未提供任何内容时使用)。提供的 ref 是响应式的——后代可以修改它们,父组件会看到更改。在大型应用中使用 Symbol 键避免冲突。
<!-- Parent.vue -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
const user = ref({ name: 'Alice' })
// provide to all descendants
provide('theme', theme)
provide('user', user)
</script>
<!-- DeepChild.vue (any level of nesting) -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme', 'light') // default 'light'
const user = inject('user')
// these are reactive refs, just like in the parent
</script>只读提供的状态
使用 readonly() 暴露状态而不允许直接修改——后代必须调用提供的 action 方法来更改它。此模式(私有可变 ref + 只读公共视图 + action 方法)是组件树范围状态的 Pinia 轻量级替代方案。它强制单向数据流并防止树深处随机修改导致的 bug。
<!-- store.js (shared composable) -->
import { ref, readonly } from 'vue'
const _count = ref(0) // private mutable state
export function useCountStore() {
return {
count: readonly(_count), // consumers can't mutate
increment: () => _count.value++ // only via action
}
}
<!-- Provider.vue -->
<script setup>
import { provide } from 'vue'
import { useCountStore } from './store'
const store = useCountStore()
provide('countStore', store)
</script>类型化注入键
使用 InjectionKey<Ref<Type>> 创建类型化 symbol 键。这提供完整的 TypeScript 推断——inject 知道返回类型。Symbol 键在多个库提供值时避免字符串冲突。对于应用范围的单例,优先使用 Pinia 而非 provide/inject——Pinia 有 devtools、持久化和更好的可测试性。对于组件树范围上下文(主题、表单、语言环境)使用 provide/inject。
<!-- keys.ts -->
import type { InjectionKey, Ref } from 'vue'
import { inject, provide } from 'vue'
export const ThemeKey: InjectionKey<Ref<string>> = Symbol('theme')
<!-- Provider.vue -->
<script setup>
import { ref } from 'vue'
import { ThemeKey } from './keys'
const theme = ref('dark')
provide(ThemeKey, theme)
</script>
<!-- Consumer.vue -->
<script setup>
import { inject } from 'vue'
import { ThemeKey } from './keys'
// TS knows theme is Ref<string> | undefined
const theme = inject(ThemeKey)
// with default
const theme2 = inject(ThemeKey, ref('light'))
</script>更新提供的值
推荐模式:提供包含响应式状态和修改方法的对象。这保持 API 显式并防止随机修改。子组件解构状态和 action。这本质上是迷你 store——对于超出几个值的任何内容,考虑返回相同形状的 composable 或 Pinia。提供的值是响应式的,因为它包含 ref。
<!-- Provider.vue -->
<script setup>
import { ref, provide } from 'vue'
const count = ref(0)
// expose value + mutator together
provide('counter', {
count, // reactive ref
increment: () => count.value++,
reset: () => count.value = 0
})
</script>
<!-- DeepChild.vue -->
<script setup>
import { inject } from 'vue'
const { count, increment, reset } = inject('counter')
// count is reactive, increment/reset are actions
</script>
<template>
<p>{{ count }}</p>
<button @click="increment">+</button>
<button @click="reset">Reset</button>
</template>应用级 Provide
app.provide() 使值对应用中每个组件可用——对于应用范围常量(API URL、功能标志、环境配置)很有用。与组件级 provide 不同,默认情况下这不是响应式的(除非您提供 ref)。这是一种注入环境特定配置而无需到处导入 env 文件的好方法。与任何组件中的 inject() 结合以访问这些全局变量。
<!-- main.js -->
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// app-level provide — available to ALL components
app.provide('apiUrl', 'https://api.example.com')
app.provide('apiKey', import.meta.env.VITE_API_KEY)
// useful for app-wide constants
app.provide('features', {
darkMode: true,
beta: false
})
app.mount('#app')相关 Vue 3 代码片段
Copy-paste ready code for common tasks.
Single-File Component
Define a Vue 3 component with script setup, template, and scoped styles.
Composition API
Organize reactive state, computed, and lifecycle logic by feature.
Refs & Reactive
Choose between ref and reactive for reactive state.
Computed & Watch
Derive values with computed and react to changes with watch.
Props & Emits
Declare inputs and events, and implement v-model on a component.
Lifecycle Hooks
Register lifecycle callbacks as composable functions.
Slots & Scoped Slots
Distribute content with default, named, and scoped slots.
Composables
Extract reusable reactive logic into a useMouse composable.
这篇内容对您有帮助吗?