Vue 3 插槽深度实战：默认、具名与作用域插槽及自定义指令
如果说 props 是组件的"输入参数"，插槽就是组件的"内容参数"。写好一个可复用的布局组件、表格组件、弹窗组件，绕不开插槽；而自定义指令则是 DOM 级复用的利器。这两个主题合起来，是 Vue 中级开发者必须吃透的内容。一、插槽的三种形态1. 默认插槽父组件传入的内容替换子组件的 <slot> 占位： <slot></slot> <Card> 订单详情 共 3 件商品，合计 ¥299.00 </Card>2. 具名插槽：多区域布局组件有多个内容区域时，用 name 区分。典型场景：页头/页身/页脚的布局组件。 <slot name="header"></slot> <slot></slot> <slot name="footer"></slot> <PageLayout> 管理后台 这里是主内容区域 © 2026 My Corp </PageLayout>注意 #header 是 v-slot:header 的缩写，且 v-slot 只能写在 上（默认插槽的简写除外）。3. 作用域插槽：数据反向传递普通插槽的内容编译在父组件作用域，无法访问子组件内部数据。作用域插槽让子组件把数据"抛"给插槽内容： <slot :item="item" :index="index"> {{ item.name }} </slot> #{{ index + 1 }} {{ item.orderNo }} {{ statusText[item.status] }} 这是设计"无头组件"（Headless Component）的核心思想：组件负责数据和逻辑，父组件负责渲染结构。Element Plus 的 Table、el-select 的 option 都大量使用作用域插槽。实战：封装一个通用描述列表组件综合运用具名 + 作用域插槽，封装一个类似 Ant Design Descriptions 的组件： {{ item.label }} <slot :name="item.field" :item="item"> {{ item.value }} </slot> <DescList :items="[ { field: 'name', label: '姓名', value: user.name }, { field: 'avatar', label: '头像', value: user.avatar }, { field: 'status', label: '状态', value: user.status } ]"> <el-tag :type="item.value === 1 ? 'success' : 'danger'"> {{ item.value === 1 ? '正常' : '禁用' }} </el-tag> </DescList>一个组件同时满足了通用性和定制性——这就是插槽设计的精髓。二、自定义指令当复用的是 DOM 行为而不是组件结构时，用指令。指令只在底层操作 DOM，不关心业务。注册与钩子// main.js 全局注册 const app = createApp(App) app.directive('focus', { mounted(el) { el.focus() } })Vue 3 指令的钩子与组件生命周期对齐：钩子触发时机created元素属性和事件监听器设置之前beforeMount挂载之前mounted挂载到 DOM 后beforeUpdate更新前updated更新后beforeUnmount卸载前unmounted卸载后绝大多数指令只需要 mounted 和 updated，可以用简写函数形式：app.directive('color', (el, binding) => { el.style.color = binding.value })实战指令一：v-loading// directives/loading.js export const vLoading = { mounted(el, binding) { const mask = document.createElement('div') mask.className = 'v-loading-mask' mask.innerHTML = ' ' el.style.position = el.style.position || 'relative' el.appendChild(mask) el.__loadingMask = mask toggle(el, binding.value) }, updated(el, binding) { toggle(el, binding.value) }, unmounted(el) { el.__loadingMask?.remove() } } function toggle(el, show) { el.__loadingMask.style.display = show ? 'flex' : 'none' } {{ row.name }} 实战指令二：v-debounce按钮防抖是高频需求，每个地方手写 setTimeout 太啰嗦：export const vDebounce = { mounted(el, binding) { const [fn, delay = 300] = binding.value instanceof Array ? binding.value : [binding.value, 300] let timer = null el.__debounceHandler = (event) => { if (timer) clearTimeout(timer) timer = setTimeout(() => fn(event), delay) } el.addEventListener('click', el.__debounceHandler) }, unmounted(el) { el.removeEventListener('click', el.__debounceHandler) } } 提交订单 实战指令三：v-permission 权限控制import { useUserStore } from '@/stores/user' export const vPermission = { mounted(el, binding) { const userStore = useUserStore() const required = binding.value // 'order:delete' const modifiers = binding.modifiers // { some: true } → v-permission.some let hasPermission if (modifiers.some) { // 任一权限即可 hasPermission = [].concat(required).some(p => userStore.permissions.includes(p)) } else { hasPermission = userStore.permissions.includes(required) } if (!hasPermission) { el.parentNode?.removeChild(el) } } } 删除订单 复合操作 插槽 vs 指令：选型原则维度插槽指令复用内容结构 + 样式DOM 行为典型场景布局、列表渲染定制权限、防抖、拖拽、埋点作用对象组件原生 DOM 元素灵活性高（父级完全接管渲染）中（操作属性和事件）一个简单的判断：需求是"换个样子"用插槽，需求是"加点行为"用指令。总结作用域插槽 = 子组件向插槽内容"回传数据"，是无头组件的基石具名插槽支撑多区域布局，#name 是标准缩写自定义指令只在需要直接操作 DOM 时使用，能用组件解决的别用指令指令钩子与组件生命周期对齐，简写形式覆盖 mounted + updatedv-loading、v-debounce、v-permission 是三个最值得收进工具箱的指令把插槽和指令用好，你封装的组件会从"能用"进化到"好用"。下一篇文章我们聊聊组合式函数（Composables）——Vue 3 逻辑复用的终极形态。
Vue Router 4 路由实战：动态路由、导航守卫与懒加载
任何多页面 SPA 都绕不开 Vue Router。但很多项目对它的使用停留在"能跳转"——路由懒加载没配、守卫写得一团乱麻、动态路由权限方案稀里糊涂。本文以 Vue Router 4（对应 Vue 3）为准，把这几个中级必考点一次讲透。基础配置与懒加载路由懒加载是必选项打包工具默认会把所有页面组件打进一个 bundle，首屏加载动辄几 MB。动态 import() 让每个页面按需加载：// router/index.ts import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: () => import('@/views/Home.vue'), children: [ { path: 'orders', component: () => import('@/views/order/List.vue') }, { path: 'orders/:id', component: () => import('@/views/order/Detail.vue'), props: true // 把路由参数作为 props 传入组件 } ] } ] })配合 Vite 的手动分包，可以进一步把公共依赖抽出来：// vite.config.ts export default { build: { rollupOptions: { output: { manualChunks: { 'vendor-vue': ['vue', 'vue-router', 'pinia'], 'vendor-ui': ['element-plus'] } } } } }props 解耦：别在组件里读 $route 函数模式更灵活：{ path: 'orders/:id', component: OrderDetail, props: route => ({ id: Number(route.params.id), tab: route.query.tab }) }动态路由与权限系统后台管理系统的经典需求：不同角色看到不同菜单。标准方案是前置白名单 + 动态添加路由：第一步：定义静态与动态路由// router/index.ts import { createRouter, createWebHistory } from 'vue-router' // 静态路由：任何人都能访问 export const constantRoutes = [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/404', component: () => import('@/views/404.vue') } ] // 动态路由：按角色分配，meta.roles 记录可访问角色 export const asyncRoutes = [ { path: '/', component: () => import('@/views/Layout.vue'), children: [ { path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '工作台', roles: ['admin', 'operator'] } }, { path: 'system', component: () => import('@/views/system/Index.vue'), meta: { title: '系统管理', roles: ['admin'] } } ] } ] const router = createRouter({ history: createWebHistory(), routes: constantRoutes })第二步：登录后过滤并挂载// stores/permission.ts import { defineStore } from 'pinia' import { asyncRoutes, constantRoutes } from '@/router' function hasPermission(roles, route) { return route.meta?.roles ? roles.some(role => route.meta.roles.includes(role)) : true // 没声明 roles 默认放行 } export function filterRoutes(routes, roles) { return routes.reduce((acc, route) => { const tmp = { ...route } if (hasPermission(roles, tmp)) { if (tmp.children) { tmp.children = filterRoutes(tmp.children, roles) } acc.push(tmp) } return acc }, []) } export const usePermissionStore = defineStore('permission', { state: () => ({ accessibleRoutes: [] }), actions: { generateRoutes(roles) { this.accessibleRoutes = [...constantRoutes, ...filterRoutes(asyncRoutes, roles)] return this.accessibleRoutes } } })第三步：addRoute 动态注册// 登录成功后 const permissionStore = usePermissionStore() const routes = permissionStore.generateRoutes(user.roles) routes.forEach(route => router.addRoute(route))注意：addRoute 后必须用 router.replace 或返回新 location 触发一次重新导航，否则匹配不到刚添加的路由。导航守卫体系Vue Router 4 的守卫分三类，执行顺序必须烂熟于心：导航触发 → beforeEach（全局前置） → beforeEnter（路由独享） → beforeRouteUpdate / beforeRouteEnter（组件内） → afterEach（全局后置）全局前置守卫：登录鉴权标准模板const WHITE_LIST = ['/login', '/404'] router.beforeEach(async (to, from) => { const userStore = useUserStore() // 1. 白名单直接放行 if (WHITE_LIST.includes(to.path)) return true // 2. 未登录跳登录页，带上 redirect 参数 if (!userStore.token) { return { path: '/login', query: { redirect: to.fullPath } } } // 3. 已登录但还没拉取用户信息/动态路由 if (!userStore.userInfo) { try { await userStore.fetchUserInfo() const permissionStore = usePermissionStore() const routes = permissionStore.generateRoutes(userStore.roles) routes.forEach(r => router.addRoute(r)) // 关键：addRoute 后重新进入当前路由 return { ...to, replace: true } } catch { userStore.logout() return { path: '/login', query: { redirect: to.fullPath } } } } // 4. 正常放行 return true })守卫返回值语义Vue Router 4 的守卫返回值规则：返回值效果undefined / true放行false取消导航路由地址对象 / 字符串重定向Promiseresolve 上述值，reject 则取消并报错afterEach：动态修改页面标题router.afterEach((to) => { document.title = to.meta?.title ? `${to.meta.title} - 管理系统` : '管理系统' })组件内守卫：离开确认表单页防止用户误关，用 onBeforeRouteLeave： 滚动行为与过渡动画const router = createRouter({ history: createWebHistory(), routes, scrollBehavior(to, from, savedPosition) { if (savedPosition) return savedPosition // 前进后退恢复位置 if (to.hash) return { el: to.hash, behavior: 'smooth' } // 锚点 return { top: 0 } // 默认回到顶部 } })路由切换配合 <transition> 做页面动画： <router-view v-slot="{ Component, route }"> <transition name="fade" mode="out-in"> <component :is="Component" :key="route.path" /> </transition> </router-view> useRoute 与 useRouter 中通过组合式 API 获取路由：import { useRoute, useRouter } from 'vue-router' const route = useRoute() // 当前路由信息（响应式）：params、query、meta const router = useRouter() // 路由实例：push、replace、go // 带参数跳转 router.push({ name: 'order-detail', params: { id: 123 }, query: { tab: 'logs' } }) // query 变化时重新拉数据（同一路由复用时 watch） watch(() => route.query.tab, (tab) => { fetchList(tab) })易错点：route.params 不是深度响应式的可靠来源，路径参数变化而组件复用时（如 /orders/1 → /orders/2），要 watch route.params.id 或给 router-view 加 :key。常见坑速查刷新 404：动态路由方案里，刷新后路由表被重置，必须在守卫里重新 addRoute（见上文模板第 3 步）通配符路由位置：{ path: '/:pathMatch(.*)*' } 必须放在动态路由 addRoute 之后，否则先匹配到 404history 模式 404：服务器需配置所有路径回落到 index.html（Nginx 的 try_files $uri $uri/ /index.html;）循环重定向：守卫里 next() 与返回值混用会导致逻辑混乱，Vue Router 4 统一用返回值风格keep-alive 失效：配合动态路由时 include 需要组件 name，确保组件显式声明了 name（defineOptions({ name: 'OrderList' })）总结懒加载是标配，props: true 让组件与路由解耦权限路由三步走：静态/动态路由分离 → 登录后按角色过滤 → addRoute + 重新导航守卫统一用返回值风格，beforeEach 模板可以直接抄走onBeforeRouteLeave 处理离开确认，scrollBehavior 处理滚动恢复刷新 404 和通配符路由顺序是动态路由方案最常踩的两个坑路由系统是后台管理项目的骨架，把这套方案吃透，遇到再复杂的权限场景都能从容拆解。
Vue 3 + TypeScript 类型化开发实战：从 Props 到泛型组件
Vue 3 是用 TS 重写的，类型支持是其核心卖点。但很多项目只是"用了 TS"，模板里的类型断言满天飞、组件 Props 无类型、第三方库全是 any。本文覆盖 Vue 3 + TS 的核心类型工具，从基础用法一路讲到泛型组件，帮你把类型系统真正用起来。一、Props 类型化的三个层次层次一：运行时校验（纯 JS 风格）defineProps({ id: [Number, String], items: { type: Array, required: true } })有运行时警告，但没有编辑器类型推导，TS 项目不推荐。层次二：类型声明（类型推导最佳） 层次三：withDefaults 提供默认值类型声明语法不支持直接给默认值，需要 withDefaults：const props = withDefaults( defineProps<{ items: Order[] pageSize?: number labels?: Record<string, string> }>(), { pageSize: 20, labels: () => ({}) // 对象/数组默认值必须用工厂函数 } )从接口自动生成 Props（进阶）interface Props { userId: number; compact?: boolean } // 响应式解包后仍是响应式的 const props = defineProps<Props>()props 是被 reactive 包装的对象，解构会丢失响应性。需要解构时用 Vue 3.5+ 的响应式 Props 解构：const { userId, compact = false } = defineProps<Props>() // 3.5+ 编译器自动保持响应性二、emit 与 ref 的类型化emit 类型const emit = defineEmits<{ (e: 'change', value: string): void (e: 'select', id: number, item: Order): void }>() // Vue 3.3+ 更简洁的具名元组语法 const emit2 = defineEmits<{ change: [value: string] select: [id: number, item: Order] }>()父组件在模板上监听时，回调参数类型自动校验。ref 的类型// 基础：自动推导 const count = ref(0) // Ref<number> // 初始值为 null 时必须显式标注 const el = ref<HTMLInputElement | null>(null) onMounted(() => el.value?.focus()) // 复杂对象推荐接口先行 interface FormData { name: string tags: string[] } const form = ref<FormData>({ name: '', tags: [] })模板引用组件实例import FormModal from './FormModal.vue' const modalRef = ref<InstanceType<typeof FormModal> | null>(null) onMounted(() => { modalRef.value?.open() // expose 的方法有完整类型 })InstanceType<typeof Component> 会读取 defineExpose 暴露的成员类型，这是父组件调用子组件方法的类型安全姿势。三、computed 与 watch 的类型const orderList = ref<Order[]>([]) const pendingCount = computed(() => orderList.value.filter(o => o.status === 'pending').length ) // 自动推导 ComputedRef<number> // watch 回调参数类型自动对应数据源 watch( () => props.order.status, (newStatus, oldStatus) => { // newStatus: 'pending' | 'paid' | 'closed' console.log(`${oldStatus} → ${newStatus}`) } ) // watchEffect 不需要指定类型，内部自动收集依赖四、provide / inject 的类型安全// symbols/keys.ts —— 集中管理 InjectionKey import type { InjectionKey, Ref } from 'vue' export interface UserContext { user: Ref<{ id: number; name: string }> refresh: () => Promise<void> } export const UserKey: InjectionKey<UserContext> = Symbol('user')// 祖先组件 provide(UserKey, { user, refresh }) // 后代组件：完整类型推导 + 缺省兜底 const ctx = inject(UserKey) if (!ctx) throw new Error('UserKey 未在祖先组件提供') ctx.user.value.name // string，自动推导五、泛型组件：类型跟着数据走普通组件的 Props 类型是固定的，泛型组件让类型由调用方决定。最典型的场景是列表组件和选择器组件。普通写法的困境// ❌ items 只能声明成 any[]，丢失元素类型 defineProps<{ items: any[]; modelValue: any }>()泛型组件写法 <slot :item="item">{{ item[labelField ?? 'id'] }}</slot> generic="T extends { id: number }" 声明泛型参数，调用方传 Order[] 时所有相关类型自动实例化为 Order：<SelectList v-model="selectedOrderId" :items="orders" label-field="title" @select="(order) => console.log(order.status)" // order: Order，类型完整 />泛型 composable：类型化的 useListexport function useList<T>(initial: T[] = []) { const list = ref<T[]>([...initial]) function add(item: T) { list.value.push(item) } function remove(predicate: (item: T) => boolean) { list.value = list.value.filter(i => !predicate(i)) } function find(predicate: (item: T) => boolean): T | undefined { return list.value.find(predicate) } return { list, add, remove, find } } // 使用：所有方法参数和返回值都有精确类型 const { list, add, find } = useList<Order>([]) add({ id: 1, title: 'x', status: 'pending' }) // OK add({ id: 2, title: 'y' }) // 报错：缺 status六、SFC 与 TS 工程细节defineComponent 与组件类型 外部类型文件的组织src/ ├── types/ │ ├── api.d.ts # 后端接口类型（可由 OpenAPI 生成） │ ├── models.ts # 业务模型 Order、User 等 │ └── global.d.ts # 全局类型扩展 ├── components/api.d.ts 建议用工具从后端 Swagger/OpenAPI 规范生成，杜绝手抄接口字段。常用工具类型速查import type { Ref, ComputedRef, MaybeRef, UnwrapRef } from 'vue' // MaybeRef<T>：参数既可以是 T 也可以是 Ref<T> function useX(source: MaybeRef<string>) { /* ... */ } // UnwrapRef<T>：ref 的解包类型 const state = ref({ nested: { count: 0 } }) // state.value.nested.count 类型是 number（自动解包） // ExtractPropTypes：从运行时 props 选项提取类型 import type { ExtractPropTypes } from 'vue' const propsSchema = { title: String, count: { type: Number, default: 0 } } type Props = ExtractPropTypes<typeof propsSchema>vue-tsc 做模板类型检查vue-tsc 能检查模板中的表达式类型，接入 CI：// package.json { "scripts": { "type-check": "vue-tsc --noEmit" } }模板里 {{ order.statu }} 这类错误会在构建前暴露，而不是上线后白屏。七、别过度类型化类型是工具不是目的，几个克制的建议：联合类型优先于枚举：'pending' | 'paid' 比 enum 更利于 tree-shaking后端接口类型交给代码生成，手写注定跟不上变化第三方库无类型时的最小兜底：declare module 'xxx'，而不是到处 any断言 as 只用于"我确信类型系统不知道的事"，当作逃生舱而非常规操作总结Props 用类型声明语法，默认值交给 withDefaultsdefineEmits 用具名元组语法，参数类型双向校验InstanceType<typeof Comp> 是引用子组件的规范类型InjectionKey<T> 让 provide/inject 摆脱字符串裸奔泛型组件 generic="T" 是封装列表/选择器类组件的杀手锏vue-tsc 进 CI，模板类型错误提前拦截类型系统用到位后，重构敢下手、接口变更立刻报错、新人看类型就能懂用法——这 defensive 能力正是中级向高级进阶的分水岭。
Vue 3 组件通信全解：props、emit、v-model 与 provide/inject
组件通信是 Vue 中级开发的核心分水岭。小型项目里随意用 ref 传递数据也能跑起来，但一旦组件层级变深、业务变复杂，选错通信方式会让代码迅速腐化。本文系统梳理 Vue 3 中所有主流通信手段的适用场景、实现原理与常见陷阱。通信方式全景图按数据流向，Vue 3 的组件通信可以分为四类：类型手段适用关系父传子props / attrs单向数据流子传父emit / expose事件回调双向绑定v-model表单、弹窗跨层级provide / inject深层嵌套全局状态Pinia任意组件一个重要的原则：优先用简单的方向。能用 props 解决的不要上 provide，能局部的不要全局。一、props：父传子的标准姿势props 遵循单向数据流，父组件修改数据会自动向下流动，子组件不允许直接修改 props。 {{ user.name }} 运行时校验与类型推导纯 JS 项目用对象语法做运行时校验；TS 项目更推荐类型声明方式，编辑器推导更完整： 常见陷阱：直接修改 props子组件里改 props 对象的内部属性（如 props.user.name = 'x'）虽然不报错（对象是引用传递），但会污染父组件状态，是最难排查的一类 bug。正确做法是通过 emit 通知父组件修改。二、emit：子传父的事件通道 <ConfirmDialog @confirm="handleConfirm" @cancel="showDialog = false" />声明式 emit 的必要性很多同学习惯不声明直接 emit('xxx')。在 <SearchInput v-model="keyword" />等价于老写法：// Vue 3.0 - 3.3 的写法 const props = defineProps(['modelValue']) const emit = defineEmits(['update:modelValue']) // 子组件更新时 emit('update:modelValue', newValue)多值绑定：具名 v-model一个组件可以绑定多个 v-model，通过参数名区分： 至 <RangePicker v-model:start="query.start" v-model:end="query.end" />修饰符v-model 支持自定义修饰符，通过 defineModel 的返回值读取： 四、provide / inject：跨层级注入当组件层级超过两层还硬用 props 层层传递（俗称"props 钻井"），就该用 provide/inject 了。 切换主题 响应性保持的关键provide 的值如果是 ref，inject 拿到的就是同一个 ref，响应性天然保持。但如果你这样写：// ❌ 错误示范：解构后再 provide，丢失响应性 const theme = ref('dark') provide('theme', theme.value) // 传入的是普通字符串后代组件拿到的是快照值，不会更新。务必传 ref 本身或包含 ref 的对象。只读注入：防止子组件乱改子组件可以直接改注入的 ref，这会破坏数据流向。更安全的模式是只暴露方法：provide('theme', { theme: readonly(theme), // 只读代理 toggleTheme // 修改必须走方法 })子组件试图改 theme.value 会收到警告。TS 类型安全注入provide/inject 的 key 是字符串或 Symbol，容易写错且无类型提示。用 InjectionKey 解决：// keys.ts import type { InjectionKey, Ref } from 'vue' export interface ThemeContext { theme: Ref<'dark' | 'light'> toggleTheme: () => void } export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')// 提供方 provide(ThemeKey, { theme, toggleTheme }) // 注入方：自动推导类型，且默认值类型也被约束 const ctx = inject(ThemeKey) if (!ctx) throw new Error('ThemeKey 未提供')五、attrs 与 expose：两个容易忽略的通道$attrs：透传属性没被声明为 props 的属性会落到 $attrs 上。封装组件时常用它透传原生属性： <slot /> 父组件写 <BaseButton type="submit" :disabled="loading"> 时，type 和 disabled 会自动透传到原生 button 上。注意 inheritAttrs: false 的设置场景：根元素有多个时需要手动绑定到正确元素。expose：受控暴露实例 选型决策清单实际开发中按这个顺序决策：父子相邻 → props + emit，没有例外表单类、弹窗类组件 → v-model（defineModel）三层以上的共享配置（主题、国际化、当前用户） → provide/inject跨页面、跨模块的业务状态 → Pinia任何"绕过去"的冲动（操作 DOM、改 $parent、事件总线）→ 停下来重新设计总结props/emit 是骨架，v-model 是语法糖，provide/inject 是电梯，Pinia 是中枢defineModel() 是 Vue 3.4+ 双向绑定的标准答案provide 必须传 ref 本身才能保持响应性InjectionKey 让 inject 拥有完整类型推导expose 让组件对外 API 显式化，避免黑盒掌握这些通信模式后，你可以应对 90% 的组件设计场景。下一篇我们讲插槽与自定义指令——组件通信之外的另一半中级技能。
组合式函数（Composables）是 Vue 3 逻辑复用的官方答案。它取代了 Vue 2 时代的 mixins，解决了命名冲突、来源不清、类型推导弱三大顽疾。本文从零封装五个高频 Composable，讲透设计要点与避坑细节。为什么是 Composables 而不是 Mixins先看 mixins 的三大原罪：来源不透明：组件里用了一个 this.fetchData()，根本不知道是组件自己的还是某个 mixin 的命名冲突：两个 mixin 定义了同名属性，静默覆盖类型黑洞：TS 无法推导 mixin 注入的内容Composables 是普通函数，数据来源明确（const { data } = useFetch(url)），返回值命名完全由调用方决定，天然解决以上所有问题：const { data: orderList, loading } = useFetch('/api/orders') const { data: userList, loading: userLoading } = useFetch('/api/users') // 命名冲突？重命名就行Composable 的三条铁律命名以 use 开头，这是社区强约定只在 setup 上下文中同步调用，不能在回调、事件处理器里调用（因为内部可能依赖 onMounted 等生命周期钩子，它们要求在 setup 同步调用期间注册）返回值用普通对象包 ref，而不是数组里混 ref 和普通值，方便调用方解构重命名实战一：useRequest —— 通用请求管理这是使用频率最高的 Composable，覆盖请求、竞态、手动触发：// composables/useRequest.ts import { ref, shallowRef, onUnmounted, type Ref, unref, watchEffect } from 'vue' interface UseRequestOptions<T> { immediate?: boolean // 是否立即执行 debounce?: number // 防抖毫秒数 } export function useRequest<T>( url: () => string | Ref<string>, options: UseRequestOptions<T> = {} ) { const { immediate = true, debounce } = options const data = shallowRef<T | null>(null) const loading = ref(false) const error = ref<Error | null>(null) let abortController: AbortController | null = null let timer: ReturnType<typeof setTimeout> | null = null async function execute() { // 竞态处理：取消上一次未完成的请求 abortController?.abort() abortController = new AbortController() loading.value = true error.value = null try { const res = await fetch(unref(url), { signal: abortController.signal }) if (!res.ok) throw new Error(`HTTP ${res.status}`) data.value = await res.json() } catch (e: any) { if (e.name !== 'AbortError') { error.value = e } } finally { loading.value = false } } function run() { if (debounce) { timer && clearTimeout(timer) timer = setTimeout(execute, debounce) } else { execute() } } // url 是响应式 ref 时，自动重新请求 watchEffect(() => { unref(url) if (immediate) run() }) // 组件卸载时取消进行中的请求，防止内存泄漏 onUnmounted(() => { abortController?.abort() timer && clearTimeout(timer) }) return { data, loading, error, run } }使用起来非常顺手： 几个设计细节值得注意：shallowRef 存 data：大列表数据不必深度响应式，浅层够用且省内存AbortController 防竞态：搜索框快速输入时，只有最后一次请求的结果会生效onUnmounted 清理：Composable 内注册生命周期钩子，自动绑定到调用它的组件上，这是 Composable 相比普通函数的核心优势实战二：useMouse —— 事件监听封装// composables/useMouse.ts import { ref, onMounted, onUnmounted } from 'vue' export function useMouse() { const x = ref(0) const y = ref(0) function update(e: MouseEvent) { x.value = e.pageX y.value = e.pageY } onMounted(() => window.addEventListener('mousemove', update)) onUnmounted(() => window.removeEventListener('mousemove', update)) return { x, y } }调用方代码极其干净： 鼠标位置：{{ x }}, {{ y }} 如果希望返回值可以直接当数字用，用 toValue 系列工具或返回 getter：import { computed } from 'vue' const { x, y } = useMouse() const pos = computed(() => `(${x.value}, ${y.value})`)实战三：useLocalStorage —— 持久化响应式状态// composables/useLocalStorage.ts import { ref, watch, type WatchCallback } from 'vue' export function useLocalStorage<T>(key: string, initialValue: T) { // 初始化：优先读缓存 const stored = localStorage.getItem(key) const state = ref<T>( stored ? JSON.parse(stored) : initialValue ) as Ref<T> // 监听变化自动写入，深度监听覆盖对象/数组 watch(state, (val) => { try { localStorage.setItem(key, JSON.stringify(val)) } catch (e) { console.warn('localStorage 写入失败', e) } }, { deep: true }) // 多标签页同步：storage 事件 if (typeof window !== 'undefined') { window.addEventListener('storage', (e) => { if (e.key === key && e.newValue) { state.value = JSON.parse(e.newValue) } }) } function remove() { localStorage.removeItem(key) state.value = initialValue } return { state, remove } }const { state: settings } = useLocalStorage('app-settings', { theme: 'dark', fontSize: 14 }) // 改 settings.value.theme 自动持久化，刷新页面不丢实战四：useToggle —— 布尔状态切换export function useToggle(initial = false) { const state = ref(initial) const toggle = (val?: boolean) => { state.value = val ?? !state.value } const setTrue = () => toggle(true) const setFalse = () => toggle(false) return { state, toggle, setTrue, setFalse } }看着简单，但弹窗、下拉、折叠面板全用得上，能把散落各处的 showXxx = !showXxx 收编成统一模式。实战五：useInterval —— 定时器管理import { ref, onUnmounted } from 'vue' export function useInterval(cb: () => void, delay = 1000, immediate = true) { const timer = ref<ReturnType<typeof setInterval> | null>(null) function start() { if (timer.value) return timer.value = setInterval(cb, delay) } function stop() { if (timer.value) { clearInterval(timer.value) timer.value = null } } if (immediate) start() onUnmounted(stop) // 组件销毁自动清理，杜绝定时器泄漏 return { start, stop } }倒计时验证码是典型场景：const seconds = ref(60) const { start, stop } = useInterval(() => { if (--seconds.value <= 0) stop() }, 1000)响应式输入：ref、getter 还是原始值？设计 Composable 的输入参数时，Vue 3.3+ 提供了统一的 toValue()：import { toValue, type MaybeRefOrGetter } from 'vue' export function useFoo(source: MaybeRefOrGetter<string>) { const value = toValue(source) // ref / getter / 原始值通吃 }这样调用方想传什么就传什么，Composable 内部不需要关心：useFoo('静态字符串') useFoo(computed(() => dynamicUrl.value)) useFoo(() => props.someProp) // getter，保持响应性常见错误与修正错误一：返回响应式丢失// ❌ 返回解构后的值，丢失响应性 return { data: data.value, loading: loading.value } // ✅ 返回 ref 本身 return { data, loading }错误二：异步函数里调用 Composable// ❌ setup 已经执行完，onMounted 无法注册 async function setup() { const { x } = await Promise.resolve().then(() => useMouse()) } // ✅ 同步调用，内部再异步 const { x } = useMouse()错误三：忘记清理副作用事件监听、定时器、WebSocket 连接必须在 onUnmounted 中清理。判断标准很简单：你注册了什么，就要注销什么。总结Composables 用函数作用域取代 mixins 的属性合并，来源清晰、类型完整生命周期钩子可以在 Composable 中注册，自动绑定到调用组件——这是它管理副作用的杀手锏数据用 shallowRef、输入用 MaybeRefOrGetter + toValue()，是高阶设计模式useRequest/useMouse/useLocalStorage 是三个最值得亲手写一遍的练手项目VueUse 已经收录了 200+ 高质量 Composables，先读它的源码再动手，事半功倍把业务里的重复逻辑沉淀成 Composables，是 Vue 项目从"堆页面"走向"有架构"的第一步。
Vite 已成为 Vue 项目的标配构建工具。本文从零搭建一个包含目录规范、请求封装、权限路由、Mock 数据、环境配置和 CI/CD 的企业级 Vue 3 项目。一、项目初始化创建项目# 创建 Vue 3 项目 npm create vue@latest my-app # 交互选择： # ✓ TypeScript # ✓ JSX Support # ✓ Router # ✓ Pinia # ✓ Vitest # ✓ ESLint # ✓ Prettier cd my-app npm install安装核心依赖# UI 组件库（按需选择） npm install element-plus npm install -D unplugin-vue-components unplugin-auto-import # HTTP 请求 npm install axios # 实用工具 npm install dayjs lodash-es @vueuse/core # 图表（按需） npm install echarts二、目录结构规范my-app/ ├── public/ # 静态资源（不参与构建） ├── src/ │ ├── api/ # API 接口定义 │ │ ├── modules/ │ │ │ ├── user.js │ │ │ ├── order.js │ │ │ └── product.js │ │ └── index.js # 统一导出 │ ├── assets/ # 静态资源（参与构建） │ │ ├── images/ │ │ └── styles/ │ ├── components/ # 全局公共组件 │ │ ├── AppTable/ │ │ │ └── index.vue │ │ └── AppDialog/ │ ├── composables/ # 组合式函数 │ │ ├── useAuth.js │ │ ├── usePagination.js │ │ └── useRequest.js │ ├── config/ # 项目配置 │ │ └── index.js │ ├── constants/ # 常量定义 │ │ └── index.js │ ├── directives/ # 自定义指令 │ │ ├── permission.js │ │ └── lazy.js │ ├── layouts/ # 布局组件 │ │ ├── DefaultLayout.vue │ │ └── BlankLayout.vue │ ├── plugins/ # 插件配置 │ │ └── elementPlus.js │ ├── router/ # 路由 │ │ ├── index.js │ │ ├── guards.js │ │ └── routes/ │ │ ├── modules/ # 路由模块 │ │ └── index.js │ ├── stores/ # Pinia Store │ │ ├── user.js │ │ ├── permission.js │ │ └── app.js │ ├── styles/ # 全局样式 │ │ ├── variables.scss │ │ ├── mixins.scss │ │ └── index.scss │ ├── utils/ # 工具函数 │ │ ├── request.js # axios 封装 │ │ ├── storage.js │ │ └── format.js │ ├── views/ # 页面组件 │ │ ├── login/ │ │ ├── dashboard/ │ │ └── system/ │ ├── App.vue │ └── main.js ├── .env # 所有环境通用 ├── .env.development # 开发环境 ├── .env.production # 生产环境 ├── .env.staging # 预发布环境 ├── vite.config.js └── package.json三、Vite 配置详解// vite.config.js import { fileURLToPath, URL } from 'node:url' import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd()) return { plugins: [ vue(), // 自动导入 Vue API AutoImport({ imports: ['vue', 'vue-router', 'pinia'], resolvers: [ElementPlusResolver()], dts: 'src/auto-imports.d.js' }), // 组件自动注册 Components({ resolvers: [ElementPlusResolver()], dts: 'src/components.d.js' }) ], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, css: { preprocessorOptions: { scss: { additionalData: `@use "@/styles/variables.scss" as *;` } } }, server: { port: 3000, open: true, proxy: { '/api': { target: env.VITE_API_BASE_URL, changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } }, build: { // 输出目录 outDir: 'dist', // 静态资源分包 rollupOptions: { output: { manualChunks: { 'vendor-vue': ['vue', 'vue-router', 'pinia'], 'vendor-ui': ['element-plus'], 'vendor-utils': ['lodash-es', 'dayjs'] }, // 分包体积警告阈值 chunkFileNames: 'assets/js/[name]-[hash].js', entryFileNames: 'assets/js/[name]-[hash].js', assetFileNames: 'assets/[ext]/[name]-[hash].[ext]' } }, // chunk 大小警告 chunkSizeWarningLimit: 1000, // 生产环境移除 console minify: 'terser', terserOptions: { compress: { drop_console: env.VITE_DROP_CONSOLE === 'true', drop_debugger: true } } } } })四、环境变量配置# .env — 通用配置 VITE_APP_TITLE=企业管理系统 # .env.development VITE_API_BASE_URL=http://localhost:8080 VITE_DROP_CONSOLE=false # .env.production VITE_API_BASE_URL=https://api.example.com VITE_DROP_CONSOLE=true// src/config/index.js export const config = { title: import.meta.env.VITE_APP_TITLE, apiBaseUrl: import.meta.env.VITE_API_BASE_URL, tokenKey: 'access_token', tokenExpireKey: 'token_expire' }五、Axios 封装// src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/user' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 15000 }) // 请求拦截器 service.interceptors.request.use( (config) => { const userStore = useUserStore() // 携带 token if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } // GET 请求防缓存 if (config.method === 'get') { config.params = { ...config.params, _t: Date.now() } } return config }, (error) => Promise.reject(error) ) // 响应拦截器 let isRefreshing = false let pendingRequests = [] service.interceptors.response.use( (response) => { const { code, data, message } = response.data // 业务状态码判断 switch (code) { case 200: return data case 401: handleUnauthorized() return Promise.reject(new Error(message)) default: ElMessage.error(message || '请求失败') return Promise.reject(new Error(message)) } }, async (error) => { const { response } = error if (response) { switch (response.status) { case 401: handleUnauthorized() break case 403: ElMessage.error('没有权限访问') break case 404: ElMessage.error('请求的资源不存在') break case 500: ElMessage.error('服务器错误') break default: ElMessage.error(response.data?.message || '请求失败') } } else if (error.code === 'ECONNABORTED') { ElMessage.error('请求超时') } else { ElMessage.error('网络异常') } return Promise.reject(error) } ) // Token 过期处理 async function handleUnauthorized() { const userStore = useUserStore() if (!isRefreshing) { isRefreshing = true try { await userStore.refreshToken() // 重放失败请求 pendingRequests.forEach(cb => cb()) pendingRequests = [] } catch { userStore.logout() router.push('/login') } finally { isRefreshing = false } } return new Promise((resolve) => { pendingRequests.push(() => resolve(service(error.config))) }) } export default service// src/api/modules/user.js import request from '@/utils/request' export const userApi = { login: (data) => request.post('/auth/login', data), logout: () => request.post('/auth/logout'), getProfile: () => request.get('/user/profile'), updateProfile: (data) => request.put('/user/profile', data), changePassword: (data) => request.put('/user/password', data), // 分页列表 getUsers: (params) => request.get('/users', { params }), createUser: (data) => request.post('/users', data), updateUser: (id, data) => request.put(`/users/${id}`, data), deleteUser: (id) => request.delete(`/users/${id}`) }六、路由与权限// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' import { setupGuards } from './guards' // 基础路由 — 无需权限 export const constantRoutes = [ { path: '/login', name: 'Login', component: () => import('@/views/login/index.vue'), meta: { title: '登录' } }, { path: '/', component: () => import('@/layouts/DefaultLayout.vue'), redirect: '/dashboard', children: [ { path: 'dashboard', name: 'Dashboard', component: () => import('@/views/dashboard/index.vue'), meta: { title: '仪表盘', icon: 'Odometer' } } ] }, { path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/404.vue') } ] const router = createRouter({ history: createWebHistory(), routes: constantRoutes }) setupGuards(router) export default router// src/router/guards.js import { useUserStore } from '@/stores/user' import { usePermissionStore } from '@/stores/permission' const whiteList = ['/login'] export function setupGuards(router) { router.beforeEach(async (to, from, next) => { document.title = `${to.meta.title || ''} - 系统名称` const userStore = useUserStore() if (userStore.token) { if (to.path === '/login') { next({ path: '/' }) } else { // 未加载用户信息和动态路由 if (!userStore.user) { try { // 获取用户信息 await userStore.getProfile() // 生成动态路由 const permissionStore = usePermissionStore() const accessRoutes = await permissionStore.generateRoutes() // 动态添加路由 accessRoutes.forEach(route => router.addRoute(route)) // 重新导航，确保路由已注册 next({ ...to, replace: true }) } catch (error) { userStore.resetToken() next(`/login?redirect=${to.path}`) } } else { next() } } } else { // 白名单直接放行 if (whiteList.includes(to.path)) { next() } else { next(`/login?redirect=${to.path}`) } } }) }// src/stores/permission.js import { defineStore } from 'pinia' import api from '@/api' // 视图模块映射 const viewModules = import.meta.glob('@/views/**/*.vue') export const usePermissionStore = defineStore('permission', () => { const routes = ref([]) async function generateRoutes() { // 从后端获取菜单权限 const menus = await api.menu.getUserMenus() // 转换后端菜单为路由 const asyncRoutes = transformRoutes(menus) routes.value = asyncRoutes return asyncRoutes } function transformRoutes(menus, parentPath = '') { return menus.map(menu => { const route = { path: menu.path.startsWith('/') ? menu.path : `${parentPath}/${menu.path}`, name: menu.name, meta: { title: menu.title, icon: menu.icon }, children: [] } // 组件解析 const componentPath = menu.component || 'layouts/DefaultLayout' route.component = viewModules[`/src/views/${componentPath}.vue`] if (menu.children?.length) { route.children = transformRoutes(menu.children, route.path) } return route }) } return { routes, generateRoutes } })七、Mock 数据// vite.config.js 增加 mock 插件 import { viteMockServe } from 'vite-plugin-mock' plugins: [ vue(), viteMockServe({ mockPath: 'mock', localEnabled: true }) ]// mock/user.js export default [ { url: '/api/user/profile', method: 'get', response: () => { return { code: 200, data: { id: 1, name: '张三', roles: ['admin'], permissions: ['system:user:list', 'system:user:create'] } } } }, { url: '/api/users', method: 'get', response: ({ query }) => { const { page = 1, pageSize = 10 } = query const list = Array.from({ length: 50 }, (_, i) => ({ id: i + 1, name: `用户${i + 1}`, email: `user${i + 1}@example.com`, status: i % 2 })) return { code: 200, data: { list: list.slice((page - 1) * pageSize, page * pageSize), total: list.length } } } } ]八、代码规范// eslint.config.js (Flat Config) import js from '@eslint/js' import vue from 'eslint-plugin-vue' import prettier from 'eslint-config-prettier' export default [ js.configs.recommended, ...vue.configs['flat/recommended'], prettier, { rules: { 'vue/multi-word-component-names': 'off', 'no-console': 'warn', 'no-debugger': 'warn' } } ]// .prettierrc { "semi": false, "singleQuote": true, "printWidth": 100, "trailingComma": "none", "arrowParens": "always" }九、Git 提交规范与 CI// commitlint.config.js export default { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [2, 'always', [ 'feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore' ]] } }# .github/workflows/ci.yml name: CI on: push: branches: [main, develop] pull_request: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - name: Install run: npm ci - name: Lint run: npm run lint - name: Test run: npm run test:unit - name: Build run: npm run build - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist十、小结企业级 Vue 项目的工程化核心：规范的目录结构让团队协作有章可循，统一的请求封装处理认证与错误，动态路由实现权限控制，Mock 让前后端并行开发，ESLint + CI 保证代码质量。这套脚手架可以直接作为新项目的起点。本文由 inspirecl.asia 原创，转载请注明出处。
从"能跑"到"跑得快"是前端工程的分水岭。本文从渲染机制出发，系统讲解 Vue 3 的性能优化手段：虚拟列表、组件缓存、懒加载、防抖节流，以及性能分析工具的完整使用。一、性能优化总览性能优化层次 │ ├── 网络层 — 资源体积、加载策略 │ ├── 路由懒加载 │ ├── 组件异步加载 │ └── 图片/资源优化 │ ├── 渲染层 — 减少不必要的渲染 │ ├── v-show vs v-if │ ├── v-memo 缓存 │ ├── KeepAlive 组件缓存 │ └── 虚拟列表 │ ├── 计算层 — 减少计算量 │ ├── computed 缓存 │ ├── 防抖节流 │ └── Web Worker │ └── 监控层 — 持续度量 ├── Performance 面板 ├── Vue DevTools └── 自定义性能指标二、渲染优化v-if vs v-show <HeavyComponent v-if="show" /> <HeavyComponent v-show="show" /> v-memo 缓存子树 {{ item.name }} {{ item.description }} KeepAlive 组件缓存 <KeepAlive include="TabA,TabB" :max="10"> <component :is="tabs[currentTab]" /> </KeepAlive> 异步组件与 Suspense <Suspense> <AsyncDashboard /> <LoadingSpinner /> </Suspense> 虚拟列表（大数据量必备）npm install @tanstack/vue-virtual {{ rows[row.index].name }} 三、计算与交互优化防抖与节流// composables/useDebounce.js import { customRef } from 'vue' // 防抖 ref export function useDebouncedRef(value, delay = 300) { let timeout return customRef((track, trigger) => ({ get() { track() return value }, set(newValue) { clearTimeout(timeout) timeout = setTimeout(() => { value = newValue trigger() }, delay) } })) } 深度 watch 的替代方案// ❌ 深度监听大对象 — 开销大 watch(bigObject, handler, { deep: true }) // ✅ 精确监听目标属性 watch(() => bigObject.targetProp, handler) // ✅ 用 shallowRef + 整体替换 const list = shallowRef([]) list.value = [...list.value, newItem] // 触发更新 // 修改内部属性不触发（性能需要时） // ✅ 数组方法触发 list.value.push(item) // 会触发（Vue 3 拦截数组方法）Web Worker 处理重计算// workers/dataProcessor.js self.onmessage = (e) => { const { data, type } = e.data let result switch (type) { case 'SORT': result = heavySort(data) break case 'STATS': result = calculateStats(data) break } self.postMessage({ result }) } function heavySort(arr) { return [...arr].sort((a, b) => a.value - b.value) }// composables/useWorker.js export function useDataProcessor() { const worker = new Worker( new URL('../workers/dataProcessor.js', import.meta.url), { type: 'module' } ) const result = ref(null) const processing = ref(false) worker.onmessage = (e) => { result.value = e.data.result processing.value = false } function process(type, data) { processing.value = true worker.postMessage({ type, data }) } onUnmounted(() => worker.terminate()) return { result, processing, process } }四、加载优化路由懒加载// router/index.js import { createRouter } from 'vue-router' const routes = [ { path: '/', component: () => import('../views/Home.vue') // 懒加载 }, { path: '/admin', component: () => import(/* webpackChunkName: "admin" */ '../views/Admin.vue'), // Vite: 单独分包 // component: () => import('../views/Admin.vue'), meta: { requiresAuth: true } } ] // 按组分包（Vite） // vite.config.js export default { build: { rollupOptions: { output: { manualChunks: { 'vendor-vue': ['vue', 'vue-router', 'pinia'], 'vendor-ui': ['element-plus'], 'vendor-chart': ['echarts'] } } } } }图片优化 骨架屏 五、性能分析工具Vue DevTools 性能面板Vue DevTools → Timeline / Performance - 组件渲染耗时 - 事件处理耗时 - 响应式依赖追踪 Component Inspector: - 查看组件的 props、state、computed - 高亮重新渲染的组件Performance API 自定义测量// main.js — Vue 性能追踪 const app = createApp(App) // 开启性能追踪（开发模式） app.config.performance = true // 自定义性能标记 performance.mark('app-init-start') app.mount('#app') performance.mark('app-init-end') performance.measure('应用初始化', 'app-init-start', 'app-init-end') // 组件级测量 // MyComponent.vue onMounted(() => { performance.mark(`${instance.uid}-mounted`) }) onBeforeMount(() => { performance.mark(`${instance.uid}-before-mount`) })Web Vitals 监控// 监控核心 Web 指标 import { onLCP, onFID, onCLS, onINP, onTTFB } from 'web-vitals' function sendToAnalytics(metric) { const body = JSON.stringify({ name: metric.name, value: metric.value, rating: metric.rating, id: metric.id }) navigator.sendBeacon('/api/vitals', body) } onLCP(sendToAnalytics) // 最大内容绘制 onFID(sendToAnalytics) // 首次输入延迟 onCLS(sendToAnalytics) // 累积布局偏移 onINP(sendToAnalytics) // 交互到下一次绘制 onTTFB(sendToAnalytics) // 首字节时间渲染性能检查清单// 开发环境全局组件渲染追踪 // main.js if (import.meta.env.DEV) { app.config.warnHandler = (msg, instance, trace) => { console.warn(`[Vue warn]: ${msg}${trace}`) } } // 检测不必要的重新渲染 import { watch } from 'vue' // 在 DevTools 中使用 "Highlight updates" 功能六、优化检查清单类别手段收益首屏路由懒加载 + 组件分包减少首包体积 50%+首屏骨架屏感知性能提升列表虚拟列表万级数据流畅渲染列表v-memo跳过不必要的 diff组件KeepAlive切换零重建成本组件异步组件按需加载重组件交互防抖节流减少触发频率计算computed 缓存避免重复计算计算Web Worker主线程零阻塞图片懒加载 + 响应式节省带宽七、小结性能优化的核心是"度量驱动"：先用 DevTools 和 Web Vitals 找到瓶颈，再对症下药。渲染层关注"减少不必要的更新"（v-memo、KeepAlive、虚拟列表），计算层关注"不阻塞主线程"（防抖、Worker），加载层关注"更快呈现"（懒加载、分包、骨架屏）。本文由 inspirecl.asia 原创，转载请注明出处。
Pinia 是 Vue 官方推荐的状态管理库，以其简洁的 API 和完整的 TypeScript 支持取代了 Vuex。本文系统讲解 Pinia 的核心概念、模块化设计、持久化方案以及从 Vuex 迁移的完整指南。一、为什么选择 PiniaPinia vs Vuex特性Vuex 4PiniaAPI 风格Mutation + Action只有 Action（同步异步统一）TypeScript支持较弱完整类型推导模块化嵌套模块 + namespace扁平 Store，天然模块化代码量较多样板代码极简组合式 API适配一般原生设计DevTools支持支持（时间旅行更佳）SSR支持支持热更新部分完整支持Pinia 移除了什么Mutations：直接修改 state 或在 action 中修改嵌套模块：每个 Store 独立扁平命名空间：Store 天然隔离辅助函数：mapState/mapGetters 不再必要（组合式写法）二、快速上手安装与初始化// main.js import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' const app = createApp(App) app.use(createPinia()) app.mount('#app')定义第一个 Store// stores/counter.js import { defineStore } from 'pinia' // 选项式写法（类似组件的 data/computed/methods） export const useCounterStore = defineStore('counter', { state: () => ({ count: 0, name: '计数器' }), getters: { double: (state) => state.count * 2, doublePlusOne() { // 通过 this 访问其他 getter return this.double + 1 } }, actions: { increment() { this.count++ }, async incrementAsync() { // 直接写异步逻辑，无需 dispatch await new Promise(r => setTimeout(r, 1000)) this.count++ } } })组合式写法（推荐）// stores/counter.js — Setup Store 写法 import { ref, computed } from 'vue' import { defineStore } from 'pinia' export const useCounterStore = defineStore('counter', () => { // state const count = ref(0) const name = ref('计数器') // getters const double = computed(() => count.value * 2) const doublePlusOne = computed(() => double.value + 1) // actions function increment() { count.value++ } async function incrementAsync() { await new Promise(r => setTimeout(r, 1000)) count.value++ } // 必须返回所有使用的属性 return { count, name, double, doublePlusOne, increment, incrementAsync } })在组件中使用 {{ counter.name }}: {{ count }} 双倍: {{ double }} +1 三、核心概念详解State// stores/user.js export const useUserStore = defineStore('user', { state: () => ({ user: null, token: '', permissions: [], settings: { theme: 'light', language: 'zh-CN' } }), actions: { // 重置整个 state 到初始值 resetState() { this.$reset() }, // 批量修改（替代 Vuex 的 mutation） setUser(user) { this.user = user this.permissions = user.permissions }, // $patch 批量更新，性能更好 updateSettings(partial) { this.$patch({ settings: { ...this.settings, ...partial } }) // 函数式写法 this.$patch((state) => { state.settings.theme = 'dark' }) } } })Gettersexport const useCartStore = defineStore('cart', { state: () => ({ items: [] }), getters: { // 基础 getter itemCount: (state) => state.items.length, // 带参数的 getter（返回函数，无缓存优势） itemById: (state) => { return (id) => state.items.find(item => item.id === id) }, // 访问其他 getter subtotal() { return this.items.reduce((sum, item) => sum + item.price * item.qty, 0) }, // 访问其他 Store total() { const userStore = useUserStore() const discount = userStore.user?.discount ?? 1 return this.subtotal * discount } } })Actions// stores/products.js import { defineStore } from 'pinia' import api from '@/api' export const useProductStore = defineStore('products', () => { const products = ref([]) const loading = ref(false) const error = ref(null) // 获取产品列表 async function fetchProducts(params) { loading.value = true error.value = null try { const { data } = await api.get('/products', { params }) products.value = data } catch (e) { error.value = e.message } finally { loading.value = false } } // 跨 Store 调用 async function purchase(productId) { const cartStore = useCartStore() const product = products.value.find(p => p.id === productId) await api.post(`/cart/items`, { productId, qty: 1 }) cartStore.addItem(product) } return { products, loading, error, fetchProducts, purchase } })四、插件系统持久化插件// plugins/persist.js import { watch } from 'vue' export function createPersistPlugin() { return ({ store }) => { // 从 localStorage 恢复 const saved = localStorage.getItem(`pinia-${store.$id}`) if (saved) { store.$patch(JSON.parse(saved)) } // 订阅变化并保存 store.$subscribe((mutation, state) => { localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state)) }) } }// main.js import { createPinia } from 'pinia' import { createPersistPlugin } from './plugins/persist' const pinia = createPinia() pinia.use(createPersistPlugin())使用官方持久化插件npm install pinia-plugin-persistedstate// stores/user.js export const useUserStore = defineStore('user', { state: () => ({ user: null, token: '' }), // 持久化配置 persist: { key: 'my-app-user', storage: localStorage, // 或 sessionStorage // 只持久化部分字段 pick: ['token', 'user.id', 'user.name'] }, actions: { login(credentials) { /* ... */ }, logout() { this.user = null this.token = '' } } })自定义日志插件// plugins/logger.js export function createLoggerPlugin() { return ({ store }) => { store.$subscribe((mutation, state) => { console.log( `[Pinia] ${mutation.storeId} ${mutation.type}:`, mutation.type === 'patch' ? mutation.events : `${mutation.payload}` ) }) store.$onAction(({ name, args, after, onError }) => { const startTime = Date.now() console.log(`[Action] ${store.$id}/${name} 开始`, args) after((result) => { console.log(`[Action] ${store.$id}/${name} 完成 (${Date.now() - startTime}ms)`) }) onError((error) => { console.error(`[Action] ${store.$id}/${name} 失败:`, error) }) }) } }五、Store 组合与模块化Store 之间相互调用// stores/cart.js import { defineStore } from 'pinia' import { useUserStore } from './user' import { useProductStore } from './products' export const useCartStore = defineStore('cart', () => { const items = ref([]) const userStore = useUserStore() // 在 setup 内调用 const total = computed(() => { const discount = userStore.user?.discount ?? 1 return items.value.reduce( (sum, item) => sum + item.price * item.qty, 0 ) * discount }) return { items, total } })组合式 Store 设计模式// stores/composables/useEntityCRUD.js // 通用的 CRUD Store 工厂 export function createCrudStore(entityName, apiEndpoint) { return defineStore(entityName, () => { const items = ref([]) const current = ref(null) const loading = ref(false) async function fetchAll(params = {}) { loading.value = true try { const { data } = await api.get(apiEndpoint, { params }) items.value = data } finally { loading.value = false } } async function fetchOne(id) { const { data } = await api.get(`${apiEndpoint}/${id}`) current.value = data } async function create(payload) { const { data } = await api.post(apiEndpoint, payload) items.value.unshift(data) return data } async function update(id, payload) { const { data } = await api.put(`${apiEndpoint}/${id}`, payload) const index = items.value.findIndex(i => i.id === id) if (index > -1) items.value[index] = data return data } async function remove(id) { await api.delete(`${apiEndpoint}/${id}`) items.value = items.value.filter(i => i.id !== id) } return { items, current, loading, fetchAll, fetchOne, create, update, remove } }) } // 使用工厂创建具体的 Store export const usePostStore = createCrudStore('posts', '/api/posts') export const useCategoryStore = createCrudStore('categories', '/api/categories')六、从 Vuex 迁移对照转换// === Vuex 4 === // store/index.js export default createStore({ state: () => ({ count: 0 }), mutations: { increment(state) { state.count++ } }, actions: { incrementAsync({ commit }) { setTimeout(() => commit('increment'), 1000) } }, getters: { double: (state) => state.count * 2 } }) // === Pinia 等价写法 === export const useCounterStore = defineStore('counter', { state: () => ({ count: 0 }), // mutation 直接变成 action actions: { increment() { this.count++ }, incrementAsync() { setTimeout(() => this.increment(), 1000) } }, getters: { double: (state) => state.count * 2 } })// === 组件中使用对比 === // Vuex // this.$store.state.count // this.$store.commit('increment') // this.$store.dispatch('incrementAsync') // mapState('counter', ['count']) // mapGetters('counter', ['double']) // Pinia const counter = useCounterStore() counter.count counter.increment() // 同步异步统一调用 counter.incrementAsync() const { count } = storeToRefs(counter)渐进式迁移策略同时安装两个库，逐模块迁移从叶子模块开始（无其他模块依赖的）用组合函数桥接：// 桥接层：让旧 Vuex 代码访问新 Pinia // stores/bridge.js export function useCounterCompat() { const counter = useCounterStore() return { get count() { return counter.count }, increment: counter.increment } }迁移完成移除 Vuex七、SSR 中的注意事项// Nuxt 3 中使用 // stores/counter.js export const useCounterStore = defineStore('counter', () => { const count = ref(0) return { count } }) // 组件中（Nuxt 自动导入 Pinia） const counter = useCounterStore() // SSR 中跨请求状态污染防范 // ✅ 在 setup 内调用 useStore()，而不是模块顶层 // ❌ const store = useCounterStore() // 模块顶层会共享状态八、小结Pinia 以极简的 API 提供了完整的状态管理能力：去掉 mutation 减少样板代码，扁平 Store 天然模块化，完整的 TypeScript 和 DevTools 支持。从 Vuex 迁移成本低，新项目直接上 Pinia 是唯一正确选择。本文由 inspirecl.asia 原创，转载请注明出处。
Vue 3 用 Proxy 重写了响应式系统，性能与能力都大幅提升。本文从源码设计角度剖析依赖收集、触发更新、副作用调度和计算属性的实现原理，并手写一个迷你响应式系统。一、Vue 2 vs Vue 3 响应式对比特性Vue 2 (defineProperty)Vue 3 (Proxy)属性新增/删除需要 Vue.set/Vue.delete直接支持数组索引赋值不支持支持Map/Set不支持支持初始化递归遍历所有属性惰性代理，访问时才转换性能大对象初始化慢按需代理，更快// Vue 2 的局限 this.obj.newProp = 'value' // ❌ 不触发更新 this.arr[0] = newItem // ❌ 不触发更新 this.arr.length = 0 // ❌ 不触发更新 // Vue 3 全部支持 state.newProp = 'value' // ✅ state.arr[0] = newItem // ✅ state.arr.length = 0 // ✅ state.map.set('key', val) // ✅二、Proxy 基础const raw = { price: 100, quantity: 2 } const proxy = new Proxy(raw, { get(target, key, receiver) { console.log(`读取 ${key}`) track(target, key) // 依赖收集 return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { console.log(`设置 ${key} = ${value}`) const result = Reflect.set(target, key, value, receiver) trigger(target, key) // 触发更新 return result }, deleteProperty(target, key) { const result = Reflect.deleteProperty(target, key) trigger(target, key) return result }, has(target, key) { track(target, key) return Reflect.has(target, key) } }) proxy.price // 读取 price proxy.quantity = 3 // 设置 quantity = 3为什么用 Reflect？ 保证 this 指向代理对象，让内嵌对象也能被正确追踪：const obj = reactive({ nested: { count: 0 } // nested 会被惰性转换为响应式 }) // obj.nested 访问时返回嵌套对象的代理 // 这是 Vue 3 实现深层响应式的关键三、手写迷你响应式系统1. 副作用函数与依赖收集let activeEffect = null const targetMap = new WeakMap() // 追踪依赖 function track(target, key) { if (!activeEffect) return // targetMap: target → key → Set<effect> let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map() targetMap.set(target, depsMap) } let deps = depsMap.get(key) if (!deps) { deps = new Set() depsMap.set(key, deps) } deps.add(activeEffect) } // 触发更新 function trigger(target, key) { const depsMap = targetMap.get(target) if (!depsMap) return const deps = depsMap.get(key) if (!deps) return // 复制一份，避免无限循环 const effectsToRun = new Set(deps) effectsToRun.forEach(effect => { // 避免递归调用自身 if (effect !== activeEffect) { effect() } }) }2. effect 函数function effect(fn) { const effectFn = () => { // 清除旧依赖（解决分支切换问题） cleanup(effectFn) activeEffect = effectFn fn() activeEffect = null } effectFn.deps = [] effectFn() return effectFn } function cleanup(effectFn) { effectFn.deps.forEach(depSet => { depSet.delete(effectFn) }) effectFn.deps.length = 0 }3. reactive 实现const reactiveMap = new Map() function reactive(target) { // 同一对象只代理一次 if (reactiveMap.has(target)) { return reactiveMap.get(target) } const proxy = new Proxy(target, { get(target, key, receiver) { if (key === '__isReactive') return true track(target, key) const result = Reflect.get(target, key, receiver) // 惰性深层代理：嵌套对象访问时才转换 if (typeof result === 'object' && result !== null) { return reactive(result) } return result }, set(target, key, value, receiver) { const oldValue = target[key] const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD' const result = Reflect.set(target, key, value, receiver) // 值变化才触发 if (oldValue !== value || (typeof oldValue === 'NaN' && typeof value === 'NaN')) { trigger(target, key, type) } return result }, deleteProperty(target, key) { const hadKey = Object.prototype.hasOwnProperty.call(target, key) const result = Reflect.deleteProperty(target, key) if (hadKey && result) { trigger(target, key, 'DELETE') } return result } }) reactiveMap.set(target, proxy) return proxy }4. ref 实现function ref(value) { return { get value() { track(this, 'value') return value }, set value(newValue) { if (newValue !== value) { value = newValue trigger(this, 'value') } } } }5. computed 实现function computed(getter) { let value let dirty = true // 脏标记：是否需要重新计算 // 作为副作用运行，依赖变化时标记为脏 const effectFn = effect(() => { // 每次依赖变化重新执行，但不会立即触发渲染 if (dirty) { value = getter() } }) // 依赖变化时置脏 // （简化实现，真实实现用 scheduler） return { get value() { if (dirty) { value = getter() dirty = false } return value } } }更接近真实的 computed：function computed(getter) { let value let dirty = true const runner = effect(getter, { lazy: true, // scheduler：依赖变化时不立即执行，只标记脏 scheduler() { if (!dirty) { dirty = true // 通知依赖该 computed 的渲染副作用更新 triggerRefValue(this) } } }) return { get value() { if (dirty) { value = runner() dirty = false } // 让当前 activeEffect 追踪此 computed trackRefValue(this) return value } } }四、副作用调度与批更新Vue 3 的组件更新是异步批处理的：const queue = new Set() let isFlushing = false function queueJob(job) { queue.add(job) // Set 自动去重，同一组件多次修改只更新一次 if (!isFlushing) { isFlushing = true Promise.resolve().then(flushJobs) } } function flushJobs() { // 按组件 id 排序，父组件先更新 const jobs = [...queue].sort((a, b) => a.id - b.id) queue.clear() isFlushing = false jobs.forEach(job => job()) }这就是为什么修改多次数据只触发一次渲染：import { ref, nextTick } from 'vue' const count = ref(0) // 触发 3 次修改，但只渲染 1 次 count.value++ count.value++ count.value++ // DOM 更新是异步的 console.log(document.querySelector('#count').textContent) // 还是旧值 nextTick(() => { console.log(document.querySelector('#count').textContent) // "3" })五、Vue 3 编译优化静态提升（hoistStatic）// 模板中的静态节点会被提升到 render 函数外部 // 只创建一次，重复渲染时直接复用 // 编译前 // // 静态内容 // {{ dynamic }} // // 编译后 const _hoisted_1 = createVNode("span", { class: "static" }, "静态内容") function render() { return createVNode("div", null, [ _hoisted_1, // 直接复用 createVNode("span", null, toDisplayString(dynamic)) ]) }Patch Flags（补丁标记）// 动态节点带上标记，diff 时只对比标记的部分 createVNode("span", null, text, 1 /* TEXT */) createVNode("span", { class: dynamicClass }, null, 2 /* CLASS */) createVNode("span", { id: dynamicId }, null, 4 /* PROPS */, ["id"]) // PatchFlag 枚举： // TEXT = 1 只有文本变化 // CLASS = 2 只有 class 变化 // STYLE = 4 只有 style 变化 // PROPS = 8 只有 props 变化 // FULL_PROPS = 16 props 有动态 key // NEED_HYDRATION = 32 // STABLE_FRAGMENT = 64 // KEYED_FRAGMENT = 128 带 key 的 fragment // ...Block Tree（块树）// 动态节点被收集到 block 中，diff 时跳过静态内容 function render() { return (openBlock(), createElementBlock("div", null, [ // 大量静态节点... createVNode("span", null, dynamic, 1 /* TEXT */) // 这个动态节点会被"block"追踪 // 更新时直接定位，不需要遍历整棵树 ])) }六、调试响应式onTrack 与 onTrigger// 仅开发模式可用 const state = reactive({ count: 0 }, { onTrack(event) { // 依赖被追踪时 console.log('track:', event.key, event.target) }, onTrigger(event) { // 依赖变化触发时 console.log('trigger:', event.key, event.type, event.newValue) } }) watch(source, callback, { onTrack(e) { debugger }, onTrigger(e) { debugger } })常见响应式陷阱// 1. 解构 reactive 丢失响应式 const state = reactive({ count: 0 }) let { count } = state // ❌ count 是普通值 const { count } = toRefs(state) // ✅ 保持响应式 // 2. ref 在模板中自动解包，在 JS 中需要 .value const count = ref(0) console.log(count) // Ref 对象 console.log(count.value) // 0 // 3. reactive 被整体替换 let state = reactive({ list: [] }) state = reactive({ list: [1] }) // ❌ 旧引用的依赖全部失效 // ✅ 用 ref 包裹，或只修改属性 // 4. markRaw 跳过转换 const state = reactive({ chart: markRaw(new ECharts()) // ✅ 第三方实例不代理 })七、小结Vue 3 响应式系统的核心是 Proxy 拦截 + WeakMap 存储依赖 + 副作用调度。理解 track/trigger 机制、惰性深层代理和异步批更新，不仅能帮你避开响应式陷阱，也是进阶 Vue 源码的第一步。手写迷你版响应式系统是最好的学习方式。本文由 inspirecl.asia 原创，转载请注明出处。
组合式 API（Composition API）是 Vue 3 最重要的特性，解决了 Options API 在复杂组件中逻辑分散的问题。本文通过实战案例深入讲解 setup、响应式 API、生命周期钩子和自定义组合函数。一、为什么需要组合式 APIOptions API 的痛点// Options API：同一功能的代码被拆散到不同选项中 export default { data() { return { // 搜索功能的数据 searchQuery: '', searchResults: [], // 用户功能的数据 user: null, userPermissions: [], } }, computed: { // 搜索功能的计算属性 filteredResults() { /* ... */ }, // 用户功能的计算属性 isAdmin() { /* ... */ }, }, methods: { // 搜索功能的方法 handleSearch() { /* ... */ }, // 用户功能的方法 loadUser() { /* ... */ }, }, mounted() { // 搜索和用户的初始化逻辑混在一起 this.handleSearch() this.loadUser() } }组合式 API 的优势 优势说明逻辑复用自定义组合函数替代 mixin，无命名冲突代码组织相关逻辑聚合，而非按选项类型分散类型推导更好的 TypeScript 支持代码压缩setup 语法糖减少样板代码生产性能编译优化（静态提升、patch flag）二、setup 与 script setup基本用法 {{ count }} × 2 = {{ double }} defineProps 与 defineEmits defineExpose 与 defineModel 三、响应式 APIref 与 reactiveimport { ref, reactive, shallowRef, shallowReactive } from 'vue' // ref — 适合基本类型和需要整体替换的对象 const count = ref(0) const user = ref({ name: '张三', age: 25 }) console.log(count.value) // 访问需 .value user.value.name = '李四' // 修改属性 user.value = { name: '王五' } // 整体替换 // reactive — 适合不替换整体的复合对象 const state = reactive({ items: [], loading: false, filters: { category: null, price: null } }) state.loading = true // 直接访问，无需 .value state.items.push({ id: 1 }) // ⚠️ reactive 的陷阱 let { items } = state // ❌ 解构丢失响应式 items = toRef(state, 'items') // ✅ 用 toRef 保持响应式ref vs reactive 选择原则场景推荐基本类型ref需要整体替换ref表单对象reactive组合函数返回值ref（保持统一）大型只读配置shallowRef / markRawreadonly 与 shallowRefimport { readonly, shallowRef, triggerRef, markRaw } from 'vue' // readonly — 防止修改（如传递给子组件的 props） const original = reactive({ count: 0 }) const copy = readonly(original) // copy.count++ // ⚠️ 警告：无法修改 // shallowRef — 只追踪 .value 本身，适合大列表 const list = shallowRef([]) // 替换整个数组触发更新 list.value = [...list.value, newItem] // 修改内部属性后手动触发 list.value.push(anotherItem) triggerRef(list) // markRaw — 排除响应式转换（如第三方类实例） const chart = markRaw(new ECharts())computed 与 watchimport { ref, computed, watch, watchEffect } from 'vue' const firstName = ref('三') const lastName = ref('张') // computed — 有缓存 const fullName = computed(() => `${lastName.value}${firstName.value}`) // 可写 computed const fullNameWritable = computed({ get: () => `${lastName.value}${firstName.value}`, set: (val) => { lastName.value = val[0] firstName.value = val.slice(1) } }) // watch — 明确监听源，可获取旧值 watch(fullName, (newVal, oldVal) => { console.log(`${oldVal} → ${newVal}`) }) // watch 对象属性 watch( () => state.filters, (newFilters) => { fetchList(newFilters) }, { deep: true, immediate: true } ) // watchEffect — 立即执行，自动追踪依赖 watchEffect(() => { console.log(`${firstName.value} ${lastName.value}`) }) // watchEffect 清理副作用 watchEffect((onCleanup) => { const timer = setTimeout(() => { console.log(firstName.value) }, 500) onCleanup(() => clearTimeout(timer)) // 依赖变化时清理 })toRefs 与 toValueimport { toRefs, toRef, toValue, reactive } from 'vue' const state = reactive({ x: 1, y: 2 }) // toRefs — 用于解构 reactive 对象时保持响应式 const { x, y } = toRefs(state) // toRef — 单个属性 const yRef = toRef(state, 'y') // toValue — 统一处理 ref / getter / 普通值（Vue 3.3+） function resolveValue(maybeRefOrGetter) { return toValue(maybeRefOrGetter) } resolveValue(refValue) // ref → .value resolveValue(() => 123) // getter → 调用结果 resolveValue(123) // 普通值 → 原样返回四、生命周期钩子 五、自定义组合函数useFetch — 数据请求// composables/useFetch.js import { ref, shallowRef, triggerRef, toValue, watchEffect } from 'vue' export function useFetch(url) { const data = shallowRef(null) const error = ref(null) const loading = ref(false) async function doFetch() { data.value = null error.value = null loading.value = true try { const res = await fetch(toValue(url)) if (!res.ok) throw new Error(res.statusText) data.value = await res.json() } catch (e) { error.value = e } finally { loading.value = false } } // url 是响应式时自动重新请求 watchEffect(doFetch) return { data, error, loading, retry: doFetch } } 加载中... 错误: {{ error.message }} {{ user.name }} useLocalStorage — 本地存储// composables/useLocalStorage.js import { ref, watch } from 'vue' export function useLocalStorage(key, defaultValue) { const value = ref( JSON.parse(localStorage.getItem(key)) ?? defaultValue ) watch(value, (val) => { localStorage.setItem(key, JSON.stringify(val)) }, { deep: true }) function remove() { localStorage.removeItem(key) value.value = defaultValue } return { value, remove } }useMouse — 鼠标追踪// composables/useMouse.js import { ref, onMounted, onUnmounted } from 'vue' export function useMouse() { const x = ref(0) const y = ref(0) function update(e) { x.value = e.pageX y.value = e.pageY } onMounted(() => window.addEventListener('mousemove', update)) onUnmounted(() => window.removeEventListener('mousemove', update)) return { x, y } }useDebounceFn — 防抖// composables/useDebounceFn.js import { customRef } from 'vue' export function debouncedRef(value, delay = 200) { let timeout return customRef((track, trigger) => { return { get() { track() return value }, set(newValue) { clearTimeout(timeout) timeout = setTimeout(() => { value = newValue trigger() }, delay) } } }) }六、provide 与 inject 七、小结组合式 API 是 Vue 3 的核心范式：ref/reactive 管理状态，computed/watch 处理派生与副作用，自定义组合函数实现逻辑复用。掌握这些 API 后，你会发现复杂组件的开发变得清晰而高效。本文由 inspirecl.asia 原创，转载请注明出处。