Loading

【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 工程化要点

  1. 路径别名:@ → src(vite.config.ts + tsconfig 配置);
  2. 环境变量:.env.development / .env.production,以 VITE_ 开头才会被 Vite 注入 import.meta.env;
  3. 代理解决跨域(开发期,本质是把浏览器请求转发到后端,规避同源策略):
// vite.config.ts
server: {
  proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } }
}
  1. 代码规范:ESLint + Prettier + husky 提交前校验;
  2. 构建产物优化:路由级懒加载(() => import())+ gzip/brotli 预压缩 + CDN 外置大依赖(如 vue、element-plus 用 build.rollupOptions.external);
  3. 打包分析:rollup-plugin-visualizer 看体积分布,定位"谁把包撑大了"。

10.2 渲染层性能优化

  • v-if vs v-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 缓存 vs method:模板里重复使用的派生值用 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 接入,到打包上线与排错。

posted @ 2026-09-19 05:41  Stellan7  阅读(12)  评论(0)    收藏  举报