Vue 3 从基础到组合式 API 全解析
目录
1.1 基础概念
MVVM 模式
MVVM 由三部分组成:Model(模型)、View(视图)、ViewModel(视图模型)。
graph LR
subgraph View_Layer["🖥️ View 层"]
direction TB
V1["📄 HTML 模板 + CSS 样式"]
V3["👆 用户交互事件<br/>click / input / submit"]
end
subgraph ViewModel_Layer["⚙️ ViewModel 层"]
direction TB
VM1["📦 响应式数据<br/>data / ref / reactive"]
VM2["🔄 计算属性<br/>computed"]
VM3["👁️ 侦听器<br/>watch"]
VM4["🔗 生命周期钩子<br/>mounted / updated"]
VM5["🛠️ 方法<br/>methods"]
end
subgraph Model_Layer["🗄️ Model 层"]
direction TB
M1["🌐 API 请求<br/>axios / fetch"]
M3["💡 业务逻辑<br/>数据处理 / 校验 / 数据模型"]
M4["🏪 状态管理<br/>Vuex / Pinia"]
end
%% View → ViewModel
V3 -- "① 用户操作触发" --> VM5
V1 -- "② v-model 双向绑定" --> VM1
%% ViewModel 内部依赖
VM1 -- "③-a 依赖数据变化<br/>触发重新计算" --> VM2
VM1 -- "③-b 依赖数据变化<br/>触发侦听器" --> VM3
VM4 -- "③-c 生命周期触发<br/>初始化加载等" --> VM5
%% ViewModel → View
VM1 -- "④ 数据驱动视图更新" --> V1
VM2 -- "⑤ 计算结果渲染到模板" --> V1
%% ViewModel → Model
VM5 -- "⑥ 调用 API" --> M1
VM3 -- "⑦ 监听变化触发业务逻辑" --> M3
%% Model → ViewModel
M1 -- "⑧ 返回数据 → 写入响应式变量" --> VM1
M4 -- "⑨ 状态变更通知 → 写入响应式变量" --> VM1
%% 自动触发渲染
M1 -. "⑧→④ 自动触发视图渲染 🔄" .-> V1
M4 -. "⑨→④ 自动触发视图渲染 🔄" .-> V1
style View_Layer fill:#E3F2FD,stroke:#1565C0,stroke-width:3px
style ViewModel_Layer fill:#FFF3E0,stroke:#E65100,stroke-width:3px
style Model_Layer fill:#E8F5E9,stroke:#2E7D32,stroke-width:3px
style V1 fill:#BBDEFB,stroke:#1565C0,stroke-width:2px
style V3 fill:#BBDEFB,stroke:#1565C0,stroke-width:2px
style VM1 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
style VM2 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
style VM3 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
style VM4 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
style VM5 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
style M1 fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px
style M3 fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px
style M4 fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px
linkStyle 0 stroke:#D32F2F,stroke-width:2.5px
linkStyle 1 stroke:#D32F2F,stroke-width:2.5px
linkStyle 2 stroke:#FF6F00,stroke-width:2.5px
linkStyle 3 stroke:#FF6F00,stroke-width:2.5px
linkStyle 4 stroke:#FF6F00,stroke-width:2.5px
linkStyle 5 stroke:#1565C0,stroke-width:2.5px
linkStyle 6 stroke:#1565C0,stroke-width:2.5px
linkStyle 7 stroke:#FF6F00,stroke-width:2.5px
linkStyle 8 stroke:#FF6F00,stroke-width:2.5px
linkStyle 9 stroke:#2E7D32,stroke-width:2.5px
linkStyle 10 stroke:#2E7D32,stroke-width:2.5px
linkStyle 11 stroke:#9C27B0,stroke-width:2px,stroke-dasharray:5
linkStyle 12 stroke:#9C27B0,stroke-width:2px,stroke-dasharray:5
三层职责与 Vue 中的映射:
| 缩写 | 全称 | 职责 | Vue 中的对应 |
|---|---|---|---|
| M | Model(模型) | 应用数据与业务逻辑。不关心数据如何展示,只负责存储和处理 |
reactive()/ref() 声明的响应式状态、API 请求返回的数据、Pinia store |
| V | View(视图) | 用户看到的界面。不包含业务逻辑,只负责声明式地描述 UI 结构 |
<template> 中的 HTML 模板、最终渲染出的真实 DOM |
| VM | ViewModel(视图模型) | M 与 V 之间的桥梁。监听 Model 变化自动更新 View,捕获 View 上的用户交互反向更新 Model | Vue 组件实例本身——编译器将模板转为渲染函数,响应式系统追踪依赖并触发更新 |
流程总结:
整个 MVVM 的运转可以概括为一个闭环:
-
用户交互驱动(V → VM): 用户在 View 层触发事件(click、input 等),通过
v-model或事件绑定将操作传递给 ViewModel 层的方法或响应式变量。 -
ViewModel 内部联动(VM 内部): 响应式数据变化后,
computed自动重新计算派生值,watch触发副作用逻辑,生命周期钩子在适当时机执行。 - 业务处理(VM → M): ViewModel 调用 Model 层的 API 请求或业务逻辑函数,完成数据的增删改查。
- 数据回写(M → VM): Model 层返回结果写入响应式变量,或 Pinia/Vuex 状态变更通知 ViewModel。
- 视图自动更新(VM → V): 响应式系统检测到数据变化,自动触发虚拟 DOM diff,最小化更新真实 DOM,用户看到最新界面。
核心价值: 开发者只需关注数据(M)和模板(V),中间的同步、diff、DOM 操作全部由 Vue 的 ViewModel 层自动完成。
v-model是:modelValue+@update:modelValue的编译时语法糖,本质仍由 VM 层协调,Vue 3 整体是单向数据流 + 双向绑定语法糖的设计。
1.2 项目创建(Vite)
基于原生 ES Module,毫秒级冷启动,HMR 不随项目规模变慢。
# 推荐使用 create-vue(Vue 官方脚手架,底层基于 Vite)
npm create vue@latest
典型项目结构:
my-project/
├── public/ # 静态资源(不经过构建)
├── src/
│ ├── assets/ # 需构建处理的资源(图片、样式)
│ ├── components/ # 通用组件
│ ├── composables/ # 组合式函数
│ ├── router/ # 路由配置
│ ├── stores/ # Pinia 状态管理
│ ├── views/ # 页面级组件
│ ├── App.vue
│ └── main.ts
├── index.html # 入口 HTML(Vite 以此为入口)
├── vite.config.ts
├── tsconfig.json
└── package.json
1.3 模板语法
插值与绑定
<template>
<!-- 文本插值 -->
<span>{{ message }}</span>
<!-- 属性绑定(v-bind 简写 :) -->
<img :src="imgUrl" :alt="title" />
<!-- 动态绑定多个属性 -->
<div v-bind="attrs"></div>
<!-- 等价于 <div :id="attrs.id" :class="attrs.class" /> -->
<!-- 事件绑定(v-on 简写 @) -->
<button @click="submit">提交</button>
<input @keyup.enter="search" /> <!-- 按键修饰符 -->
<form @submit.prevent="save" /> <!-- 阻止默认行为 -->
</template>
条件渲染
<template>
<!-- v-if:条件为 false 时 DOM 不存在(适合不频繁切换) -->
<div v-if="status === 'loading'">加载中</div>
<div v-else-if="status === 'error'">出错了</div>
<div v-else>{{ data }}</div>
<!-- v-show:始终渲染 DOM,通过 display 切换(适合频繁切换) -->
<div v-show="visible">我一直在 DOM 中</div>
</template>
| 指令 | DOM 行为 | 初始开销 | 切换开销 | 适用场景 |
|---|---|---|---|---|
v-if |
销毁/重建 | 低(不渲染) | 高 | 条件很少变化 |
v-show |
display: none |
高(始终渲染) | 低 | 频繁切换显示 |
列表渲染
<template>
<!-- 数组遍历 -->
<li v-for="(item, index) in list" :key="item.id">
{{ index }}. {{ item.name }}
</li>
<!-- 对象遍历 -->
<div v-for="(value, key) in obj" :key="key">
{{ key }}: {{ value }}
</div>
<!-- v-for + v-if 不能同级使用,需用 <template> 包裹 -->
<template v-for="item in list" :key="item.id">
<li v-if="item.active">{{ item.name }}</li>
</template>
</template>
key的作用: 帮助 Vue 的 diff 算法识别节点身份,复用和重排已有元素而非重新创建。务必使用唯一业务 ID,避免用index(排序/删除时会导致错误复用)。
2. 组件开发
组件是 Vue 的核心抽象单元——将 UI 拆分为独立、可复用的模块,每个组件封装自己的模板、逻辑和样式,通过明确的接口(props/emits)进行通信。
2.1 组件基础
组件定义与注册
Vue 3 推荐使用单文件组件(SFC) + <script setup> 语法,编译器自动处理注册,无需手动声明。
<!-- MyButton.vue — 单文件组件 -->
<template>
<button :class="type" @click="emit('click', $event)">
<slot />
</button>
</template>
<script setup lang="ts">
defineProps<{ type?: 'primary' | 'default' }>()
const emit = defineEmits<{ click: [e: MouseEvent] }>()
</script>
<style scoped>
.primary { background: #409eff; color: #fff; }
</style>
使用方式: 在 <script setup> 中导入即可直接在模板使用,无需注册。
<template>
<MyButton type="primary" @click="save">保存</MyButton>
</template>
<script setup lang="ts">
import MyButton from './MyButton.vue'
</script>
SFC 的价值: 一个
.vue文件 = 模板 + 逻辑 + 样式,scoped实现样式隔离,<script setup>减少样板代码,编译器自动优化。
2.2 组件通信
Vue 组件间通信方式按场景选择,核心原则:props 向下,events 向上,跨层用 provide/inject。
Props(父 → 子)
父组件通过属性向子组件传递数据,子组件只读不可修改。
<!-- Child.vue -->
<template>
<h2>{{ title }} ({{ count }})</h2>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
title: string
count?: number
}>(), {
count: 0 // 类型声明的 props 通过 withDefaults 设置默认值
})
</script>
<!-- Parent.vue -->
<Child title="订单" :count="orderCount" />
Emits(子 → 父)
子组件通过事件通知父组件,保持单向数据流。
<!-- Child.vue -->
<template>
<button @click="remove(item.id)">删除</button>
</template>
<script setup lang="ts">
const emit = defineEmits<{
update: [value: string]
delete: [id: number]
}>()
function remove(id: number) {
emit('delete', id) // 触发事件,父组件通过 @delete 监听
}
</script>
<!-- Parent.vue -->
<Child @update="handleUpdate" @delete="handleDelete" />
v-model(双向绑定语法糖)
v-model 本质是 :modelValue + @update:modelValue 的简写,支持多个 v-model。
<!-- SearchInput.vue -->
<template>
<input v-model="keyword" />
<select v-model="status">
<option value="all">全部</option>
<option value="active">启用</option>
</select>
</template>
<script setup lang="ts">
const keyword = defineModel<string>() // 默认 v-model
const status = defineModel<string>('status') // v-model:status
</script>
<!-- Parent.vue — 语法糖写法 -->
<SearchInput v-model="keyword" v-model:status="currentStatus" />
上面的 v-model:status 等价于展开写法:
<!-- Parent.vue — 展开写法(与上方完全等价) -->
<SearchInput
v-model="keyword"
:status="currentStatus"
@update:status="currentStatus = $event"
/>
v-model:status编译后就是:status+@update:status,defineModel('status')内部帮你处理了 props 接收和 emit 触发。
Provide / Inject(跨层级)
祖先组件提供数据,任意后代组件注入,避免 props 逐层透传。
<!-- 祖先组件 -->
<script setup lang="ts">
import { provide, ref } from 'vue'
const theme = ref<'light' | 'dark'>('light')
provide('theme', theme) // key-value 形式提供
</script>
<!-- 任意深度的后代组件 -->
<template>
<div :class="theme">当前主题:{{ theme }}</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import type { Ref } from 'vue'
const theme = inject<Ref<'light' | 'dark'>>('theme') // 注入
</script>
使用 InjectionKey 实现类型安全(推荐):
字符串 key 容易拼错且无类型推导,推荐抽取 InjectionKey 常量:
// keys.ts — 统一管理 injection key
import type { InjectionKey, Ref } from 'vue'
export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
// 祖先组件中 provide
import { ThemeKey } from './keys'
provide(ThemeKey, theme) // TS 自动校验 value 类型
// 后代组件中 inject
import { ThemeKey } from './keys'
const theme = inject(ThemeKey) // 自动推导为 Ref<'light' | 'dark'> | undefined
适用场景: 主题、国际化、全局配置等需要跨多层传递但不适合放 Pinia 的数据。
Ref(父访问子实例)
通过模板 ref 获取子组件实例,直接调用子组件暴露的方法或属性。
defineExpose 的作用: 在 <script setup> 中,组件内部的变量和方法默认对外不可见(与 Options API 不同)。必须通过 defineExpose 显式声明哪些内容允许父组件通过 ref 访问。未暴露的内容,父组件拿到 ref 后也无法调用。
<!-- Child.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const secret = ref('隐藏数据') // 未暴露,父组件无法访问
function reset() { count.value = 0 }
// 只有 count 和 reset 对外可见,secret 外部不可访问
defineExpose({ count, reset })
</script>
<!-- Parent.vue -->
<template>
<Child ref="childRef" />
<button @click="resetChild">重置子组件</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'
const childRef = ref<InstanceType<typeof Child>>()
function resetChild() {
childRef.value?.reset() // 调用子组件通过 defineExpose 暴露的 reset 方法
}
</script>
注意: 模板 ref 访问破坏了组件封装性,仅在表单校验、弹窗控制等必要场景使用。
2.3 插槽
插槽让父组件向子组件内部注入模板片段,实现布局和内容的解耦。
默认插槽
子组件用 <slot /> 占位,父组件传入内容替换。
<!-- Card.vue -->
<template>
<div class="card">
<slot /> <!-- 父组件传入的内容渲染在这里 -->
</div>
</template>
<Card>
<p>这段内容会替换 slot 占位</p>
</Card>
具名插槽
多个插槽通过 name 区分,父组件用 v-slot:name(简写 #name)指定。
<!-- Layout.vue -->
<template>
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>
</template>
<Layout>
<template #header>
<h1>页面标题</h1>
</template>
<p>默认插槽内容(main 区域)</p>
<template #footer>
<span>© 2026</span>
</template>
</Layout>
作用域插槽
子组件通过 slot 向父组件回传数据,父组件拿到数据后自定义渲染。
<!-- DataList.vue -->
<script setup lang="ts">
defineProps<{ items: { id: number; name: string }[] }>()
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot :item="item" :index="item.id" /> <!-- 回传数据 -->
</li>
</ul>
</template>
<!-- 父组件自定义每一行的渲染方式 -->
<DataList :items="list">
<template #default="{ item, index }">
<span>{{ index }}. {{ item.name }}</span>
<button @click="remove(item.id)">删除</button>
</template>
</DataList>
作用域插槽的价值: 子组件负责数据遍历和逻辑,父组件负责 UI 呈现,实现逻辑与视图的分离。常见于表格列自定义、列表项渲染等场景。
2.4 动态组件
<component :is>
根据变量动态切换渲染的组件,适用于 Tab 切换、多表单步骤等场景。
<template>
<button v-for="tab in tabs" :key="tab.label" @click="current = tab.comp">
{{ tab.label }}
</button>
<component :is="current" />
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import TabA from './TabA.vue'
import TabB from './TabB.vue'
import TabC from './TabC.vue'
const tabs = [
{ label: '基本信息', comp: TabA },
{ label: '详细配置', comp: TabB },
{ label: '操作日志', comp: TabC },
]
const current = shallowRef(tabs[0].comp) // shallowRef 避免深度代理组件对象
</script>
<keep-alive>
缓存被切走的组件实例,切回时保留状态(表单输入、滚动位置等),避免重新创建和销毁。
<keep-alive :include="['TabA', 'TabB']" :max="5">
<component :is="current" />
</keep-alive>
| 属性 | 说明 |
|---|---|
include |
只缓存匹配的组件(名称或正则) |
exclude |
排除不缓存的组件 |
max |
最大缓存实例数,超出时销毁最久未使用的(LRU) |
被 keep-alive 缓存的组件可使用两个专属生命周期:
<script setup lang="ts">
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
// 组件从缓存中被激活(切回)时触发,可用于刷新数据
})
onDeactivated(() => {
// 组件被缓存(切走)时触发,可用于清理定时器
})
</script>
典型场景: 后台管理的多 Tab 页面切换——用户在 Tab A 填了一半表单,切到 Tab B 再切回来,数据不丢失。
defineAsyncComponent(异步组件)
将组件的 JS 代码从主包中分离,用到时才加载,减少首屏体积。Vite 构建时会自动将其拆为独立 chunk。
import { defineAsyncComponent } from 'vue'
// 基本用法:传入返回 import() 的工厂函数
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))
// 完整配置:加载状态、超时、错误处理
const HeavyChart = defineAsyncComponent({
loader: () => import('./HeavyChart.vue'),
loadingComponent: LoadingSpinner, // 加载中显示的组件
errorComponent: ErrorBlock, // 加载失败显示的组件
delay: 200, // 延迟 200ms 后才显示 loading(避免闪烁)
timeout: 10000, // 超过 10s 视为超时,显示 errorComponent
})
<!-- 在模板中像普通组件一样使用,Vue 自动处理懒加载 -->
<template>
<HeavyChart v-if="showChart" :data="chartData" />
</template>
适用场景: 大型图表、富文本编辑器、PDF 预览等体积较大且非首屏必需的组件。与路由懒加载(
() => import('./views/xxx.vue'))原理相同,区别在于异步组件是组件级别的按需加载。
2.5 Teleport
将组件模板的一部分渲染到DOM 树的其他位置(如 body),解决弹窗/浮层被父组件 overflow: hidden 或 z-index 影响的问题。
<template>
<button @click="visible = true">打开弹窗</button>
<!-- 内容渲染到 body 下,而非当前组件 DOM 内 -->
<Teleport to="body">
<div v-if="visible" class="modal-overlay">
<div class="modal">
<p>弹窗内容</p>
<button @click="visible = false">关闭</button>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
</script>
| 属性 | 说明 |
|---|---|
to |
CSS 选择器或 DOM 元素,指定渲染目标(如 "body"、"#modal-root") |
disabled |
为 true 时禁用传送,内容回到组件原位 |
逻辑上仍属于当前组件(props/emits/provide 照常工作),只是 DOM 位置变了。
2.6 自定义指令
封装对 DOM 的底层操作为可复用指令,命名 v-xxx。
// directives/vFocus.ts
import type { Directive } from 'vue'
export const vFocus: Directive = {
mounted(el: HTMLElement) {
el.focus() // 元素挂载后自动聚焦
}
}
<template>
<input v-focus />
</template>
<script setup lang="ts">
import { vFocus } from '@/directives/vFocus'
</script>
指令生命周期钩子:
| 钩子 | 触发时机 |
|---|---|
created |
元素属性/事件绑定前 |
beforeMount |
插入 DOM 前 |
mounted |
插入 DOM 后 ✅ 最常用 |
beforeUpdate |
组件更新前 |
updated |
组件更新后 |
beforeUnmount |
卸载前 |
unmounted |
卸载后 |
带参数的实际示例(权限指令):
// directives/vPermission.ts
import type { Directive } from 'vue'
export const vPermission: Directive<HTMLElement, string> = {
mounted(el, binding) {
// binding.value 就是 v-permission="'admin'" 中的 'admin'
const userRole = getUserRole()
if (userRole !== binding.value) {
el.parentNode?.removeChild(el) // 无权限则移除元素
}
}
}
<button v-permission="'admin'">仅管理员可见</button>
3. Composition API
Composition API 是 Vue 3 的核心编程范式,以函数为基本组织单位,替代 Options API 的 data/methods/computed/watch 分散写法,使相关逻辑聚合在一起,便于复用和维护。
3.1 响应式 API
ref
包装任意类型为响应式数据。JS/TS 中通过 .value 读写,模板中自动解包。包装对象时内部调用 reactive 实现深层响应。
import { ref } from 'vue'
const count = ref(0) // 基本类型
const user = ref<User | null>(null) // 对象类型,支持泛型
count.value++ // JS 中需要 .value
// 嵌套对象同样响应式(内部自动 reactive)
const config = ref({ theme: { color: 'blue' } })
config.value.theme.color = 'red' // ✅ 视图更新
config.value = { theme: { color: 'green' } } // ✅ 整体替换也响应式
// 数组同样深层响应式
const list = ref([{ id: 1, name: '张三' }, { id: 2, name: '李四' }])
list.value.push({ id: 3, name: '王五' }) // ✅ 新增元素,视图更新
list.value[0].name = '赵六' // ✅ 修改元素属性,视图更新
list.value = list.value.filter(i => i.id !== 2) // ✅ 整体替换,视图更新
自动解包规则 & 注意事项:
| 场景 | 需要 .value? |
说明 |
|---|---|---|
模板 {{ count }}
|
否 | 自动解包 |
| JS/TS 代码 | 是 | count.value++ |
嵌入 reactive 对象 |
否 | reactive({ count }).count++ |
| 放入数组 / Map | 是 | reactive([ref(1)])[0].value |
解构 .value
|
— | 丢失响应性,需 toRefs() 转换 |
reactive
将对象转为深层响应式代理,访问属性无需 .value。不能用于基本类型,且不能整体替换(会丢失响应性)。
import { reactive } from 'vue'
const form = reactive({
name: '',
age: 0,
address: { city: '', zip: '' } // 嵌套对象也是响应式
})
form.name = '张三' // 直接赋值,无需 .value
form.address.city = '深圳' // 深层属性也是响应式
ref vs reactive 选择:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 基本类型(string / number / boolean) | ref |
reactive 不支持基本类型 |
| 可能被整体替换的对象 | ref |
reactive 重新赋值会丢失响应性 |
| 表单等字段固定的复杂对象 | reactive |
无需 .value,代码更简洁 |
| composable 函数返回值 | ref |
解构时不丢失响应性 |
computed
基于响应式依赖自动缓存的派生值,依赖不变则不重新计算。
import { ref, computed } from 'vue'
const price = ref(100)
const quantity = ref(3)
// 只读计算属性
const total = computed(() => price.value * quantity.value)
// 可写计算属性(少用)
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (val: string) => {
const [first, last] = val.split(' ')
firstName.value = first
lastName.value = last ?? ''
}
})
与方法的区别:
computed有缓存,多次访问只在依赖变化时重新计算;方法每次调用都重新执行。
watch
监听特定响应式数据,变化时执行回调。适合需要旧值对比或有条件执行的场景。
import { ref, watch } from 'vue'
const keyword = ref('')
// 监听单个 ref
watch(keyword, (newVal, oldVal) => {
console.log(`搜索词从 "${oldVal}" 变为 "${newVal}"`)
})
// 监听多个源
watch([keyword, page], ([newKeyword, newPage], [oldKeyword, oldPage]) => {
fetchList(newKeyword, newPage)
})
// 监听 reactive 对象的某个属性(需用 getter 函数)
const form = reactive({ name: '', age: 0 })
watch(
() => form.name,
(newName) => { validate(newName) }
)
// 常用选项
watch(keyword, handler, {
immediate: true, // 创建时立即执行一次
deep: true, // 深层监听(reactive 对象默认深层,ref 对象需手动开启)
flush: 'post', // 在 DOM 更新后执行回调(默认 'pre')
})
watchEffect
自动追踪回调中用到的所有响应式依赖,不需要指定监听源。适合"用了什么就监听什么"的场景。
import { ref, watchEffect } from 'vue'
const keyword = ref('')
const page = ref(1)
// 回调中访问了 keyword 和 page,两者变化都会重新执行
const stop = watchEffect(() => {
fetchList(keyword.value, page.value)
})
stop() // 手动停止监听(组件卸载时自动停止)
watch vs watchEffect 对比:
| 维度 | watch |
watchEffect |
|---|---|---|
| 监听源 | 需显式指定 | 自动追踪回调中的依赖 |
| 旧值访问 | ✅ (newVal, oldVal)
|
❌ 无旧值 |
| 首次执行 | 默认不执行(immediate: true 开启) |
默认立即执行 |
| 适用场景 | 需要旧值对比、条件触发 | 依赖多且不需要旧值 |
nextTick
Vue 的 DOM 更新是异步批量的,修改数据后 DOM 不会立即更新。nextTick 等待 DOM 更新完成后执行回调。
import { ref, nextTick } from 'vue'
const show = ref(false)
async function expand() {
show.value = true
// 此时 DOM 尚未更新,拿不到新元素
await nextTick()
// DOM 已更新,可安全操作
document.querySelector('.detail')?.scrollIntoView()
}
响应式工具函数
| 函数 | 作用 | 典型场景 |
|---|---|---|
toRef(obj, key) |
将 reactive 对象的单个属性转为 ref | 传递单个属性给 composable |
toRefs(obj) |
将 reactive 对象的所有属性转为 ref | 解构 reactive 不丢失响应性 |
toRaw(proxy) |
返回代理的原始对象 | 传给第三方库(避免代理副作用) |
shallowRef(val) |
只有 .value 替换时触发更新,深层属性变化不触发
|
大型对象 / 组件引用 |
shallowReactive(obj) |
只有顶层属性变化触发更新 | 扁平配置对象 |
markRaw(obj) |
标记对象永不被代理 | 第三方类实例(echarts、地图等) |
import { reactive, toRefs, toRaw, shallowRef, markRaw } from 'vue'
// toRefs:解构不丢失响应性
const state = reactive({ name: '张三', age: 20 })
const { name, age } = toRefs(state) // name、age 都是 Ref
name.value = '李四' // state.name 同步变化
// shallowRef:大型对象只在整体替换时触发更新
const tableData = shallowRef<Row[]>([])
tableData.value[0].name = '新名字' // ❌ 不触发更新
tableData.value = [...tableData.value] // ✅ 整体替换才触发
// markRaw:排除不需要响应式的对象
const chart = markRaw(echarts.init(el))
3.2 依赖注入(provide / inject)
已在 2.2 组件通信 — Provide / Inject 中详细介绍,包含基本用法和 InjectionKey 类型安全写法。
核心要点回顾:
-
provide(key, value)在祖先组件提供数据 -
inject(key)在任意后代组件注入 - 推荐使用
InjectionKey<T>常量管理 key,实现自动类型推导 - 适用于主题、国际化等跨层级共享数据的场景
3.3 生命周期钩子
Composition API 中通过 onXxx 函数注册生命周期回调,对应组件从创建到销毁的各个阶段。
graph TD
A["setup()"] --> B["onBeforeMount"]
B --> C["onMounted<br/>DOM 已挂载,可访问 DOM / 发请求"]
C --> D["onBeforeUpdate"]
D --> E["onUpdated<br/>DOM 已更新"]
E --> D
C --> F["onBeforeUnmount"]
F --> G["onUnmounted<br/>组件已销毁,清理副作用"]
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted
} from 'vue'
// setup 本身等价于 beforeCreate + created,无需对应钩子
onMounted(() => {
// DOM 已渲染,适合:获取 DOM 引用、发起初始请求、初始化第三方库
initChart()
fetchData()
})
onUpdated(() => {
// 响应式数据变化导致 DOM 更新后触发
// 注意:避免在此修改响应式数据,可能导致无限循环
})
onBeforeUnmount(() => {
// 组件即将销毁,适合:清除定时器、取消订阅、销毁第三方库实例
clearInterval(timer)
chart?.dispose()
})
Options API 与 Composition API 钩子映射:
| Options API | Composition API | 说明 |
|---|---|---|
beforeCreate |
setup() 本身 |
setup 在所有 Options API 钩子之前执行 |
created |
setup() 本身 |
响应式数据已就绪,但 DOM 未挂载 |
beforeMount |
onBeforeMount |
DOM 挂载前 |
mounted |
onMounted |
DOM 已挂载 ✅ |
beforeUpdate |
onBeforeUpdate |
数据变化,DOM 更新前 |
updated |
onUpdated |
DOM 已更新 |
beforeUnmount |
onBeforeUnmount |
组件销毁前 |
unmounted |
onUnmounted |
组件已销毁 |
常用原则: 初始化请求放
onMounted(而非 setup),清理工作放onBeforeUnmount。同一钩子可多次调用,按注册顺序执行。
3.4 组合式函数(Composables)
将相关联的响应式状态 + 逻辑提取为独立函数,实现跨组件复用。命名约定以 use 开头。
// composables/useFetch.ts
import { ref, type Ref } from 'vue'
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<string>
isFetching: Ref<boolean>
execute: () => Promise<void>
}
export function useFetch<T>(url: string | Ref<string>): UseFetchReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref('')
const isFetching = ref(false)
async function execute() {
isFetching.value = true
error.value = ''
try {
const resolvedUrl = typeof url === 'string' ? url : url.value
const res = await fetch(resolvedUrl)
data.value = await res.json()
} catch (e: any) {
error.value = e.message
} finally {
isFetching.value = false
}
}
execute() // 创建时自动执行一次
return { data, error, isFetching, execute }
}
<!-- 在组件中使用 -->
<template>
<div v-if="isFetching">加载中...</div>
<div v-else-if="error">{{ error }}</div>
<ul v-else>
<li v-for="item in data" :key="item.id">{{ item.name }}</li>
</ul>
</template>
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'
const { data, error, isFetching } = useFetch<Item[]>('/api/items')
</script>
Composable 设计原则:
| 原则 | 说明 | |
|---|---|---|
| 单一职责 | 一个 composable 只解决一类问题(如请求、分页、表单校验) | |
返回 ref
|
返回值使用 ref 而非 reactive,调用方解构时不丢失响应性 |
|
命名 useXxx
|
约定以 use 开头,表明这是一个组合式函数 |
|
| 可接收 ref 参数 | 参数支持 `string | Ref`,提高灵活性 |
与 Mixin 的对比: Options API 时代用 Mixin 复用逻辑,但存在命名冲突、来源不明、隐式依赖等问题。Composable 通过显式导入和返回值,完全解决了这些问题。
3.5 <script setup> 语法糖
<script setup> 是 Composition API 在 SFC 中的编译时语法糖,编译器自动处理导出、注册、类型推导,减少样板代码。
核心编译宏
编译宏无需导入,编译器自动识别:
| 宏 | 作用 | 示例 |
|---|---|---|
defineProps |
声明 props | defineProps<{ title: string }>() |
defineEmits |
声明 emits | defineEmits<{ change: [value: string] }>() |
defineExpose |
暴露实例属性/方法 | defineExpose({ reset }) |
defineModel |
声明 v-model 双向绑定 | defineModel<string>('status') |
withDefaults |
为类型声明的 props 设置默认值 | withDefaults(defineProps<P>(), { count: 0 }) |
defineOptions |
声明组件选项(如 name / inheritAttrs) | defineOptions({ name: 'MyComp' }) |
<script setup> vs 普通 <script>
<!-- ❌ 普通 script:需要手动 return、注册组件 -->
<script lang="ts">
import { ref, defineComponent } from 'vue'
import MyButton from './MyButton.vue'
export default defineComponent({
components: { MyButton },
setup() {
const count = ref(0)
function increment() { count.value++ }
return { count, increment } // 必须手动 return
}
})
</script>
<!-- ✅ script setup:顶层变量/导入组件自动暴露给模板 -->
<script setup lang="ts">
import { ref } from 'vue'
import MyButton from './MyButton.vue' // 自动注册,模板中直接用
const count = ref(0)
function increment() { count.value++ }
// 无需 return,顶层声明自动可在模板中使用
</script>
defineOptions 与属性透传(inheritAttrs)
默认情况下,父组件传给子组件的未声明为 props 的属性(如 class、style、id、data-*)会自动透传到子组件的根元素上。通过 defineOptions({ inheritAttrs: false }) 可关闭自动透传,改用 useAttrs() 手动控制。
<!-- BaseInput.vue -->
<template>
<!-- 关闭自动透传后,attrs 不会自动加到根 div 上 -->
<div class="input-wrapper">
<!-- 手动绑定到指定元素 -->
<input v-bind="attrs" />
</div>
</template>
<script setup lang="ts">
import { useAttrs } from 'vue'
defineOptions({
name: 'BaseInput', // 组件名(keep-alive include 匹配用)
inheritAttrs: false // 关闭自动透传
})
const attrs = useAttrs() // 获取所有透传属性
</script>
<!-- 父组件使用 -->
<template>
<!-- class、placeholder、@focus 都会透传到 BaseInput 内部的 <input> 上 -->
<BaseInput class="custom" placeholder="请输入" @focus="onFocus" />
</template>
| 场景 | inheritAttrs |
效果 |
|---|---|---|
| 单根元素组件(默认) | true |
attrs 自动添加到根元素 |
| 需要将 attrs 绑定到非根元素 |
false + v-bind="attrs"
|
手动控制透传目标 |
| 多根元素组件 | — | Vue 警告,必须手动 v-bind="$attrs" 指定 |