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
← 返回帖子 更多前端内容 →