【WebWiki】01 · 前端 Vue 3
08 前端(Vue3)
WebWiki · 前端篇(后端工程师够用版)
本文定位:面向有 Java/后端基础、需要快速掌握 Vue3 现代写法的工程师。重点不在"会写模板",而在"理解原理 + 能落地企业级项目"。文中所有代码块均带中文注释,建议边读边敲。
1. 技术栈基线(2026)
| 层 | 选型 | 选型理由(一句话) |
|---|---|---|
| 框架 | Vue 3.4+(Composition API + <script setup>) |
响应式基于 Proxy,组合式 API 逻辑可复用,TS 友好 |
| 构建 | Vite(秒级冷启动,替代 webpack CLI) | 基于 ESM 的按需编译,冷启动与 HMR 比 webpack 快一个数量级 |
| 语言 | TypeScript | 接口契约与后端 DTO 对齐,重构安全 |
| 状态 | Pinia | Vuex 的官方继任者,更简洁、TS 推导更好、无 mutations 概念 |
| 路由 | Vue Router 4 | 与 Vue3 配套的路由,支持 <script setup> 与组合式导航守卫 |
| UI | Element Plus / Ant Design Vue / Naive UI | 中后台三选一即可,Element Plus 生态最全 |
| 请求 | Axios 封装 | 统一拦截 token、错误、401,屏蔽后端 R<T> 结构 |
| 包管理 | pnpm | 硬链接 + 内容寻址存储,安装快、磁盘占用小、依赖隔离严格 |
为什么放弃了 Vue2? Vue2 的响应式基于
Object.defineProperty,无法监听对象新增/删除属性与数组索引变化,需要$set/$delete兜底;Vue3 改为 Proxy,原生支持这些场景,且支持Map/Set等集合类型。新项目一律 Vue3。
2. 快速起步
# 使用官方脚手架创建一个 Vue3 + TS 项目
pnpm create vite mall-web --template vue-ts
cd mall-web && pnpm install && pnpm dev # http://localhost:5173
生成目录(重点看 src/):
mall-web
├── index.html # 唯一的 HTML 入口,Vite 从这里挂载
├── vite.config.ts # 构建与代理配置(路径别名、devServer 代理)
├── tsconfig.json # TS 配置,注意 paths 与 vite 别名保持一致
└── src
├── main.ts # 应用入口:createApp(App).mount('#app')
├── App.vue # 根组件
├── components/ # 通用组件
├── views/ # 页面级组件(路由挂载)
├── stores/ # Pinia 状态
├── router/ # Vue Router 配置
├── utils/ # http、工具函数
└── api/ # 接口定义(按模块拆分)
踩坑提示:
pnpm默认严格隔离依赖(phantom dependencies)。某些老库没声明依赖却直接import会报Cannot find module。解法:在package.json用pnpm.peerDependencyRules或显式安装缺失依赖,不要图省事切回 npm。
3. 组合式 API 核心
3.1 设计动机:为什么要有组合式 API
选项式 API(Options API)把同一业务逻辑分散在 data、methods、computed、watch、mounted 等不同选项中,一个功能的代码横跨多个区块,组件变大后极难维护。组合式 API(Composition API)允许按"功能"聚合代码:
- 同一块逻辑(如"商品搜索")的响应式状态、计算、侦听、生命周期写在一起;
- 用组合函数(Composable)把可复用逻辑抽成
useXxx(),跨组件复用,替代 Vue2 的 mixin(mixin 有命名冲突、来源不清晰、无法传参等痛点); - 天然兼容 TypeScript,类型推导远好于
this。
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
// 响应式状态
const count = ref(0)
const products = ref<Product[]>([])
const keyword = ref('')
// 计算属性:只有依赖变化才重算,结果被缓存(等价于 Java 里带 memo 的 getter)
const expensive = computed(() => products.value.filter(p => p.price > 100))
// 侦听器(防抖搜索):watch 精确监听某个 ref
let timer: number
watch(keyword, kw => {
clearTimeout(timer)
timer = setTimeout(() => search(kw), 300)
})
// 生命周期钩子:在 setup 阶段直接调用,等价于 Options 的 mounted
onMounted(loadProducts)
async function loadProducts() {
products.value = await api.listProducts()
}
</script>
<template>
<input v-model="keyword" placeholder="搜索商品" />
<div v-for="p in expensive" :key="p.id" @click="count++">
{{ p.name }} — ¥{{ p.price }}
</div>
<span>点击次数:{{ count }}</span>
</template>
3.2 ref 与 reactive 怎么选
| 场景 | 用法 | 注意 |
|---|---|---|
| 基本类型(number/string/boolean) | ref(0) |
模板里直接用 {{ count }},脚本里要 .value |
| 对象 / 数组 | reactive({...}) 或 ref({...}) |
推荐统一用 ref,解构/传递时引用稳定,避免 reactive 解构丢失响应性 |
| 集合类型 | ref(new Map()) |
Vue3 的 reactive 已支持 Map/Set |
经验法则:组合式项目里"一律用
ref"是最省心的写法。ref包裹对象时底层仍是reactive,<script setup>模板中会自动解包,无需.value。
3.3 组合函数(Composable)示例
// composables/useCounter.ts —— 把"计数器"逻辑抽成可复用函数
import { ref, computed, onUnmounted } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const double = computed(() => count.value * 2)
// 组合函数内也能注册生命周期,组件卸载时自动清理
const t = setInterval(() => count.value++, 1000)
onUnmounted(() => clearInterval(t))
function reset() { count.value = initial }
return { count, double, reset }
}
3.4 核心 API 速查
| API | 用途 |
|---|---|
ref / reactive |
基础 / 对象响应式(组合用 ref 居多) |
computed |
派生值,自动缓存 |
watch / watchEffect |
侦听变化执行副作用(watch 懒执行、watchEffect 立即执行并自动收集依赖) |
defineProps / defineEmits |
组件输入 / 事件输出(编译宏,无需 import) |
provide / inject |
跨层级注入(祖先 provide,后代 inject) |
4. 响应式原理(Proxy + 依赖收集)
理解响应式是看懂 Vue3 的关键。一句话概括:用 Proxy 拦截对数据的"读"和"写",读时收集依赖(track),写时触发更新(trigger)。
4.1 Vue2 的 defineProperty 局限
// Vue2:遍历已有属性逐个定义 getter/setter
Object.defineProperty(obj, 'name', {
get() { /* 收集依赖 */ return val },
set(v) { /* 触发更新 */ val = v }
})
局限:
- 无法监听新增/删除属性(必须
Vue.set/Vue.delete); - 无法监听数组索引/长度变化(需要重写数组方法);
- 初始化时要递归遍历整棵对象树做响应式转换,大对象首屏慢。
4.2 Vue3 的 Proxy 方案
// 伪代码:Vue3 reactive 的核心思想
function reactive(target) {
return new Proxy(target, {
// 读取属性时:收集"谁在用我"
get(obj, key, receiver) {
const res = Reflect.get(obj, key, receiver)
track(obj, key) // 依赖收集:把当前正在执行的 effect 记下来
return isObject(res) ? reactive(res) : res // 懒递归,用到才代理
},
// 修改属性时:通知所有用到它的 effect 重新执行
set(obj, key, value, receiver) {
const result = Reflect.set(obj, key, value, receiver)
trigger(obj, key) // 派发更新
return result
},
deleteProperty(obj, key) {
const had = key in obj
const result = Reflect.deleteProperty(obj, key)
if (had) trigger(obj, key)
return result
}
})
}
track / trigger 背后是一个全局的"依赖桶":
targetMap: Map<目标对象, Map<属性key, Set<副作用函数 effect>>>
- track(依赖收集):在组件渲染或
computed/watch执行期间,会有一个"活跃 effect",读取响应式属性时把"属性 → 活跃 effect"写进依赖桶。 - trigger(派发更新):属性被
set时,从依赖桶取出对应的 effect 集合并逐个执行(组件会重新渲染,computed 会标脏,watch 会回调)。
为什么用 Reflect?
Reflect.get/set配合Proxy的receiver能正确处理继承、this指向与私有字段,避免super场景下的依赖收集错位。Proxy还能拦截in、deleteProperty、has等操作,这是defineProperty做不到的。
注意:Proxy 是 ES6 特性,IE 不支持,这也是 Vue3 放弃 IE 的根本原因。同时
ref对基本类型的响应式其实是"在对象{ value }上套 Proxy/defineProperty",所以脚本里要.value。
5. 生命周期与执行顺序
Vue3 生命周期(组合式写法是在 setup 里直接调用同名函数):
| 生命周期 | Options API | Composition API | 触发时机 |
|---|---|---|---|
| 创建前 | beforeCreate |
——(setup 本身即此阶段) | 实例初始化,data 未生成 |
| 创建后 | created |
——(setup 本身即此阶段) | data/props 可用 |
| 挂载前 | beforeMount |
onBeforeMount |
模板编译完、DOM 未插入 |
| 挂载后 | mounted |
onMounted |
DOM 已插入,可拿 $refs、发请求 |
| 更新前 | beforeUpdate |
onBeforeUpdate |
数据变、DOM 重渲染前 |
| 更新后 | updated |
onUpdated |
DOM 已更新,注意别改依赖导致死循环 |
| 卸载前 | beforeUnmount |
onBeforeUnmount |
清理定时器/事件监听 |
| 卸载后 | unmounted |
onUnmounted |
实例销毁 |
| 错误捕获 | errorCaptured |
onErrorCaptured |
子孙组件抛错 |
父子组件执行顺序(高频面试题):
- 挂载阶段:
父 setup→父 onBeforeMount→子 setup→子 onBeforeMount→子 onMounted→父 onMounted。
(先创建子节点再挂载父,因为父的 DOM 依赖子渲染完成。) - 更新阶段:
父 onBeforeUpdate→子 onBeforeUpdate→子 onUpdated→父 onUpdated。 - 卸载阶段:
父 onBeforeUnmount→子 onBeforeUnmount→子 onUnmounted→父 onUnmounted。
<script setup>
import { onMounted, onBeforeUnmount } from 'vue'
onMounted(() => {
// 此处 DOM 已就绪,可初始化第三方库(如 ECharts、地图)
})
onBeforeUnmount(() => {
// 必须清理:定时器、window 事件、WebSocket 连接,否则内存泄漏
window.removeEventListener('resize', onResize)
})
</script>
6. 组件通信八种方式
| # | 场景 | 方案 | 说明 |
|---|---|---|---|
| 1 | 父 → 子 | props |
单向数据流,子组件不能直接改 props(要改用 v-model 或 emit) |
| 2 | 子 → 父 | emit 事件 |
defineEmits 声明事件,子组件 emit('update', val) |
| 3 | 双向绑定 | v-model |
Vue3 支持多个 v-model,编译为 modelValue + update:modelValue |
| 4 | 兄弟 / 跨级 | Pinia | 全局状态库,最干净 |
| 5 | 跨层级注入 | provide / inject |
祖先 provide,任意后代 inject,适合主题/用户态等"深层透传" |
| 6 | 模板引用 | ref + defineExpose |
父拿子组件实例/方法 |
| 7 | 全局事件总线 | mitt 等 |
轻量发布订阅,替代 Vue2 的 $on/$off(Vue3 已移除实例事件总线) |
| 8 | 路由传参 | router / route |
query、params、props: true 解耦 |
<!-- 父组件:多 v-model 写法 -->
<script setup lang="ts">
import Child from './Child.vue'
import { ref } from 'vue'
const title = ref('')
const visible = ref(false)
</script>
<template>
<Child v-model="title" v-model:visible="visible" />
</template>
<!-- 子组件 Child.vue -->
<script setup lang="ts">
// defineModel 是 Vue3.4 的语法糖,等价于 props+emit 的双向绑定
const title = defineModel<string>()
const visible = defineModel<boolean>('visible')
</script>
<!-- provide/inject 跨层透传用户态 -->
<!-- 祖先 -->
<script setup>
import { provide } from 'vue'
provide('theme', 'dark') // 第一个参数是 key
</script>
<!-- 任意后代(无需逐层传 props) -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme', 'light') // 第二个参数是默认值
</script>
7. Pinia 状态管理
Pinia 相比 Vuex 的改进:无 mutations、TS 推导更好、defineStore 写法统一、支持组合式与选项式两种定义风格。
// stores/user.ts —— 组合式风格(推荐:逻辑最自由)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '@/utils/http'
export const useUserStore = defineStore('user', () => {
// state:用 ref 定义
const token = ref(localStorage.getItem('token') ?? '')
const profile = ref<UserProfile | null>(null)
// getter:用 computed 定义
const isLoggedIn = computed(() => token.value !== '')
// action:直接用函数
async function login(form: LoginForm) {
const { data } = await api.post('/auth/login', form)
token.value = data.token
localStorage.setItem('token', data.token) // 持久化,刷新不丢
}
function logout() { token.value = ''; localStorage.removeItem('token') }
return { token, profile, isLoggedIn, login, logout }
})
// 选项式风格(类似 Vuex,习惯 Vuex 的人好上手)
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] as CartItem[] }),
getters: {
total: (s) => s.items.reduce((sum, i) => sum + i.price * i.qty, 0)
},
actions: {
add(item: CartItem) { this.items.push(item) }
}
})
// 在组件中使用
import { useUserStore } from '@/stores/user'
const user = useUserStore()
await user.login(form) // 直接调用 action
console.log(user.isLoggedIn) // 模板里也能直接用
持久化技巧:用
pinia-plugin-persistedstate插件可一行配置把指定 store 同步到localStorage/sessionStorage,比手写localStorage更优雅。
解构陷阱:const { token } = user会丢失响应性,应storeToRefs(user)解构:const { token, isLoggedIn } = storeToRefs(user)。
8. Axios 统一封装
// utils/http.ts —— 统一拦截:带 token、统一错误提示、401 跳登录
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
const http = axios.create({ baseURL: '/api', timeout: 10000 })
// 请求拦截器:统一加 Authorization 头
http.interceptors.request.use(cfg => {
const user = useUserStore()
if (user.token) cfg.headers.Authorization = `Bearer ${user.token}`
return cfg
})
// 响应拦截器:统一拆包后端 R<T>、错误提示、401 处理
http.interceptors.response.use(
resp => {
const { code, msg, data } = resp.data // 匹配后端 R<T> 结构
if (code !== 0) { ElMessage.error(msg); throw new Error(msg) }
return data // 业务成功只返回 data,调用方更清爽
},
err => {
if (err.response?.status === 401) {
// 未登录/过期:清状态并跳登录(注意 router 实例需在外层注入)
useUserStore().logout()
useRouter().push('/login')
}
ElMessage.error('网络异常,请稍后重试')
throw err
}
)
export default http
关于响应拦截返回
data:返回原始resp.data还是data是团队约定。返回data能让业务代码更短,但会丢失headers/status;如需状态码在接口层单独处理,建议保留resp.data或封装http.getRaw()。
9. 路由与权限(Vue Router 原理)
9.1 hash 与 history 模式原理
| 模式 | URL 形态 | 原理 | 服务端要求 |
|---|---|---|---|
| hash | http://a.com/#/orders |
利用 location.hash 与 hashchange 事件,# 后内容不发给服务器 |
无需特殊配置,刷新不会 404 |
| history | http://a.com/orders |
基于 HTML5 History API(pushState/replaceState + popstate) |
需服务端兜底:任意路径都返回 index.html(否则刷新 404) |
// router/index.ts —— history 模式配置
import { createRouter, createWebHistory } from 'vue-router'
const routes: RouteRecordRaw[] = [
{ path: '/login', component: Login },
{ path: '/', component: Layout, meta: { requiresAuth: true }, children: [
// 路由级懒加载:编译时拆分成独立 chunk,进入才加载
{ path: 'orders', component: () => import('@/views/OrderList.vue') },
{ path: 'products', component: () => import('@/views/ProductList.vue') },
]},
]
const router = createRouter({
history: createWebHistory(), // 改用 createWebHashHistory() 即 hash 模式
routes
})
9.2 全局前置守卫做鉴权
router.beforeEach((to) => {
const user = useUserStore()
// 需要登录却未登录:中断导航并跳登录(return 路径即重定向)
if (to.meta.requiresAuth && !user.isLoggedIn) return '/login'
// 已登录还想进登录页:直接去首页
if (to.path === '/login' && user.isLoggedIn) return '/'
})
9.3 动态路由(菜单级权限)
登录后按后端返回的菜单/权限码,用 router.addRoute() 注入页面,实现"看不到菜单就进不了页":
// 登录成功后:把后端菜单转成路由并动态添加
function buildRoutes(menus: Menu[]): RouteRecordRaw[] {
return menus.map(m => ({
path: m.path,
name: m.name,
component: () => import(`@/views/${m.component}.vue`),
meta: { permission: m.code }
}))
}
menus.forEach(r => router.addRoute('Layout', r)) // 挂到某个父路由下
刷新白屏问题:动态路由是运行时加的,刷新后丢失。解决:在
main.ts里根据用户态预先构建一次路由(或配合 Pinia 持久化 + 路由守卫里补加)。
10. 工程化与性能优化
10.1 工程化要点
- 路径别名:
@→src(vite.config.ts + tsconfig 配置); - 环境变量:
.env.development/.env.production,以VITE_开头才会被 Vite 注入import.meta.env; - 代理解决跨域(开发期,本质是把浏览器请求转发到后端,规避同源策略):
// vite.config.ts
server: {
proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } }
}
- 代码规范:ESLint + Prettier + husky 提交前校验;
- 构建产物优化:路由级懒加载(
() => import())+ gzip/brotli 预压缩 + CDN 外置大依赖(如vue、element-plus用build.rollupOptions.external); - 打包分析:
rollup-plugin-visualizer看体积分布,定位"谁把包撑大了"。
10.2 渲染层性能优化
v-ifvsv-show:v-if真正销毁/重建 DOM(运行时条件、切换少用它);v-show只是display:none(频繁切换的标签页用)。v-memo:跳过子树中"依赖未变化"的块,适合大列表里的静态行,避免无谓的 diff。
<template>
<!-- 只有 item.id 或 item.name 变化时才重新渲染该行 -->
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.name]">
{{ item.name }}
</div>
</template>
keep-alive缓存列表页:切换路由时保活组件实例,避免重复请求、保留滚动位置。
<template>
<!-- include 指定要缓存的组件名;max 限制缓存个数防止内存膨胀 -->
<router-view v-slot="{ Component }">
<keep-alive :include="['OrderList', 'ProductList']" :max="10">
<component :is="Component" />
</keep-alive>
</router-view>
</template>
- 大列表虚拟滚动:数据上万行用
vue-virtual-scroller等,只渲染可视区 DOM。 shallowRef/shallowReactive:当状态是大型不可变对象(如一次性赋值的表格),用浅响应式避免逐属性代理开销。computed缓存 vsmethod:模板里重复使用的派生值用computed,函数调用每次渲染都重算。
10.3 编译期优化(Vue3 特有)
Vue3 的编译器会把模板编译成带"静态提升(hoist)"的渲染函数:静态节点只创建一次、被提升到渲染函数外;带补丁标记(patch flag)的动态节点在 diff 时只比对标记的属性,跳过静态内容。这是 Vue3 比 Vue2 渲染快的重要原因之一。
11. 小结与排错速查
- Vue3 响应式 = Proxy + 依赖收集(track/trigger),天然支持增删属性与集合类型;
- 组合式 API 按"功能"聚合代码,用
useXxx()组合函数替代 mixin; - 组件通信:父子用 props/emit、跨层用 provide/inject、全局用 Pinia、解耦用路由;
- 性能三板斧:
v-if/v-show选对、keep-alive缓存页面、v-memo+ 虚拟滚动压大列表; - 常见坑:解构 store 要用
storeToRefs;刷新动态路由白屏要预构建;onUnmounted务必清理副作用;history 模式部署需服务端 fallback 到index.html。
下一篇《02-Vue 整合实操》会带你把上述知识点串成一个可部署的工程:从 Vite 初始化、目录划分、Pinia/Axios/Router 接入,到打包上线与排错。

浙公网安备 33010602011771号