Skip to content

Vue 3 速查表

用于构建用户界面的渐进式 JavaScript 框架。

01

入门

Hello World 与注释

Vue 3 应用通过 createApp() 和 mount() 挂载到 DOM 元素来启动。插值使用双花括号 {{ }} 来渲染响应式数据。HTML 注释会出现在渲染后的 DOM 中;要避免注释泄漏,可在 <script> 块中使用 /* */ 注释。

vue3
<!-- 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 模块。

vue3
<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() 之前注册插件、提供全局值和设置配置。挂载后,进一步的配置更改将不会生效。

vue3
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 是更底层的工具。

vue3
# 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-ts

defineOptions 与 defineProps

defineProps、defineEmits 和 defineOptions 是编译时宏——它们不需要导入。defineProps 通过运行时验证声明组件的 prop 契约。defineOptions 让您设置 name、inheritAttrs 和其他无法直接在 <script setup> 中表达的选项。

vue3
<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>
02

模板语法

文本插值

{{ }} 安全地渲染文本(HTML 转义)。v-once 在首次渲染后冻结绑定,适用于静态内容。v-html 注入原始 HTML——只用于可信内容以避免 XSS。在 {{ }} 内可以使用任何单个 JavaScript 表达式,但不能是赋值或 var 声明等语句。

vue3
<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] 让属性名本身也是响应式的——对于事件名或本地化属性很有用。

vue3
<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。

vue3
<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)在赋值前转换输入值。

vue3
<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。

vue3
<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>
03

指令

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 低。

vue3
<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 一起使用。

vue3
<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——避免在同一元素上同时使用两者。

vue3
<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(去除空白)。

vue3
<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 工作如焦点、工具提示或拖拽。

vue3
<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>
04

计算属性

基础计算属性

computed 创建一个缓存的 getter,仅在其依赖项改变时重新求值。与方法调用不同,重复访问返回缓存值直到依赖项更新。在 <script> 中通过 .value 访问;在模板中 Vue 自动解包。当值依赖于响应式状态时优先使用 computed 而非方法——它更快且是声明式的。

vue3
<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 的自身依赖。

vue3
<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 '执行此操作'。

vue3
<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。

vue3
<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 访问。

vue3
<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>
05

侦听器

基础 watch

watch 在侦听的源改变时运行回调。源可以是 ref、响应式对象属性(通过 getter)或源数组。回调接收 (newValue, oldValue)。使用 getter () => x.y 侦听嵌套响应式属性。侦听器用于副作用(fetch、日志、持久化)——不用于派生状态(使用 computed)。

vue3
<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 以获得更好的性能。

vue3
<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。对于设置读取多个响应式源的订阅很有用。

vue3
<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 与数组源在回调中给出新/旧值数组。

vue3
<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' 在依赖改变时同步运行——很少需要且可能导致性能问题。

vue3
<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>
06

条件渲染

v-if vs v-show

v-if 真正地从 DOM 添加/移除元素——初始成本较高但隐藏时零成本。v-show 总是渲染元素并切换 display:none——切换更便宜但即使隐藏元素也存在。对于很少改变的条件(或隐藏元素根本不应存在时)使用 v-if。对于频繁切换的 UI 如选项卡、下拉菜单、模态框使用 v-show。

vue3
<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(加载、错误、空、成功)。

vue3
<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。

vue3
<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。

vue3
<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。

vue3
<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>
07

列表渲染

v-for 与数组

v-for 迭代数组,暴露每个项目和(可选)索引。始终将 :key 绑定到稳定的唯一值如 item.id——这让 Vue 在重新排序时高效匹配旧节点和新节点,保留组件状态和 DOM。使用索引作为 key 在项目插入/移除/重新排序时会出问题,导致微妙的 bug。

vue3
<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)。对于过滤或排序列表,优先使用计算属性而不是内联逻辑以提高可读性和缓存。

vue3
<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 产生新数组——将它们赋值回去以更新。

vue3
<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 以实现平滑移动。

vue3
<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 的风险。

vue3
<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>
08

事件处理

内联处理程序

@click(v-on:click 的简写)绑定点击处理程序。内联语句(count++)适用于简单情况。方法名(greet)自动接收原生事件作为第一个参数。要传递自定义参数和事件,用 $event 显式调用方法:greet('hi', $event)。

vue3
<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,从而实现更平滑的滚动——对触摸/滚动处理程序很重要。

vue3
<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)。

vue3
<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 自动转换。

vue3
<!-- 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。

vue3
<!-- 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>
09

表单绑定

文本与 Textarea

文本输入上的 v-model 在每次 input 事件时同步。对于 textarea,将 v-model 直接放在元素上(而不是 :value——属性中的多行内容不起作用)。修饰符:.lazy 在 blur/change 时同步(更少更新),.trim 去除空白,.number 将值转换为 Number。可链式使用:v-model.lazy.trim。

vue3
<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 属性自定义布尔映射。

vue3
<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 然后绑定到选中值的数组。

vue3
<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 风格的提交。

vue3
<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。

vue3
<!-- 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>
10

组件

定义与使用组件

组件是一个带有模板、脚本和可选样式的 .vue 文件。导入它(在 <script setup> 中自动本地注册)并将其用作自定义 HTML 标签。脚本中的组件名是 PascalCase,模板中可以是 kebab-case(my-button)——Vue 规范化它们。全局注册的组件(app.component)无需导入即可在任何地方使用。

vue3
<!-- 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)以避免深度响应式开销。

vue3
<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() 自动拆分)。

vue3
<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'。

vue3
<!-- 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 处理递归组件但要注意无限循环——始终有基本情况(无子节点)。

vue3
<!-- 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>
11

Props

声明 Props

defineProps 声明组件的输入契约。运行时声明支持 type、default、required 和 validator。Vue 3 基于类型的声明(仅 TS)提供类型安全但失去运行时验证(与 volar + 验证库一起使用)。对象/数组的默认值必须使用工厂函数:default: () => []。

vue3
<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。传递数字/布尔值/对象始终使用 : 以避免它们被视为字符串。

vue3
<!-- 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 的舞蹈。

vue3
<!-- 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)。验证器在类型检查后运行。

vue3
<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 }) 禁用透传以获得完全控制。

vue3
<!-- 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>
12

Emits

声明与 Emit

defineEmits 声明组件可以 emit 的事件。对象形式允许验证器接收有效载荷并返回 false 以警告(开发模式)。emit 向上传播——父组件用 @event-name 侦听。始终显式声明 emit:它记录 API、启用 attr 透传排除并支持验证。emit() 接受事件名加可选的有效载荷参数。

vue3
<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' 使其成为自定义事件。

vue3
<!-- 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。这使构建像原生输入一样工作的表单组件成为可能。

vue3
<!-- 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——只是警告。这在开发早期捕获契约违规。使用它们记录预期的有效载荷形状。在生产环境中,验证器被剥离,因此不要依赖它们进行安全或控制流。

vue3
<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 用于'这是您的数据'。

vue3
<!-- 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 -->
13

插槽

默认插槽

插槽让父组件将内容注入子组件的模板。<slot> 元素是占位符。父组件的子内容填充默认插槽。插槽实现组合:Card 处理包装器样式,父组件控制内部内容。没有插槽,您需要为每个可能的内部元素设置一个 prop——插槽灵活得多。

vue3
<!-- 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),父组件填充每个区域。

vue3
<!-- 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 }')接收。这是可重用列表组件(数据表、虚拟列表)的基础:子组件处理迭代,父组件控制每个项目的渲染。

vue3
<!-- 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> 之间。仅在父组件未提供插槽内容时渲染。这对于合理的默认值很棒——按钮标签、空状态消息、占位符。如果父组件甚至传递空字符串,回退将被覆盖。

vue3
<!-- 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)是一种强大的组合技术——子组件拥有数据流,父组件拥有渲染。

vue3
<!-- 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>
14

生命周期钩子

组合式 API 钩子

生命周期钩子在特定阶段运行。onMounted 在 DOM 渲染后触发(安全访问元素、运行 canvas 库、获取数据)。onUpdated 在每次响应式更新后触发——避免在此处修改状态(无限循环)。onUnmounted 用于清理(计时器、侦听器、订阅)。钩子必须在 setup 中同步调用,但可以多次调用。

vue3
<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 无论如何都开始)。

vue3
<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 函数或它们在卸载时自动清理。

vue3
<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 从不触发。这些钩子让您优化缓存选项卡的资源使用。

vue3
<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 结合处理异步错误状态。

vue3
<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>
15

组合式 API

ref vs reactive

ref() 适用于任何值(原始值、对象)。响应式访问在 JS 中需要 .value,但在模板中自动解包。reactive() 只适用于对象/数组——不需要 .value。原始值和可替换值使用 ref;分组状态使用 reactive。经验法则:单个值优先使用 ref(),状态对象使用 reactive()。混合使用也可以——ref 在 reactive 对象内自动解包。

vue3
<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)且解构友好。

vue3
<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),以便消费者可以解构而不破坏响应式。

vue3
<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 中封装逻辑。

vue3
<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。仅当您测量到性能问题时才使用它们。

vue3
<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>
16

组合式函数

编写组合式函数

组合式函数是封装可重用响应式逻辑的函数。按照约定,命名为 useSomething。它使用组合式 API 钩子(onMounted 等),这些钩子绑定到使用它的组件的生命周期。返回 ref 和方法。组件解构它们。组合式函数是 React hooks 的 Vue 等价物——它们让您共享有状态逻辑而无需 render props 或 HOC。

vue3
<!-- 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 支持)。

vue3
<!-- 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 的基础。

vue3
<!-- 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 相对于实用工具函数的主要优势:它们参与组件生命周期。

vue3
<!-- 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 相互组合且可预测使用。

vue3
<!-- 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)))
}
17

响应式深入

响应式 Proxy 机制

Vue 3 响应式使用 Proxy(ES6)。reactive() 返回一个拦截 get(追踪依赖)和 set(触发更新)的 Proxy。原始对象不被修改——您得到一个包装版本。isReactive() 检查某物是否为响应式 proxy。这比 Vue 2 的 Object.defineProperty 方法更强大,后者无法检测新属性添加。

vue3
<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。

vue3
<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——但它是引擎。理解它有助于推理重新排序和时序。

vue3
<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 的状态同时防止直接修改。

vue3
<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 更清晰。

vue3
<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>
18

Vue Router

路由设置

createRouter 设置路由。createWebHistory 使用 HTML5 history 模式(干净的 URL,需要服务器回退到 index.html)。使用动态 import() 延迟加载路由组件以进行代码拆分——每个路由成为自己的 bundle。动态段(:id)通过 route.params 访问。使用 app.use(router) 将路由器安装为插件。

vue3
<!-- 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 类用于样式化当前链接。

vue3
<!-- 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。

vue3
<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 等标志。

vue3
<!-- 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 名注释捆绑相关路由。

vue3
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' }
  }
]
19

状态管理(Pinia)

定义 Store

Pinia 是 Vue 的官方状态管理(Vuex 继任者)。defineStore 带有 setup 函数提供组合式 API 语法——ref/computed 是 state/getter,返回的函数是 action。第一个参数是唯一的 store id。Setup store 灵活(您可以在内部使用任何 composable)。或者,使用 Options 语法(state/getters/actions 对象)获得更像 Vuex 的感觉。

vue3
<!-- 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。

vue3
<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' 上下文。

vue3
<!-- 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 完成或失败做出反应。组件卸载时自动清理。

vue3
<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 集成。

vue3
<!-- 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)
20

过渡与动画

Transition 组件

将 v-if/v-show 元素包装在 <Transition> 中以动画化进入/离开。Vue 在正确的时机添加 6 个类:*-enter-from(开始状态)、*-enter-active(过渡期间)、*-enter-to(结束状态)以及 *-leave-*。在这些类上定义 CSS 过渡/动画。name prop 将 'v-' 前缀替换为指定名称。只允许一个子元素;列表使用 TransitionGroup。

vue3
<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 钩子(轻微性能提升)。

vue3
<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 使离开项目脱离流,让兄弟项目平滑滑动到位。

vue3
<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 动画框架桥接。

vue3
<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 状态。

vue3
<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>
21

Teleport 与 Suspense

Teleport 基础

Teleport 将内容渲染到文档中其他位置的 DOM 元素(通常在 body 级别),但组件保留其父组件的响应式上下文、prop、emit 等。这解决了模态框、工具提示和下拉菜单的 z-index/overflow 问题,这些问题会被父组件 overflow:hidden 或堆叠上下文裁剪。'to' 选择器指向目标元素。

vue3
<!-- 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 一起工作。

vue3
<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,模态框组件处理定位和动画。

vue3
<!-- 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 状态。

vue3
<!-- 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 处理错误。

vue3
<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>
22

依赖注入

基础 Provide 与 Inject

provide() 使值对所有后代组件可用;inject() 在树的任何位置检索它。这避免了深层嵌套组件的 prop 传递。inject 的第二个参数是默认值(未提供任何内容时使用)。提供的 ref 是响应式的——后代可以修改它们,父组件会看到更改。在大型应用中使用 Symbol 键避免冲突。

vue3
<!-- 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。

vue3
<!-- 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。

vue3
<!-- 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。

vue3
<!-- 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() 结合以访问这些全局变量。

vue3
<!-- 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')

这篇内容对您有帮助吗?

学习路径

从零开始学习

通过结构化课程从头学习这个语言。