Skip to content

章节4 — Vue3 组件化开发


一、章节概述

Vue 3 是目前最流行的前端框架之一。本章聚焦组合式 API(Composition API),深入讲解响应式原理(Proxy)、组件通信方式、Vue Router 路由配置与导航守卫,以及 Pinia 状态管理,帮助学员建立现代组件化开发思维。


二、学习目标

目标分类具体目标
知识目标理解组合式 API 与选项式 API 的区别;理解 Proxy 响应式原理与 Vue2 的差异
技能目标能使用 ref / reactive / computed 开发组件;能配置 Vue Router 并实现路由守卫;能用 Pinia 管理全局状态
素质目标培养组件化、声明式编程思维,理解单向数据流原则

三、核心知识点

3.1 Vue3 组合式 API(Composition API)

3.1.1 setup 函数

setup 是组合式 API 的入口,在组件创建之前执行。

vue
<script>
import { ref, reactive, computed, onMounted } from "vue";

export default {
  // setup 接收 props 和 context
  setup(props, { emit, slots, attrs }) {
    // 在这里定义响应式状态和方法
    // 返回的对象暴露给模板

    return { /* ... */ };
  },
};
</script>

3.1.2 <script setup> 语法糖(推荐)

Vue 3.2+ 推荐的简化写法,顶层所有变量和方法自动暴露给模板。

vue
<script setup>
import { ref, reactive, computed, onMounted } from "vue";

// 顶层 await — 组件自动成为异步组件
const data = await fetch("/api/data").then(r => r.json());
</script>

<template>
  <div>{{ data }}</div>
</template>

3.1.3 ref 与 reactive

API类型支持访问方式适用场景
ref(value)任意类型脚本中 .value,模板自动解包基本类型、单个值
reactive(obj)仅对象/数组直接访问(深层响应式)复杂的嵌套对象
vue
<script setup>
import { ref, reactive } from "vue";

// ref — 基本类型
const count = ref(0);
const message = ref("你好");

function increment() {
  count.value++; // 脚本中必须 .value
}

// ref — 对象(底层也是 reactive)
const user = ref({ name: "Alice", age: 25 });
console.log(user.value.name); // 需要 .value

// reactive — 对象
const state = reactive({
  list: [],
  loading: false,
  error: null,
});

// reactive 直接访问
state.loading = true;
state.list.push({ id: 1, title: "学习 Vue3" });
</script>

<template>
  <!-- 模板中自动解包,不用 .value -->
  <p>{{ count }} — {{ message }}</p>
  <p>{{ state.loading }} — {{ state.list.length }}</p>
  <button @click="increment">+1</button>
</template>

提醒reactive 不可重新赋值(会失去响应式);ref 可重新赋值。对象首选 ref 更灵活。

3.1.4 computed 计算属性

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

const todos = ref([
  { text: "学习 HTML", done: true },
  { text: "学习 CSS", done: false },
  { text: "学习 JavaScript", done: false },
]);

// 只读计算属性
const doneCount = computed(() => todos.value.filter(t => t.done).length);
const progress = computed(() =>
  `${doneCount.value} / ${todos.value.length}`
);

// 可写计算属性(极少用)
const allDone = computed({
  get: () => todos.value.every(t => t.done),
  set: (value) => {
    todos.value.forEach(t => (t.done = value));
  },
});
</script>

<template>
  <p>进度:{{ progress }}</p>
  <label>
    <input type="checkbox" v-model="allDone" />
    全部完成
  </label>
</template>

3.1.5 生命周期钩子

Vue2 选项式Vue3 组合式时机
beforeCreate—(直接写在 setup 中)组件初始化
created—(直接写在 setup 中)组件创建
beforeMountonBeforeMountDOM 挂载前
mountedonMountedDOM 挂载后
beforeUpdateonBeforeUpdate数据更新前
updatedonUpdated数据更新后
beforeUnmountonBeforeUnmount卸载前
unmountedonUnmounted卸载后
vue
<script setup>
import { onMounted, onUnmounted, ref } from "vue";

const data = ref(null);

onMounted(async () => {
  data.value = await fetch("/api/data").then(r => r.json());
});

onUnmounted(() => {
  console.log("组件已卸载,清理工作");
});
</script>

3.2 响应式原理(Proxy)

3.2.1 Vue2 vs Vue3 响应式

对比维度Vue2(Object.defineProperty)Vue3(Proxy)
拦截方式逐个定义属性代理整个对象
新增/删除属性❌ 无法检测(需 $set / $delete✅ 自动检测
数组索引/长度❌ 无法检测✅ 自动检测
性能初始化递归遍历懒代理(使用时才代理)
兼容性IE11+现代浏览器

3.2.2 Proxy 基本原理

javascript
// 模拟 Vue3 响应式
const handler = {
  get(target, key, receiver) {
    console.log(`读取 ${String(key)}`);
    // 收集依赖(track)
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    console.log(`设置 ${String(key)} =`, value);
    const result = Reflect.set(target, key, value, receiver);
    // 触发更新(trigger)
    return result;
  },
  deleteProperty(target, key) {
    console.log(`删除 ${String(key)}`);
    // 触发更新
    return Reflect.deleteProperty(target, key);
  },
};

const state = new Proxy({ count: 0 }, handler);
state.count;       // 读取 count
state.count = 10;  // 设置 count = 10
delete state.count; // 删除 count

理解即可:Vue3 内部通过 reactive 对对象使用 Proxyref 对基本类型包装为 { value: ... } 再代理。


3.3 组件通信

3.3.1 父子通信:props / emit

vue
<!-- Child.vue -->
<script setup>
// 定义 props
const props = defineProps({
  title: { type: String, required: true },
  count: { type: Number, default: 0 },
  items: { type: Array, default: () => [] },
});

// 定义 emits
const emit = defineEmits(["update", "delete"]);

function handleUpdate() {
  emit("update", { id: props.items[0]?.id });
}
</script>

<template>
  <div class="child">
    <h3>{{ title }}</h3>
    <p>数量:{{ count }}</p>
    <button @click="handleUpdate">更新</button>
    <button @click="emit('delete', 1)">删除</button>
  </div>
</template>
vue
<!-- Parent.vue -->
<script setup>
import { ref } from "vue";
import Child from "./Child.vue";

const items = ref([{ id: 1, name: "项目A" }]);

function onUpdate(payload) {
  console.log("收到更新:", payload);
}
</script>

<template>
  <Child
    title="子组件标题"
    :count="items.length"
    :items="items"
    @update="onUpdate"
    @delete="(id) => items = items.filter(i => i.id !== id)"
  />
</template>

3.3.2 依赖注入:provide / inject

适合深层嵌套的祖先 ↔ 后代通信,避免逐层传 props。

vue
<!-- Ancestor.vue -->
<script setup>
import { provide, ref } from "vue";

const theme = ref("light");
const user = ref({ name: "管理员" });

// 提供响应式数据
provide("theme", theme);
provide("user", user);

// 提供修改方法
provide("toggleTheme", () => {
  theme.value = theme.value === "light" ? "dark" : "light";
});
</script>
vue
<!-- DeepChild.vue -->
<script setup>
import { inject } from "vue";

const theme = inject("theme", "light"); // 第二个参数为默认值
const user = inject("user");
const toggleTheme = inject("toggleTheme");
</script>

<template>
  <div :class="`app-${theme}`">
    <p>当前用户:{{ user?.name }}</p>
    <button @click="toggleTheme">切换主题</button>
  </div>
</template>

3.3.3 v-model 双向绑定(组件版)

vue
<!-- InputComponent.vue -->
<script setup>
const model = defineModel(); // Vue 3.4+ 语法糖

// 等价于:
// const props = defineProps(['modelValue']);
// const emit = defineEmits(['update:modelValue']);
// const model = computed({
//   get: () => props.modelValue,
//   set: (v) => emit('update:modelValue', v),
// });
</script>

<template>
  <input v-model="model" placeholder="输入内容..." />
</template>
vue
<!-- Parent -->
<script setup>
import { ref } from "vue";
import InputComponent from "./InputComponent.vue";

const text = ref("");
</script>

<template>
  <InputComponent v-model="text" />
  <p>你输入了:{{ text }}</p>
</template>

3.4 Vue Router 路由配置与导航守卫

3.4.1 基本配置

javascript
// router/index.js
import { createRouter, createWebHistory } from "vue-router";
import Home from "@/views/Home.vue";

const routes = [
  {
    path: "/",
    name: "Home",
    component: Home,
    meta: { title: "首页" },
  },
  {
    path: "/about",
    name: "About",
    component: () => import("@/views/About.vue"), // 懒加载
  },
  {
    path: "/user/:id",
    name: "User",
    component: () => import("@/views/User.vue"),
    props: true, // 将 params 作为 props 传入组件
  },
  {
    path: "/:pathMatch(.*)*",
    name: "NotFound",
    component: () => import("@/views/NotFound.vue"), // 404
  },
];

const router = createRouter({
  history: createWebHistory(), // HTML5 模式(需要服务端支持)
  routes,
});

export default router;
vue
<!-- App.vue -->
<template>
  <nav>
    <router-link to="/">首页</router-link>
    <router-link to="/about">关于</router-link>
    <router-link :to="`/user/${userId}`">用户</router-link>
  </nav>

  <router-view /> <!-- 路由出口 -->
</template>

3.4.2 编程式导航

vue
<script setup>
import { useRouter, useRoute } from "vue-router";

const router = useRouter();
const route = useRoute();

function goToUser(id) {
  // 方式1
  router.push(`/user/${id}`);

  // 方式2:命名的路由
  router.push({ name: "User", params: { id } });

  // 方式3:带查询参数
  router.push({ path: "/search", query: { q: "vue" } });
}

function goBack() {
  router.back(); // 后退
}
</script>

3.4.3 导航守卫

javascript
// 全局前置守卫 — 常用(权限控制、页面标题)
router.beforeEach(async (to, from) => {
  // 设置页面标题
  document.title = to.meta?.title || "默认标题";

  // 登录验证
  const token = localStorage.getItem("token");
  if (to.meta.requiresAuth && !token) {
    // 重定向到登录页,并保存原始路径
    return { name: "Login", query: { redirect: to.fullPath } };
  }
});

// 全局后置守卫
router.afterEach((to, from) => {
  console.log(`从 ${from.path} 跳转到 ${to.path}`);
});

// 路由独享守卫
const routes = [
  {
    path: "/admin",
    component: () => import("@/views/Admin.vue"),
    beforeEnter: (to, from) => {
      const role = localStorage.getItem("role");
      if (role !== "admin") return { name: "Home" };
    },
  },
];

3.5 Pinia 状态管理

3.5.1 定义 Store

Pinia 是 Vue3 官方推荐的状态管理库,替代 Vuex。TypeScript 支持友好,无 mutations。

javascript
// stores/counter.js
import { defineStore } from "pinia";
import { ref, computed } from "vue";

export const useCounterStore = defineStore("counter", () => {
  // state
  const count = ref(0);
  const history = ref([]);

  // getters
  const doubleCount = computed(() => count.value * 2);
  const recentHistory = computed(() => history.value.slice(-5));

  // actions(支持 async)
  function increment() {
    count.value++;
    history.value.push(`+1 → ${count.value}`);
  }

  async function fetchAndSet() {
    const res = await fetch("/api/count");
    const data = await res.json();
    count.value = data.count;
  }

  return { count, history, doubleCount, recentHistory, increment, fetchAndSet };
});

3.5.2 在组件中使用

vue
<script setup>
import { useCounterStore } from "@/stores/counter";

const counter = useCounterStore();

// 直接解构会丢失响应式!
// ❌ const { count, increment } = useCounterStore();
// ✅ 用 storeToRefs 解构
import { storeToRefs } from "pinia";
const { count, doubleCount } = storeToRefs(counter);
const { increment, fetchAndSet } = counter; // 方法不用解构
</script>

<template>
  <div>
    <p>计数:{{ count }}</p>
    <p>双倍:{{ doubleCount }}</p>
    <p>直接访问:{{ counter.count }}</p>
    <button @click="increment">+1</button>
    <button @click="counter.$reset">重置</button>
    <button @click="fetchAndSet">从 API 读取</button>
  </div>
</template>

3.5.3 Pinia 模块化

javascript
// stores/user.js
export const useUserStore = defineStore("user", () => {
  const user = ref(null);
  const token = ref(null);

  const isLoggedIn = computed(() => !!token.value);

  async function login(email, password) {
    const res = await fetch("/api/login", {
      method: "POST",
      body: JSON.stringify({ email, password }),
    });
    const data = await res.json();
    token.value = data.token;
    user.value = data.user;
    localStorage.setItem("token", data.token);
  }

  function logout() {
    token.value = null;
    user.value = null;
    localStorage.removeItem("token");
  }

  return { user, token, isLoggedIn, login, logout };
});
javascript
// 在组件中跨 store 调用
import { useCounterStore } from "./counter";
import { useUserStore } from "./user";

export const useAppStore = defineStore("app", () => {
  const counter = useCounterStore();
  const user = useUserStore();

  const summary = computed(() => ({
    user: user.user?.name,
    count: counter.count,
  }));

  function resetAll() {
    counter.$reset();
    user.logout();
  }

  return { summary, resetAll };
});

四、小结

知识点核心要点
组合式 API<script setup> + ref/reactive/computed 是 Vue3 标准写法
响应式原理Proxy 代理整个对象,支持增删属性和数组变化检测
组件通信props 向下传,emit 向上发;provide/inject 跨层级;v-model 双向绑定
Vue RoutercreateRouter + createWebHistory;导航守卫做权限控制
PiniadefineStore + storeToRefs;无 mutations,支持 async actions

五、练习

基础题

  1. 创建 Counter.vue 组件,使用 ref 实现计数功能,包含「+1」「-1」「重置」三个按钮。
  2. 创建 TodoItem.vue 接收 title(String)和 done(Boolean)两个 props,并通过 emit 向外触发 toggledelete 事件。

进阶题

  1. 配置 Vue Router:实现 HomeAboutUser/:id 三条路由,并在全局前置守卫中设置页面 document.title
  2. 使用 Pinia 创建一个 todoStore,包含 todos 数组、addTodo(text)toggleTodo(id) 方法,在组件中展示列表并支持添加和切换完成状态。

挑战题

  1. 实现一个用户登录流程:Login 页面 → 调用 Pinia action 登录成功 → 路由守卫自动跳转到之前访问的页面(或首页);未登录时访问 /profile 自动重定向到 /login?redirect=/profile。用 provide + inject 在全局 header 组件中显示当前用户名。

Python 学习资料