> ## Content Index
> Fetch the complete content index at: https://blog.vercanti.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Vue 3 入门指南
- URL: https://blog.vercanti.com/vue-3-ru-men-zhi-nan/
- Published: 2026-08-28T14:35:28.000Z
- Updated: 2026-08-28T14:58:52.000Z
- Description: 1. Vue 是什么(#vue-%E6%98%AF%E4%BB%80%E4%B9%88) 2. 安装与项目创建(#%E5%AE%89%E8%A3%85%E4%B8%8E%E9%A1%B9%E7%9B%AE%E5%88%9B%E5%BB%BA) 3. 项目结构(#%E9%A1%B9%E7%9B%AE%E7%BB%93%E6%9E%84) 4. .vue 文件结构(#vue-%E6%96%87%E4%BB%B6%E7%BB%93%E6%9E%84) 5. 响应式基础(#%E5%93%8D%E5%BA%94%E5%BC%8F%E5%9F%BA%E7%A1%80
- Author: yellowdog
- Tags: 前端开发, Vue生态

> 官方文档：<https://cn.vuejs.org/>  
> 最后更新：2026-03-05

---

## 目录

1. [Vue 是什么](#vue-%E6%98%AF%E4%BB%80%E4%B9%88)
2. [安装与项目创建](#%E5%AE%89%E8%A3%85%E4%B8%8E%E9%A1%B9%E7%9B%AE%E5%88%9B%E5%BB%BA)
3. [项目结构](#%E9%A1%B9%E7%9B%AE%E7%BB%93%E6%9E%84)
4. [.vue 文件结构](#vue-%E6%96%87%E4%BB%B6%E7%BB%93%E6%9E%84)
5. [响应式基础](#%E5%93%8D%E5%BA%94%E5%BC%8F%E5%9F%BA%E7%A1%80)
6. [模板语法](#%E6%A8%A1%E6%9D%BF%E8%AF%AD%E6%B3%95)
7. [组件系统](#%E7%BB%84%E4%BB%B6%E7%B3%BB%E7%BB%9F)
8. [生命周期](#%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F)
9. [响应式工具函数](#%E5%93%8D%E5%BA%94%E5%BC%8F%E5%B7%A5%E5%85%B7%E5%87%BD%E6%95%B0)
10. [内置组件](#%E5%86%85%E7%BD%AE%E7%BB%84%E4%BB%B6)
11. [Vue Router 基础](#vue-router-%E5%9F%BA%E7%A1%80)
12. [Pinia 状态管理](#pinia-%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86)
13. [综合实战：博客列表应用](#%E7%BB%BC%E5%90%88%E5%AE%9E%E6%88%98%EF%BC%9A%E5%8D%9A%E5%AE%A2%E5%88%97%E8%A1%A8%E5%BA%94%E7%94%A8)

---

## Vue 是什么

Vue 是一个用于构建用户界面的渐进式 JavaScript 框架。"渐进式"意味着你可以只用它的一小部分功能，也可以将其作为完整的前端解决方案。

### MVVM 概念

MVVM 是一种软件架构模式，分为三层：

| 层级 | 全称              | 作用                          |
| -- | --------------- | --------------------------- |
| M  | Model（模型）       | 应用的数据和业务逻辑                  |
| V  | View（视图）        | 用户看到的界面（HTML/DOM）           |
| VM | ViewModel（视图模型） | 连接 Model 和 View，Vue 实例充当此角色 |

Vue 的核心是**数据驱动视图**：你只需要修改数据，界面会自动更新，不需要手动操作 DOM。

```
数据（Model） <--双向绑定--> Vue 实例（ViewModel） <--自动渲染--> 界面（View）

```

### 虚拟 DOM

虚拟 DOM（Virtual DOM）是 Vue 在内存中维护的一棵 JavaScript 对象树，用来描述真实 DOM 的结构。

工作原理：

1. 数据发生变化时，Vue 先生成新的虚拟 DOM 树
2. 与旧的虚拟 DOM 树进行对比（diff 算法）
3. 找出最小差异，只更新真实 DOM 中变化的部分

好处：避免不必要的 DOM 操作（DOM 操作开销大），提升性能。

---

## 安装与项目创建

### 方式一：CDN 引入（适合快速体验）

```html
<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <title>Vue 3 CDN 示例</title>
</head>
<body>
  <div id="app">
    <!-- 模板语法：双花括号插值 -->
    <p>{{ message }}</p>
    <button @click="changeMessage">点击修改</button>
  </div>

  <!-- 引入 Vue 3 CDN -->
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script>
    const { createApp, ref } = Vue

    createApp({
      setup() {
        const message = ref('你好，Vue 3！')

        function changeMessage() {
          message.value = '消息已修改'
        }

        return { message, changeMessage }
      }
    }).mount('#app')
  </script>
</body>
</html>

```

### 方式二：create-vue 脚手架（推荐，基于 Vite）

**前提条件**：已安装 Node.js（推荐 18.0 以上版本）。

```bash
# 创建项目，会交互式询问配置选项
npm create vue@latest

# 进入项目目录（my-vue-app 是你填写的项目名）
cd my-vue-app

# 安装依赖
npm install

# 启动开发服务器
npm run dev

```

运行 `npm create vue@latest` 后，会询问以下选项：

```
项目名称: my-vue-app
是否添加 TypeScript? No（初学者选 No）
是否添加 JSX 支持? No
是否添加 Vue Router? Yes
是否添加 Pinia? Yes
是否添加 Vitest 单元测试? No
是否添加端到端测试? No
是否添加 ESLint? Yes

```

---

## 项目结构

```
my-vue-app/
├── public/                  # 静态资源，直接复制到构建输出，不经过处理
│   └── favicon.ico
├── src/                     # 源代码目录
│   ├── assets/              # 需要处理的静态资源（图片、字体等）
│   ├── components/          # 可复用组件
│   │   └── HelloWorld.vue
│   ├── router/              # Vue Router 配置
│   │   └── index.js
│   ├── stores/              # Pinia 状态管理
│   │   └── counter.js
│   ├── views/               # 页面级组件（与路由对应）
│   │   └── HomeView.vue
│   ├── App.vue              # 根组件，整个应用的入口组件
│   └── main.js              # 应用入口文件，创建并挂载 Vue 实例
├── index.html               # HTML 入口文件
├── package.json             # 项目依赖与脚本配置
└── vite.config.js           # Vite 构建工具配置

```

### main.js 说明

```javascript
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

// 创建 Vue 应用实例
const app = createApp(App)

// 安装插件
app.use(createPinia()) // 注册 Pinia 状态管理
app.use(router)        // 注册路由

// 将应用挂载到 index.html 中 id 为 app 的元素上
app.mount('#app')

```

---

## .vue 文件结构

`.vue` 文件是 Vue 的单文件组件（Single File Component，SFC），将模板、逻辑、样式写在同一个文件中。

```vue
<!-- template：组件的 HTML 模板，有且只有一个根元素（Vue 3 支持多根元素） -->
<template>
  <div class="greeting">
    <h1>{{ title }}</h1>
    <p>计数：{{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

<!-- script setup：Composition API 写法，推荐 -->
<script setup>
import { ref } from 'vue'

// 在 <script setup> 中声明的变量和函数，可以直接在模板中使用
const title = ref('你好，Vue！')
const count = ref(0)

function increment() {
  count.value++
}
</script>

<!-- style：组件样式，scoped 表示样式只作用于当前组件 -->
<style scoped>
.greeting {
  text-align: center;
  color: #42b883; /* Vue 的绿色 */
}
</style>

```

### Composition API vs Options API 对比

Vue 3 推荐使用 Composition API，Vue 2 使用 Options API。两者都能完成同样的工作，但 Composition API 逻辑组织更灵活，适合复杂组件。

```vue
<!-- Options API 写法（Vue 2 风格，Vue 3 仍然支持） -->
<script>
export default {
  data() {
    return {
      count: 0
    }
  },
  computed: {
    doubled() {
      return this.count * 2
    }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}
</script>

<!-- Composition API 写法（Vue 3 推荐） -->
<script setup>
import { ref, computed } from 'vue'

const count = ref(0)
const doubled = computed(() => count.value * 2)

function increment() {
  count.value++
}
</script>

```

---

## 响应式基础

### ref()

`ref()` 用于创建一个响应式的数据引用，适合基本类型（number、string、boolean）和单个值。

**参数说明：**

| 参数    | 类型  | 必填 | 说明          |
| ----- | --- | -- | ----------- |
| value | any | 是  | 初始值，可以是任意类型 |

**返回值**：一个 Ref 对象，通过 `.value` 访问和修改内部值。

```vue
<template>
  <div>
    <!-- 在模板中使用 ref，不需要写 .value，Vue 会自动解包 -->
    <p>姓名：{{ name }}</p>
    <p>年龄：{{ age }}</p>
    <p>是否登录：{{ isLoggedIn }}</p>
    <button @click="birthday">过生日</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// 基本类型
const name = ref('张三')
const age = ref(25)
const isLoggedIn = ref(false)

// 也可以包裹对象（但推荐用 reactive）
const user = ref({ name: '李四', age: 30 })

function birthday() {
  age.value++        // 在 JS 中访问必须用 .value
  console.log(age.value) // 26
}

// 访问 ref 包裹的对象内部属性
console.log(user.value.name) // 李四
</script>

```

### reactive()

`reactive()` 用于创建一个响应式对象，适合对象和数组。

**参数说明：**

| 参数     | 类型             | 必填 | 说明           |
| ------ | -------------- | -- | ------------ |
| object | object / array | 是  | 要变为响应式的对象或数组 |

**返回值**：原始对象的响应式代理（Proxy）。

```vue
<template>
  <div>
    <p>{{ user.name }} - {{ user.age }} 岁</p>
    <ul>
      <li v-for="item in list" :key="item.id">{{ item.text }}</li>
    </ul>
    <button @click="addItem">添加项目</button>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

// 创建响应式对象，访问时不需要 .value
const user = reactive({
  name: '王五',
  age: 28,
  address: {
    city: '北京'
  }
})

const list = reactive([
  { id: 1, text: '第一项' },
  { id: 2, text: '第二项' }
])

function addItem() {
  user.age++           // 直接修改，不需要 .value
  list.push({ id: list.length + 1, text: `第${list.length + 1}项` })
}
</script>

```

**reactive() 的局限性：**

```javascript
import { reactive } from 'vue'

const state = reactive({ count: 0 })

// 错误：解构会失去响应性
const { count } = state   // count 不再是响应式的

// 错误：直接替换整个对象，响应性断开
// state = reactive({ count: 1 })  // 这样做不行

// 正确：使用 toRefs 解构，或直接通过 state.xxx 访问
import { toRefs } from 'vue'
const { count } = toRefs(state)  // count 现在是 ref，保持响应性

```

### computed()

`computed()` 用于声明派生自其他响应式数据的计算属性，具有缓存特性：只有依赖数据变化时才重新计算。

**参数说明：**

| 参数      | 类型                               | 必填      | 说明                 |
| ------- | -------------------------------- | ------- | ------------------ |
| getter  | Function                         | 是（简写形式） | 返回计算值的函数           |
| options | { get: Function, set: Function } | 是（完整形式） | 包含 getter 和 setter |

```vue
<template>
  <div>
    <p>原始价格：{{ price }} 元</p>
    <p>折后价格：{{ discountedPrice }} 元</p>
    <p>购物车共 {{ cartCount }} 件，合计 {{ total }} 元</p>
    <input v-model.number="customDiscount" type="number" min="1" max="10"> 折
    <p>自定义折扣价：{{ customPrice }} 元</p>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const price = ref(100)
const cartCount = ref(3)
const customDiscount = ref(8) // 8 折

// 只读计算属性（最常用）
const discountedPrice = computed(() => {
  return (price.value * 0.8).toFixed(2)
})

// 依赖多个响应式数据
const total = computed(() => {
  return (price.value * cartCount.value).toFixed(2)
})

// 可写计算属性（getter + setter）
const customPrice = computed({
  get() {
    return (price.value * customDiscount.value / 10).toFixed(2)
  },
  set(newValue) {
    // 根据新的折后价反推折扣
    customDiscount.value = Math.round((newValue / price.value) * 10)
  }
})
</script>

```

### watch()

`watch()` 用于监听响应式数据的变化，执行副作用（如发起请求、操作 DOM）。

**参数说明：**

| 参数       | 类型                                      | 必填 | 说明         |
| -------- | --------------------------------------- | -- | ---------- |
| source   | Ref / reactive对象 / getter函数 / 数组        | 是  | 要监听的数据源    |
| callback | (newValue, oldValue, onCleanup) => void | 是  | 数据变化时执行的回调 |
| options  | WatchOptions 对象                         | 否  | 配置选项，见下表   |

**options 配置项：**

| 选项        | 类型                      | 默认值   | 说明                          |
| --------- | ----------------------- | ----- | --------------------------- |
| immediate | boolean                 | false | 是否在创建时立即执行一次回调              |
| deep      | boolean                 | false | 是否深度监听对象内部变化                |
| flush     | 'pre' / 'post' / 'sync' | 'pre' | 回调执行时机，'post' 表示在 DOM 更新后执行 |
| once      | boolean                 | false | 是否只触发一次                     |

```vue
<script setup>
import { ref, reactive, watch } from 'vue'

const count = ref(0)
const user = reactive({ name: '张三', age: 25 })
const searchQuery = ref('')

// 监听单个 ref
watch(count, (newValue, oldValue) => {
  console.log(`count 从 ${oldValue} 变为 ${newValue}`)
})

// 监听 ref，立即执行（immediate: true）
watch(searchQuery, async (newQuery) => {
  if (newQuery.trim()) {
    // 根据搜索词发起请求
    const result = await fetchSearchResults(newQuery)
    console.log(result)
  }
}, { immediate: true })

// 监听 reactive 对象的某个属性，必须用 getter 函数
watch(
  () => user.age,  // getter 函数
  (newAge, oldAge) => {
    console.log(`年龄从 ${oldAge} 变为 ${newAge}`)
  }
)

// 深度监听整个 reactive 对象
watch(
  user,
  (newUser) => {
    console.log('user 对象有任何变化：', newUser)
  },
  { deep: true }
)

// 同时监听多个数据源
watch(
  [count, () => user.name],  // 数组形式
  ([newCount, newName], [oldCount, oldName]) => {
    console.log('count 或 name 发生了变化')
  }
)

// 停止监听
const stop = watch(count, () => {
  // ...
})
// 调用返回函数来停止监听
stop()
</script>

```

### watchEffect()

`watchEffect()` 自动追踪回调中用到的所有响应式数据，任意一个变化就重新执行。

**参数说明：**

| 参数      | 类型                            | 必填 | 说明            |
| ------- | ----------------------------- | -- | ------------- |
| effect  | (onCleanup: Function) => void | 是  | 副作用函数，会立即执行一次 |
| options | { flush, onTrack, onTrigger } | 否  | 配置选项          |

```vue
<script setup>
import { ref, watchEffect } from 'vue'

const userId = ref(1)
const userData = ref(null)

// 会立即执行，并自动追踪 userId
// userId 变化时自动重新执行
watchEffect(async (onCleanup) => {
  // 取消请求的控制器
  const controller = new AbortController()

  // onCleanup 在下次执行前或组件卸载时调用，用于清理副作用
  onCleanup(() => {
    controller.abort()
  })

  const response = await fetch(`/api/user/${userId.value}`, {
    signal: controller.signal
  })
  userData.value = await response.json()
})
</script>

```

**watch() 与 watchEffect() 的区别：**

| 对比项   | watch()                   | watchEffect() |
| ----- | ------------------------- | ------------- |
| 数据源声明 | 需要明确指定监听的数据源              | 自动追踪，无需声明     |
| 初次执行  | 默认不立即执行（immediate: false） | 立即执行          |
| 访问旧值  | 可以通过回调参数获取旧值              | 无法获取旧值        |
| 适用场景  | 需要对比新旧值，或懒执行的情况           | 依赖多个数据，只关心副作用 |

---

## 模板语法

### 文本插值

```vue
<template>
  <div>
    <!-- 双花括号：文本插值，将数据渲染为纯文本 -->
    <p>{{ message }}</p>

    <!-- 支持 JavaScript 表达式（不能是语句） -->
    <p>{{ message.toUpperCase() }}</p>
    <p>{{ count > 0 ? '正数' : '零或负数' }}</p>
    <p>{{ list.join(', ') }}</p>

    <!-- v-html：渲染 HTML（注意防止 XSS，不要渲染用户输入的内容） -->
    <p v-html="rawHtml"></p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const message = ref('你好，世界')
const count = ref(5)
const list = ref(['苹果', '香蕉', '橙子'])
const rawHtml = ref('<strong>加粗文字</strong>')
</script>

```

### v-bind（绑定属性）

```vue
<template>
  <div>
    <!-- 绑定单个属性 -->
    <a :href="url">访问链接</a>
    <img :src="imageSrc" :alt="imageAlt">

    <!-- 绑定 class（对象语法）：key 是类名，value 是是否添加 -->
    <div :class="{ active: isActive, 'has-error': hasError }">对象语法</div>

    <!-- 绑定 class（数组语法） -->
    <div :class="[baseClass, isActive ? 'active' : '']">数组语法</div>

    <!-- 绑定 style（对象语法）：驼峰命名或字符串 -->
    <div :style="{ color: textColor, fontSize: fontSize + 'px' }">内联样式</div>

    <!-- 绑定 style（数组语法，合并多个样式对象） -->
    <div :style="[baseStyles, extraStyles]">多个样式对象</div>

    <!-- 一次性绑定多个属性（使用对象） -->
    <input v-bind="inputAttrs">

    <!-- 动态属性名 -->
    <a :[dynamicAttr]="dynamicValue">动态属性名</a>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const url = ref('https://cn.vuejs.org/')
const imageSrc = ref('/images/logo.png')
const imageAlt = ref('Vue logo')

const isActive = ref(true)
const hasError = ref(false)
const baseClass = ref('btn')

const textColor = ref('#42b883')
const fontSize = ref(16)

const baseStyles = reactive({ margin: '0', padding: '8px' })
const extraStyles = reactive({ border: '1px solid #ccc' })

// 一次性绑定多个属性
const inputAttrs = reactive({
  type: 'text',
  placeholder: '请输入...',
  maxlength: 100
})

const dynamicAttr = ref('href')
const dynamicValue = ref('https://cn.vuejs.org/')
</script>

```

### v-if / v-else-if / v-else 与 v-show

```vue
<template>
  <div>
    <!-- v-if 系列：条件为 false 时，元素从 DOM 中移除 -->
    <div v-if="score >= 90">优秀</div>
    <div v-else-if="score >= 70">良好</div>
    <div v-else-if="score >= 60">及格</div>
    <div v-else>不及格</div>

    <!-- v-show：条件为 false 时，元素仍在 DOM 中，只是 display: none -->
    <div v-show="isVisible">这个元素始终在 DOM 中</div>

    <!-- 使用 template 包裹多个元素，不会渲染额外节点 -->
    <template v-if="isLoggedIn">
      <p>欢迎回来，{{ username }}</p>
      <button @click="logout">退出登录</button>
    </template>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const score = ref(85)
const isVisible = ref(true)
const isLoggedIn = ref(true)
const username = ref('张三')

function logout() {
  isLoggedIn.value = false
}
</script>

```

**v-if 与 v-show 的区别：**

| 对比项    | v-if            | v-show                |
| ------ | --------------- | --------------------- |
| DOM 行为 | false 时从 DOM 移除 | false 时 display: none |
| 初始渲染成本 | 低（false 时不渲染）   | 高（总是渲染）               |
| 切换成本   | 高（需要销毁/重建）      | 低（只改 CSS）             |
| 适用场景   | 很少切换的条件显示       | 频繁切换的显示隐藏             |

### v-for（列表渲染）

```vue
<template>
  <div>
    <!-- 遍历数组，key 必须是唯一值，帮助 Vue 高效更新 DOM -->
    <ul>
      <li v-for="item in fruits" :key="item">{{ item }}</li>
    </ul>

    <!-- 遍历对象数组，带索引 -->
    <ul>
      <li v-for="(user, index) in users" :key="user.id">
        {{ index + 1 }}. {{ user.name }} - {{ user.age }} 岁
      </li>
    </ul>

    <!-- 遍历对象的属性 -->
    <ul>
      <li v-for="(value, key, index) in person" :key="key">
        {{ index }}. {{ key }}: {{ value }}
      </li>
    </ul>

    <!-- 遍历数字范围（1 到 5） -->
    <span v-for="n in 5" :key="n">{{ n }} </span>

    <!-- 嵌套 v-for -->
    <div v-for="category in categories" :key="category.id">
      <h3>{{ category.name }}</h3>
      <ul>
        <li v-for="product in category.products" :key="product.id">
          {{ product.name }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const fruits = reactive(['苹果', '香蕉', '橙子'])

const users = reactive([
  { id: 1, name: '张三', age: 25 },
  { id: 2, name: '李四', age: 30 },
  { id: 3, name: '王五', age: 28 }
])

const person = reactive({
  name: '张三',
  age: 25,
  city: '北京'
})

const categories = reactive([
  {
    id: 1,
    name: '水果',
    products: [{ id: 101, name: '苹果' }, { id: 102, name: '香蕉' }]
  },
  {
    id: 2,
    name: '蔬菜',
    products: [{ id: 201, name: '白菜' }, { id: 202, name: '萝卜' }]
  }
])
</script>

```

**key 的作用：**

`key` 是 Vue 用来识别 v-for 中每个节点的唯一标识。有了 key，Vue 在更新列表时可以复用现有 DOM 节点（移动、更新），而不是全部重新创建，性能更好。key 应使用稳定的唯一 ID，不推荐用数组索引（index），因为删除/排序后索引会变化，导致意外行为。

### v-on（事件绑定）

```vue
<template>
  <div>
    <!-- 基本事件绑定 -->
    <button @click="handleClick">点击</button>

    <!-- 内联处理器（简单逻辑） -->
    <button @click="count++">计数：{{ count }}</button>

    <!-- 传递参数 -->
    <button @click="greet('张三')">打招呼</button>

    <!-- 访问原生事件对象 -->
    <button @click="handleWithEvent($event)">获取事件</button>

    <!-- 修饰符：阻止默认行为 -->
    <form @submit.prevent="handleSubmit">
      <button type="submit">提交（不刷新页面）</button>
    </form>

    <!-- 修饰符：阻止冒泡 -->
    <div @click="outerClick">
      <button @click.stop="innerClick">内部按钮（不触发外层）</button>
    </div>

    <!-- 修饰符：只触发一次 -->
    <button @click.once="onceHandler">只触发一次</button>

    <!-- 键盘修饰符 -->
    <input @keyup.enter="submitOnEnter" placeholder="按回车提交">
    <input @keyup.ctrl.enter="submitCtrlEnter" placeholder="Ctrl+回车提交">

    <!-- 多个修饰符可以链式使用 -->
    <a @click.prevent.stop="handleLink">链接（阻止默认+阻止冒泡）</a>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)

function handleClick() {
  console.log('按钮被点击')
}

function greet(name) {
  alert(`你好，${name}！`)
}

function handleWithEvent(event) {
  console.log('事件对象：', event)
  console.log('点击位置：', event.clientX, event.clientY)
}

function handleSubmit() {
  console.log('表单提交（页面不刷新）')
}

function outerClick() {
  console.log('外层点击')
}

function innerClick() {
  console.log('内层点击（不会触发外层）')
}

function onceHandler() {
  console.log('只执行一次')
}

function submitOnEnter() {
  console.log('回车提交')
}

function submitCtrlEnter() {
  console.log('Ctrl+回车提交')
}

function handleLink() {
  console.log('处理链接点击')
}
</script>

```

**常用事件修饰符：**

| 修饰符      | 说明                                       |
| -------- | ---------------------------------------- |
| .prevent | event.preventDefault()，阻止默认行为（如表单提交刷新页面） |
| .stop    | event.stopPropagation()，阻止事件冒泡           |
| .once    | 事件只触发一次                                  |
| .self    | 只有事件在元素本身触发时才处理（不包括子元素触发的冒泡）             |
| .capture | 使用事件捕获模式                                 |

**常用键盘修饰符：**

| 修饰符                           | 说明                   |
| ----------------------------- | -------------------- |
| .enter                        | Enter 键              |
| .tab                          | Tab 键                |
| .delete                       | Delete 或 Backspace 键 |
| .esc                          | Escape 键             |
| .space                        | 空格键                  |
| .up / .down / .left / .right  | 方向键                  |
| .ctrl / .alt / .shift / .meta | 系统修饰键                |

### v-model（双向绑定）

```vue
<template>
  <div>
    <!-- 文本输入框 -->
    <input v-model="text" placeholder="输入文本">
    <p>输入内容：{{ text }}</p>

    <!-- 多行文本框 -->
    <textarea v-model="description" rows="4"></textarea>

    <!-- 复选框（单个，绑定 boolean） -->
    <label>
      <input type="checkbox" v-model="agreed">
      同意用户协议
    </label>
    <p>是否同意：{{ agreed }}</p>

    <!-- 复选框（多个，绑定数组） -->
    <label v-for="fruit in fruitOptions" :key="fruit">
      <input type="checkbox" v-model="selectedFruits" :value="fruit">
      {{ fruit }}
    </label>
    <p>已选：{{ selectedFruits.join(', ') }}</p>

    <!-- 单选按钮 -->
    <label v-for="option in genderOptions" :key="option.value">
      <input type="radio" v-model="gender" :value="option.value">
      {{ option.label }}
    </label>
    <p>性别：{{ gender }}</p>

    <!-- 下拉选择框 -->
    <select v-model="city">
      <option value="">请选择城市</option>
      <option value="beijing">北京</option>
      <option value="shanghai">上海</option>
      <option value="guangzhou">广州</option>
    </select>
    <p>城市：{{ city }}</p>

    <!-- v-model 修饰符 -->
    <!-- .trim：自动去除首尾空格 -->
    <input v-model.trim="trimmedText" placeholder="自动去除空格">

    <!-- .number：自动转为数字类型 -->
    <input v-model.number="numValue" type="number" placeholder="数字输入">
    <p>类型：{{ typeof numValue }}</p>

    <!-- .lazy：在失去焦点时同步（而不是每次输入） -->
    <input v-model.lazy="lazyText" placeholder="失去焦点时同步">
  </div>
</template>

<script setup>
import { ref } from 'vue'

const text = ref('')
const description = ref('')
const agreed = ref(false)
const selectedFruits = ref([])
const fruitOptions = ref(['苹果', '香蕉', '橙子', '葡萄'])

const gender = ref('')
const genderOptions = ref([
  { value: 'male', label: '男' },
  { value: 'female', label: '女' }
])

const city = ref('')
const trimmedText = ref('')
const numValue = ref(0)
const lazyText = ref('')
</script>

```

### v-slot（插槽）

插槽用于向子组件传递模板内容，在"组件系统"章节有完整示例。

```vue
<!-- 父组件中使用具名插槽 -->
<template>
  <MyCard>
    <!-- 默认插槽内容 -->
    <p>这是默认插槽内容</p>

    <!-- 具名插槽，v-slot:header 可简写为 #header -->
    <template #header>
      <h2>卡片标题</h2>
    </template>

    <template #footer>
      <button>确认</button>
    </template>
  </MyCard>
</template>

```

---

## 组件系统

### 创建与局部注册组件

```vue
<!-- src/components/UserCard.vue -->
<template>
  <div class="user-card">
    <img :src="avatar" :alt="name">
    <h3>{{ name }}</h3>
    <p>{{ bio }}</p>
    <slot></slot><!-- 默认插槽，父组件可以插入内容 -->
  </div>
</template>

<script setup>
// 使用 defineProps 接收父组件传来的数据
const props = defineProps({
  name: {
    type: String,
    required: true   // 必填
  },
  avatar: {
    type: String,
    default: '/default-avatar.png'  // 默认值
  },
  bio: {
    type: String,
    default: '暂无简介'
  }
})
</script>

<style scoped>
.user-card {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 16px;
}
</style>

```

```vue
<!-- 父组件中局部注册并使用 UserCard -->
<template>
  <div>
    <!-- 使用组件，传递 props -->
    <UserCard
      name="张三"
      avatar="/images/zhangsan.jpg"
      bio="前端开发工程师"
    >
      <!-- 传入默认插槽内容 -->
      <button @click="follow">关注</button>
    </UserCard>

    <!-- 动态绑定 props -->
    <UserCard v-bind="userInfo" />
  </div>
</template>

<script setup>
// 局部注册：直接 import 即可，在 <script setup> 中无需 components 选项
import UserCard from '@/components/UserCard.vue'
import { reactive } from 'vue'

const userInfo = reactive({
  name: '李四',
  bio: '后端开发工程师'
})

function follow() {
  alert('已关注')
}
</script>

```

### props（父传子）

**defineProps() 参数说明：**

| 参数        | 类型             | 说明                                                  |
| --------- | -------------- | --------------------------------------------------- |
| type      | 构造函数或数组        | 期望的类型，如 String、Number、Boolean、Array、Object、Function |
| required  | boolean        | 是否必填，默认 false                                       |
| default   | any / Function | 默认值（对象/数组类型必须用工厂函数返回）                               |
| validator | Function       | 自定义验证函数，返回 true/false                               |

```vue
<script setup>
const props = defineProps({
  // 只指定类型
  title: String,

  // 完整配置
  count: {
    type: Number,
    default: 0
  },

  // 多种类型
  id: {
    type: [String, Number],
    required: true
  },

  // 对象类型，默认值必须用工厂函数
  config: {
    type: Object,
    default: () => ({ theme: 'light' })
  },

  // 数组类型，默认值必须用工厂函数
  tags: {
    type: Array,
    default: () => []
  },

  // 自定义验证
  status: {
    type: String,
    validator(value) {
      // 只允许这三个值
      return ['pending', 'active', 'inactive'].includes(value)
    }
  }
})

// 访问 props
console.log(props.title)
console.log(props.count)
</script>

```

### emit（子传父）

```vue
<!-- 子组件：src/components/SearchBar.vue -->
<template>
  <div class="search-bar">
    <input
      v-model="inputValue"
      @keyup.enter="handleSearch"
      placeholder="搜索..."
    >
    <button @click="handleSearch">搜索</button>
    <button @click="handleClear">清空</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// 声明组件可以触发的事件
const emit = defineEmits({
  // 带验证的事件声明（参数是验证函数，返回 true/false）
  search: (query) => {
    if (typeof query !== 'string') return false
    return query.length > 0
  },
  // 简单声明（无验证）
  clear: null
})

const inputValue = ref('')

function handleSearch() {
  if (inputValue.value.trim()) {
    // 触发事件，传递数据给父组件
    emit('search', inputValue.value.trim())
  }
}

function handleClear() {
  inputValue.value = ''
  emit('clear')
}
</script>

```

```vue
<!-- 父组件中监听子组件事件 -->
<template>
  <div>
    <SearchBar
      @search="handleSearch"
      @clear="handleClear"
    />
    <p v-if="searchQuery">搜索词：{{ searchQuery }}</p>
  </div>
</template>

<script setup>
import SearchBar from '@/components/SearchBar.vue'
import { ref } from 'vue'

const searchQuery = ref('')

function handleSearch(query) {
  searchQuery.value = query
  console.log('收到搜索词：', query)
}

function handleClear() {
  searchQuery.value = ''
  console.log('搜索已清空')
}
</script>

```

### 插槽（slot）

```vue
<!-- 子组件：src/components/BaseCard.vue -->
<template>
  <div class="base-card">
    <!-- 具名插槽：header -->
    <div class="card-header" v-if="$slots.header">
      <slot name="header"></slot>
    </div>

    <!-- 默认插槽（匿名插槽） -->
    <div class="card-body">
      <slot>
        <!-- 默认内容：父组件没有提供内容时显示 -->
        <p>暂无内容</p>
      </slot>
    </div>

    <!-- 具名插槽：footer -->
    <div class="card-footer" v-if="$slots.footer">
      <slot name="footer"></slot>
    </div>

    <!-- 作用域插槽：将子组件数据暴露给父组件 -->
    <div class="card-extra">
      <slot name="extra" :item="internalData" :index="0"></slot>
    </div>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const internalData = reactive({ text: '来自子组件的数据', count: 42 })
</script>

```

```vue
<!-- 父组件使用插槽 -->
<template>
  <BaseCard>
    <!-- 具名插槽：#header 是 v-slot:header 的简写 -->
    <template #header>
      <h2>卡片标题</h2>
    </template>

    <!-- 默认插槽内容 -->
    <p>这是卡片的主要内容</p>

    <!-- 具名插槽 footer -->
    <template #footer>
      <button>确认</button>
      <button>取消</button>
    </template>

    <!-- 作用域插槽：接收子组件传来的数据 -->
    <template #extra="{ item, index }">
      <p>{{ index }}: {{ item.text }} ({{ item.count }})</p>
    </template>
  </BaseCard>
</template>

<script setup>
import BaseCard from '@/components/BaseCard.vue'
</script>

```

### provide / inject（跨层级传值）

```vue
<!-- 祖先组件（App.vue 或某个父组件） -->
<script setup>
import { provide, ref, readonly } from 'vue'

const theme = ref('light')
const currentUser = ref({ name: '张三', role: 'admin' })

// provide(key, value)
// 使用 readonly 防止子孙组件意外修改
provide('theme', readonly(theme))
provide('currentUser', readonly(currentUser))

// 提供修改方法，让子孙组件通过方法修改数据（而不是直接修改）
function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide('toggleTheme', toggleTheme)
</script>

```

```vue
<!-- 任意深度的子孙组件 -->
<template>
  <div :class="['page', theme]">
    <p>当前用户：{{ currentUser.name }}（{{ currentUser.role }}）</p>
    <p>当前主题：{{ theme }}</p>
    <button @click="toggleTheme">切换主题</button>
  </div>
</template>

<script setup>
import { inject } from 'vue'

// inject(key, defaultValue)
const theme = inject('theme', 'light')      // 第二个参数是默认值
const currentUser = inject('currentUser')
const toggleTheme = inject('toggleTheme')
</script>

```

---

## 生命周期

### 生命周期钩子

每个 Vue 组件实例在创建和销毁过程中会经历一系列阶段，每个阶段都有对应的钩子函数可以执行自定义代码。

```vue
<script setup>
import {
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted,
  onErrorCaptured,
  onActivated,
  onDeactivated
} from 'vue'

// 组件挂载到 DOM 之前（此时 DOM 还不存在）
onBeforeMount(() => {
  console.log('beforeMount：组件即将挂载，DOM 还没有创建')
})

// 组件挂载到 DOM 之后（最常用，可以操作 DOM、发起请求）
onMounted(() => {
  console.log('mounted：组件已挂载，可以访问 DOM')
  // 适合：发起初始数据请求、初始化第三方库、操作 DOM
  fetchInitialData()
})

// 组件数据变化，DOM 更新之前
onBeforeUpdate(() => {
  console.log('beforeUpdate：数据已变化，DOM 即将更新')
})

// 组件数据变化，DOM 更新之后
onUpdated(() => {
  console.log('updated：DOM 已更新')
  // 注意：不要在这里修改响应式数据，否则会造成无限循环
})

// 组件卸载之前（此时组件实例还可用）
onBeforeUnmount(() => {
  console.log('beforeUnmount：组件即将卸载')
  // 适合：清除定时器、取消网络请求、移除事件监听
  clearInterval(timer)
})

// 组件卸载之后
onUnmounted(() => {
  console.log('unmounted：组件已卸载')
})

// 捕获子孙组件抛出的错误
onErrorCaptured((error, instance, info) => {
  console.error('捕获到错误：', error)
  return false // 返回 false 阻止错误继续传播
})

// 被 KeepAlive 缓存的组件激活时
onActivated(() => {
  console.log('activated：从缓存中激活')
})

// 被 KeepAlive 缓存的组件停用时
onDeactivated(() => {
  console.log('deactivated：被缓存停用')
})

async function fetchInitialData() {
  // 发起初始请求
}

let timer = null
onMounted(() => {
  timer = setInterval(() => {
    console.log('定时器执行')
  }, 1000)
})
</script>

```

### 与 Options API 生命周期对应关系

| Composition API  | Options API   | 触发时机            |
| ---------------- | ------------- | --------------- |
| onBeforeMount    | beforeMount   | 组件挂载前，DOM 不存在   |
| onMounted        | mounted       | 组件挂载后，可访问 DOM   |
| onBeforeUpdate   | beforeUpdate  | 数据变化，DOM 更新前    |
| onUpdated        | updated       | 数据变化，DOM 更新后    |
| onBeforeUnmount  | beforeDestroy | 组件卸载前           |
| onUnmounted      | destroyed     | 组件卸载后           |
| onErrorCaptured  | errorCaptured | 子孙组件报错时         |
| onActivated      | activated     | KeepAlive 激活时   |
| onDeactivated    | deactivated   | KeepAlive 停用时   |
| （无，用 setup() 本身） | created       | 实例创建后，响应式数据已初始化 |
| （无，用 setup() 本身） | beforeCreate  | 实例创建前           |

注意：在 `<script setup>` 中，顶层代码相当于在 `created` 钩子中执行。

---

## 响应式工具函数

### toRef / toRefs

```vue
<script setup>
import { reactive, toRef, toRefs } from 'vue'

const state = reactive({
  name: '张三',
  age: 25,
  city: '北京'
})

// toRef：为响应式对象的某个属性创建 ref，保持响应性
const nameRef = toRef(state, 'name')
// 修改 nameRef.value 会同步修改 state.name，反之亦然
nameRef.value = '李四'
console.log(state.name) // 李四

// toRefs：将整个响应式对象转为一组 ref，用于解构
const { name, age, city } = toRefs(state)
// 修改 name.value 会同步修改 state.name
name.value = '王五'
console.log(state.name) // 王五
</script>

```

### isRef / isReactive / unref

```vue
<script setup>
import { ref, reactive, isRef, isReactive, unref } from 'vue'

const count = ref(0)
const user = reactive({ name: '张三' })
const normalValue = 42

// isRef：检查是否是 ref
console.log(isRef(count))       // true
console.log(isRef(user))        // false
console.log(isRef(normalValue)) // false

// isReactive：检查是否是 reactive 对象
console.log(isReactive(user))  // true
console.log(isReactive(count)) // false

// unref：如果是 ref 则返回 .value，否则原样返回
// 等价于：isRef(value) ? value.value : value
console.log(unref(count))       // 0
console.log(unref(normalValue)) // 42
</script>

```

### readonly

```vue
<script setup>
import { reactive, readonly, ref } from 'vue'

const original = reactive({ count: 0, name: '张三' })

// 创建只读版本
const readonlyState = readonly(original)

// 尝试修改会在开发模式下发出警告，且修改无效
// readonlyState.count++  // 警告：Set operation on key "count" failed

// 修改原始对象，只读副本会自动同步
original.count++
console.log(readonlyState.count) // 1（已同步）

// ref 也可以用 readonly
const countRef = ref(0)
const readonlyCount = readonly(countRef)
</script>

```

---

## 内置组件

### Transition（过渡动画）

**Transition 参数说明：**

| 属性     | 类型                         | 默认值   | 说明                 |
| ------ | -------------------------- | ----- | ------------------ |
| name   | string                     | 'v'   | 过渡类名前缀             |
| mode   | 'in-out' / 'out-in'        | 无     | 过渡模式，out-in 表示先出后入 |
| appear | boolean                    | false | 初始渲染时是否应用过渡        |
| type   | 'transition' / 'animation' | 自动    | 指定监听哪种 CSS 事件      |

```vue
<template>
  <div>
    <button @click="show = !show">切换</button>

    <!-- name="fade" 会生成 fade-enter-active、fade-leave-active 等类名 -->
    <Transition name="fade" mode="out-in">
      <p v-if="show">这段文字会有淡入淡出效果</p>
      <p v-else>这是另一段文字</p>
    </Transition>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>

<style>
/* 进入的起始状态 和 离开的结束状态 */
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

/* 进入过渡激活期间 和 离开过渡激活期间 */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}

/* 进入的结束状态 和 离开的起始状态（通常不需要显式设置，保持默认） */
.fade-enter-to,
.fade-leave-from {
  opacity: 1;
}
</style>

```

**CSS 过渡类名说明：**

| 类名             | 触发时机      |
| -------------- | --------- |
| v-enter-from   | 进入动画的起始帧  |
| v-enter-active | 进入动画的整个过程 |
| v-enter-to     | 进入动画的结束帧  |
| v-leave-from   | 离开动画的起始帧  |
| v-leave-active | 离开动画的整个过程 |
| v-leave-to     | 离开动画的结束帧  |

注：`v-` 是默认前缀，使用 `name="fade"` 时变为 `fade-`。

### TransitionGroup（列表动画）

```vue
<template>
  <div>
    <button @click="addItem">添加</button>
    <button @click="removeItem">删除</button>

    <!-- tag 指定渲染的包裹元素，默认不渲染包裹元素 -->
    <TransitionGroup name="list" tag="ul">
      <li v-for="item in items" :key="item.id" class="list-item">
        {{ item.text }}
      </li>
    </TransitionGroup>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const items = reactive([
  { id: 1, text: '项目一' },
  { id: 2, text: '项目二' },
  { id: 3, text: '项目三' }
])

let nextId = 4

function addItem() {
  items.push({ id: nextId++, text: `项目${nextId - 1}` })
}

function removeItem() {
  if (items.length > 0) items.pop()
}
</script>

<style>
.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

.list-enter-active,
.list-leave-active {
  transition: all 0.3s ease;
}

/* 列表排序移动动画（TransitionGroup 特有） */
.list-move {
  transition: transform 0.3s ease;
}
</style>

```

### KeepAlive（组件缓存）

KeepAlive 可以缓存组件实例，避免组件切换时重新创建和销毁，保留组件的状态。

**KeepAlive 参数说明：**

| 属性      | 类型                      | 说明                      |
| ------- | ----------------------- | ----------------------- |
| include | string / RegExp / Array | 只缓存名字匹配的组件              |
| exclude | string / RegExp / Array | 不缓存名字匹配的组件              |
| max     | number                  | 最多缓存多少个组件实例，超出时销毁最久未访问的 |

```vue
<template>
  <div>
    <button @click="current = 'TabA'">标签 A</button>
    <button @click="current = 'TabB'">标签 B</button>

    <!-- 只缓存 TabA，TabB 不缓存 -->
    <KeepAlive include="TabA" :max="5">
      <component :is="current === 'TabA' ? TabA : TabB" />
    </KeepAlive>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import TabA from '@/components/TabA.vue'
import TabB from '@/components/TabB.vue'

const current = ref('TabA')
</script>

```

被 KeepAlive 缓存的组件需要通过 `defineOptions` 设置组件名，才能被 `include/exclude` 识别：

```vue
<!-- TabA.vue -->
<script setup>
import { defineOptions } from 'vue'

// 设置组件名，供 KeepAlive 的 include/exclude 匹配
defineOptions({
  name: 'TabA'
})
</script>

```

### Teleport（传送门）

Teleport 可以将组件模板的一部分"传送"到 DOM 中的其他位置（比如 `body`），常用于模态框、提示框等。

**Teleport 参数说明：**

| 属性       | 类型                   | 必填 | 说明                         |
| -------- | -------------------- | -- | -------------------------- |
| to       | string / HTMLElement | 是  | 目标 DOM 元素的 CSS 选择器或 DOM 节点 |
| disabled | boolean              | 否  | 是否禁用传送（禁用时渲染在原位置）          |

```vue
<template>
  <div class="component">
    <p>这段内容渲染在组件内部</p>

    <!-- 模态框被传送到 body 下，避免被父元素的 overflow:hidden 裁剪 -->
    <Teleport to="body">
      <div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
        <div class="modal">
          <h2>模态框标题</h2>
          <p>这个模态框被渲染到 body 下，而不是组件内部</p>
          <button @click="showModal = false">关闭</button>
        </div>
      </div>
    </Teleport>

    <button @click="showModal = true">打开模态框</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const showModal = ref(false)
</script>

```

---

## Vue Router 基础

### 安装与配置

```bash
npm install vue-router@4

```

```javascript
// src/router/index.js
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'

const routes = [
  {
    path: '/',           // URL 路径
    name: 'home',        // 路由名称（可选，但推荐）
    component: HomeView  // 对应的组件
  },
  {
    path: '/about',
    name: 'about',
    // 懒加载：只在访问此路由时才加载组件，减小初始包体积
    component: () => import('@/views/AboutView.vue')
  },
  {
    // 动态路由参数，:id 会匹配任意值
    path: '/post/:id',
    name: 'post-detail',
    component: () => import('@/views/PostDetail.vue')
  },
  {
    // 嵌套路由
    path: '/user',
    component: () => import('@/views/UserLayout.vue'),
    children: [
      {
        path: '',          // 空路径匹配 /user
        name: 'user-home',
        component: () => import('@/views/UserHome.vue')
      },
      {
        path: 'profile',   // 匹配 /user/profile
        name: 'user-profile',
        component: () => import('@/views/UserProfile.vue')
      }
    ]
  },
  {
    // 重定向
    path: '/old-path',
    redirect: '/new-path'
  },
  {
    // 404 页面，匹配所有未匹配的路径
    path: '/:pathMatch(.*)*',
    name: 'not-found',
    component: () => import('@/views/NotFound.vue')
  }
]

const router = createRouter({
  // createWebHistory：使用 HTML5 History 模式，URL 没有 # 号（需要服务器配置）
  // createWebHashHistory：使用 Hash 模式，URL 有 # 号，不需要服务器配置
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

export default router

```

**History 模式对比：**

| 模式                   | URL 示例   | 优点           | 缺点                       |
| -------------------- | -------- | ------------ | ------------------------ |
| createWebHistory     | /about   | URL 简洁美观     | 需要服务器配置，直接访问子路径时不能返回 404 |
| createWebHashHistory | /#/about | 无需服务器配置，兼容性好 | URL 不美观，SEO 较差           |

### RouterView 与 RouterLink

```vue
<!-- App.vue -->
<template>
  <div>
    <!-- 导航链接，会自动添加 active-class -->
    <nav>
      <!-- RouterLink 会渲染为 <a> 标签 -->
      <RouterLink to="/">首页</RouterLink>
      <RouterLink to="/about">关于</RouterLink>

      <!-- 使用命名路由 -->
      <RouterLink :to="{ name: 'post-detail', params: { id: 1 } }">文章详情</RouterLink>

      <!-- 带查询参数 -->
      <RouterLink :to="{ path: '/search', query: { q: 'vue' } }">
        搜索
      </RouterLink>
    </nav>

    <!-- 路由组件渲染的位置 -->
    <RouterView />
  </div>
</template>

```

### useRouter / useRoute

```vue
<script setup>
import { useRouter, useRoute } from 'vue-router'
import { computed } from 'vue'

// useRouter：获取路由器实例，用于编程式导航
const router = useRouter()

// useRoute：获取当前路由信息（响应式）
const route = useRoute()

// 访问路由信息
console.log(route.path)     // 当前路径，如 '/post/123'
console.log(route.name)     // 路由名称，如 'post-detail'
console.log(route.params)   // 动态路由参数，如 { id: '123' }
console.log(route.query)    // 查询参数，如 { page: '1', sort: 'date' }
console.log(route.meta)     // 路由元信息

// 动态路由参数
const postId = computed(() => route.params.id)

// 编程式导航
function goToHome() {
  router.push('/')                           // 导航到路径
  router.push({ name: 'home' })             // 使用命名路由
  router.push({ path: '/post/1' })          // 带路径
  router.push({ name: 'post-detail', params: { id: 2 } })  // 带参数
  router.push({ path: '/search', query: { q: 'vue' } })     // 带查询参数
}

function replaceRoute() {
  // replace：替换当前历史记录（无法后退）
  router.replace('/login')
}

function goBack() {
  router.go(-1)  // 后退一步，等同于浏览器后退
  router.go(1)   // 前进一步
  router.back()  // 同 router.go(-1)
  router.forward() // 同 router.go(1)
}
</script>

```

### 路由守卫

```javascript
// src/router/index.js

// 全局前置守卫：每次路由跳转前执行
router.beforeEach((to, from, next) => {
  // to：即将进入的路由
  // from：正在离开的路由
  // next：调用此函数来 resolve 守卫

  const isLoggedIn = localStorage.getItem('token')

  // 路由元信息 meta.requiresAuth 标记需要登录的路由
  if (to.meta.requiresAuth && !isLoggedIn) {
    // 未登录，跳转到登录页，并记录来源页
    next({ name: 'login', query: { redirect: to.fullPath } })
  } else {
    next() // 正常放行
  }
})

// 全局后置守卫：路由跳转完成后执行（没有 next 参数）
router.afterEach((to, from) => {
  // 修改页面标题
  document.title = to.meta.title || '默认标题'
})

```

在路由配置中添加 meta：

```javascript
const routes = [
  {
    path: '/dashboard',
    name: 'dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: {
      requiresAuth: true,  // 需要登录
      title: '控制台'
    }
  }
]

```

### params vs query

```javascript
// params：动态路由参数，是路径的一部分
// 路由配置：{ path: '/post/:id' }
router.push({ name: 'post-detail', params: { id: 123 } })
// URL：/post/123
// 组件中获取：route.params.id  → '123'（字符串）

// query：查询字符串，附加在 URL 后面
router.push({ path: '/search', query: { keyword: 'vue', page: 2 } })
// URL：/search?keyword=vue&page=2
// 组件中获取：route.query.keyword → 'vue'，route.query.page → '2'

```

---

## Pinia 状态管理

Pinia 是 Vue 官方推荐的状态管理库，用于在多个组件之间共享状态。

### 安装与配置

```bash
npm install pinia

```

已在 main.js 中配置（见"项目结构"章节）。

### defineStore

```javascript
// src/stores/userStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// defineStore 参数：
// 第一个参数：store 的唯一 ID（字符串），用于 devtools 识别
// 第二个参数：setup 函数（Composition API 风格，推荐）
export const useUserStore = defineStore('user', () => {
  // state：响应式数据
  const currentUser = ref(null)
  const token = ref(localStorage.getItem('token') || '')
  const loading = ref(false)

  // getters：计算属性
  const isLoggedIn = computed(() => !!token.value)
  const username = computed(() => currentUser.value?.name || '游客')

  // actions：方法（可以是异步的）
  async function login(username, password) {
    loading.value = true
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password })
      })
      const data = await response.json()

      token.value = data.token
      currentUser.value = data.user
      localStorage.setItem('token', data.token)
    } catch (error) {
      console.error('登录失败：', error)
      throw error
    } finally {
      loading.value = false
    }
  }

  function logout() {
    token.value = ''
    currentUser.value = null
    localStorage.removeItem('token')
  }

  // 返回所有需要暴露的内容
  return {
    currentUser,
    token,
    loading,
    isLoggedIn,
    username,
    login,
    logout
  }
})

```

也可以用 Options 风格定义（与 Vue 2 的 Vuex 更接近）：

```javascript
// Options 风格（了解即可）
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),
  getters: {
    doubled: (state) => state.count * 2
  },
  actions: {
    increment() {
      this.count++
    }
  }
})

```

### 在组件中使用 Store

```vue
<template>
  <div>
    <p v-if="userStore.isLoggedIn">
      欢迎，{{ userStore.username }}
    </p>
    <p v-else>请先登录</p>

    <!-- 使用 storeToRefs 解构的响应式属性 -->
    <p>登录状态：{{ isLoggedIn }}</p>
    <p>当前用户：{{ username }}</p>

    <button @click="handleLogout" v-if="isLoggedIn">退出登录</button>
  </div>
</template>

<script setup>
import { useUserStore } from '@/stores/userStore'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()

// storeToRefs：解构 store 的 state 和 getters，保持响应性
// 注意：actions 不需要解构，直接从 store 调用
const { isLoggedIn, username, loading } = storeToRefs(userStore)

// actions 直接从 store 实例解构（不用 storeToRefs）
const { login, logout } = userStore

function handleLogout() {
  logout()
}
</script>

```

### 与 Vuex 的对比

| 对比项           | Pinia                      | Vuex 4（Vue 3 版）                    |
| ------------- | -------------------------- | ---------------------------------- |
| 模板文件结构        | store、getters、actions 扁平结构 | state、getters、mutations、actions 四层 |
| 修改状态          | 直接修改或在 action 中修改          | 必须通过 mutation（同步）才能修改              |
| TypeScript 支持 | 原生支持，类型推断好                 | 需要额外配置                             |
| 模块化           | 每个 store 文件是独立模块           | 需要显式 modules 配置                    |
| devtools      | 支持                         | 支持                                 |
| 代码量           | 更少                         | 更多（需要写 mutation）                   |

---

## 综合实战：博客列表应用

本节实现一个包含路由、Pinia 状态管理、父子组件通信、数据请求的完整应用。

### 项目结构

```
src/
├── components/
│   ├── PostCard.vue      # 文章卡片组件（子组件）
│   └── LoadingSpinner.vue # 加载动画组件
├── views/
│   ├── HomeView.vue      # 首页（文章列表）
│   └── PostDetail.vue    # 文章详情页
├── stores/
│   └── postsStore.js     # 文章状态管理
└── router/
    └── index.js          # 路由配置

```

### 路由配置

```javascript
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/HomeView.vue'),
    meta: { title: '博客列表' }
  },
  {
    path: '/post/:id',
    name: 'post-detail',
    component: () => import('@/views/PostDetail.vue'),
    meta: { title: '文章详情' }
  }
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

router.afterEach((to) => {
  document.title = to.meta.title || '我的博客'
})

export default router

```

### Pinia Store

```javascript
// src/stores/postsStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const usePostsStore = defineStore('posts', () => {
  const posts = ref([])
  const currentPost = ref(null)
  const loading = ref(false)
  const error = ref(null)

  // 使用 JSONPlaceholder 提供的免费 API
  const API_BASE = 'https://jsonplaceholder.typicode.com'

  const postCount = computed(() => posts.value.length)

  async function fetchPosts() {
    loading.value = true
    error.value = null
    try {
      const response = await fetch(`${API_BASE}/posts?_limit=10`)
      if (!response.ok) throw new Error('请求失败')
      posts.value = await response.json()
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  async function fetchPostById(id) {
    loading.value = true
    error.value = null
    currentPost.value = null
    try {
      const [postResponse, commentsResponse] = await Promise.all([
        fetch(`${API_BASE}/posts/${id}`),
        fetch(`${API_BASE}/posts/${id}/comments`)
      ])
      const post = await postResponse.json()
      const comments = await commentsResponse.json()
      currentPost.value = { ...post, comments }
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  return {
    posts,
    currentPost,
    loading,
    error,
    postCount,
    fetchPosts,
    fetchPostById
  }
})

```

### 文章卡片组件（子组件）

```vue
<!-- src/components/PostCard.vue -->
<template>
  <div class="post-card" @click="handleClick">
    <div class="post-meta">
      <span class="post-id">文章 #{{ post.id }}</span>
      <span class="user-id">作者 ID：{{ post.userId }}</span>
    </div>
    <h2 class="post-title">{{ post.title }}</h2>
    <p class="post-body">{{ truncatedBody }}</p>
    <div class="post-actions">
      <!-- 触发自定义事件，通知父组件 -->
      <button @click.stop="handleClick" class="read-btn">阅读全文</button>
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue'

// 接收父组件传来的数据
const props = defineProps({
  post: {
    type: Object,
    required: true
  }
})

// 声明要触发的事件
const emit = defineEmits({
  select: (post) => post && typeof post.id === 'number'
})

// 截断正文，只显示前 80 个字符
const truncatedBody = computed(() => {
  if (props.post.body.length > 80) {
    return props.post.body.substring(0, 80) + '...'
  }
  return props.post.body
})

function handleClick() {
  // 将文章数据传给父组件
  emit('select', props.post)
}
</script>

<style scoped>
.post-card {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px;
  margin-bottom: 16px;
  cursor: pointer;
  transition: box-shadow 0.2s ease;
}

.post-card:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.post-meta {
  display: flex;
  gap: 12px;
  margin-bottom: 8px;
  font-size: 12px;
  color: #888;
}

.post-title {
  font-size: 18px;
  margin-bottom: 8px;
  color: #333;
  text-transform: capitalize;
}

.post-body {
  color: #666;
  line-height: 1.6;
  margin-bottom: 12px;
}

.read-btn {
  padding: 6px 16px;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

```

### 加载动画组件

```vue
<!-- src/components/LoadingSpinner.vue -->
<template>
  <div class="loading-wrapper">
    <div class="spinner"></div>
    <p>{{ text }}</p>
  </div>
</template>

<script setup>
defineProps({
  text: {
    type: String,
    default: '加载中...'
  }
})
</script>

<style scoped>
.loading-wrapper {
  text-align: center;
  padding: 40px;
  color: #888;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 3px solid #e0e0e0;
  border-top-color: #42b883;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
  margin: 0 auto 12px;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}
</style>

```

### 首页（文章列表）

```vue
<!-- src/views/HomeView.vue -->
<template>
  <div class="home">
    <header class="page-header">
      <h1>我的博客</h1>
      <p>共 {{ postCount }} 篇文章</p>
    </header>

    <!-- 加载状态 -->
    <LoadingSpinner v-if="loading" text="正在加载文章..." />

    <!-- 错误状态 -->
    <div v-else-if="error" class="error-message">
      <p>加载失败：{{ error }}</p>
      <button @click="store.fetchPosts()">重试</button>
    </div>

    <!-- 文章列表 -->
    <div v-else class="post-list">
      <PostCard
        v-for="post in posts"
        :key="post.id"
        :post="post"
        @select="handlePostSelect"
      />
    </div>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import PostCard from '@/components/PostCard.vue'
import LoadingSpinner from '@/components/LoadingSpinner.vue'
import { usePostsStore } from '@/stores/postsStore'

const router = useRouter()
const store = usePostsStore()

// 解构响应式数据
const { posts, loading, error, postCount } = storeToRefs(store)

// 组件挂载后发起请求
onMounted(async () => {
  // 如果已有数据，不重复请求
  if (posts.value.length === 0) {
    await store.fetchPosts()
  }
})

// 接收子组件传来的文章，跳转到详情页
function handlePostSelect(post) {
  router.push({ name: 'post-detail', params: { id: post.id } })
}
</script>

<style scoped>
.home {
  max-width: 800px;
  margin: 0 auto;
  padding: 24px 16px;
}

.page-header {
  margin-bottom: 24px;
  border-bottom: 1px solid #e0e0e0;
  padding-bottom: 16px;
}

.page-header h1 {
  color: #42b883;
  margin-bottom: 4px;
}

.error-message {
  text-align: center;
  padding: 40px;
  color: #e74c3c;
}
</style>

```

### 文章详情页

```vue
<!-- src/views/PostDetail.vue -->
<template>
  <div class="post-detail">
    <button @click="router.back()" class="back-btn">返回列表</button>

    <LoadingSpinner v-if="loading" text="正在加载文章..." />

    <div v-else-if="error" class="error-message">
      <p>加载失败：{{ error }}</p>
    </div>

    <article v-else-if="currentPost" class="article">
      <header>
        <div class="meta">
          <span>文章 #{{ currentPost.id }}</span>
          <span>作者 ID：{{ currentPost.userId }}</span>
        </div>
        <h1>{{ currentPost.title }}</h1>
      </header>

      <div class="body">
        <p>{{ currentPost.body }}</p>
      </div>

      <!-- 评论区 -->
      <section class="comments">
        <h2>评论（{{ currentPost.comments.length }}）</h2>
        <div
          v-for="comment in currentPost.comments"
          :key="comment.id"
          class="comment"
        >
          <div class="comment-header">
            <strong>{{ comment.name }}</strong>
            <span>{{ comment.email }}</span>
          </div>
          <p>{{ comment.body }}</p>
        </div>
      </section>
    </article>
  </div>
</template>

<script setup>
import { onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import LoadingSpinner from '@/components/LoadingSpinner.vue'
import { usePostsStore } from '@/stores/postsStore'

const route = useRoute()
const router = useRouter()
const store = usePostsStore()

const { currentPost, loading, error } = storeToRefs(store)

// 根据路由参数加载文章
async function loadPost(id) {
  await store.fetchPostById(id)
}

// 组件挂载时加载
onMounted(() => {
  loadPost(route.params.id)
})

// 路由参数变化时重新加载（从一篇文章直接跳到另一篇）
watch(() => route.params.id, (newId) => {
  if (newId) {
    loadPost(newId)
  }
})
</script>

<style scoped>
.post-detail {
  max-width: 800px;
  margin: 0 auto;
  padding: 24px 16px;
}

.back-btn {
  margin-bottom: 24px;
  padding: 8px 16px;
  background: none;
  border: 1px solid #42b883;
  color: #42b883;
  border-radius: 4px;
  cursor: pointer;
}

.back-btn:hover {
  background-color: #42b883;
  color: white;
}

.article header {
  margin-bottom: 24px;
  padding-bottom: 16px;
  border-bottom: 1px solid #e0e0e0;
}

.meta {
  display: flex;
  gap: 12px;
  font-size: 12px;
  color: #888;
  margin-bottom: 8px;
}

.article h1 {
  font-size: 24px;
  color: #333;
  text-transform: capitalize;
}

.body {
  line-height: 1.8;
  color: #555;
  margin-bottom: 32px;
}

.comments h2 {
  font-size: 18px;
  margin-bottom: 16px;
  color: #333;
}

.comment {
  border-left: 3px solid #42b883;
  padding: 12px;
  margin-bottom: 12px;
  background-color: #f9f9f9;
}

.comment-header {
  display: flex;
  gap: 8px;
  align-items: center;
  margin-bottom: 8px;
}

.comment-header strong {
  font-size: 14px;
}

.comment-header span {
  font-size: 12px;
  color: #888;
}

.comment p {
  font-size: 14px;
  color: #666;
}
</style>

```

---

## 注意事项与常见误区

### ref 相关

- 在 `<script>` 中访问 ref 值必须用 `.value`；在模板中不需要。
- 将 ref 传给函数时，如果函数内修改的是 `.value`，响应性保持；如果用解构赋值，响应性断开。

```javascript
const count = ref(0)

// 正确：传引用，函数内通过 .value 修改
function increment(countRef) {
  countRef.value++
}
increment(count)

// 错误：解构丢失响应性
function wrong({ value }) {
  value++ // 这里修改的只是局部变量，不会触发视图更新
}

```

### reactive 相关

- 不能解构 reactive 对象，解构后的值失去响应性，用 `toRefs` 解决。
- 不能直接替换 reactive 对象（如 `state = reactive({...})`），会断开响应性。
- 只支持对象类型（object、array、Map、Set），不支持基本类型。

### v-for 与 key

- 始终为 `v-for` 提供 `:key`，且使用稳定的唯一 ID，不要用 index。
- 不要在同一元素上同时使用 `v-for` 和 `v-if`，用 `<template>` 分层处理。

### 生命周期

- 在 `onMounted` 中才能操作 DOM，不要在 `setup` 顶层操作 DOM。
- 在 `onBeforeUnmount` 中清理副作用（定时器、事件监听、WebSocket 等）。

### 路由

- `route.params.id` 是字符串类型，和数字比较时注意类型转换。
- 使用命名路由（`name`）而不是硬编码路径，路径变化时只需修改路由配置。

---

## 最佳实践

**用 `ref` 存储基本类型，用 `reactive` 存储对象，不混用**：`ref` 通过 `.value` 访问，全场景适用；`reactive` 只支持对象，但访问字段时无需 `.value`，适合复杂表单对象。混用导致规则不一致，维护成本高。

**将复杂状态逻辑提取为 Composable（`useXxx`）**：超过 50 行的组件 `setup` 逻辑应拆分为独立的 `use*.ts` 文件，每个 Composable 关注一个关切点，便于测试和复用。

```typescript
// composables/useCounter.ts
export function useCounter(initial = 0) {
  const count = ref(initial)
  const increment = () => count.value++
  return { count, increment }
}

```

**使用 `computed` 缓存派生状态，不要在模板中写复杂表达式**：`computed` 只在依赖变化时重新计算，模板中的方法调用每次渲染都执行，性能差且难以调试。

**在 `onBeforeUnmount` 中清理所有副作用**：`setInterval`、`addEventListener`、WebSocket 连接等若不清理，组件卸载后仍在运行，造成内存泄漏和意外行为。

**`v-for` 始终绑定稳定的业务 ID 作为 `:key`，不用 index**：用 index 作为 key 时，列表项删除或插入会导致 Vue 复用错误的 DOM 节点，产生数据显示错误，尤其是含有表单控件的列表。

---

## 常见陷阱

### 陷阱：解构 `reactive` 对象后失去响应性

**现象：** 从 `reactive` 对象中解构的变量修改后，视图没有更新。

**原因：** `reactive` 的响应性依赖对对象属性的 Proxy 拦截，解构后变量持有的是原始值（基本类型）或原始引用（对象），与 Proxy 断开连接，无法追踪变化。

**解决：** 使用 `toRefs` 将 `reactive` 对象的属性转为 `ref`，或直接使用 `ref` 而非 `reactive`。

```typescript
const state = reactive({ count: 0, name: 'Alice' })

// 错误：解构丢失响应性
const { count } = state

// 正确：toRefs 保持响应性
const { count, name } = toRefs(state)

```

### 陷阱：在模板中对列表用 index 作为 key

**现象：** 删除列表中间的某一项后，其他项的显示出现错位，或输入框的内容跑到错误的行。

**原因：** Vue 使用 key 来识别哪些节点需要复用。用 index 作 key 时，删除中间项会导致后续所有项的 key 全部变化，Vue 无法正确复用 DOM，会错误地将旧节点内容保留在新位置。

**解决：** 始终用稳定的唯一业务 ID（如数据库主键）作为 `:key`。

```html
<!-- 错误 -->
<div v-for="(item, index) in list" :key="index">

<!-- 正确 -->
<div v-for="item in list" :key="item.id">

```

### 陷阱：在 Composition API 中访问 `this`

**现象：** 在 `setup()` 中使用 `this` 访问组件实例的属性或方法，返回 `undefined`。

**原因：** `setup()` 在组件实例创建前执行，`this` 不指向组件实例。Composition API 的设计就是为了摆脱对 `this` 的依赖。

**解决：** 用 `ref`、`reactive`、Composable 等替代，通过 `getCurrentInstance()` 获取实例（仅在特殊场景使用）。

---

## 参见

- [JavaScript入门](https://blog.vercanti.com/javascript-ru-men/)
- [HTML与CSS入门](https://blog.vercanti.com/html-yu-css-ru-men/)
- [Vue Router完全指南](https://blog.vercanti.com/vue-router-wan-quan-zhi-nan/)
- [Pinia完全指南](https://blog.vercanti.com/pinia-wan-quan-zhi-nan/)
- [TypeScript完全指南](https://blog.vercanti.com/typescript-wan-quan-zhi-nan/)