POST · 前端 · 文章
Vue 3 从入门到进阶
2026/11/13 · by ZHANG XING YUN · 阅读 1 次 · 4144 字 · 预计阅读 11 分钟
本文按"模板 → 响应式 → 组件 → 路由与状态 → 工程化与性能"的顺序整理 Vue 3 的核心用法,示例基于组合式 API 与 <script setup>。
- 环境:Vue 3.4+、Vite 5+、Node 20+
- 示例使用 Vue 3 + TypeScript 可选;纯 JS 同样适用
- 说明:Vue 3.5 起新增
useTemplateRef、props 解构保持响应性等能力,文中标注了版本
一、Vue 是什么
Vue 是渐进式前端框架:可以从一个 script 标签引入做局部增强,也可以用 Vite 搭完整工程。
三个核心机制:
| 机制 | 作用 |
|---|---|
| 响应式系统 | 数据变化自动触发视图更新(基于 Proxy) |
| 模板编译 | 模板在构建期编译为渲染函数,运行时开销小 |
| 组件系统 | 单文件组件(SFC)把模板、逻辑、样式放在一起 |
与 React 的主要差异:
| 维度 | Vue | React |
|---|---|---|
| 更新方式 | 响应式追踪,精确更新 | 状态变化触发组件重渲染,靠 diff |
| 模板 | HTML 模板 + 指令 | JSX |
| 状态 | ref / reactive |
useState / useReducer |
| 样式 | scoped 开箱即用 |
CSS Modules / CSS-in-JS |
二、创建项目与单文件组件
npm create vite@latest my-app -- --template vue
cd my-app
npm install
npm run dev
单文件组件(SFC):
<script setup>
import { ref } from "vue";
const count = ref(0);
</script>
<template>
<button @click="count++">点击 {{ count }}</button>
</template>
<style scoped>
button {
padding: 8px 14px;
}
</style>
三块内容的分工:
| 块 | 作用 |
|---|---|
<script setup> |
组件逻辑,顶层变量自动暴露给模板 |
<template> |
视图结构,编译为渲染函数 |
<style scoped> |
组件作用域样式 |
<script setup> 是语法糖:不用 return,不用 export default,编译后等价于 setup() + 返回绑定。
三、模板语法
3.1 插值
<template>
<h1>{{ title }}</h1>
<p>{{ count + 1 }}</p>
<p>{{ user.name }}</p>
<p>{{ formatDate(createdAt) }}</p>
<!-- v-html 会解析 HTML,注意 XSS -->
<div v-html="rawHtml"></div>
</template>
插值只支持表达式,不支持语句(if、for、赋值)。
3.2 属性绑定
<template>
<img :src="avatar" :alt="name" />
<button :disabled="loading">提交</button>
<!-- 布尔属性:值为 false 时不渲染 -->
<input :readonly="isReadonly" />
<!-- 对象批量绑定 -->
<div v-bind="attrs"></div>
<!-- 同名简写(3.4+) -->
<div :id></div>
</template>
3.3 事件
<template>
<button @click="handleClick">点击</button>
<button @click="count++">自增</button>
<form @submit.prevent="onSubmit">...</form>
<input @keyup.enter="onEnter" />
<div @click.stop="onInner">阻止冒泡</div>
</template>
常用修饰符:.prevent、.stop、.self、.once、.capture、按键修饰符 .enter、.esc。
3.4 双向绑定
<template>
<input v-model="name" />
<input v-model.trim="name" />
<input v-model.number="age" type="number" />
<input v-model.lazy="keyword" />
</template>
v-model 本质是 :value + @input 的语法糖(组件上是 modelValue + update:modelValue)。
四、指令
| 指令 | 作用 | 示例 |
|---|---|---|
v-if / v-else-if / v-else |
条件渲染(销毁/重建) | <div v-if="ok"> |
v-show |
显示隐藏(display:none,保留 DOM) |
<div v-show="ok"> |
v-for |
列表渲染 | <li v-for="item in list" :key="item.id"> |
v-bind |
属性绑定 | :class="cls" |
v-on |
事件绑定 | @click="fn" |
v-model |
双向绑定 | <input v-model="x"> |
v-slot |
插槽内容 | #header |
v-html |
渲染 HTML | 注意 XSS |
v-text |
设置 textContent | 等价于插值 |
v-once |
只渲染一次 | 静态内容 |
v-memo |
依赖不变时跳过更新 | 大列表优化 |
4.1 v-if 与 v-show 的选择
v-if:真正的条件渲染,切换会销毁/重建组件,初始为假时不渲染。v-show:始终渲染,只切换 CSS,适合频繁切换。
4.2 v-for 的 key
<template>
<li v-for="(item, index) in list" :key="item.id">
{{ index }} - {{ item.name }}
</li>
</template>
key必须唯一且稳定,不要用 index(列表增删会导致状态错位)。v-for与v-if不要同时用在同一元素:v-if优先级更高,会拿不到循环变量。正确做法是用计算属性先过滤。
4.3 class 与 style
<template>
<div :class="{ active: isActive, 'text-danger': hasError }"></div>
<div :class="[baseClass, isActive ? 'active' : '']"></div>
<div :style="{ color: textColor, fontSize: size + 'px' }"></div>
</template>
五、响应式基础
5.1 ref 与 reactive
<script setup>
import { ref, reactive } from "vue";
const count = ref(0);
count.value++; // 脚本中要 .value
const form = reactive({ name: "", age: 18 });
form.name = "张三"; // reactive 不需要 .value
</script>
<template>
<!-- 模板中 ref 自动解包,不用 .value -->
<p>{{ count }}</p>
<p>{{ form.name }}</p>
</template>
选择建议:
| 场景 | 推荐 |
|---|---|
| 基本类型(数字、字符串、布尔) | ref |
| 对象 / 表单整体 | reactive |
| 需要整体替换的对象 | ref(reactive 会丢失响应性) |
| 与第三方库实例共存 | shallowRef / markRaw |
reactive 的限制:
let state = reactive({ count: 0 });
state = reactive({ count: 1 }); // 替换后原引用失效,模板仍指向旧对象
// 解构会丢失响应性
const { count } = state; // count 是普通数字
const { count: countRef } = toRefs(state); // 保持响应性
5.2 响应式原理
- Vue 3 用
Proxy拦截 get/set,读取时收集依赖(track),修改时触发更新(trigger)。 - 组件渲染时访问了哪些响应式数据,就只在这些数据变化时重新渲染。
ref对内层对象也会转为响应式(ref({})的.value是 proxy)。
5.3 不触发更新的情况
// 1) 直接改数组索引(Vue 3 已支持,但替换 length 等仍需注意)
// 2) 添加新属性:Vue 3 支持(Proxy),Vue 2 不支持
// 3) 非响应式对象:普通对象、markRaw 过的对象
const raw = markRaw({ big: "data" });
// 4) 冻结对象
Object.freeze(obj);
六、计算属性与侦听器
6.1 computed
<script setup>
import { ref, computed } from "vue";
const price = ref(100);
const count = ref(2);
// 只读:有缓存,依赖不变不重新计算
const total = computed(() => price.value * count.value);
// 可写
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set(value) {
[firstName.value, lastName.value] = value.split(" ");
}
});
</script>
计算属性 vs 方法:
| computed | 方法调用 | |
|---|---|---|
| 缓存 | 有(依赖不变不重算) | 每次渲染都执行 |
| 用法 | {{ total }} |
{{ getTotal() }} |
6.2 watch 与 watchEffect
import { ref, watch, watchEffect } from "vue";
const keyword = ref("");
const page = ref(1);
// 侦听单个来源
watch(keyword, (value, oldValue) => {
console.log("关键词变化", oldValue, "->", value);
});
// 侦听多个来源
watch([keyword, page], ([kw, p], [oldKw, oldPage]) => {
search(kw, p);
});
// 深层侦听对象
watch(form, newForm => console.log(newForm), { deep: true });
// 立即执行一次
watch(keyword, handler, { immediate: true });
// 自动追踪依赖,不需要指定来源
watchEffect(() => {
console.log("依赖变化", keyword.value);
});
选择:
- 明确知道要侦听什么、需要旧值、需要
immediate→watch。 - 逻辑里同时用到多个响应式数据、不需要旧值 →
watchEffect。
停止侦听:
const stop = watch(keyword, handler);
stop(); // 手动停止;组件卸载时会自动停止
watch 的回调在组件更新之前触发;需要等 DOM 更新后用 await nextTick()。
七、组件
7.1 props
<script setup>
// 运行时声明
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 },
tags: { type: Array, default: () => [] } // 对象/数组默认值必须用工厂函数
});
</script>
<!-- TypeScript 写法 -->
<script setup lang="ts">
interface Props {
title: string;
count?: number;
}
const props = withDefaults(defineProps<Props>(), { count: 0 });
</script>
要点:
- props 是单向数据流,子组件不能直接修改,需要
emit通知父组件。 props解构在 Vue 3.5 之前会丢失响应性;3.5+ 支持解构保持响应(const { count } = defineProps())。- 布尔 prop 传
false时必须写:disabled="false",只写disabled等价于true。
7.2 emits
<script setup>
const emit = defineEmits(["update", "delete"]);
function onSave() {
emit("update", { id: 1, name: "张三" });
}
</script>
<!-- 父组件 -->
<Child @update="handleUpdate" @delete="handleDelete" />
TypeScript 写法:
const emit = defineEmits<{
(e: "update", payload: { id: number }): void;
(e: "delete", id: number): void;
}>();
7.3 v-model 组件
<!-- 子组件:3.4+ 推荐 defineModel -->
<script setup>
const model = defineModel({ type: String, default: "" });
</script>
<template>
<input v-model="model" />
</template>
<!-- 等价的手写方式 -->
<script setup>
const props = defineProps({ modelValue: String });
const emit = defineEmits(["update:modelValue"]);
</script>
<template>
<input :value="modelValue" @input="emit('update:modelValue', $event.target.value)" />
</template>
多个 v-model:
<UserForm v-model:name="name" v-model:age="age" />
7.4 透传属性
<!-- 父组件 -->
<MyInput class="big" placeholder="请输入" />
<!-- 子组件:根元素会自动继承 class / placeholder -->
<!-- 多个根节点时需要显式 v-bind="$attrs" -->
<script setup>
defineOptions({ inheritAttrs: false });
</script>
<template>
<label>输入</label>
<input v-bind="$attrs" />
</template>
7.5 expose
<script setup>
import { ref } from "vue";
const inputRef = ref(null);
function focus() {
inputRef.value?.focus();
}
defineExpose({ focus });
</script>
// 父组件
const child = ref(null);
child.value?.focus();
<script setup> 默认关闭组件实例的暴露,必须 defineExpose 才能被父组件通过 ref 调用。
八、插槽与动态组件
8.1 插槽
<!-- 子组件 Card.vue -->
<template>
<div class="card">
<header><slot name="header">默认标题</slot></header>
<main><slot>默认内容</slot></main>
<footer><slot name="footer" :year="2026" /></footer>
</div>
</template>
<!-- 父组件 -->
<template>
<Card>
<template #header>
<h2>用户信息</h2>
</template>
<p>主体内容</p>
<template #footer="{ year }">
<span>{{ year }}</span>
</template>
</Card>
</template>
作用域插槽:子组件通过 :prop 把数据传给父组件的插槽内容。
8.2 动态组件与异步组件
<script setup>
import { shallowRef } from "vue";
import TableView from "./TableView.vue";
import CardView from "./CardView.vue";
const current = shallowRef(TableView);
</script>
<template>
<component :is="current" :list="list" />
<KeepAlive>
<component :is="current" />
</KeepAlive>
</template>
动态组件用
shallowRef或markRaw包装组件,避免组件对象被深度代理导致性能问题。
异步组件:
import { defineAsyncComponent } from "vue";
const HeavyChart = defineAsyncComponent(() => import("./HeavyChart.vue"));
// 带加载与错误状态
const HeavyChart2 = defineAsyncComponent({
loader: () => import("./HeavyChart.vue"),
loadingComponent: Loading,
errorComponent: ErrorView,
delay: 200,
timeout: 5000
});
8.3 KeepAlive
<KeepAlive :include="['UserList']" :max="5">
<component :is="current" />
</KeepAlive>
被缓存的组件会触发 onActivated / onDeactivated,而不是重新 onMounted。
九、生命周期
| 选项式 | 组合式 | 时机 |
|---|---|---|
beforeCreate / created |
setup 内直接执行 |
实例创建 |
beforeMount |
onBeforeMount |
挂载前 |
mounted |
onMounted |
DOM 已挂载 |
beforeUpdate |
onBeforeUpdate |
数据更新、DOM 更新前 |
updated |
onUpdated |
DOM 更新后 |
beforeUnmount |
onBeforeUnmount |
卸载前 |
unmounted |
onUnmounted |
卸载后 |
activated / deactivated |
onActivated / onDeactivated |
KeepAlive 激活/失活 |
errorCaptured |
onErrorCaptured |
捕获子组件错误 |
<script setup>
import { onMounted, onUnmounted } from "vue";
let timer = null;
onMounted(() => {
timer = setInterval(() => console.log("tick"), 1000);
});
onUnmounted(() => {
clearInterval(timer); // 必须清理,否则泄漏
});
</script>
onMounted 中才能拿到真实 DOM(模板 ref 已赋值)。
十、组合式函数与依赖注入
10.1 组合式函数(composable)
// composables/useCounter.js
import { ref } from "vue";
export function useCounter(initial = 0) {
const count = ref(initial);
const increment = () => (count.value += 1);
const reset = () => (count.value = initial);
return { count, increment, reset };
}
<script setup>
import { useCounter } from "@/composables/useCounter";
const { count, increment } = useCounter(10);
</script>
约定:文件名以 use 开头,返回值用对象(便于解构与重命名)。
10.2 provide / inject
// 祖先:提供
import { provide, ref } from "vue";
const theme = ref("light");
provide("theme", theme);
// 后代:注入(任意层级)
import { inject, ref } from "vue";
const theme = inject("theme", ref("light")); // 第二个参数为默认值
要点:
- 注入的是引用,子组件修改会影响祖先(需要单向时提供修改函数)。
- 跨层级超过 3 层再考虑 provide/inject,否则 props 更清晰。
- 键名建议用
Symbol避免冲突。
十一、路由
11.1 安装与配置
npm i vue-router
// router/index.js
import { createRouter, createWebHistory } from "vue-router";
const routes = [
{ path: "/", component: () => import("@/views/Home.vue") },
{
path: "/users",
component: () => import("@/layouts/UserLayout.vue"),
children: [
{ path: "", name: "user-list", component: () => import("@/views/UserList.vue") },
{ path: ":id", name: "user-detail", component: () => import("@/views/UserDetail.vue"), props: true }
]
},
{ path: "/:pathMatch(.*)*", component: () => import("@/views/NotFound.vue") }
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
return savedPosition || { top: 0 };
}
});
export default router;
11.2 使用
<script setup>
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
// 读取参数
console.log(route.params.id, route.query.page);
// 跳转
router.push({ name: "user-detail", params: { id: 1 }, query: { tab: "info" } });
router.replace("/home");
router.back();
</script>
<template>
<RouterLink to="/users" active-class="active">用户</RouterLink>
<RouterView />
</template>
11.3 守卫
// 全局前置守卫:鉴权
router.beforeEach((to, from) => {
const token = localStorage.getItem("token");
if (!token && to.meta.requiresAuth) {
return { name: "login", query: { redirect: to.fullPath } };
}
return true; // 放行
});
// 全局后置
router.afterEach(to => {
document.title = to.meta.title || "应用";
});
// 组件内守卫(组合式)
import { onBeforeRouteLeave } from "vue-router";
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
return window.confirm("有未保存的修改,确定离开?");
}
});
守卫返回值:true 放行,false 取消,路由对象重定向。
11.4 路由元信息与懒加载
const adminRoute = {
path: "/admin",
component: () => import("@/views/Admin.vue"),
meta: { requiresAuth: true, title: "管理后台", roles: ["admin"] }
};
() => import(...)实现路由级代码分割,首屏更小。- 重定向与别名:
redirect: "/home"、alias: ["/index"]。
11.5 history 模式的服务端配置
createWebHistory 需要服务器把未知路径回退到 index.html,否则刷新 404:
location / {
try_files $uri $uri/ /index.html;
}
十二、状态管理(Pinia)
npm i pinia
// main.js
import { createPinia } from "pinia";
app.use(createPinia());
// stores/user.js
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useUserStore = defineStore("user", () => {
// state
const token = ref("");
const profile = ref(null);
// getters
const isLoggedIn = computed(() => Boolean(token.value));
// actions
async function login(credentials) {
const data = await api.login(credentials);
token.value = data.token;
profile.value = data.user;
}
function logout() {
token.value = "";
profile.value = null;
}
return { token, profile, isLoggedIn, login, logout };
});
<script setup>
import { storeToRefs } from "pinia";
import { useUserStore } from "@/stores/user";
const userStore = useUserStore();
const { token, isLoggedIn } = storeToRefs(userStore); // 保持响应性
</script>
要点:
- 从 store 解构时必须用
storeToRefs,否则拿到的是普通值。 - 选项式写法:
state、getters、actions。 - 持久化可用
pinia-plugin-persistedstate,注意敏感信息不要落localStorage。 - 一个 store 一个领域,不要做成"全局大对象"。
十三、样式
13.1 scoped
<style scoped>
.title {
color: red;
}
</style>
scoped 通过给元素加 data-v-xxx 属性、样式加属性选择器实现,只作用于当前组件。
13.2 深度选择器与全局样式
<style scoped>
/* 修改子组件内部样式 */
:deep(.el-input__inner) {
border-color: red;
}
/* 全局 */
:global(.app-body) {
margin: 0;
}
</style>
<style>
/* 不加 scoped 即全局 */
</style>
13.3 CSS 变量与 v-bind
<script setup>
import { ref } from "vue";
const color = ref("red");
</script>
<template>
<p class="text">动态颜色</p>
</template>
<style scoped>
.text {
color: v-bind(color);
}
</style>
13.4 CSS Modules
<template>
<p :class="$style.title">标题</p>
</template>
<style module>
.title {
font-weight: bold;
}
</style>
十四、与后端交互
// api/http.js
import axios from "axios";
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE,
timeout: 10000
});
http.interceptors.request.use(config => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
http.interceptors.response.use(
response => response.data,
error => {
if (error.response?.status === 401) {
router.push("/login");
}
return Promise.reject(error);
}
);
export default http;
请求取消(搜索框场景):
const controller = new AbortController();
async function search(keyword) {
controller.abort(); // 取消上一次
const current = new AbortController();
const response = await fetch(`/api/search?q=${keyword}`, { signal: current.signal });
return response.json();
}
环境变量:Vite 只暴露 VITE_ 前缀的变量,通过 import.meta.env.VITE_API_BASE 读取。
十五、性能优化
| 手段 | 说明 |
|---|---|
| 路由懒加载 | () => import(...),按路由拆包 |
v-once |
静态内容只渲染一次 |
v-memo |
指定依赖,未变则跳过子树更新 |
KeepAlive |
保留组件实例,避免重复渲染 |
shallowRef / shallowReactive |
大对象不需要深层响应时使用 |
markRaw |
第三方实例(地图、图表)避免被代理 |
| 虚拟滚动 | 长列表只渲染可视区域 |
| 计算属性缓存 | 避免在模板里写复杂表达式 |
| 组件拆分 | 减少单个组件的更新范围 |
| 图片懒加载 | loading="lazy" 或 IntersectionObserver |
v-memo 示例:
<div v-for="item in list" :key="item.id" v-memo="[item.selected]">
{{ item.name }}
</div>
仅当 item.selected 变化时才重新渲染该项。
十六、TypeScript 集成
<script setup lang="ts">
import { ref, computed } from "vue";
interface User {
id: number;
name: string;
}
const users = ref<User[]>([]);
const count = computed(() => users.value.length);
function add(user: User) {
users.value.push(user);
}
</script>
要点:
ref<User[]>([])指定泛型,避免never[]。defineProps<Props>()、defineEmits<...>()提供类型。vue-tsc做类型检查:vue-tsc --noEmit。- 模板中访问
ref自动解包,脚本中需要.value。
十七、常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 修改数据视图不更新 | 用了普通对象 / markRaw / 冻结对象 |
用 ref/reactive,或整体替换 |
| 解构后失去响应 | 解构 reactive 或 props |
toRefs、storeToRefs,或 3.5+ 解构 |
v-for 状态错乱 |
key 用了 index |
用唯一 id |
v-if 与 v-for 同元素 |
v-if 优先级高,拿不到循环变量 |
用计算属性过滤 |
| 子组件改 props 报错 | 单向数据流 | emit 通知父组件 |
onMounted 拿不到 DOM |
元素在 v-if 中未渲染 |
用 nextTick 或改为 v-show |
| 样式改不动子组件 | scoped 隔离 | :deep() |
| 路由刷新 404 | history 模式未回退 | 服务端 try_files |
| 内存持续增长 | 定时器/监听未清理 | onUnmounted 清理 |
computed 不更新 |
依赖不是响应式 | 检查依赖是否被代理 |
十八、FAQ
Q1:ref 和 reactive 怎么选?
基本类型与需要整体替换的对象用 ref;表单等固定对象的字段操作用 reactive。团队内最好统一一种主风格。
Q2:为什么脚本里要写 .value?
ref 是一个包装对象,.value 才是值。模板中 Vue 会自动解包,所以不用写。
Q3:computed 和 watch 的区别?
computed 用于派生数据(有缓存、返回值);watch 用于副作用(请求、写日志、操作 DOM)。
Q4:watch 和 watchEffect 怎么选?
需要旧值、需要精确指定依赖、需要 immediate 时用 watch;逻辑里自然引用多个响应式数据时用 watchEffect。
Q5:组件通信有哪几种方式?
props/emit(父子)、v-model(双向)、provide/inject(跨层)、Pinia(全局)、事件总线(不推荐,Vue 3 已移除 $on)。
Q6:<script setup> 里怎么定义组件名?
defineOptions({ name: "UserList" })(3.3+);或另写一个 <script> 块 export default { name }。
Q7:为什么 KeepAlive 缓存的页面数据不刷新?
实例被保留,不会重新 mounted。在 onActivated 里刷新数据。
Q8:v-model 在组件上为什么修改不了?
子组件必须 emit('update:modelValue', value),或使用 defineModel。
Q9:scoped 样式为什么影响不到子组件?
设计如此。用 :deep() 穿透,或把样式放到子组件内部。
Q10:Vue 3 还有 $on 事件总线吗?
已移除。跨组件通信用 Pinia、provide/inject 或第三方事件库(mitt)。
附录:API 速查
// 响应式
ref(0) / reactive({}) / shallowRef / shallowReactive / readonly
computed(() => {}) / computed({ get, set })
watch(source, cb, { immediate, deep, flush })
watchEffect(fn) / toRef / toRefs / unref / markRaw / nextTick
// 组件
defineProps / defineEmits / defineExpose / defineOptions / defineModel
withDefaults(defineProps(), {})
provide / inject
useAttrs / useSlots / useTemplateRef(3.5+)
// 生命周期
onBeforeMount / onMounted / onBeforeUpdate / onUpdated
onBeforeUnmount / onUnmounted / onActivated / onDeactivated / onErrorCaptured
// 路由
createRouter / createWebHistory / createWebHashHistory
RouterLink / RouterView / useRoute / useRouter
beforeEach / afterEach / onBeforeRouteLeave
// 状态
defineStore / storeToRefs / createPinia
// 模板
v-if / v-else / v-show / v-for / v-bind / v-on / v-model
v-html / v-text / v-once / v-memo / v-slot