一、Vue 脚手架(Vue CLI / Vite)
1. 什么是脚手架?
脚手架 就是帮你自动生成一个 "已经配好工程化环境"的 Vue 项目文件夹。
打个比方:你要盖房子,以前你得自己打地基、拉水电、砌墙;现在你直接买一套"精装样板间"的图纸和材料包,拿到手就能开始装修(写业务代码)了。
2. 为什么需要脚手架?
| 原生方式(CDN 引入) | 脚手架方式 |
|---|---|
所有代码写在一个 .html 里 |
组件拆分成 .vue 文件,按模块组织 |
| 只能写简单 demo | 支持大型项目、多人协作 |
| 没有打包优化 | 自动压缩、代码分割、热更新 |
| 手动引入库 | 通过 npm 管理依赖 |
3. 创建项目的两种方式
方式一:Vue CLI(老牌,稳定)
bash
# 全局安装
npm install -g @vue/cli
# 创建项目
vue create my-project
方式二:Vite(新一代,速度更快)
bash
# 创建项目
npm create vue@latest
官方现在推荐 Vite,启动速度飞快。
4. 项目结构(看一眼就懂)
my-project/
├── public/ # 静态资源(不经过打包)
│ └── index.html # 入口 HTML
├── src/ # 核心代码(你主要在这里工作)
│ ├── assets/ # 图片、样式等资源
│ ├── components/ # 组件目录(.vue 文件)
│ ├── views/ # 页面级组件(路由用)
│ ├── router/ # 路由配置
│ ├── store/ # Vuex 状态管理
│ ├── App.vue # 根组件(整个应用的入口)
│ └── main.js # 入口 JS(创建 Vue 实例)
├── package.json # 依赖管理
└── vite.config.js # 构建配置
5. main.js 里发生了什么?
javascript
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
// 创建 Vue 应用,挂载到 index.html 里的 #app 上
createApp(App)
.use(router) // 装上路由
.use(store) // 装上状态管理
.mount('#app')
总结 :脚手架就是给你一个"开箱即用"的工程目录,你只需要往 src/ 里填业务代码就行。
二、组件(Component)
1. 什么是组件?
组件就是一块独立的、可复用的 UI 片段 ,它把 HTML + CSS + JS 打包在一个 .vue 文件里。
你可以把它理解成乐高积木------一个小按钮是一个组件,一个导航栏是一个组件,一个页面是由多个组件拼起来的。
2. .vue 文件的构成(Vue 2 / Vue 3 / Vue 3 + TS)
每个 .vue 文件本质上是一个单文件组件(SFC),由三部分组成:
vue
<template>
<!-- 视图层:HTML 结构 -->
</template>
<script>
// 逻辑层:JS / TS 代码
</script>
<style>
/* 样式层:CSS(scoped 可选) */
</style>
不同版本的写法对比:
| 版本 | <script> 写法 |
特点 |
|---|---|---|
| Vue 2 | export default { data() {...}, methods: {...} } |
选项式 API,this 指向实例 |
| Vue 3(选项式) | 同上,但底层已升级为 Vue 3 响应式 | 兼容 Vue 2 写法,适合过渡 |
| Vue 3(组合式) | export default { setup() {...} } |
用 ref/reactive,逻辑更聚合 |
| Vue 3 + TS(推荐) | <script setup lang="ts"> |
语法糖 + 类型推断,最简洁 |
本文统一采用 Vue 3 + 组合式 API(
<script setup>)写法,这是目前最主流、最推荐的方式。
3. 一个组件的完整写法(.vue 文件)
vue
<template>
<!-- 模板:写 HTML 结构 -->
<div class="card">
<h2>{{ title }}</h2>
<p>{{ content }}</p>
<button @click="handleClick">点我</button>
</div>
</template>
<script setup>
// 组合式 API 写法(Vue 3 推荐)
import { ref } from 'vue'
// 接收父组件传进来的数据
const props = defineProps({
title: String,
content: String
})
// 组件自己的数据
const isLiked = ref(false)
// 方法
const handleClick = () => {
isLiked.value = !isLiked.value
emit('like', isLiked.value) // 向父组件发射事件
}
// 声明可触发的事件
const emit = defineEmits(['like'])
</script>
<style scoped>
/* scoped 表示样式只对当前组件生效,不影响外面 */
.card {
border: 1px solid #ddd;
padding: 16px;
border-radius: 8px;
}
</style>
4. 组件三大核心
| 概念 | 说明 | 示例 |
|---|---|---|
| props | 父组件传给子组件的数据(单向流,只能父→子) | <Card title="标题" /> |
| events | 子组件向父组件发消息(用 defineEmits) |
emit('like', true) |
| slots | 插槽,让父组件往子组件里"塞"内容 | <Card><p>自定义内容</p></Card> |
5. 组件间值传递的四种场景
场景一:父传子 → props
vue
<!-- 父组件 Parent.vue -->
<template>
<Child :userName="name" :age="18" />
</template>
<script setup>
import Child from './Child.vue'
const name = '张三'
</script>
vue
<!-- 子组件 Child.vue -->
<script setup>
// 方式一:声明接收(无类型)
const props = defineProps(['userName', 'age'])
// 方式二:带类型(TS 写法)
// const props = defineProps<{ userName: string; age: number }>()
</script>
<template>
<p>{{ userName }} 今年 {{ age }} 岁</p>
</template>
场景二:子传父 → defineEmits
vue
<!-- 子组件 Child.vue -->
<script setup>
const emit = defineEmits(['update', 'delete'])
const handleUpdate = () => {
emit('update', { id: 1, name: '新名字' })
}
</script>
vue
<!-- 父组件 Parent.vue -->
<template>
<Child @update="onUpdate" @delete="onDelete" />
</template>
<script setup>
import Child from './Child.vue'
const onUpdate = (data) => console.log('收到子组件数据:', data)
const onDelete = () => console.log('删除了')
</script>
场景三:同级传值 → 通过父组件中转 或 事件总线
方式一:父组件中转(推荐)
组件A → 触发事件 → 父组件接收 → 通过 props 传给 组件B
方式二:事件总线(Vue 3 用 mitt 库)
javascript
// bus.js
import mitt from 'mitt'
export const bus = mitt()
// 组件A 发送
import { bus } from './bus'
bus.emit('message', 'hello')
// 组件B 接收
bus.on('message', (data) => console.log(data))
场景四:爷孙传值(跨层级) → provide / inject
vue
<!-- 爷爷组件 Grandparent.vue -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme) // 提供数据
provide('updateTheme', (val) => theme.value = val) // 提供方法
</script>
vue
<!-- 孙组件 Grandchild.vue(跳过父组件,直接拿到) -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const updateTheme = inject('updateTheme')
</script>
<template>
<p>当前主题:{{ theme }}</p>
<button @click="updateTheme('light')">切换亮色</button>
</template>
6. 组件的使用流程
vue
<!-- 父组件 Parent.vue -->
<template>
<div>
<!-- 1. 使用子组件,并传 props,监听事件 -->
<Card title="新闻标题" content="新闻内容" @like="onLike" />
</div>
</template>
<script setup>
// 2. 引入子组件
import Card from './Card.vue'
// 3. 定义事件处理函数
const onLike = (val) => {
console.log('点赞状态:', val)
}
</script>
总结 :组件就是 Vue 里的"最小工作单元",一个页面就是一个组件树,从根组件 App.vue 一层层往下嵌套。
7 插槽
插槽是 Vue 的内容分发机制 ,允许父组件向子组件传递 HTML 内容,实现组件的高度可定制化。
1. 基础插槽(默认插槽)
子组件(Child.vue)
vue
<template>
<div class="card">
<div class="card-header">
<h3>卡片标题</h3>
</div>
<div class="card-body">
<!-- 默认插槽:父组件传入的内容将显示在这里 -->
<slot></slot>
</div>
</div>
</template>
父组件使用
vue
<template>
<Card>
<!-- 这里的内容会替换 <slot></slot> -->
<p>这是卡片的内容</p>
<button>点击我</button>
</Card>
</template>
渲染结果
html
<div class="card">
<div class="card-header">
<h3>卡片标题</h3>
</div>
<div class="card-body">
<p>这是卡片的内容</p>
<button>点击我</button>
</div>
</div>
2. 具名插槽(Named Slots)
可以有多个插槽 ,通过 name 属性区分。
子组件(Layout.vue)
vue
<template>
<div class="layout">
<header class="header">
<!-- 具名插槽:header -->
<slot name="header"></slot>
</header>
<main class="main">
<!-- 默认插槽 -->
<slot></slot>
</main>
<footer class="footer">
<!-- 具名插槽:footer -->
<slot name="footer"></slot>
</footer>
</div>
</template>
父组件使用(Vue 3 语法)
vue
<template>
<Layout>
<!-- 使用 v-slot:name 或 #name 指定插槽 -->
<template #header>
<h1>页面标题</h1>
<nav>导航栏</nav>
</template>
<!-- 默认插槽(没有 # 的就是默认) -->
<article>
<p>主要内容区域</p>
</article>
<template #footer>
<p>版权信息 ©2026</p>
</template>
</Layout>
</template>
渲染结果
html
<div class="layout">
<header class="header">
<h1>页面标题</h1>
<nav>导航栏</nav>
</header>
<main class="main">
<article>
<p>主要内容区域</p>
</article>
</main>
<footer class="footer">
<p>版权信息 ©2026</p>
</footer>
</div>
3. 作用域插槽(Scoped Slots)
核心概念 :子组件可以把数据传递给父组件,父组件决定如何渲染。
子组件(TodoList.vue)
vue
<template>
<ul>
<li v-for="item in list" :key="item.id">
<!--
通过 v-bind 把数据暴露给父组件
父组件可以用 "slotProps" 接收
-->
<slot :item="item" :index="index">
<!-- 默认内容(当父组件没传内容时显示) -->
{{ item.text }}
</slot>
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
const list = ref([
{ id: 1, text: '学习 Vue', completed: false },
{ id: 2, text: '练习插槽', completed: true },
{ id: 3, text: '写项目', completed: false }
])
</script>
父组件使用
vue
<template>
<TodoList>
<!--
v-slot:default="slotProps"
可以简写为 #default="slotProps"
也可以直接解构 { item, index }
-->
<template #default="{ item, index }">
<span :style="{ textDecoration: item.completed ? 'line-through' : 'none' }">
{{ index + 1 }}. {{ item.text }}
</span>
<span v-if="item.completed">✅</span>
<span v-else>⏳</span>
</template>
</TodoList>
</template>
渲染结果
html
<ul>
<li>
<span>1. 学习 Vue ⏳</span>
</li>
<li>
<span style="text-decoration: line-through">2. 练习插槽 ✅</span>
</li>
<li>
<span>3. 写项目 ⏳</span>
</li>
</ul>
4. 具名作用域插槽
结合具名插槽和作用域插槽。
子组件(Table.vue)
vue
<template>
<table>
<thead>
<tr>
<slot name="header" :columns="columns"></slot>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in data" :key="index">
<slot name="row" :item="item" :index="index"></slot>
</tr>
</tbody>
</table>
</template>
<script setup>
const props = defineProps({
columns: Array,
data: Array
})
</script>
父组件使用
vue
<template>
<Table :columns="columns" :data="users">
<!-- 表头插槽 -->
<template #header="{ columns }">
<th v-for="col in columns" :key="col.key">
{{ col.label }}
</th>
<th>操作</th>
</template>
<!-- 行插槽 -->
<template #row="{ item, index }">
<td>{{ item.name }}</td>
<td>{{ item.age }}</td>
<td>{{ item.email }}</td>
<td>
<button @click="editUser(index)">编辑</button>
<button @click="deleteUser(index)">删除</button>
</td>
</template>
</Table>
</template>
<script setup>
const columns = [
{ key: 'name', label: '姓名' },
{ key: 'age', label: '年龄' },
{ key: 'email', label: '邮箱' }
]
const users = ref([
{ name: '张三', age: 25, email: 'zhangsan@example.com' },
{ name: '李四', age: 30, email: 'lisi@example.com' }
])
</script>
5. 插槽简写语法
| 完整写法 | 简写 | 说明 |
|---|---|---|
v-slot:header |
#header |
具名插槽 |
v-slot:default |
#default |
默认插槽 |
v-slot:default="props" |
#default="props" |
带数据 |
v-slot="{ item }" |
#default="{ item }" |
解构数据 |
vue
<!-- 完整写法 -->
<template v-slot:header="slotProps">
{{ slotProps.title }}
</template>
<!-- 简写 -->
<template #header="{ title }">
{{ title }}
</template>
6. 动态插槽名
根据变量动态决定使用哪个插槽。
vue
<template>
<Layout>
<template #[dynamicSlotName]>
<p>动态插槽内容</p>
</template>
</Layout>
</template>
<script setup>
import { ref } from 'vue'
const dynamicSlotName = ref('header') // 可以是 'header', 'footer', 'sidebar'
</script>
7. 插槽默认内容
当父组件没有传递内容时,显示默认内容。
vue
<!-- 子组件 -->
<template>
<div class="alert">
<slot>
<!-- 默认内容 -->
<span>默认提示信息</span>
</slot>
</div>
</template>
vue
<!-- 父组件 -->
<template>
<!-- 不传内容 → 显示 "默认提示信息" -->
<Alert />
<!-- 传内容 → 覆盖默认内容 -->
<Alert>
<strong>自定义错误信息</strong>
</Alert>
</template>
三、指令
指令是 Vue 模板中带 v- 前缀的特殊属性,用于在 DOM 上应用响应式行为。
1. v-if --- 条件渲染
作用 :根据条件决定是否渲染元素(真正的销毁/重建)。
vue
<template>
<!-- 为 true 时渲染,为 false 时从 DOM 移除 -->
<div v-if="isShow">我是显示的</div>
<!-- v-else-if 必须跟在 v-if 后面 -->
<div v-else-if="status === 'loading'">加载中...</div>
<!-- v-else 必须跟在 v-if 或 v-else-if 后面 -->
<div v-else>加载失败</div>
</template>
<script setup>
import { ref } from 'vue'
const isShow = ref(true)
const status = ref('success')
</script>
v-if vs v-show
vue
<template>
<!-- v-if:真正的条件渲染,切换开销大,适合不频繁切换 -->
<div v-if="isShow">我是 v-if</div>
<!-- v-show:始终渲染,用 display:none 控制,适合频繁切换 -->
<div v-show="isShow">我是 v-show</div>
</template>
| 特性 | v-if | v-show |
|---|---|---|
| 渲染方式 | 真实销毁/重建 | CSS display 控制 |
| 初始渲染 | 条件为 false 时不渲染 | 始终渲染 |
| 切换开销 | 大(销毁重建) | 小(切换 CSS) |
| 适用场景 | 不频繁切换 | 频繁切换 |
2. v-bind --- 属性绑定(简写 :)
作用:动态绑定 HTML 属性、CSS 类、样式等。
vue
<template>
<!-- 完整写法 -->
<img v-bind:src="imageUrl" v-bind:alt="imageAlt">
<!-- 简写 : -->
<img :src="imageUrl" :alt="imageAlt">
<!-- 绑定布尔属性 -->
<button :disabled="isDisabled">提交</button>
<!-- 绑定多个属性(对象) -->
<div v-bind="attrsObj">内容</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
const imageUrl = ref('https://example.com/photo.jpg')
const imageAlt = ref('示例图片')
const isDisabled = ref(true)
const attrsObj = {
id: 'main',
class: 'container',
'data-role': 'admin'
}
</script>
绑定 CSS 类
vue
<template>
<!-- 对象语法 -->
<div :class="{ active: isActive, 'text-danger': hasError }">
类名动态切换
</div>
<!-- 数组语法 -->
<div :class="[activeClass, errorClass]">多个类</div>
<!-- 混合使用 -->
<div :class="['base', { active: isActive }]">混合</div>
</template>
<script setup>
const isActive = ref(true)
const hasError = ref(false)
const activeClass = ref('active')
const errorClass = ref('error')
</script>
绑定内联样式
vue
<template>
<!-- 对象语法 -->
<div :style="{ color: textColor, fontSize: fontSize + 'px' }">
动态样式
</div>
<!-- 数组语法 -->
<div :style="[baseStyles, overrideStyles]">合并样式</div>
</template>
<script setup>
const textColor = ref('red')
const fontSize = ref(20)
const baseStyles = { color: 'blue', fontSize: '14px' }
const overrideStyles = { fontSize: '18px' }
</script>
3. v-on --- 事件监听(简写 @)
作用:监听 DOM 事件并执行 JavaScript 代码。
vue
<template>
<!-- 完整写法 -->
<button v-on:click="handleClick">点击我</button>
<!-- 简写 @ -->
<button @click="handleClick">点击我</button>
<!-- 直接写表达式 -->
<button @click="count++">计数器: {{ count }}</button>
<!-- 传递参数 -->
<button @click="sayHello('张三')">打招呼</button>
<!-- 传递事件对象 -->
<button @click="handleEvent($event)">事件对象</button>
<!-- 同时传递参数和事件对象 -->
<button @click="handleData('参数', $event)">带事件</button>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
const handleClick = () => {
console.log('按钮被点击了')
}
const sayHello = (name) => {
console.log(`你好,${name}`)
}
const handleEvent = (event) => {
console.log('事件对象:', event)
console.log('目标元素:', event.target)
}
const handleData = (data, event) => {
console.log('数据:', data)
console.log('事件:', event)
}
</script>
事件修饰符
vue
<template>
<!-- .stop:阻止事件冒泡 -->
<div @click.stop="handleClick">阻止冒泡</div>
<!-- .prevent:阻止默认行为 -->
<form @submit.prevent="handleSubmit">阻止提交</form>
<!-- .once:只触发一次 -->
<button @click.once="handleOnce">只触发一次</button>
<!-- .self:只有元素自身触发时才执行 -->
<div @click.self="handleSelf">只有点击自己才触发</div>
<!-- 链式使用 -->
<a @click.stop.prevent="handleLink">阻止冒泡+默认行为</a>
<!-- 按键修饰符 -->
<input @keyup.enter="handleEnter" placeholder="按回车触发">
<input @keyup.esc="handleEsc" placeholder="按 ESC 触发">
<input @keyup.ctrl.s="handleSave" placeholder="Ctrl+S 触发">
</template>
4. v-for --- 列表渲染
作用:基于数据多次渲染元素或模板。
vue
<template>
<!-- 遍历数组 -->
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</ul>
<!-- 带索引 -->
<ul>
<li v-for="(item, index) in items" :key="item.id">
{{ index + 1 }}. {{ item.name }}
</li>
</ul>
<!-- 遍历对象 -->
<ul>
<li v-for="(value, key, index) in user" :key="key">
{{ index }}. {{ key }}: {{ value }}
</li>
</ul>
<!-- 遍历数字 -->
<span v-for="n in 10" :key="n">{{ n }}</span>
<!-- 遍历字符串 -->
<span v-for="char in 'Hello'" :key="char">{{ char }}</span>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 3, name: '王五' }
])
const user = ref({
name: '张三',
age: 25,
email: 'zhangsan@example.com'
})
</script>
⚠️ 重要:必须使用 :key
vue
<template>
<!-- ✅ 正确:使用唯一 ID -->
<div v-for="item in items" :key="item.id">
{{ item.name }}
</div>
<!-- ⚠️ 也可以使用 index(不推荐,列表会变化时) -->
<div v-for="(item, index) in items" :key="index">
{{ item.name }}
</div>
<!-- ❌ 错误:没有 key(会有性能问题和状态混乱) -->
<div v-for="item in items">
{{ item.name }}
</div>
</template>
v-for 与 v-if 同时使用
vue
<template>
<!-- ❌ 不推荐:v-for 优先级更高,会先渲染所有再过滤 -->
<div v-for="item in items" v-if="item.active" :key="item.id">
{{ item.name }}
</div>
<!-- ✅ 推荐:先用计算属性过滤 -->
<div v-for="item in activeItems" :key="item.id">
{{ item.name }}
</div>
</template>
<script setup>
import { computed } from 'vue'
const items = ref([
{ id: 1, name: '张三', active: true },
{ id: 2, name: '李四', active: false },
{ id: 3, name: '王五', active: true }
])
const activeItems = computed(() => {
return items.value.filter(item => item.active)
})
</script>
5. v-model --- 双向数据绑定
作用:在表单元素上创建双向数据绑定(数据变化 → 视图更新,视图变化 → 数据更新)。
vue
<template>
<!-- 文本输入 -->
<input v-model="username" placeholder="输入用户名">
<p>用户名: {{ username }}</p>
<!-- 多行文本 -->
<textarea v-model="description" placeholder="描述"></textarea>
<!-- 复选框(单个) -->
<input type="checkbox" v-model="isChecked">
<p>已选择: {{ isChecked }}</p>
<!-- 复选框(多个) -->
<input type="checkbox" value="篮球" v-model="hobbies"> 篮球
<input type="checkbox" value="足球" v-model="hobbies"> 足球
<input type="checkbox" value="羽毛球" v-model="hobbies"> 羽毛球
<p>爱好: {{ hobbies }}</p>
<!-- 单选框 -->
<input type="radio" value="男" v-model="gender"> 男
<input type="radio" value="女" v-model="gender"> 女
<p>性别: {{ gender }}</p>
<!-- 下拉选择框 -->
<select v-model="selectedCity">
<option value="">请选择城市</option>
<option value="北京">北京</option>
<option value="上海">上海</option>
<option value="广州">广州</option>
</select>
<p>城市: {{ selectedCity }}</p>
</template>
<script setup>
import { ref } from 'vue'
const username = ref('')
const description = ref('')
const isChecked = ref(false)
const hobbies = ref([])
const gender = ref('')
const selectedCity = ref('')
</script>
v-model 修饰符
vue
<template>
<!-- .trim:去除首尾空格 -->
<input v-model.trim="username" placeholder="自动去除空格">
<!-- .number:自动转为数字 -->
<input v-model.number="age" type="number" placeholder="年龄">
<!-- .lazy:change 事件触发(而非 input) -->
<input v-model.lazy="content" placeholder="失去焦点时更新">
</template>
自定义 v-model
vue
<!-- 子组件 Child.vue -->
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
<!-- 父组件 -->
<template>
<Child v-model="parentData" />
<p>父组件数据: {{ parentData }}</p>
</template>
<script setup>
import { ref } from 'vue'
const parentData = ref('')
</script>
多个 v-model 绑定
vue
<!-- 子组件 -->
<template>
<div>
<input
:value="firstName"
@input="$emit('update:firstName', $event.target.value)"
placeholder="姓"
/>
<input
:value="lastName"
@input="$emit('update:lastName', $event.target.value)"
placeholder="名"
/>
</div>
</template>
<script setup>
defineProps(['firstName', 'lastName'])
defineEmits(['update:firstName', 'update:lastName'])
</script>
<!-- 父组件 -->
<template>
<Child
v-model:first-name="user.firstName"
v-model:last-name="user.lastName"
/>
</template>
6. 其他常用指令
vue
<template>
<!-- v-text:更新文本内容 -->
<span v-text="message"></span>
<!-- 等价于 -->
<span>{{ message }}</span>
<!-- v-html:渲染 HTML(⚠️ 有 XSS 风险) -->
<div v-html="htmlContent"></div>
<!-- v-once:只渲染一次,不更新 -->
<div v-once>{{ staticData }}</div>
<!-- v-pre:跳过编译,显示原始内容 -->
<div v-pre>{{ 这会原样显示,不会编译 }}</div>
<!-- v-cloak:未编译时隐藏,防止闪烁 -->
<div v-cloak>{{ 编译后才显示 }}</div>
</template>
四、路由(Vue Router)
1. 什么是路由?
路由就是控制"页面上显示哪个组件"的开关。
比如:你点"首页" → 显示 Home.vue;点"关于" → 显示 About.vue。这个"URL 变化 → 组件切换"的机制就叫路由。
记住:Vue 是 SPA(单页应用) ,整个应用只有一个
.html文件,路由切换其实是在替换页面中间的组件,不会真正刷新浏览器。
2. 路由的核心配置
javascript
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import About from '../views/About.vue'
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
{
path: '/user/:id', // 动态路径参数
component: () => import('../views/User.vue') // 懒加载
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
3. 页面里怎么用路由
vue
<template>
<div>
<!-- 导航链接 -->
<router-link to="/">首页</router-link>
<router-link to="/about">关于</router-link>
<!-- 路由出口:匹配到的组件会显示在这里 -->
<router-view />
</div>
</template>
4. 路由传参
方式一:路径参数(URL 里带 id)
javascript
// 配置
{ path: '/user/:id', component: User }
// 跳转
<router-link :to="'/user/' + userId">用户</router-link>
// 获取(组合式 API)
import { useRoute } from 'vue-router'
const route = useRoute()
const id = route.params.id
方式二:查询参数(?key=value)
javascript
<router-link :to="{ path: '/user', query: { id: 1 } }">用户</router-link>
// 获取
import { useRoute } from 'vue-router'
const route = useRoute()
const id = route.query.id
方式三:编程式跳转(JS 里跳)
javascript
import { useRouter } from 'vue-router'
const router = useRouter()
router.push('/about')
router.push({ name: 'User', params: { id: 1 } })
router.go(-1) // 后退
5. 动态路由
场景:菜单、路由路径、权限都由后端控制,前端只负责渲染。用户登录后(或刷新页面时 token 仍有效),前端请求后端获取该用户的路由/菜单数据,动态生成路由。
后端返回的菜单数据示例:
json
[
{
path: '/dashboard',
name: 'Dashboard',
component: 'Dashboard', // 对应前端组件名
meta: { title: '仪表盘', icon: 'home' }
},
{
path: '/user',
name: 'User',
component: 'Layout',
meta: { title: '用户管理', icon: 'user' },
children: [
{ path: 'list', name: 'UserList', component: 'UserList', meta: { title: '用户列表' } },
{ path: 'add', name: 'UserAdd', component: 'UserAdd', meta: { title: '添加用户' } }
]
}
]
步骤一:定义基础路由(不需要权限的页面)
javascript
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
// 常量路由:所有人可访问(404、登录页等)
const constantRoutes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue')
},
{
path: '/404',
name: 'NotFound',
component: () => import('@/views/404.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes: constantRoutes
})
export default router
步骤二:维护前端组件映射表
javascript
// router/componentMap.js
// 后端返回的是组件名字符串,前端需要根据名字找到对应的组件
export const componentMap = {
Dashboard: () => import('@/views/Dashboard.vue'),
UserList: () => import('@/views/user/List.vue'),
UserAdd: () => import('@/views/user/Add.vue'),
Layout: () => import('@/layouts/Layout.vue'), // 布局组件
// ... 所有需要动态加载的页面组件
}
步骤三:将后端菜单数据转为路由配置
javascript
// router/dynamic.js
import { componentMap } from './componentMap'
/**
* 递归生成路由配置
*/
export function generateRoutes(menuData) {
const routes = []
menuData.forEach(menu => {
const route = {
path: menu.path,
name: menu.name,
meta: menu.meta || {},
// 根据后端返回的组件名,从映射表里取组件
component: componentMap[menu.component] || null
}
// 如果有子菜单,递归处理
if (menu.children && menu.children.length > 0) {
route.children = generateRoutes(menu.children)
}
routes.push(route)
})
return routes
}
/**
* 动态添加路由到 router
*/
export function addDynamicRoutes(router, menuData) {
const routes = generateRoutes(menuData)
routes.forEach(route => {
router.addRoute(route)
})
// 最后添加 404 兜底路由(必须放在最后,否则会拦截所有未匹配路径)
router.addRoute({
path: '/:pathMatch(.*)*',
redirect: '/404'
})
return routes
}
步骤四:在登录后或页面刷新时获取路由
javascript
// utils/initRouter.js
import { getMenus } from '@/api/menu'
import { addDynamicRoutes } from '@/router/dynamic'
// 标记是否已添加动态路由(避免重复添加)
let isDynamicRoutesAdded = false
/**
* 初始化动态路由
* 在登录成功后 或 页面刷新时调用
*/
export async function initDynamicRoutes(router) {
// 如果已经添加过,直接返回
if (isDynamicRoutesAdded) {
return
}
try {
// 从后端获取当前用户的路由/菜单数据
const res = await getMenus()
const menuData = res.data
// 动态添加路由
addDynamicRoutes(router, menuData)
isDynamicRoutesAdded = true
console.log('✅ 动态路由加载完成')
} catch (error) {
console.error('❌ 获取动态路由失败:', error)
// 可以跳转到 404 或登录页
}
}
步骤五:在路由守卫中自动初始化
javascript
// router/index.js(接步骤一,在 router 实例后面添加)
import { initDynamicRoutes } from '@/utils/initRouter'
let hasInit = false // 防止重复初始化
router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem('token')
// 如果有 token,说明用户已登录
if (token) {
// 如果是登录页,直接去首页
if (to.path === '/login') {
return next('/dashboard')
}
// 如果动态路由还没初始化,就初始化
if (!hasInit) {
await initDynamicRoutes(router)
hasInit = true
// 刷新页面后重新进入,需要重定向到目标路径让路由重新匹配
return next({ ...to, replace: true })
}
// 已初始化,正常放行
next()
} else {
// 没有 token,只能访问白名单页面
if (['/login', '/404'].includes(to.path)) {
next()
} else {
next('/login')
}
}
})
步骤六:登录成功后调用(可选)
javascript
// 在登录接口成功后的回调中
import { initDynamicRoutes } from '@/utils/initRouter'
const handleLoginSuccess = async () => {
// 保存 token
localStorage.setItem('token', res.data.token)
// 初始化动态路由
await initDynamicRoutes(router)
// 跳转到首页
router.push('/dashboard')
}
总结:
- 核心逻辑就三步:请求后端菜单 → 转成路由配置 →
router.addRoute()添加 - 登录后调用一次,刷新页面时路由守卫里再调用一次(但不会重复请求)
- 前端只需要维护一个"组件名字符串 → 实际组件"的映射表
后端返回数据示例:
json
[
{ path: '/dashboard', name: 'Dashboard', meta: { title: '仪表盘', icon: 'home' } },
{ path: '/user', name: 'User', meta: { title: '用户管理', icon: 'user' },
children: [
{ path: 'list', name: 'UserList', meta: { title: '用户列表' } },
{ path: 'add', name: 'UserAdd', meta: { title: '添加用户' } }
]
},
{ path: '/404', name: 'NotFound', meta: { title: '404' } }
]
步骤六:404 页面特殊处理
vue
<!-- views/404.vue -->
<template>
<div class="not-found">
<h1>404</h1>
<p>页面不存在</p>
<router-link to="/">回到首页</router-link>
</div>
</template>
⚠️ 注意:404 路由必须在动态路由的最后添加,否则会拦截所有未匹配路径。
javascript
router.addRoute(route) // 添加动态路由
router.addRoute({ // 最后添加 404 兜底
path: '/:pathMatch(.*)*',
redirect: '/404'
})
完整流程图
用户登录
↓
请求后端获取菜单数据
↓
前端通过 router.addRoute() 动态添加路由
↓
用户访问页面 → 路由守卫拦截 → 校验权限
↓
有权限 → 正常显示;无权限 → 跳转 404
总结 :动态路由的核心思想是 "前端只定义基础路由,业务路由由后端返回,前端动态添加"。这样权限控制完全在后端,前端只需维护一个"组件名 ↔ 组件"的映射表即可。
五、Vuex(状态管理)
1. 什么是 Vuex?
Vuex 是 Vue 的全局数据仓库 ,专门用来管理多个组件共享的数据。
什么时候用?当你的数据被两个以上不相干的组件需要的时候(比如用户登录信息、购物车数据、主题颜色等)。
2. 为什么要用 Vuex?
| 问题场景 | 没有 Vuex | 有 Vuex |
|---|---|---|
| A 组件改了数据,B 组件要知道 | 需要一层层传,复杂 | 直接修改仓库,所有用到的组件自动更新 |
| 多个页面共享登录状态 | 每次刷新都要重新请求 | 存到 Vuex 里,全局可用 |
| 调试困难 | 不知道数据是谁改的 | Vuex 提供时间旅行调试,每一步都能追踪 |
3. Vuex 的核心概念(五行口诀)
javascript
// store/index.js
import { createStore } from 'vuex'
const store = createStore({
// 1. state:存数据
state() {
return {
count: 0,
user: null
}
},
// 2. mutations:改数据(必须同步)
mutations: {
increment(state) {
state.count++
},
setUser(state, user) {
state.user = user
}
},
// 3. actions:处理异步 + 提交 mutations
actions: {
async login({ commit }, userInfo) {
const res = await api.login(userInfo)
commit('setUser', res.data)
}
},
// 4. getters:计算派生数据(类似计算属性)
getters: {
doubleCount(state) {
return state.count * 2
}
}
})
4. 在组件里怎么用(组合式 API)
vue
<template>
<div>
<!-- 直接显示 state -->
<p>{{ store.state.count }}</p>
<!-- 用 getters -->
<p>{{ store.getters.doubleCount }}</p>
<!-- 提交 mutation(同步) -->
<button @click="store.commit('increment')">+1</button>
<!-- 派发 action(异步) -->
<button @click="store.dispatch('login', { name: '张三' })">登录</button>
</div>
</template>
<script setup>
import { useStore } from 'vuex'
const store = useStore()
</script>
5. 辅助函数(在组合式 API 中结合 computed 使用)
vue
<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex'
const store = useStore()
// 把 state 映射成计算属性
const count = computed(() => store.state.count)
const user = computed(() => store.state.user)
// 把 getters 映射成计算属性
const doubleCount = computed(() => store.getters.doubleCount)
// 把 mutations 映射成方法(直接解构)
const { increment } = mapMutations(['increment'])
// 使用时:increment()
// 把 actions 映射成方法
const { login } = mapActions(['login'])
// 使用时:login({ name: '张三' })
</script>
6. 模块化(项目大了拆分)
javascript
const userModule = {
namespaced: true,
state: { name: '张三' },
mutations: { setName(state, name) { state.name = name } }
}
const store = createStore({
modules: {
user: userModule
}
})
// 使用时带上模块名
// 在组件中:
store.state.user.name
store.commit('user/setName', '李四')
总结:Vuex 就是 Vue 全家桶里的"全局数据中心",适合管理多组件共享的数据,解决"数据传着传着就乱了"的问题。
六、Fetch API
Fetch API 是浏览器提供的原生 JavaScript 接口 ,用于发送 HTTP 请求(获取资源、提交数据等)。它是 Ajax 的现代替代方案 ,比传统的 XMLHttpRequest(XHR)更强大、更简洁。
| 对比项 | XMLHttpRequest (XHR) | Fetch API |
|---|---|---|
| 年代 | 1999 年推出(老古董) | 2015 年推出(现代标准) |
| 语法 | 回调地狱,代码冗长 | Promise 风格,简洁优雅 |
| 流式处理 | 不支持 | 支持(response.body 可流式读取) |
| 默认行为 | 默认携带 Cookie | 默认不携带 Cookie(需手动配置) |
| 错误处理 | 网络错误不抛异常 | 网络错误会 reject,但 HTTP 状态码(404/500)不抛异常 |
Fetch 的优缺点
✅ 优点
| 优点 | 说明 |
|---|---|
| 语法简洁 | Promise + async/await,告别回调地狱 |
| 原生支持 | 浏览器内置,无需安装第三方库 |
| 流式处理 | 支持流(Stream),可边下载边处理大文件 |
| 更灵活 | 可配置 cache、credentials、redirect 等 |
| Service Worker 支持 | 是 PWA 离线缓存的基石 |
❌ 缺点
| 缺点 | 说明 |
|---|---|
| 不自动携带 Cookie | 需要手动配置 credentials: 'include' |
| 不自动抛异常 | 404/500 不会进入 catch,需手动检查 response.ok |
| 不支持超时控制 | 需借助 AbortController 实现 |
| 不支持上传进度 | 无法像 XHR 那样监听 onprogress |
| 无请求取消(旧浏览器) | 新浏览器支持 AbortController |
Fetch Demo
1. 注册拦截器(main.js 或单独文件)
javascript
// utils/interceptors.js
import { addRequestInterceptor, addResponseInterceptor } from './request'
let routerInstance = null
// 设置路由实例(在 main.js 中调用)
export const setRouter = (router) => {
routerInstance = router
}
// ---------- 请求拦截器 ----------
// 自动添加 Token
addRequestInterceptor(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers = {
...config.headers,
'Authorization': `Bearer ${token}`
}
}
return config
},
(error) => {
console.error('请求拦截器错误:', error)
return Promise.reject(error)
}
)
// ---------- 响应拦截器 ----------
// 统一处理响应和错误
addResponseInterceptor(
(response) => {
// 如果后端返回了新的 token,自动更新
if (response.headers && response.headers.get('x-new-token')) {
const newToken = response.headers.get('x-new-token')
localStorage.setItem('token', newToken)
}
// 业务状态码处理
if (response.data && response.data.code !== undefined && response.data.code !== 0) {
// 如果是登录过期(假设 code 为 401 或 403 表示未登录)
if (response.data.code === 401 || response.data.code === 403) {
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
if (routerInstance) {
routerInstance.push('/login')
} else {
window.location.href = '/login'
}
}
const error = new Error(response.data.message || '业务处理失败')
error.code = response.data.code
error.response = response
return Promise.reject(error)
}
return response
},
(error) => {
// HTTP 401 未授权处理
if (error.response && error.response.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
if (routerInstance) {
routerInstance.push('/login')
} else {
window.location.href = '/login'
}
}
// 网络错误统一提示
if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
console.error('请求超时,请稍后重试')
} else if (error.message.includes('NetworkError') || !error.response) {
console.error('网络异常,请检查网络连接')
} else {
console.error(error.message || '请求失败')
}
return Promise.reject(error)
}
)
2. 在 main.js 中挂载(便于拦截器使用)
javascript
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
// 导入拦截器(注册)
import './utils/interceptors'
import { setRouter } from './utils/interceptors'
const app = createApp(App)
// 将 router 注入到拦截器中
setRouter(router)
app.use(router)
app.use(store)
app.mount('#app')
3. 请求封装(utils/request.js)
javascript
// utils/request.js
import axios from 'axios'
// 创建 axios 实例
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 存储拦截器
const requestInterceptors = []
const responseInterceptors = []
// 添加请求拦截器
export const addRequestInterceptor = (success, error) => {
const interceptor = service.interceptors.request.use(success, error)
requestInterceptors.push(interceptor)
return interceptor
}
// 添加响应拦截器
export const addResponseInterceptor = (success, error) => {
const interceptor = service.interceptors.response.use(success, error)
responseInterceptors.push(interceptor)
return interceptor
}
// 基础请求方法
export const request = (config) => {
return service(config)
}
// GET 请求
export const get = (url, params = {}, config = {}) => {
return service.get(url, { params, ...config })
}
// POST 请求
export const post = (url, data = {}, config = {}) => {
return service.post(url, data, config)
}
// PUT 请求
export const put = (url, data = {}, config = {}) => {
return service.put(url, data, config)
}
// DELETE 请求
export const del = (url, params = {}, config = {}) => {
return service.delete(url, { params, ...config })
}
// 文件上传
export const upload = (url, formData, config = {}) => {
return service.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
...config
})
}
// 文件下载(获取 blob)
export const download = (url, params = {}, config = {}) => {
return service.get(url, {
params,
responseType: 'blob',
...config
})
}
export default service
4. API 接口封装(api/user.js)
javascript
// api/user.js
import { get, post, put, del, upload } from '@/utils/request'
// 用户登录
export const login = (username, password) => {
return post('/user/login', { username, password })
}
// 获取用户信息
export const getUserInfo = () => {
return get('/user/info')
}
// 获取菜单路由
export const getMenus = () => {
return get('/user/menus')
}
// 更新用户信息
export const updateUser = (data) => {
return put('/user/update', data)
}
// 修改密码
export const changePassword = (oldPassword, newPassword) => {
return post('/user/change-password', { oldPassword, newPassword })
}
// 上传头像
export const uploadAvatar = (file) => {
const formData = new FormData()
formData.append('avatar', file)
return upload('/user/avatar', formData)
}
5. 在组件中使用
vue
<template>
<div>
<button @click="fetchUserInfo">获取用户信息</button>
<button @click="updateUser">更新用户</button>
<button @click="uploadAvatar">上传头像</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { getUserInfo, updateUser, uploadAvatar } from '@/api/user'
const userInfo = ref(null)
// 获取用户信息
const fetchUserInfo = async () => {
try {
const res = await getUserInfo()
userInfo.value = res.data
console.log('用户信息:', res)
} catch (error) {
console.error('获取用户信息失败:', error)
}
}
// 更新用户
const updateUser = async () => {
try {
const res = await updateUser({
name: '张三',
age: 25
})
console.log('更新成功:', res)
} catch (error) {
console.error('更新失败:', error)
}
}
// 上传头像
const uploadAvatar = async () => {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = 'image/*'
fileInput.onchange = async (e) => {
const file = e.target.files[0]
if (file) {
try {
const res = await uploadAvatar(file)
console.log('上传成功:', res)
} catch (error) {
console.error('上传失败:', error)
}
}
}
fileInput.click()
}
</script>
6. 环境变量配置(.env)
bash
# .env.development
VITE_API_BASE_URL=/api
# .env.production
VITE_API_BASE_URL=https://api.example.com