Vue 3 入门指南

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

分享

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


目录

  1. Vue 是什么
  2. 安装与项目创建
  3. 项目结构
  4. .vue 文件结构
  5. 响应式基础
  6. 模板语法
  7. 组件系统
  8. 生命周期
  9. 响应式工具函数
  10. 内置组件
  11. Vue Router 基础
  12. Pinia 状态管理
  13. 综合实战:博客列表应用

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 引入(适合快速体验)

<!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 以上版本)。

# 创建项目,会交互式询问配置选项
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 说明

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),将模板、逻辑、样式写在同一个文件中。

<!-- 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 逻辑组织更灵活,适合复杂组件。

<!-- 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 访问和修改内部值。

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

<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() 的局限性:

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
<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 是否只触发一次
<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 } 配置选项
<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) 立即执行
访问旧值 可以通过回调参数获取旧值 无法获取旧值
适用场景 需要对比新旧值,或懒执行的情况 依赖多个数据,只关心副作用

模板语法

文本插值

<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(绑定属性)

<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

<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(列表渲染)

<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(事件绑定)

<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(双向绑定)

<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(插槽)

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

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

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

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

组件系统

创建与局部注册组件

<!-- 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>
<!-- 父组件中局部注册并使用 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
<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(子传父)

<!-- 子组件: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>
<!-- 父组件中监听子组件事件 -->
<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)

<!-- 子组件: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>
<!-- 父组件使用插槽 -->
<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(跨层级传值)

<!-- 祖先组件(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>
<!-- 任意深度的子孙组件 -->
<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 组件实例在创建和销毁过程中会经历一系列阶段,每个阶段都有对应的钩子函数可以执行自定义代码。

<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

<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

<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

<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 事件
<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(列表动画)

<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 最多缓存多少个组件实例,超出时销毁最久未访问的
<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 识别:

<!-- 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 是否禁用传送(禁用时渲染在原位置)
<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 基础

安装与配置

npm install vue-router@4
// 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 较差
<!-- 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

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

路由守卫

// 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:

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

params vs query

// 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 官方推荐的状态管理库,用于在多个组件之间共享状态。

安装与配置

npm install pinia

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

defineStore

// 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 更接近):

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

在组件中使用 Store

<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          # 路由配置

路由配置

// 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

// 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
  }
})

文章卡片组件(子组件)

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

加载动画组件

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

首页(文章列表)

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

文章详情页

<!-- 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,响应性保持;如果用解构赋值,响应性断开。
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-forv-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 关注一个关切点,便于测试和复用。

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

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

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

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


常见陷阱

陷阱:解构 reactive 对象后失去响应性

现象:reactive 对象中解构的变量修改后,视图没有更新。

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

解决: 使用 toRefsreactive 对象的属性转为 ref,或直接使用 ref 而非 reactive

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

<!-- 错误 -->
<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 的依赖。

解决:refreactive、Composable 等替代,通过 getCurrentInstance() 获取实例(仅在特殊场景使用)。


参见

阅读更多

Web 安全基础

1. HTML 转义(服务端渲染必须): 2. CSP(Content Security Policy): 3. HttpOnly Cookie:防止 JS 读取会话 Cookie: 4. 前端框架防护: 攻击者在第三方网站构造一个表单,诱导已登录用户提交,浏览器会自动携带目标站的 Cookie。 触发条件: 1. 用户已登录目标网站(Cookie 有效) 2. 目标 API 仅凭 Cookie 识别用户身份 3. 请求来源未验证 1. CSRF Token(推荐): 2. SameSite Cookie: 3. 验证 Origin/Referer 头:

By yellowdog

HTTP 协议深度指南

HTTP(HyperText Transfer Protocol)是 Web 的基础传输协议,基于 TCP/IP,采用请求/响应模型。 相关文档:Web安全基础(/web-an-quan-ji-chu/) FastAPI完全指南(/fastapi-wan-quan-zhi-nan/) Nginx完全指南(/nginx-wan-quan-zhi-nan/) 幂等性:多次执行相同请求,服务器状态结果相同。PUT /users/1 多次执行结果一致;POST /users 每次创建新资源,非幂等。 浏览器直接从本地缓存读取,不向服务器发送请求。 缓存命中时,状

By yellowdog

系统设计基础

SLA 对照表: 选择建议:无状态服务(Web 层、API 层)优先水平扩展;数据库初期垂直扩展,达到瓶颈后考虑分库分表或读写分离。 缓存穿透(查询不存在的 key,每次都打到 DB): 缓存击穿(热点 key 过期,瞬间大量请求打到 DB): 缓存雪崩(大量 key 同时过期,或缓存服务宕机): 令牌桶 Python 实现: Redis 实现分布式限流(滑动窗口): URL 命名规则: Cursor 分页响应格式: 雪花算法结构(64 bit): 定义:分布式系统不能同时满足以下三个特性: 在分布式环境中 P 是必须保证的,所以实际是 CP vs AP

By yellowdog

算法思路与模板

二分查找要求序列有序,每次将搜索范围缩减一半,时间复杂度 O(log n)。 两个指针从两端向中间收缩,常用于有序数组。 滑动窗口维护一个满足条件的区间 left, right,right 不断向右扩张,条件不满足时收缩 left。 滑动窗口通用框架: 1. 确定"子问题":原问题可以分解为哪些规模更小的同类问题 2. 定义 dpi 或 dpij 的含义,要足够清晰 3. 推导状态转移方程 4. 确定初始状态(边界条件) 5. 确定计算顺序(确保依赖的子问题先计算) 每件物品最多选一次。dpj = 容量为 j 时的最大价值,逆序遍历容量防止重复选取。 每

By yellowdog