首页
直播
壁纸
友链
搜索
1
微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款
266 阅读
2
微信小程序云开发实战:云函数、云数据库与云存储的正确使用姿势
256 阅读
3
微信小程序自定义 tabBar 实战:custom-tab-bar 从适配到深色模式
255 阅读
4
微信小程序 Skyline 渲染引擎实战:worklet 动画从原理到落地
253 阅读
5
微信小程序分包进阶:独立分包、预下载与分包异步化实战
246 阅读
服务器运维
后端技术
前端技术
梯子
数据库
小程序
登录
搜索
标签搜索
fastadmin
Redis
微信小程序
前端开发
RabbitMQ
Go
服务器
codex
buildadmin
小程序
mysql
Nginx
Docker
Vue3
Node.js
MySQL优化
Linux
TypeScript
JWT
PHP
沿途的风景
累计撰写
74
篇文章
累计收到
0
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
11
篇与
» Vue3
的结果
2026-07-14
Vue 3 插槽深度实战:默认、具名与作用域插槽及自定义指令
如果说 props 是组件的"输入参数",插槽就是组件的"内容参数"。写好一个可复用的布局组件、表格组件、弹窗组件,绕不开插槽;而自定义指令则是 DOM 级复用的利器。这两个主题合起来,是 Vue 中级开发者必须吃透的内容。一、插槽的三种形态1. 默认插槽父组件传入的内容替换子组件的 <slot> 占位:<!-- Card.vue --> <template> <div class="card"> <div class="card-body"> <slot></slot> </div> </div> </template><!-- 使用 --> <Card> <h3>订单详情</h3> <p>共 3 件商品,合计 ¥299.00</p> </Card>2. 具名插槽:多区域布局组件有多个内容区域时,用 name 区分。典型场景:页头/页身/页脚的布局组件。<!-- PageLayout.vue --> <template> <div class="page"> <header class="page-header"> <slot name="header"></slot> </header> <main class="page-main"> <slot></slot> <!-- 默认插槽 --> </main> <footer class="page-footer"> <slot name="footer"></slot> </footer> </div> </template><!-- 使用:template + v-slot 指定区域 --> <PageLayout> <template #header> <h1>管理后台</h1> </template> <p>这里是主内容区域</p> <template #footer> <span>© 2026 My Corp</span> </template> </PageLayout>注意 #header 是 v-slot:header 的缩写,且 v-slot 只能写在 <template> 上(默认插槽的简写除外)。3. 作用域插槽:数据反向传递普通插槽的内容编译在父组件作用域,无法访问子组件内部数据。作用域插槽让子组件把数据"抛"给插槽内容:<!-- DataList.vue --> <script setup> const props = defineProps({ items: { type: Array, required: true } }) </script> <template> <ul class="data-list"> <li v-for="(item, index) in items" :key="item.id"> <slot :item="item" :index="index"> <!-- 后备内容:父组件不传插槽时的默认渲染 --> {{ item.name }} </slot> </li> </ul> </template><!-- 父组件决定每一行怎么渲染 --> <DataList :items="orders"> <template #default="{ item, index }"> <div class="order-row"> <span>#{{ index + 1 }}</span> <span>{{ item.orderNo }}</span> <span :class="item.status">{{ statusText[item.status] }}</span> </div> </template> </DataList>这是设计"无头组件"(Headless Component)的核心思想:组件负责数据和逻辑,父组件负责渲染结构。Element Plus 的 Table、el-select 的 option 都大量使用作用域插槽。实战:封装一个通用描述列表组件综合运用具名 + 作用域插槽,封装一个类似 Ant Design Descriptions 的组件:<!-- DescList.vue --> <script setup> defineProps({ items: { type: Array, required: true } }) </script> <template> <dl class="desc-list"> <template v-for="item in items" :key="item.field"> <dt>{{ item.label }}</dt> <dd> <!-- 命名插槽接管某个字段的渲染,否则显示原始值 --> <slot :name="item.field" :item="item"> {{ item.value }} </slot> </dd> </template> </dl> </template><!-- 使用方 --> <DescList :items="[ { field: 'name', label: '姓名', value: user.name }, { field: 'avatar', label: '头像', value: user.avatar }, { field: 'status', label: '状态', value: user.status } ]"> <template #avatar="{ item }"> <img :src="item.value" class="avatar" /> </template> <template #status="{ item }"> <el-tag :type="item.value === 1 ? 'success' : 'danger'"> {{ item.value === 1 ? '正常' : '禁用' }} </el-tag> </template> </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 = '<div class="v-loading-spinner"></div>' 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' }<div class="panel" v-loading="fetching"> <p v-for="row in rows" :key="row.id">{{ row.name }}</p> </div>实战指令二: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) } }<button v-debounce="[saveOrder, 500]">提交订单</button>实战指令三: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) } } }<button v-permission="'order:delete'">删除订单</button> <button v-permission.some="['a', 'b']">复合操作</button><script setup> 中局部注册指令也支持在 SFC 内直接定义,变量名以 v 开头即可自动注册:<script setup> // 变量名 vFocus 自动映射为 v-focus 指令 const vFocus = { mounted: (el) => el.focus() } </script> <template> <input v-focus /> </template>插槽 vs 指令:选型原则维度插槽指令复用内容结构 + 样式DOM 行为典型场景布局、列表渲染定制权限、防抖、拖拽、埋点作用对象组件原生 DOM 元素灵活性高(父级完全接管渲染)中(操作属性和事件)一个简单的判断:需求是"换个样子"用插槽,需求是"加点行为"用指令。总结作用域插槽 = 子组件向插槽内容"回传数据",是无头组件的基石具名插槽支撑多区域布局,#name 是标准缩写自定义指令只在需要直接操作 DOM 时使用,能用组件解决的别用指令指令钩子与组件生命周期对齐,简写形式覆盖 mounted + updatedv-loading、v-debounce、v-permission 是三个最值得收进工具箱的指令把插槽和指令用好,你封装的组件会从"能用"进化到"好用"。下一篇文章我们聊聊组合式函数(Composables)——Vue 3 逻辑复用的终极形态。
2026年07月14日
5 阅读
0 评论
0 点赞
2026-07-12
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<!-- ❌ 与路由强耦合,组件无法复用 --> <script setup> import { useRoute } from 'vue-router' const route = useRoute() const id = route.params.id </script> <!-- ✅ props: true 后,组件像普通组件一样接收参数 --> <script setup> defineProps({ id: String }) </script>函数模式更灵活:{ 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:<script setup> import { ref } from 'vue' import { onBeforeRouteLeave } from 'vue-router' const dirty = ref(false) onBeforeRouteLeave(() => { if (dirty.value) { return window.confirm('表单未保存,确定离开?') } }) </script>滚动行为与过渡动画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> 做页面动画:<template> <router-view v-slot="{ Component, route }"> <transition name="fade" mode="out-in"> <component :is="Component" :key="route.path" /> </transition> </router-view> </template> <style> .fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; } </style>useRoute 与 useRouter<script setup> 中通过组合式 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 和通配符路由顺序是动态路由方案最常踩的两个坑路由系统是后台管理项目的骨架,把这套方案吃透,遇到再复杂的权限场景都能从容拆解。
2026年07月12日
5 阅读
0 评论
0 点赞
2026-07-11
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 项目不推荐。层次二:类型声明(类型推导最佳)<script setup lang="ts"> interface Order { id: number title: string status: 'pending' | 'paid' | 'closed' } const props = defineProps<{ order: Order showActions?: boolean }>() // props.order.status 自动推导为联合类型 // 模板中尝试比较错误状态会有类型提示 </script>层次三: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 }>()泛型组件写法<!-- SelectList.vue --> <script setup lang="ts" generic="T extends { id: number }"> defineProps<{ items: T[] modelValue: T['id'] | null labelField?: keyof T }>() const emit = defineEmits<{ 'update:modelValue': [id: T['id'] | null] select: [item: T] }>() </script> <template> <ul class="select-list"> <li v-for="item in items" :key="item.id" :class="{ active: item.id === modelValue }" @click="emit('update:modelValue', item.id); emit('select', item)" > <slot :item="item">{{ item[labelField ?? 'id'] }}</slot> </li> </ul> </template>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 与组件类型<script setup> 的组件是匿名的,需要显式 name 时(keep-alive include、devtools 显示):<script setup lang="ts"> defineOptions({ name: 'OrderList' }) </script>外部类型文件的组织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 能力正是中级向高级进阶的分水岭。
2026年07月11日
6 阅读
0 评论
0 点赞
2026-07-08
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。<!-- 子组件 UserCard.vue --> <script setup> defineProps({ user: { type: Object, required: true }, showAvatar: { type: Boolean, default: true } }) </script> <template> <div class="user-card"> <img v-if="showAvatar" :src="user.avatar" /> <span>{{ user.name }}</span> </div> </template>运行时校验与类型推导纯 JS 项目用对象语法做运行时校验;TS 项目更推荐类型声明方式,编辑器推导更完整:<script setup lang="ts"> interface User { id: number name: string avatar?: string } const props = defineProps<{ user: User showAvatar?: boolean }>() // 带默认值时使用 withDefaults const props2 = withDefaults(defineProps<{ user: User showAvatar?: boolean }>(), { showAvatar: true }) </script>常见陷阱:直接修改 props子组件里改 props 对象的内部属性(如 props.user.name = 'x')虽然不报错(对象是引用传递),但会污染父组件状态,是最难排查的一类 bug。正确做法是通过 emit 通知父组件修改。二、emit:子传父的事件通道<!-- 子组件 ConfirmDialog.vue --> <script setup> const emit = defineEmits(['confirm', 'cancel']) function handleConfirm() { emit('confirm', { confirmedAt: Date.now() }) } </script><!-- 父组件 --> <ConfirmDialog @confirm="handleConfirm" @cancel="showDialog = false" />声明式 emit 的必要性很多同学习惯不声明直接 emit('xxx')。在 <script setup> 中不声明也能用,但显式声明有两个好处:文档化:组件的使用者一眼看清它对外暴露哪些事件事件校验:可以像 props 一样带校验逻辑const emit = defineEmits({ // 无校验 click: null, // 带校验 submit: (payload) => { if (!payload || typeof payload.email !== 'string') { console.warn('submit 事件必须携带 email 字段') return false } return true } })三、v-model:双向绑定的本质v-model 不是黑魔法,它是 props + emit 的语法糖。单值绑定<!-- 子组件 SearchInput.vue --> <script setup> const model = defineModel() // Vue 3.4+ 推荐写法 </script> <template> <input :value="model" @input="model = $event.target.value" /> </template><!-- 父组件 --> <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.vue --> <script setup> const start = defineModel('start') const end = defineModel('end') </script> <template> <div class="range-picker"> <input v-model="start" type="date" /> <span>至</span> <input v-model="end" type="date" /> </div> </template><!-- 父组件 --> <RangePicker v-model:start="query.start" v-model:end="query.end" />修饰符v-model 支持自定义修饰符,通过 defineModel 的返回值读取:<script setup> const [model, modifiers] = defineModel({ set(value) { // .trim 修饰符时自动去空格 if (modifiers.trim) return value.trim() return value } }) </script>四、provide / inject:跨层级注入当组件层级超过两层还硬用 props 层层传递(俗称"props 钻井"),就该用 provide/inject 了。<!-- 顶层组件 --> <script setup> import { provide, ref } from 'vue' const theme = ref('dark') const toggleTheme = () => { theme.value = theme.value === 'dark' ? 'light' : 'dark' } provide('theme', { theme, toggleTheme }) </script><!-- 任意深层后代组件 --> <script setup> import { inject } from 'vue' const { theme, toggleTheme } = inject('theme') </script> <template> <button :class="theme" @click="toggleTheme">切换主题</button> </template>响应性保持的关键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 上。封装组件时常用它透传原生属性:<!-- BaseButton.vue --> <template> <button class="btn" v-bind="$attrs"> <slot /> </button> </template>父组件写 <BaseButton type="submit" :disabled="loading"> 时,type 和 disabled 会自动透传到原生 button 上。注意 inheritAttrs: false 的设置场景:根元素有多个时需要手动绑定到正确元素。expose:受控暴露实例<script setup> 组件默认是"封闭"的,父组件通过 ref 拿不到内部任何东西,除非显式 expose:<!-- 子组件 FormModal.vue --> <script setup> import { ref, expose } from 'vue' const formRef = ref(null) const visible = ref(false) function open() { visible.value = true } function close() { visible.value = false } async function validate() { return await formRef.value.validate() } // 只暴露三个方法,内部状态不泄露 defineExpose({ open, close, validate }) </script><!-- 父组件 --> <script setup> const modalRef = ref(null) modalRef.value.open() </script>选型决策清单实际开发中按这个顺序决策:父子相邻 → 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% 的组件设计场景。下一篇我们讲插槽与自定义指令——组件通信之外的另一半中级技能。
2026年07月08日
6 阅读
0 评论
0 点赞
2026-07-08
Vue 3 组合式函数实战:Composables 封装与逻辑复用指南
组合式函数(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 } }使用起来非常顺手:<script setup> import { computed } from 'vue' const page = ref(1) const url = computed(() => `/api/orders?page=${page.value}`) const { data, loading, run } = useRequest(url) function nextPage() { page.value++ // url 变了会自动重新请求,也可以手动 run() } </script>几个设计细节值得注意: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 } }调用方代码极其干净:<script setup> const { x, y } = useMouse() </script> <template>鼠标位置:{{ x }}, {{ y }}</template>如果希望返回值可以直接当数字用,用 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 项目从"堆页面"走向"有架构"的第一步。
2026年07月08日
6 阅读
0 评论
0 点赞
1
2
3
0:00