普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月24日首页

Vue 3 从基础到组合式 API 全解析

作者 赵_叶紫
2026年2月24日 14:40

目录


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 的运转可以概括为一个闭环:

  1. 用户交互驱动(V → VM): 用户在 View 层触发事件(click、input 等),通过 v-model 或事件绑定将操作传递给 ViewModel 层的方法或响应式变量。
  2. ViewModel 内部联动(VM 内部): 响应式数据变化后,computed 自动重新计算派生值,watch 触发副作用逻辑,生命周期钩子在适当时机执行。
  3. 业务处理(VM → M): ViewModel 调用 Model 层的 API 请求或业务逻辑函数,完成数据的增删改查。
  4. 数据回写(M → VM): Model 层返回结果写入响应式变量,或 Pinia/Vuex 状态变更通知 ViewModel。
  5. 视图自动更新(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:statusdefineModel('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: hiddenz-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 的属性(如 classstyleiddata-*)会自动透传到子组件的根元素上。通过 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" 指定
❌
❌