外观
Vue 系统学习文档
从零开始系统掌握 Vue 框架,涵盖 Vue 2 和 Vue 3,对比两者差异
1. Vue 简介
目录
- Vue 简介与基础概念
- 开发环境搭建
- Vue 实例与应用
- 模板语法与数据绑定
- 计算属性与侦听器
- Class 与 Style 绑定
- 条件渲染与列表渲染
- 事件处理
- 表单输入绑定
- 组件基础
- 组件通信
- 插槽(Slot)
- 生命周期钩子
- Composition API vs Options API
- 响应式原理
- 状态管理:Vuex vs Pinia
- 路由:Vue Router
- 脚手架与工程化
- Vue 2 项目迁移到 Vue 3
- 常见模式与最佳实践
- 学习路线总结
1. Vue 简介与基础概念
1.1 什么是 Vue
Vue(读音 /vjuː/,类似于 "view")是一款用于构建用户界面的渐进式 JavaScript 框架。由尤雨溪(Evan You)于 2014 年创建。
核心特点:
- 渐进式框架:逐步采用,从 CDN 引入到完整单页应用
- 声明式渲染:通过模板语法简洁渲染到 DOM
- 组件化:UI 拆分为可复用的组件
- 响应式:数据变化自动驱动视图更新
1.2 Vue 2 vs Vue 3:整体对比
| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 发布时间 | 2016 年 | 2020 年 9 月 |
| 响应式系统 | Object.defineProperty(有缺陷) | Proxy(更强大) |
| API 风格 | Options API 为主 | Options API + Composition API |
| TypeScript 支持 | 较弱 | 原生支持(用 TS 重写) |
| 性能 | 基线 | 快 1.3~2 倍,包体积减少 40%+ |
| Tree-shaking | 不支持 | 原生支持 |
| Fragment | 不支持(需一个根元素) | 支持(多根节点) |
| Teleport | 无 | 支持 |
| 状态管理 | Vuex 3.x | Pinia(推荐)或 Vuex 4.x |
| 脚手架 | Vue CLI | create-vue / Vite |
| 维护状态 | 2023 年底停止维护 | 活跃开发中 |
1.3 核心技术栈总览
Vue 2 项目技术栈:
- 脚手架:Vue CLI(Webpack)
- 状态管理:Vuex 3.x
- 路由:Vue Router 3.x
- 构建工具:Webpack
- UI 库:Element UI / Vant 2
Vue 3 项目技术栈:
- 脚手架:create-vue(Vite)
- 状态管理:Pinia(推荐)或 Vuex 4.x
- 路由:Vue Router 4.x
- 构建工具:Vite
- UI 库:Element Plus / Vant 4 / Naive UI
2. 开发环境搭建
2.1 Vue 2 项目创建
Vue CLI(传统方式):
bash
npm install -g @vue/cli
vue create my-vue2-app
# 选择 Vue 2 预设Vite + Vue 2.7(需专用插件):
bash
# Vite 官方 vue 模板默认生成 Vue 3 项目;Vue 2.7 需使用专用插件
npm create vite@latest my-vue2-app
npm install vue@2.7 @vitejs/plugin-vue2注意:
@vitejs/plugin-vue只支持 Vue 3;Vue 2.7 走 Vite 必须用@vitejs/plugin-vue2。且 Vue 2 已于 2023-12-31 停止维护,新项目建议直接用 Vue 3 + Vite。
2.2 Vue 3 项目创建
推荐方式:create-vue(基于 Vite):
bash
npm create vue@latest
# 或一步带选项
npm create vue@latest my-vue3-app -- --typescript --pinia --router2.3 CDN 方式(快速学习用)
Vue 2:
html
<script src="https://unpkg.com/vue@2.7.16/dist/vue.js"></script>Vue 3:
html
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>2.4 目录结构对比
Vue CLI (Vue 2):
my-vue2-app/
├── node_modules/
├── public/index.html
├── src/
│ ├── assets/
│ ├── components/
│ ├── router/ # Vue Router 3
│ ├── store/ # Vuex
│ ├── views/
│ ├── App.vue
│ └── main.js
├── vue.config.js
└── package.jsoncreate-vue (Vue 3):
my-vue3-app/
├── node_modules/
├── public/
├── src/
│ ├── assets/
│ ├── components/
│ ├── router/ # Vue Router 4
│ ├── stores/ # Pinia
│ ├── views/
│ ├── App.vue
│ └── main.ts
├── index.html # 在根目录
├── vite.config.ts
├── tsconfig.json
└── package.json3. Vue 实例与应用
3.1 Vue 2 创建实例
javascript
import Vue from "vue";
import App from "./App.vue";
new Vue({
render: (h) => h(App),
}).$mount("#app");3.2 Vue 3 创建应用
javascript
import { createApp } from "vue";
import App from "./App.vue";
const app = createApp(App);
app.mount("#app");3.3 核心区别
| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 创建方式 | new Vue(options) | createApp(options) |
| 挂载 | $mount("#app") | app.mount("#app") |
| 全局 API | Vue.component() | app.component() |
| 插件安装 | Vue.use(plugin) | app.use(plugin) |
| 多应用 | 不支持 | 支持 |
全局 API 迁移示例:
javascript
// Vue 2
Vue.component("my-component", {});
Vue.directive("my-directive", {});
Vue.mixin({});
// Vue 3
const app = createApp(App);
app.component("my-component", {});
app.directive("my-directive", {});
app.mixin({});4. 模板语法与数据绑定
4.1 文本插值
html
<span>{{ message }}</span>
<span>{{ number + 1 }}</span>
<span>{{ ok ? "YES" : "NO" }}</span>4.2 属性绑定
html
<div v-bind:id="dynamicId">内容</div>
<div :id="dynamicId">简写</div>
<div :[attributeName]="value">动态属性</div>
<div v-bind="objectOfAttrs">批量绑定</div>4.3 数据定义对比
javascript
// Vue 2
export default {
data() {
return { message: "Hello", count: 0 };
}
};
// Vue 3 Options API(相同写法)
export default {
data() {
return { message: "Hello Vue 3!", count: 0 };
}
};
// Vue 3 Composition API(推荐)
import { ref, reactive } from "vue";
export default {
setup() {
const message = ref("Hello Vue 3!");
const count = ref(0);
const user = reactive({ name: "Alice" });
return { message, count, user };
}
};4.4 ref vs reactive(Vue 3)
javascript
import { ref, reactive } from "vue";
// ref:包裹基本类型,通过 .value 访问
const count = ref(0);
count.value++;
// reactive:包裹对象,直接访问
const user = reactive({ name: "Alice" });
user.name = "Bob";
// 模板中自动解包 ref
// <div>{{ count }}</div> <!-- 不需要 .value -->5. 计算属性与侦听器
5.1 计算属性(Computed)
Vue 2 Options API:
javascript
export default {
data() { return { firstName: "John", lastName: "Doe" }; },
computed: {
fullName: {
get() { return `${this.firstName} ${this.lastName}`; },
set(value) { [this.firstName, this.lastName] = value.split(" "); }
},
greeting() { return `Hello, ${this.fullName}`; }
}
};Vue 3 Composition API:
javascript
import { ref, computed } from "vue";
export default {
setup() {
const firstName = ref("John");
const lastName = ref("Doe");
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (val) => [firstName.value, lastName.value] = val.split(" ")
});
return { firstName, lastName, fullName };
}
};5.2 侦听器(Watch)
Vue 2:
javascript
export default {
data() { return { question: "" }; },
watch: {
question(newVal, oldVal) {
console.log(`从 ${oldVal} 变为 ${newVal}`);
},
"user.name": {
handler(newVal, oldVal) {},
deep: true,
immediate: true
}
}
};Vue 3 Composition API:
javascript
import { ref, watch, watchEffect } from "vue";
export default {
setup() {
const question = ref("");
const answer = ref("");
watch(question, (newVal, oldVal) => {
console.log(`从 ${oldVal} 变为 ${newVal}`);
});
// 侦听多个源
watch([question, answer], ([newQ, newA], [oldQ, oldA]) => {});
// watchEffect:自动追踪依赖,立即执行
watchEffect(() => console.log(question.value));
}
};5.3 computed vs watch
| 特性 | computed | watch |
|---|---|---|
| 用途 | 计算衍生数据 | 执行副作用 |
| 缓存 | 有 | 无 |
| 异步 | 不支持 | 支持 |
| 场景 | 模板中衍生数据 | 数据变化后自动操作 |
6. Class 与 Style 绑定
html
<!-- 对象语法 -->
<div :class="{ active: isActive, 'text-danger': hasError }">
<!-- 数组语法 -->
<div :class="[activeClass, errorClass]">
<div :class="[{ active: isActive }, 'static-class']">
<!-- Style 绑定 -->
<div :style="{ color: activeColor, fontSize: fontSize + 'px' }">
<div :style="[baseStyles, overridingStyles]">
<!-- Vue 3 支持 CSS 变量绑定 -->
<div :style="{ '--custom-color': color }">
<span style="color: var(--custom-color)">内容</span>
</div>7. 条件渲染与列表渲染
7.1 条件渲染
html
<div v-if="type === 'A'">A</div>
<div v-else-if="type === 'B'">B</div>
<div v-else>其他</div>
<!-- v-show 切换 display,频繁切换用 v-show -->
<div v-show="isVisible">可见</div>| 特性 | v-if | v-show |
|---|---|---|
| 渲染 | 条件 false 时不渲染 DOM | 始终渲染,切换 display |
| 切换成本 | 高(销毁/重建) | 低(CSS 切换) |
| 场景 | 运行时很少改变 | 频繁切换 |
7.2 列表渲染
html
<li v-for="(item, index) in items" :key="item.id">
{{ index }} - {{ item.name }}
</li>
<!-- 遍历对象 -->
<div v-for="(value, key, index) in object" :key="key">
{{ index }}. {{ key }}: {{ value }}
</div>
<!-- 数字范围 -->
<span v-for="n in 10" :key="n">{{ n }}</span>7.3 Vue 2 模板限制
html
<!-- Vue 2:必须有单个根元素 -->
<template>
<div>
<h1>标题</h1>
<p>内容</p>
</div>
</template>
<!-- Vue 3:支持多根节点(Fragment) -->
<template>
<h1>标题</h1>
<p>内容</p>
</template>8. 事件处理
html
<button v-on:click="handleClick">点击</button>
<button @click="handleClick">简写</button>
<button @click="count++">内联语句</button>
<!-- 事件修饰符 -->
<a @click.prevent="onSubmit">阻止默认</a>
<form @submit.prevent="onSubmit">阻止提交</form>
<div @click.once="doOnce">只触发一次</div>
<div @click.stop="doThis">阻止冒泡</div>
<div @click.self="doThis">点击自身才触发</div>
<!-- 修饰符可组合使用(Vue 2 / Vue 3 均支持) -->
<div @click.capture.once="doThis">捕获模式只一次</div>
<input @keyup.ctrl.enter="submit"> 组合按键
<!-- 按键修饰符 -->
<input @keyup.enter="submit">
<input @keyup.esc="cancel">
<!-- Vue 2 支持按键码(Vue 3 不再支持) -->
<input @keyup.13="submit"> <!-- Vue 3 已移除 -->9. 表单输入绑定
html
<input v-model="message" placeholder="请输入">
<textarea v-model="message"></textarea>
<input type="checkbox" v-model="checked">
<input type="radio" v-model="picked" value="A">
<select v-model="selected">
<option>A</option>
<option>B</option>
</select>
<!-- 修饰符 -->
<input v-model.lazy="msg">
<input v-model.number="age">
<input v-model.trim="name">9.1 Vue 3 v-model 重大变化
html
<!-- Vue 2:默认 prop 是 value,默认事件是 input -->
<ChildComponent v-model="value" />
<!-- 等价于 :value="value" @input="value = $event" -->
<!-- Vue 3:默认 prop 是 modelValue,默认事件是 update:modelValue -->
<ChildComponent v-model="value" />
<!-- 等价于 :modelValue="value" @update:modelValue="value = $event" -->
<!-- Vue 3 支持多个 v-model(重大改进!) -->
<UserForm v-model:name="name" v-model:email="email" />
<!-- Vue 3 支持自定义修饰符 -->
<ChildComponent v-model.capitalize="text" />9.2 自定义 v-model 组件对比
Vue 2:
javascript
export default {
model: { prop: "value", event: "input" },
props: { value: String },
methods: { update(e) { this.$emit("input", e.target.value); } }
};Vue 3:
javascript
export default {
props: { modelValue: String },
emits: ["update:modelValue"],
methods: { update(e) { this.$emit("update:modelValue", e.target.value); } }
};
// script setup
// const props = defineProps({ modelValue: String });
// const emit = defineEmits(["update:modelValue"]);
// emit("update:modelValue", newValue);| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 默认 prop | value | modelValue |
| 默认事件 | input | update:modelValue |
| 多 v-model | 不支持 | 支持 |
| 自定义修饰符 | 不支持 | 支持 |
10. 组件基础
10.1 单文件组件(SFC)
Vue 2:
vue
<template>
<div class="my-component"> <!-- 必须有根元素 -->
<h1>{{ title }}</h1>
</div>
</template>
<script>
export default {
name: "MyComponent",
props: { title: String }
};
</script>
<style scoped>
.my-component h1 { color: blue; }
</style>Vue 3(支持多根元素 + CSS v-bind):
vue
<template>
<h1>{{ title }}</h1>
<p>无需根元素包裹</p>
</template>
<script setup>
defineProps({ title: String });
const themeColor = "#42b883";
</script>
<style scoped>
h1 { color: v-bind(themeColor); }
</style>10.2 Vue 3 script setup(推荐)
vue
<script setup>
import { ref, computed } from "vue";
import ChildComponent from "./ChildComponent.vue";
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
function increment() { count.value++; }
</script>
<template>
<button @click="increment">{{ count }}</button>
<p>双倍:{{ doubleCount }}</p>
<ChildComponent />
</template>
<!-- 不需 export default,不需 return -->11. 组件通信
11.1 Props(父到子)
Vue 2 和 Vue 3 Options API 相同:
javascript
export default {
props: {
title: { type: String, required: true },
count: { type: Number, default: 0 }
}
};Vue 3 script setup:
javascript
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 }
});11.2 Emits(子到父)
javascript
// Vue 2
this.$emit("update", data);
// Vue 3(推荐声明 emits)
export default {
emits: ["update", "delete"],
methods: { submit() { this.$emit("update", { id: 1 }); } }
};
// Vue 3 script setup
const emit = defineEmits(["update", "delete"]);
emit("update", { id: 1 });11.3 Provide / Inject
Vue 2:
javascript
// 祖先
export default {
provide() { return { theme: this.theme }; }
};
// 后代
export default { inject: ["theme"] };Vue 3:
javascript
// 祖先
import { provide, ref } from "vue";
setup() {
const theme = ref("dark");
provide("theme", theme);
provide("toggleTheme", () => theme.value = theme.value === "dark" ? "light" : "dark");
}
// 后代
import { inject } from "vue";
setup() {
const theme = inject("theme", "light");
const toggle = inject("toggleTheme");
return { theme, toggle };
}11.4 Event Bus(Vue 2 常用,Vue 3 不推荐)
javascript
// Vue 2
import Vue from "vue";
export const bus = new Vue();
bus.$emit("event", data);
bus.$on("event", (data) => {});
// Vue 3 替代方案
import mitt from "mitt";
export const emitter = mitt();
emitter.emit("event", data);
emitter.on("event", (data) => {});
// 或使用 provide/inject
// 或使用 Pinia 全局状态11.5 通信方式总结
| 方式 | 方向 | 适用场景 |
|---|---|---|
| Props | 父到子 | 基本数据传递 |
| Emits | 子到父 | 触发父组件行为 |
| v-model | 双向 | 表单组件 |
| Provide/Inject | 祖到后代 | 深层通信、主题配置 |
| Event Bus | 任意 | Vue 2 常用 |
| Vuex/Pinia | 全局 | 复杂全局状态 |
11.6 Vue 3 $attrs 变化
javascript
// Vue 2:$listeners 单独存在,$attrs 不含 class/style
// Vue 3:$listeners 合并到 $attrs,$attrs 包含 class/style
// 多根节点时需手动绑定 $attrs11.7 通信方式深度对比与选型决策
前面的表格列出了「有什么」,这一节补充「怎么选、为什么」。
单向数据流是默认原则:Vue 推荐「父传子用 props,子传父用 emit」的单向数据流——数据总是从父流向子,子组件要改数据必须通过 emit 请求父组件改。这样数据流向清晰、容易追踪,也避免子组件偷偷修改父组件状态导致难以排查的 bug。
选择决策树:
text
这个状态需要共享吗?
├─ 不需要,只在当前组件用 → 局部 ref/reactive,别进 store
├─ 需要,但只在父子两层
│ ├─ 父 → 子单向传值 → props
│ ├─ 子 → 父通知事件 → emits
│ └─ 需要双向绑定(表单类)→ v-model(本质是 props + emits 的语法糖)
├─ 需要,跨越多层(祖 → 孙,跳过中间层)
│ └─ provide / inject(注意:默认不是响应式的,要传 ref/reactive)
└─ 需要,任意组件之间、跨页面
└─ Pinia 全局 store(唯一推荐的全局方案)每种方式的坑:
| 方式 | 主要坑 | 应对 |
|---|---|---|
| props | 子组件直接改 props 对象内部字段(引用类型会「穿透」) | 坚持单向数据流,需要改就 emit |
| emits | 不声明 emits 时,Vue 3 会把未声明的事件当作原生事件 | 始终显式声明 emits |
| v-model | 多 v-model 时 prop 名与 update:xxx 事件名要一一对应 | 遵循 v-model:name ↔ update:name 协议 |
| provide/inject | 提供的是普通值时不响应;后代随意改父级状态 | 提供 ref/reactive,或提供函数封装修改逻辑 |
| Event Bus | Vue 3 已移除 $on/$off;回调不清理会内存泄漏 | 用 mitt、provide/inject 或 Pinia 替代 |
| Pinia | 把所有状态都塞进 store,store 膨胀 | 只放真正需要共享的全局状态 |
⚠️ 一个高频反模式:父组件把大对象整个传给子组件,子组件直接改对象内部字段。引用类型是共享内存的,子组件的修改会直接反映到父组件,等于绕过了单向数据流。正确做法是子组件 emit 具体事件,由父组件决定如何修改。
12. 插槽(Slot)
html
<!-- 子组件 -->
<div class="card">
<slot></slot> <!-- 默认插槽 -->
</div>
<!-- 具名插槽 -->
<div class="layout">
<header><slot name="header"></slot></header>
<main><slot></slot></main>
<footer><slot name="footer"></slot></footer>
</div>Vue 2:
html
<Layout>
<template slot="header">头部</template>
<template slot="footer">底部</template>
</Layout>
<!-- 作用域插槽 -->
<List :items="items">
<template slot="item" slot-scope="{ item, index }">
{{ index }}: {{ item.name }}
</template>
</List>Vue 3:
html
<Layout>
<template #header>头部</template>
<template #footer>底部</template>
</Layout>
<!-- 作用域插槽 -->
<List :items="items">
<template #item="{ item, index }">
{{ index }}: {{ item.name }}
</template>
</List>
<!-- 动态插槽名 -->
<template #[dynamicSlotName]>内容</template>| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 具名插槽 | slot="name" | v-slot:name / #name |
| 作用域插槽 | slot-scope="{ item }" | #default="{ item }" |
| slot/slot-scope 属性 | 支持 | 已移除 |
13. 生命周期钩子
| 阶段 | Vue 2 | Vue 3 Options API | Vue 3 Composition API |
|---|---|---|---|
| 创建前 | beforeCreate | beforeCreate | setup() |
| 创建后 | created | created | setup() |
| 挂载前 | beforeMount | beforeMount | onBeforeMount |
| 挂载后 | mounted | mounted | onMounted |
| 更新前 | beforeUpdate | beforeUpdate | onBeforeUpdate |
| 更新后 | updated | updated | onUpdated |
| 卸载前 | beforeDestroy | beforeUnmount | onBeforeUnmount |
| 卸载后 | destroyed | unmounted | onUnmounted |
Vue 2 的
beforeDestroy/destroyed在 Vue 3 中重命名为beforeUnmount/unmounted
Vue 2 生命周期示例:
javascript
export default {
created() {
console.log("实例创建完成");
this.fetchData();
},
mounted() { console.log("DOM 已挂载"); },
beforeDestroy() {
clearInterval(this.timer);
},
destroyed() { console.log("实例已销毁"); }
};Vue 3 Composition API:
javascript
import { ref, onMounted, onBeforeUnmount } from "vue";
export default {
setup() {
const timer = ref(null);
onMounted(() => {
console.log("DOM 已挂载");
timer.value = setInterval(() => {}, 1000);
});
onBeforeUnmount(() => clearInterval(timer.value));
}
};父子组件生命周期执行顺序:
父 created -> 父 beforeMount -> 子 created -> 子 mounted -> 父 mounted
父 beforeUpdate -> 子 beforeUpdate -> 子 updated -> 父 updated
父 beforeDestroy/beforeUnmount -> 子 beforeDestroy/beforeUnmount -> 子 destroyed/unmounted -> 父 destroyed/unmounted14. Composition API vs Options API
14.1 对比
| 特性 | Options API | Composition API |
|---|---|---|
| 引入版本 | Vue 1 开始 | Vue 3(Vue 2.7 可选) |
| 组织方式 | 按选项类型(data/methods) | 按功能逻辑 |
| 逻辑复用 | Mixins(有缺陷) | Composables(函数式) |
| TypeScript | 支持有限 | 原生支持 |
| 学习曲线 | 较低 | 需理解响应式 API |
14.2 同一功能两种写法
Options API:
vue
<script>
export default {
data() { return { count: 0, loading: false }; },
computed: { doubleCount() { return this.count * 2; } },
watch: {
count(newVal) {
if (newVal > 10) this.count = 0;
}
},
methods: { increment() { this.count++; } }
};
</script>Composition API(推荐):
vue
<script setup>
import { ref, computed, watch } from "vue";
const count = ref(0);
const loading = ref(false);
const doubleCount = computed(() => count.value * 2);
watch(count, (newVal) => {
if (newVal > 10) count.value = 0;
});
function increment() { count.value++; }
</script>14.3 逻辑复用对比
14.3.1 Vue 中的 Mixin 是什么
Vue 的 Mixin 是一种组件选项复用机制。
它和 JavaScript 普通 Mixin 的区别是:
| 对比点 | JavaScript Mixin | Vue Mixin |
|---|---|---|
| 混入对象 | 普通对象、原型、类 | Vue 组件选项对象 |
| 混入方式 | 复制属性或返回增强类 | Vue 按选项合并策略合并 |
| 常见内容 | 方法、属性、事件能力 | data、methods、computed、watch、生命周期 |
| 执行时机 | 由开发者手动调用 | 组件创建时由 Vue 合并 |
| 主要风险 | 原型污染、命名冲突 | 命名冲突、来源不清晰、生命周期顺序复杂 |
也就是说,Vue Mixin 不是把方法直接塞进原型链,而是把一份“组件配置”合并到当前组件中。
14.3.2 Vue 2 Mixin 基础写法
javascript
// mixins/counter.js
export const counterMixin = {
data() {
return {
count: 0
};
},
computed: {
doubleCount() {
return this.count * 2;
}
},
methods: {
increment() {
this.count++;
}
},
mounted() {
console.log("counter mixin mounted");
}
};组件中使用:
vue
<template>
<button @click="increment">
{{ count }} / {{ doubleCount }}
</button>
</template>
<script>
import { counterMixin } from "@/mixins/counter";
export default {
name: "CounterButton",
mixins: [counterMixin],
mounted() {
console.log("component mounted");
}
};
</script>最终组件等价于拥有:
text
data.count
computed.doubleCount
methods.increment
mounted: [counterMixin.mounted, component.mounted]所以模板里可以直接使用 count、doubleCount、increment。
14.3.3 Vue 3 中还能不能用 Mixin
Vue 3 仍然支持 Options API 和 Mixin:
javascript
import { createApp } from "vue";
import App from "./App.vue";
import { authMixin } from "./mixins/auth";
const app = createApp(App);
app.mixin(authMixin); // 全局 mixin,不建议随便使用
app.mount("#app");单个组件里也能继续写:
javascript
export default {
mixins: [authMixin],
created() {
this.checkLogin();
}
};但 Vue 3 更推荐 Composition API / Composables,因为它能把逻辑来源显式暴露出来。
14.3.4 选项合并规则
Vue Mixin 最大的难点是“合并规则”。不同选项的合并方式不一样。
| 选项 | 合并规则 | 示例结果 |
|---|---|---|
data | 都会执行,返回对象浅合并;同名字段组件优先 | 组件的 count 覆盖 mixin 的 count |
methods | 对象合并;同名方法组件优先 | 组件的 submit 覆盖 mixin 的 submit |
computed | 对象合并;同名计算属性组件优先 | 组件的 fullName 覆盖 mixin 的 fullName |
watch | 同名 watch 会合并成数组,都会执行 | mixin 和组件的 watch 都触发 |
| 生命周期钩子 | 合并成数组,mixin 先执行,组件后执行 | mixin created -> component created |
components / directives | 对象合并;组件局部定义优先 | 局部组件覆盖 mixin 中同名组件 |
示例:
javascript
const mixinA = {
data() {
return {
title: "from mixin",
page: 1
};
},
created() {
console.log("mixin created");
},
methods: {
reload() {
console.log("mixin reload");
}
},
watch: {
page() {
console.log("mixin watch page");
}
}
};
export default {
mixins: [mixinA],
data() {
return {
title: "from component"
};
},
created() {
console.log("component created");
},
methods: {
reload() {
console.log("component reload");
}
},
watch: {
page() {
console.log("component watch page");
}
}
};结果:
text
title = "from component"
page = 1
created 执行顺序:mixin created -> component created
reload 调用结果:component reload
page 变化时:mixin watch page 和 component watch page 都会执行14.3.5 全局 Mixin 为什么危险
全局 Mixin 会影响每一个组件,包括第三方组件库里的组件。
javascript
// main.js
Vue.mixin({
created() {
console.log("every component created");
}
});这段代码会在项目中所有组件创建时执行。小项目里看似方便,大项目里会带来几个问题:
- 每个组件都被隐式注入逻辑,排查性能问题困难。
- 第三方 UI 组件也会执行这段逻辑。
- 容易造成全局副作用,例如埋点重复、接口重复调用。
- 很难知道某个
this.xxx到底来自组件自身还是全局 mixin。
全局 Mixin 只适合非常底层、非常稳定的插件能力,例如框架级埋点、错误采集、兼容层注入,并且要严格控制逻辑量。
14.3.6 Vue Mixin 的典型问题
Mixin 不是不能用,而是容易被滥用。
问题一:命名冲突
javascript
const searchMixin = {
data() {
return { keyword: "" };
},
methods: {
submit() {
console.log("search submit");
}
}
};
const formMixin = {
data() {
return { keyword: "default" };
},
methods: {
submit() {
console.log("form submit");
}
}
};
export default {
mixins: [searchMixin, formMixin]
};这里 keyword 和 submit 都发生冲突。最终结果取决于 Vue 的合并策略和 mixin 顺序,代码可读性会下降。
问题二:来源不明确
javascript
export default {
mixins: [authMixin, tableMixin, exportMixin],
mounted() {
this.checkPermission();
this.fetchTableData();
this.exportExcel();
}
};读组件时很难一眼看出这些方法来自哪里。项目变大以后,Mixin 会让组件 API 像“凭空出现”一样。
问题三:隐式依赖
javascript
export const requestMixin = {
methods: {
async requestList() {
// 依赖组件必须提供 apiUrl 和 query
const res = await fetch(`${this.apiUrl}?q=${this.query}`);
this.list = await res.json();
}
}
};这个 mixin 要求组件必须有 apiUrl、query、list,但这些依赖没有通过参数声明,只能靠约定。后来维护的人很容易漏掉。
14.3.7 什么时候还可以使用 Mixin
可以使用:
- Vue 2 存量项目,暂时无法迁移 Composition API。
- 极小、极稳定、无业务状态的能力,比如页面标题、简单埋点、权限按钮判断。
- 框架插件内部,用于统一注入能力。
- 临时兼容层,例如 Vue 2 到 Vue 3 迁移期间过渡。
不建议使用:
- 复杂业务逻辑复用。
- 表格、搜索、弹窗、上传等包含大量状态和副作用的逻辑。
- 多个 mixin 之间互相依赖执行顺序。
- 需要强类型推导和清晰依赖关系的 Vue 3 项目。
14.3.8 Composition API / Composable 替代方案
Vue 3 Composable(推荐):
javascript
// composables/useCounter.js
import { computed, ref } from "vue";
export function useCounter(init = 0) {
const count = ref(init);
const doubleCount = computed(() => count.value * 2);
function increment() {
count.value++;
}
return {
count,
doubleCount,
increment
};
}组件中使用:
vue
<script setup>
import { useCounter } from "@/composables/useCounter";
const { count, doubleCount, increment } = useCounter(10);
</script>
<template>
<button @click="increment">
{{ count }} / {{ doubleCount }}
</button>
</template>Composable 比 Mixin 清晰的地方:
- 来源明确:
useCounter从哪里来,一眼能看到。 - 参数明确:
useCounter(10)明确传入初始值。 - 返回明确:组件能拿到哪些状态和方法,由
return决定。 - 不存在选项合并规则,不会偷偷覆盖组件方法。
- TypeScript 推导更友好。
14.3.9 从 Mixin 迁移到 Composable
Vue 2 老项目常见 Mixin:
javascript
// mixins/listPage.js
export const listPageMixin = {
data() {
return {
loading: false,
list: [],
page: 1,
pageSize: 10
};
},
methods: {
async fetchList() {
this.loading = true;
try {
const res = await this.requestApi({
page: this.page,
pageSize: this.pageSize
});
this.list = res.list;
} finally {
this.loading = false;
}
}
}
};迁移成 Composable:
javascript
// composables/useListPage.js
import { ref } from "vue";
export function useListPage(requestApi, options = {}) {
const loading = ref(false);
const list = ref([]);
const page = ref(options.page ?? 1);
const pageSize = ref(options.pageSize ?? 10);
async function fetchList() {
loading.value = true;
try {
const res = await requestApi({
page: page.value,
pageSize: pageSize.value
});
list.value = res.list;
} finally {
loading.value = false;
}
}
return {
loading,
list,
page,
pageSize,
fetchList
};
}组件中使用:
vue
<script setup>
import { onMounted } from "vue";
import { useListPage } from "@/composables/useListPage";
import { getUsers } from "@/api/user";
const {
loading,
list,
page,
pageSize,
fetchList
} = useListPage(getUsers, { pageSize: 20 });
onMounted(fetchList);
</script>这就是 Vue 3 不再推荐 Mixin 的核心原因:不是因为 Mixin 完全不能用,而是 Composable 能把“依赖、状态、返回值、调用时机”都显式写出来。
14.4 Vue 2 也可用 Composition API
bash
# Vue 2.7+ 已内置
# Vue 2.6 需要 @vue/composition-api
npm install @vue/composition-apijavascript
// Vue 2.6
import Vue from "vue";
import VueCompositionAPI from "@vue/composition-api";
Vue.use(VueCompositionAPI);15. 响应式原理
Vue 的核心能力是“状态变化自动驱动视图更新”。这不是魔法,而是依赖收集、变更触发、调度更新三部分协作完成的。
15.1 响应式系统要解决什么问题
假设模板中使用了 count:
vue
<template>
<button>{{ count }}</button>
</template>当 count 改变时,Vue 需要知道:
- 哪些地方读取过
count。 count变化后应该通知谁。- 多次变化时如何合并更新,避免重复渲染。
响应式系统本质上就是建立“数据 -> 副作用”的依赖图。
text
count
-> 组件渲染函数
-> computed getter
-> watch 回调15.2 Vue 2:Object.defineProperty
Vue 2 在初始化 data 时,会把每个字段转换成 getter/setter。
简化模型:
javascript
function defineReactive(obj, key, value) {
const deps = new Set();
Object.defineProperty(obj, key, {
get() {
if (activeEffect) {
deps.add(activeEffect);
}
return value;
},
set(newValue) {
if (newValue === value) return;
value = newValue;
deps.forEach(effect => effect());
},
});
}局限:
- 新增对象属性无法自动被监听,需要
Vue.set。 - 删除属性无法自动被监听,需要
Vue.delete。 - 数组下标直接赋值、修改 length 等场景需要特殊处理。
- 初始化时需要递归遍历 data,深层对象成本较高。
javascript
// Vue 2 中不推荐
this.user.age = 18;
// Vue 2 中应使用
this.$set(this.user, "age", 18);15.3 Vue 3:Proxy
Vue 3 使用 Proxy 代理整个对象,可以拦截读取、设置、删除、判断属性、遍历等操作。
简化模型:
javascript
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
track(obj, key);
return Reflect.get(obj, key, receiver);
},
set(obj, key, value, receiver) {
const result = Reflect.set(obj, key, value, receiver);
trigger(obj, key);
return result;
},
deleteProperty(obj, key) {
const result = Reflect.deleteProperty(obj, key);
trigger(obj, key);
return result;
},
});
}优势:
- 能监听新增属性。
- 能监听删除属性。
- 对数组、Map、Set 等结构支持更好。
- 可以懒代理深层对象,性能模型更灵活。
15.3.1 effect 依赖收集的内部机制(track / trigger / targetMap)
track 和 trigger 是响应式系统的两个核心原语:track 负责「记录谁依赖了谁」,trigger 负责「通知依赖者数据变了」。Vue 3 内部用三层结构保存这份依赖关系:
text
targetMap (WeakMap<target, depsMap>)
└─ depsMap (Map<key, dep>)
└─ dep (Set<ReactiveEffect>) // 依赖同一个 key 的所有副作用- targetMap:
WeakMap,键是响应式对象,值是该对象自己的depsMap。 - depsMap:
Map,键是属性名,值是该属性的dep。 - dep:
Set,存放所有依赖这个属性的副作用函数(effect)。
为什么最外层用 WeakMap 而不是 Map?因为它的键是对象,WeakMap 对键是弱引用——当某个响应式对象不再被任何地方引用时,它可以被垃圾回收,targetMap 里对应条目也随之消失,不会造成内存泄漏。若用 Map 强引用,这个对象就永远无法被 GC。
完整链路(简化版):
javascript
const targetMap = new WeakMap(); // target -> depsMap
let activeEffect = null; // 当前正在执行的 effect
function track(target, key) {
if (!activeEffect) return; // 没有正在运行的 effect,无需收集
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, (dep = new Set()));
dep.add(activeEffect); // 把当前 effect 记录进 dep
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) dep.forEach(effect => effect.run());
}effect 是副作用函数的最小执行单元。Vue 3 里的组件渲染、computed、watch 本质上都被包装成一个 ReactiveEffect:
javascript
class ReactiveEffect {
constructor(fn) { this.fn = fn; this.deps = []; }
run() {
// 关键:执行前把自身设为 activeEffect,执行完再还原
const prevEffect = activeEffect;
activeEffect = this;
try {
return this.fn(); // fn 内部读取响应式数据时触发 track
} finally {
activeEffect = prevEffect; // 还原,支持嵌套 effect
}
}
}
function effect(fn) {
const _effect = new ReactiveEffect(fn);
_effect.run(); // 首次执行一次,建立依赖
return _effect;
}执行流程:
text
effect(() => document.body.textContent = count.value)
1. run() 将 activeEffect 设为该 effect
2. fn 读取 count.value → 触发 get → track(count, "value") → dep.add(effect)
3. 之后 count.value 被修改 → 触发 set → trigger → effect.run() 重新执行💡 核心结论:响应式不是「数据变了视图就变」,而是「数据被读取时收集依赖(get → track),数据被写入时派发更新(set → trigger)」。依赖收集发生在读取阶段,这也正是 computed 能缓存的原因——依赖没变时它就不重新计算。
15.4 ref 与 reactive 的底层区别
ref 适合基本类型,也可以包对象。它通过 .value 暴露值。
javascript
const count = ref(0);
count.value += 1;reactive 适合对象:
javascript
const user = reactive({
name: "Alice",
profile: {
age: 18,
},
});
user.profile.age += 1;选择建议:
| 场景 | 推荐 |
|---|---|
| number/string/boolean | ref |
| 需要整体替换的对象 | ref |
| 复杂对象状态 | reactive |
| 组合式函数对外返回状态 | 通常返回多个 ref 更方便解构 |
| 大型不可变数据 | shallowRef 或普通对象 |
解构陷阱:
javascript
const state = reactive({ count: 0 });
const { count } = state;
// count 是普通 number,不再响应式解决:
javascript
const state = reactive({ count: 0 });
const { count } = toRefs(state);15.4.1 ref 的底层实现(RefImpl)
ref 并不是什么魔法,它只是一个包裹了 getter/setter 的对象实例。Vue 3 内部用 RefImpl 类实现:
javascript
class RefImpl {
constructor(value) {
this._rawValue = value; // 原始值
this._value = toReactive(value); // 对象会被转成 reactive
this.__v_isRef = true; // 标记这是一个 ref
this.dep = new Set(); // 单个 dep(因为只有一个 .value)
}
get value() {
trackRefValue(this); // 依赖收集
return this._value;
}
set value(newValue) {
if (hasChanged(newValue, this._rawValue)) { // 值真的变了才触发
this._rawValue = newValue;
this._value = toReactive(newValue); // 重新处理新值
triggerRefValue(this); // 派发更新
}
}
}几个关键点:
.value是必须的:基本类型(number/string/boolean)无法被 Proxy 代理,只能靠 getter/setter 拦截,.value就是这个 getter/setter 的入口。- 对象会被自动转成 reactive:
ref({ count: 0 })内部的_value是reactive({ count: 0 }),所以ref.value.count++也能触发更新(由toReactive完成转换)。 hasChanged用的是Object.is而不是===:Object.is(NaN, NaN) === true,所以NaN赋给NaN会被判定为「没变」,避免无意义更新;但0和-0会被判定为「变了」。- 模板自动解包:
<div>{{ count }}</div>里不用写.value,是因为编译器对 setup 返回的 ref 做了自动解包;但在<script>里必须写.value。
💡 什么时候 ref 里放对象、什么时候直接 reactive?判断标准很实用:需要「整体替换」这个值就用 ref,只是改对象内部字段就用 reactive。例如分页数据 list.value = newList 用 ref 更自然。
15.4.2 reactive 的细节:缓存、只读、浅层与陷阱
1. 缓存(reactiveMap):对同一个原始对象多次调用 reactive,返回同一个 Proxy:
javascript
const raw = { count: 0 };
const a = reactive(raw);
const b = reactive(raw);
console.log(a === b); // true,内部有 reactiveMap 缓存
console.log(a === raw); // false,Proxy 不等于原对象2. 只读与浅层变体:
| API | 说明 |
|---|---|
readonly(obj) | 只读代理,修改会触发警告 |
shallowReactive(obj) | 只代理第一层,深层属性不响应 |
shallowReadonly(obj) | 只读 + 只代理第一层 |
3. 陷阱一:整体替换会破坏响应式。reactive 返回的是代理对象,把它整个替换成新对象就丢了代理:
javascript
let state = reactive({ count: 0 });
state = { count: 1 }; // ❌ state 现在是普通对象,不再响应式
// 模板里 state.count 不会再更新需要整体替换时,用 ref 包一层,或把 reactive 对象作为某个父对象的属性去替换。
4. 陷阱二:解构丢失响应式(前面已提到,用 toRefs 解决)。本质原因是解构得到的是原始值的拷贝,而不是代理对象上的 getter 引用。
15.5 computed 的缓存机制
computed 是带缓存的派生状态。只有依赖变化时才会重新计算。
javascript
const firstName = ref("Ada");
const lastName = ref("Lovelace");
const fullName = computed(() => {
console.log("computed run");
return `${firstName.value} ${lastName.value}`;
});
console.log(fullName.value);
console.log(fullName.value); // 第二次不重新执行 getter适合:
- 从已有状态推导新值。
- 排序、筛选、格式化。
- 模板中重复使用的派生数据。
不适合:
- 异步请求。
- 修改其他状态。
- 复杂副作用。
15.6 watch 与 watchEffect
watch 明确指定监听源:
javascript
watch(
() => user.id,
async (id, oldId) => {
profile.value = await fetchProfile(id);
},
{ immediate: true }
);watchEffect 自动收集同步执行过程中读取的依赖:
javascript
watchEffect(() => {
console.log(user.id, user.name);
});区别:
| 对比 | watch | watchEffect |
|---|---|---|
| 依赖来源 | 显式指定 | 自动收集 |
| 是否立即执行 | 默认不立即,可配置 immediate | 默认立即 |
| 新旧值 | 可以拿到 | 不强调新旧值 |
| 适合场景 | 精确监听、请求、表单联动 | 简单副作用、调试依赖 |
15.7 Vue 更新调度与 nextTick
Vue 不会每次状态变化都立即同步更新 DOM,而是把更新任务放入队列,批量合并。
javascript
count.value++;
count.value++;
count.value++;这通常只触发一次组件更新。
如果修改状态后立刻读取 DOM,可能读到旧 DOM:
javascript
count.value++;
console.log(el.value.textContent); // 可能还是旧值使用 nextTick:
javascript
count.value++;
await nextTick();
console.log(el.value.textContent); // DOM 已更新15.7.1 nextTick 的内部机制(微任务队列)
nextTick 的本质是「把回调推迟到本次更新队列 flush 完成、DOM 已经更新之后」执行。它底层依赖 JavaScript 的微任务(microtask),而不是 setTimeout 这类宏任务。
简化实现:
javascript
let isFlushPending = false;
let currentFlushPromise = null;
const resolvedPromise = Promise.resolve(); // 一个已 resolve 的 Promise
function queueFlush() {
if (!isFlushPending) {
isFlushPending = true;
currentFlushPromise = resolvedPromise.then(flushJobs); // 把刷新安排到微任务
}
}
function nextTick(fn) {
// 复用本次 flush 的 promise,保证和本次 DOM 更新在同一批次
const p = currentFlushPromise || resolvedPromise;
return fn ? p.then(fn) : p;
}执行时序:
text
count.value++ // 同步执行,触发 trigger,把组件更新任务放入队列
queueFlush() // 安排一次微任务 flushJobs
count.value++ // 同步执行,再次入队(同一任务被去重合并)
await nextTick() // 挂到 currentFlushPromise 上
-> 同步代码全部执行完
-> 微任务 flushJobs 执行:清空队列,patch 真实 DOM
-> nextTick 的回调在 DOM 更新后执行⚠️ 为什么用微任务而不是 setTimeout(宏任务)?
- 微任务在当前事件循环结束前立刻执行,比宏任务更早、延迟更小。
- 若用宏任务,两次同步修改之间可能被浏览器渲染打断,出现「闪一下旧值再闪新值」的中间态。
- 微任务保证同一轮同步代码里的所有修改被合并到一次 DOM 更新,避免重复渲染。
💡 nextTick() 返回一个 Promise,既支持 await nextTick(),也支持 nextTick(callback) 回调形式。Vue 2 里是 this.$nextTick(),Vue 3 里是直接导入的 nextTick。
15.8 虚拟 DOM 与 diff
Vue 模板会被编译成渲染函数,渲染函数返回虚拟 DOM。状态变化后,Vue 会生成新的虚拟 DOM,并与旧虚拟 DOM 对比,计算最小更新。
text
template
-> compiler
-> render function
-> vnode
-> patch old vnode/new vnode
-> update real DOMkey 对 diff 非常重要:
vue
<li v-for="item in list" :key="item.id">
{{ item.name }}
</li>不要用数组下标当 key,尤其是列表会插入、删除、排序时:
vue
<!-- 不推荐 -->
<li v-for="(item, index) in list" :key="index">原因:下标不能稳定代表业务身份,可能导致组件状态复用错误。
15.8.1 diff 算法的具体过程(双端对比 + 最长递增子序列)
当新旧两个 v-for 列表的节点都带 key 时,Vue 3 的 patchKeyedChildren 分五步处理,核心思想是先跳过不变的前后公共部分,只对中间真正变化的区间做处理:
text
旧: [a, b, c, d, e, f]
新: [a, b, e, c, d, g, f]
第一步:sync from start(头指针 i 从 0 向后,key+type 相同就复用 patch)
a、b 相同 → 跳过
第二步:sync from end(尾指针 e1/e2 向前)
f 相同 → 跳过
第三步/第四步:旧中间 [c, d, e] 与新中间 [e, c, d, g]具体策略:
text
1. sync from start 头指针 i 从 0 向后,key 相同直接 patch,直到遇到不同
2. sync from end 尾指针 e1/e2 向前,同样复用 patch
3. common + mount 旧节点比对完、新节点还有剩余 → 直接挂载新节点
4. common + unmount 新节点比对完、旧节点还有剩余 → 直接卸载旧节点
5. unknown sequence 两端对齐后剩下的「未知区间」:
a. 为新节点建立 key -> index 映射(keyToNewIndexMap)
b. 遍历旧中间节点,能匹配上 key 就复用 DOM 并 patch,记录新旧位置关系
c. 对记录的位置数组求「最长递增子序列」(LIS)
d. 不在 LIS 里的节点需移动,新出现的节点需挂载,消失的节点需卸载为什么要求「最长递增子序列」?它代表「已经保持正确相对顺序、不需要移动」的最长节点集合。找到它之后,只需移动其余少数节点,就能用最少的 DOM 移动次数把旧顺序变成新顺序。
key 的作用:diff 判断两个节点「是否是同一个」靠 key + type 都相同。没有 key 时 Vue 按位置复用节点,列表一旦插入/删除/排序,节点内容和 DOM 状态(输入框光标、组件内部状态)就会错位。
💡 这就是 15.8 前面说的「不要用 index 当 key」的深层原因:index 在列表变化时不能稳定标识同一个业务实体,diff 会复用错误的 DOM 节点,导致状态串位。
16. 状态管理:Vuex vs Pinia
16.1 什么状态需要放进全局状态
不是所有状态都应该进 store。
适合放入 store:
- 登录用户信息。
- 权限和菜单。
- 全局主题和布局设置。
- 跨页面共享的筛选条件。
- 多组件共享且生命周期较长的数据。
不适合放入 store:
- 单个表单的临时输入。
- 弹窗内部状态。
- 只在一个组件中使用的数据。
- 可以由 props 或 computed 推导出的数据。
16.2 Vuex 核心模型
Vuex 常见于 Vue 2 项目:
javascript
const store = new Vuex.Store({
state: {
count: 0,
},
getters: {
doubleCount: state => state.count * 2,
},
mutations: {
increment(state) {
state.count++;
},
},
actions: {
async incrementLater({ commit }) {
await delay(1000);
commit("increment");
},
},
});职责:
| 概念 | 作用 |
|---|---|
| state | 状态 |
| getters | 派生状态 |
| mutations | 同步修改状态 |
| actions | 异步流程,最终提交 mutation |
| modules | 拆分大型 store |
16.3 Pinia 核心模型
Pinia 是 Vue 3 推荐状态管理方案,也可以用于 Vue 2。
javascript
import { defineStore } from "pinia";
export const useUserStore = defineStore("user", {
state: () => ({
userInfo: null,
token: "",
}),
getters: {
isLogin: state => Boolean(state.token),
},
actions: {
async login(payload) {
const result = await loginApi(payload);
this.token = result.token;
this.userInfo = result.user;
},
},
});组件中使用:
vue
<script setup>
import { storeToRefs } from "pinia";
import { useUserStore } from "@/stores/user";
const userStore = useUserStore();
const { userInfo, isLogin } = storeToRefs(userStore);
function logout() {
userStore.token = "";
userStore.userInfo = null;
}
</script>为什么用 storeToRefs?
直接解构 store 会丢失响应式:
javascript
const { userInfo } = userStore; // 不推荐storeToRefs 可以把 state/getters 转成 ref,同时保留 actions 方法直接调用。
16.4 Vuex 与 Pinia 对比
| 对比 | Vuex | Pinia |
|---|---|---|
| 推荐生态 | Vue 2 存量项目 | Vue 3 新项目 |
| 修改状态 | mutation/action 分离 | action 中可直接改 |
| TypeScript | 较繁琐 | 更友好 |
| 模块拆分 | modules | 多个 defineStore |
| API 风格 | 较重 | 更轻 |
| DevTools | 支持 | 支持 |
迁移建议:
- Vue 2 存量项目稳定运行时,不必为了迁移而迁移。
- Vue 3 新项目优先 Pinia。
- 大型项目按领域拆 store,例如
user、permission、settings、cart。
17. 路由:Vue Router
17.1 基础配置
Vue 3 + Vue Router 4:
javascript
import { createRouter, createWebHistory } from "vue-router";
const routes = [
{
path: "/",
component: () => import("@/views/HomeView.vue"),
},
{
path: "/users/:id",
name: "user-detail",
component: () => import("@/views/UserDetailView.vue"),
props: true,
meta: {
requiresAuth: true,
title: "用户详情",
},
},
];
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});17.2 hash 模式与 history 模式
| 模式 | URL | 服务端要求 |
|---|---|---|
| hash | /#/users/1 | 基本不需要额外配置 |
| history | /users/1 | 服务端需要 fallback 到 index.html |
history 模式刷新 404 的原因:
text
浏览器直接请求 /users/1
-> 服务器找 /users/1 文件或路由
-> 找不到就 404解决:Nginx 配置 fallback。
nginx
location / {
try_files $uri $uri/ /index.html;
}17.3 路由守卫
全局登录校验:
javascript
router.beforeEach(async (to) => {
const userStore = useUserStore();
if (to.meta.requiresAuth && !userStore.isLogin) {
return {
path: "/login",
query: { redirect: to.fullPath },
};
}
});设置标题:
javascript
router.afterEach((to) => {
document.title = to.meta.title ? `${to.meta.title} - Admin` : "Admin";
});守卫返回值:
false:取消导航。- 路由地址对象:重定向。
undefined或true:继续。
17.4 动态路由与权限
后台管理系统常见做法:
- 登录后拿到用户角色和权限码。
- 根据权限生成可访问路由。
- 动态添加路由。
- 侧边菜单从路由 meta 或服务端菜单生成。
javascript
const asyncRoutes = [
{
path: "/system",
component: Layout,
meta: { permission: "system:view" },
children: [
{
path: "users",
component: () => import("@/views/system/UserList.vue"),
meta: { permission: "user:list" },
},
],
},
];
function filterRoutes(routes, permissions) {
return routes
.filter(route => !route.meta?.permission || permissions.includes(route.meta.permission))
.map(route => ({
...route,
children: route.children ? filterRoutes(route.children, permissions) : undefined,
}));
}注意:前端路由权限只负责用户体验,真正的数据权限必须由后端接口校验。
17.5 路由懒加载
javascript
const UserList = () => import("@/views/UserList.vue");好处:
- 首屏包更小。
- 访问页面时再加载对应 chunk。
- 配合 Vite/Webpack 自动代码分割。
不要把所有页面都同步 import:
javascript
// 不推荐:所有页面进入首屏 bundle
import UserList from "@/views/UserList.vue";18. 脚手架与工程化
18.1 Vue CLI 与 Vite
| 对比 | Vue CLI | Vite |
|---|---|---|
| 典型时代 | Vue 2 存量项目 | Vue 3 新项目 |
| 底层 | Webpack | 开发 ESM,生产 Rollup |
| 启动速度 | 大项目较慢 | 通常更快 |
| 配置文件 | vue.config.js | vite.config.ts |
| 插件生态 | Vue CLI 插件 | Vite/Rollup 插件 |
新项目通常优先 Vite;存量 Vue 2 项目可以继续维护 Vue CLI,但要理解构建链路。
18.2 推荐目录结构
text
src/
├── api/ # API 请求封装
├── assets/ # 静态资源
├── components/ # 通用组件
├── composables/ # 组合式函数
├── directives/ # 自定义指令
├── layouts/ # 布局组件
├── router/ # 路由
├── stores/ # Pinia
├── styles/ # 全局样式
├── types/ # TS 类型
├── utils/ # 工具函数
└── views/ # 页面原则:
- 页面级组件放
views。 - 可复用 UI 放
components。 - 可复用逻辑放
composables。 - 请求函数按领域放
api。 - 不要把所有工具都塞进一个巨大
utils/index.js。
18.3 API 请求封装
javascript
import axios from "axios";
export const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10_000,
});
http.interceptors.request.use((config) => {
const userStore = useUserStore();
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`;
}
return config;
});
http.interceptors.response.use(
response => response.data,
error => {
if (error.response?.status === 401) {
// 退出登录或跳转登录页
}
return Promise.reject(error);
}
);18.4 组件设计原则
一个好组件应该:
- props 清晰。
- emits 明确。
- 不偷偷修改父组件传入对象。
- slot 用于开放扩展点。
- 样式边界清晰。
- 可测试。
示例:
vue
<script setup>
const props = defineProps({
modelValue: {
type: String,
default: "",
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "blur"]);
function onInput(event) {
emit("update:modelValue", event.target.value);
}
</script>
<template>
<input
:value="modelValue"
:disabled="disabled"
@input="onInput"
@blur="emit('blur')"
/>
</template>19. Vue 2 项目迁移到 Vue 3
19.1 迁移优先级
不要一上来就重写项目。推荐顺序:
- 升级构建和依赖,确认测试能跑。
- 清理废弃 API 和全局混入。
- 处理过滤器、事件总线、插槽语法等差异。
- 引入 Composition API 改造复杂逻辑。
- 迁移 Vue Router、Vuex/Pinia。
- 最后处理性能和工程化优化。
19.2 常见破坏性变化
| Vue 2 | Vue 3 |
|---|---|
new Vue() | createApp() |
Vue.use() | app.use() |
| filters | 推荐 computed/methods |
$listeners | 合并到 $attrs |
.sync | v-model:propName |
| Vuex 常用 | Pinia 推荐 |
| 全局 API | app 实例 API |
19.3 迁移示例:v-model
Vue 2:
vue
<CustomInput :value="name" @input="name = $event" />Vue 3:
vue
<CustomInput v-model="name" />组件内部:
vue
<script setup>
defineProps({
modelValue: String,
});
const emit = defineEmits(["update:modelValue"]);
</script>多个 v-model:
vue
<UserEditor v-model:name="name" v-model:email="email" />20. 常见模式与最佳实践
20.1 Composable 设计
好的 composable 应该:
- 以
use开头。 - 输入输出明确。
- 不强依赖具体组件。
- 在组件卸载时清理副作用。
javascript
import { onBeforeUnmount, ref } from "vue";
export function useWindowSize() {
const width = ref(window.innerWidth);
const height = ref(window.innerHeight);
function update() {
width.value = window.innerWidth;
height.value = window.innerHeight;
}
window.addEventListener("resize", update);
onBeforeUnmount(() => {
window.removeEventListener("resize", update);
});
return { width, height };
}20.2 表单模式
复杂表单建议拆分:
- 表单状态。
- 校验规则。
- 提交逻辑。
- 字段组件。
javascript
const form = reactive({
name: "",
email: "",
});
const errors = reactive({});
function validate() {
errors.name = form.name ? "" : "请输入姓名";
errors.email = form.email.includes("@") ? "" : "邮箱格式不正确";
return !errors.name && !errors.email;
}20.3 性能优化
常见优化:
v-for使用稳定 key。- 大列表使用虚拟滚动。
- 路由懒加载。
- 组件按需引入。
- 避免在模板中调用复杂函数。
- 合理使用
computed缓存。 - 大对象使用
shallowRef或拆分状态。 v-once用于永不变化内容。v-memo用于跳过部分子树更新。
示例:
vue
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.version]">
{{ item.name }}
</div>20.3.1 v-memo 深入:何时用、何时别用
v-memo(Vue 3.2+)接收一个固定长度的依赖数组。每次更新时,Vue 会把数组里的每个值和上一次渲染时的值做 Object.is 比较——如果全部相同,就跳过整棵子树的更新(内部的 v-for、子组件都不重新渲染)。
vue
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.version]">
<HeavyChart :data="item" />
</div>- 只有当
item.id或item.version变化时,这一行才重新渲染。 v-memo="[]"空数组等价于v-once:永远只渲染一次。- 依赖数组长度必须固定,否则比较行为不可预测。
⚠️ 注意事项:
- 适合:列表很长、每行渲染成本高(图表、复杂嵌套组件),且行数据大部分时间不变。
- 不适合:依赖数组漏掉了会影响渲染的变量——Vue 会跳过本应发生的更新,产生「该更新却没更新」的 bug。
v-memo是「锦上添花」的优化,应先用稳定key、computed缓存等基础手段,遇到可测量的性能瓶颈再上v-memo。
20.3.2 shallowRef / shallowReactive:跳过深层响应式
shallowRef 和 shallowReactive 只对「顶层」做响应式,深层数据不会被代理:
javascript
const state = shallowRef({ list: [1, 2, 3] });
state.value.list.push(4); // ❌ 不会触发更新(深层修改不被追踪)
state.value = { list: [4] }; // ✅ 整体替换 .value 才触发| API | 响应式范围 | 适用场景 |
|---|---|---|
shallowRef | 只有 .value 的赋值触发 | 大型不可变数据、接入外部状态库 |
shallowReactive | 只有对象第一层属性 | 根级只读配置、图表配置对象 |
- 典型场景:一个几万条的大数组/大对象,你只会「整体替换」它(如
list.value = newData),不需要监听内部每个元素的改动。用shallowRef可省去深层代理的初始化成本和内存占用。 - 若确实对深层做了修改又想手动触发,
triggerRef(ref)可以强制派发一次更新。
💡 和 reactive 解构陷阱一样,shallowRef 的关键判断是「你究竟在改 .value 还是改内部字段」。改 .value 就触发,改内部字段就不触发。
20.4 常见反模式
- 所有状态都放 Pinia。
- 子组件直接修改 props。
- 使用 index 作为 key。
- 组件里同时写大量请求、转换、权限、UI 状态。
- watch 里无限修改被监听状态。
- 模板表达式过重。
- 全局事件总线不清理。
- API 请求散落在各个组件中。
- 路由权限只靠前端控制。
- 迁移 Vue 3 时一次性重写整个项目。
21. 学习路线总结
推荐顺序:
- 模板语法、事件、表单、列表渲染。
- props、emits、slot、组件通信。
- 生命周期与组件设计。
- ref、reactive、computed、watch。
- 响应式原理和 nextTick。
- Vue Router 和路由懒加载。
- Pinia 状态管理。
- Vite 工程化、环境变量、代理、部署。
- 组件抽象、composable、权限路由。
- Vue 2 到 Vue 3 迁移。
必须能回答的问题:
- Vue 2 和 Vue 3 响应式原理有什么区别?
ref和reactive怎么选?computed和watch的区别是什么?- 为什么修改状态后要
await nextTick()才能读到新 DOM? v-for为什么必须使用稳定 key?- Pinia 相比 Vuex 简化了什么?
- history 路由刷新 404 的原因是什么?
- 前端权限路由为什么不能替代后端鉴权?
- composable 和 mixin 的核心区别是什么?
- Vue 项目如何做首屏和构建优化?