Skip to content

Vue 系统学习文档 ​

从零开始系统掌握 Vue 框架,涵盖 Vue 2 和 Vue 3,对比两者差异


1. Vue 简介 ​

目录 ​

  1. Vue 简介与基础概念
  2. 开发环境搭建
  3. Vue 实例与应用
  4. 模板语法与数据绑定
  5. 计算属性与侦听器
  6. Class 与 Style 绑定
  7. 条件渲染与列表渲染
  8. 事件处理
  9. 表单输入绑定
  10. 组件基础
  11. 组件通信
  12. 插槽(Slot)
  13. 生命周期钩子
  14. Composition API vs Options API
  15. 响应式原理
  16. 状态管理:Vuex vs Pinia
  17. 路由:Vue Router
  18. 脚手架与工程化
  19. Vue 2 项目迁移到 Vue 3
  20. 常见模式与最佳实践
  21. 学习路线总结

1. Vue 简介与基础概念 ​

1.1 什么是 Vue ​

Vue(读音 /vjuː/,类似于 "view")是一款用于构建用户界面的渐进式 JavaScript 框架。由尤雨溪(Evan You)于 2014 年创建。

核心特点:

  • 渐进式框架:逐步采用,从 CDN 引入到完整单页应用
  • 声明式渲染:通过模板语法简洁渲染到 DOM
  • 组件化:UI 拆分为可复用的组件
  • 响应式:数据变化自动驱动视图更新

1.2 Vue 2 vs Vue 3:整体对比 ​

特性Vue 2Vue 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.xPinia(推荐)或 Vuex 4.x
脚手架Vue CLIcreate-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 --router

2.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.json

create-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.json

3. 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 2Vue 3
创建方式new Vue(options)createApp(options)
挂载$mount("#app")app.mount("#app")
全局 APIVue.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 ​

特性computedwatch
用途计算衍生数据执行副作用
缓存有无
异步不支持支持
场景模板中衍生数据数据变化后自动操作

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-ifv-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 2Vue 3
默认 propvaluemodelValue
默认事件inputupdate: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
// 多根节点时需手动绑定 $attrs

11.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 BusVue 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 2Vue 3
具名插槽slot="name"v-slot:name / #name
作用域插槽slot-scope="{ item }"#default="{ item }"
slot/slot-scope 属性支持已移除

13. 生命周期钩子 ​

阶段Vue 2Vue 3 Options APIVue 3 Composition API
创建前beforeCreatebeforeCreatesetup()
创建后createdcreatedsetup()
挂载前beforeMountbeforeMountonBeforeMount
挂载后mountedmountedonMounted
更新前beforeUpdatebeforeUpdateonBeforeUpdate
更新后updatedupdatedonUpdated
卸载前beforeDestroybeforeUnmountonBeforeUnmount
卸载后destroyedunmountedonUnmounted

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

14. Composition API vs Options API ​

14.1 对比 ​

特性Options APIComposition 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 MixinVue 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-api
javascript
// 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 需要知道:

  1. 哪些地方读取过 count。
  2. count 变化后应该通知谁。
  3. 多次变化时如何合并更新,避免重复渲染。

响应式系统本质上就是建立“数据 -> 副作用”的依赖图。

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/booleanref
需要整体替换的对象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);
});

区别:

对比watchwatchEffect
依赖来源显式指定自动收集
是否立即执行默认不立即,可配置 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 DOM

key 对 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 对比 ​

对比VuexPinia
推荐生态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 动态路由与权限 ​

后台管理系统常见做法:

  1. 登录后拿到用户角色和权限码。
  2. 根据权限生成可访问路由。
  3. 动态添加路由。
  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 CLIVite
典型时代Vue 2 存量项目Vue 3 新项目
底层Webpack开发 ESM,生产 Rollup
启动速度大项目较慢通常更快
配置文件vue.config.jsvite.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 迁移优先级 ​

不要一上来就重写项目。推荐顺序:

  1. 升级构建和依赖,确认测试能跑。
  2. 清理废弃 API 和全局混入。
  3. 处理过滤器、事件总线、插槽语法等差异。
  4. 引入 Composition API 改造复杂逻辑。
  5. 迁移 Vue Router、Vuex/Pinia。
  6. 最后处理性能和工程化优化。

19.2 常见破坏性变化 ​

Vue 2Vue 3
new Vue()createApp()
Vue.use()app.use()
filters推荐 computed/methods
$listeners合并到 $attrs
.syncv-model:propName
Vuex 常用Pinia 推荐
全局 APIapp 实例 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 常见反模式 ​

  1. 所有状态都放 Pinia。
  2. 子组件直接修改 props。
  3. 使用 index 作为 key。
  4. 组件里同时写大量请求、转换、权限、UI 状态。
  5. watch 里无限修改被监听状态。
  6. 模板表达式过重。
  7. 全局事件总线不清理。
  8. API 请求散落在各个组件中。
  9. 路由权限只靠前端控制。
  10. 迁移 Vue 3 时一次性重写整个项目。

21. 学习路线总结 ​

推荐顺序:

  1. 模板语法、事件、表单、列表渲染。
  2. props、emits、slot、组件通信。
  3. 生命周期与组件设计。
  4. ref、reactive、computed、watch。
  5. 响应式原理和 nextTick。
  6. Vue Router 和路由懒加载。
  7. Pinia 状态管理。
  8. Vite 工程化、环境变量、代理、部署。
  9. 组件抽象、composable、权限路由。
  10. Vue 2 到 Vue 3 迁移。

必须能回答的问题:

  1. Vue 2 和 Vue 3 响应式原理有什么区别?
  2. ref 和 reactive 怎么选?
  3. computed 和 watch 的区别是什么?
  4. 为什么修改状态后要 await nextTick() 才能读到新 DOM?
  5. v-for 为什么必须使用稳定 key?
  6. Pinia 相比 Vuex 简化了什么?
  7. history 路由刷新 404 的原因是什么?
  8. 前端权限路由为什么不能替代后端鉴权?
  9. composable 和 mixin 的核心区别是什么?
  10. Vue 项目如何做首屏和构建优化?

基于 VitePress 构建的个人学习文档知识库