Vue3 进阶 API 全解析:ant-design-vue、VueUse、Pinia 源码高频使用的十个宝藏功能
Vue3 有一大批宝藏 API,藏在官方文档的角落里,平时没人提,但是 ant-design-vue、VueUse、Pinia 这些顶级开源库在源码里大量使用,用起来相当顺手。
一、ExtractPropTypes:Props 类型的"单一数据源"
先说痛点
你用 defineComponent 加 props 的老写法(或者封装组件库)时,是不是经常遇到这个场景:
// 运行时 props 定义
const MyButton = defineComponent({
props: {
size: {
type: String as PropType<'sm' | 'lg'>,
default: 'sm'
},
disabled: Boolean,
onClick: Function as PropType<() => void>
},
setup(props) {
// 这里 props 的类型是什么?TS 能不能推断出来?
}
})答案:能推断,但不够精确。特别是当你想把 Props 类型导出给外部用的时候,往往要手写一遍类型,和 props 定义分开维护,改一个地方忘了改另一个,bug 就来了。
ExtractPropTypes 登场
这个 API 的作用简单粗暴:把你的运行时 props 对象,自动变成 TypeScript 类型。
import type { ExtractPropTypes, PropType } from 'vue'
// 先把 props 定义成一个函数(方便复用和组合)
const buttonProps = () => ({
size: {
type: String as PropType<'sm' | 'lg'>,
default: 'sm'
},
disabled: Boolean,
count: { type: Number, default: 0 }
})
// 关键来了:用 ExtractPropTypes 提取内部类型
type ButtonProps = ExtractPropTypes<ReturnType<typeof buttonProps>>
// 结果是:
// {
// size: 'sm' | 'lg' ← 组件内部必有值(有 default)
// disabled: boolean ← 组件内部必有值(Boolean 类型自动补 false)
// count: number ← 组件内部必有值(有 default)
// }注意到没有?有 default 的属性和 Boolean 类型的属性,在组件内部都变成了必选的(非 undefined)。因为组件内部这些值一定有。
ExtractPublicPropTypes:给外部用户看的版本
Vue 3.3+ 还加了个兄弟 API:ExtractPublicPropTypes。区别是它提取的是外部使用者的视角——有 default 的属性在外部传的时候是可以不传的:
import type { ExtractPublicPropTypes } from 'vue'
type ButtonPublicProps = ExtractPublicPropTypes<ReturnType<typeof buttonProps>>
// 结果是:
// {
// size?: 'sm' | 'lg' ← 外部可以不传(组件有默认值兜底)
// disabled?: boolean ← 外部可以不传
// count?: number ← 外部可以不传
// }ant-design-vue 怎么用的?
打开 ant-design-vue 的 components/input/inputProps.ts,满眼都是这个模式:
// ant-design-vue 源码(简化版)
import type { ExtractPropTypes, PropType } from 'vue'
const inputProps = () => ({
value: String,
defaultValue: String,
allowClear: Boolean,
bordered: { type: Boolean, default: true },
// ... 很多属性
})
// 对外暴露的类型:使用 Partial,让外部所有属性都可选
export type InputProps = Partial<ExtractPropTypes<ReturnType<typeof inputProps>>>
export type TextAreaProps = Partial<ExtractPropTypes<ReturnType<typeof textAreaProps>>>这么做的好处:一处修改,处处同步。props 定义变了,类型自动更新,不用维护两份文件,组件库的健壮性直接上一个台阶。
二、effectScope + onScopeDispose:响应式副作用的"总开关"
先问一个问题
你写过全局共享状态吗?比如一个"监听窗口大小"的 composable:
// 你可能这么写
function useWindowSize() {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
const handleResize = () => {
width.value = window.innerWidth
height.value = window.innerHeight
}
window.addEventListener('resize', handleResize)
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
return { width, height }
}如果 10 个组件同时调用 useWindowSize(),会发生什么?
注册了 10 个 resize 监听器,创建了 10 份 width/height 的响应式副本。这完全是浪费,而且每个组件各自的 onUnmounted 才会清理,时序还不好控制。
effectScope 的思路
effectScope 可以创建一个"作用域容器",把一批响应式副作用装进去,一键统一管理和销毁:
import { effectScope, ref, watch, onScopeDispose } from 'vue'
// 单例的共享状态
let scope: ReturnType<typeof effectScope> | null = null
let width: Ref<number>
let height: Ref<number>
function useWindowSize() {
if (!scope) {
// 只创建一次
scope = effectScope(true) // true = 独立作用域,不跟随父作用域
scope.run(() => {
width = ref(window.innerWidth)
height = ref(window.innerHeight)
const handleResize = () => {
width.value = window.innerWidth
height.value = window.innerHeight
}
window.addEventListener('resize', handleResize)
// 在 scope 里注册清理函数
onScopeDispose(() => {
window.removeEventListener('resize', handleResize)
scope = null
})
})
}
return { width, height }
}
// 需要时手动停止整个作用域
// scope.stop() → 触发 onScopeDispose,自动清理事件监听现在不管多少个组件调用这个函数,只有一个事件监听器,一份响应式数据。
ant-design-vue 和 VueUse 怎么用
VueUse 库里很多全局共享 composable(比如 useEventListener、useMouse)都用了 effectScope 来实现单例响应式状态。
而 ant-design-vue 的 notification、message 这类全局弹窗组件,在管理多个通知实例的响应式更新时也采用了类似思路——把一批 watch 和 computed 放进一个"容器"里统一管理。
三、customRef:手动掌控响应式的"节拍"
普通 ref 的局限
ref 的行为是:读取时追踪依赖,写入时立即触发更新。
但有些场景你不想"立即触发",比如搜索框的防抖——用户每敲一个字就触发一次请求,这是要烧服务器的。
customRef 给你完全控制权
import { customRef } from 'vue'
function useDebouncedRef<T>(value: T, delay = 300) {
let timer: ReturnType<typeof setTimeout>
return customRef<T>((track, trigger) => {
return {
get() {
track() // 告诉 Vue:有人在读我,记录这个依赖
return value
},
set(newValue) {
clearTimeout(timer)
timer = setTimeout(() => {
value = newValue
trigger() // 告诉 Vue:我变了,可以更新了
}, delay)
}
}
})
}
// 使用
const searchText = useDebouncedRef('', 500)
// v-model 绑定 searchText,用户输入停顿 500ms 才触发响应大白话就是:customRef 让你把 track("记住谁在用我")和 trigger("通知大家我变了")拆开来,自己决定什么时候调用。
受控组件的实现原理
这个 API 在实现受控组件时也非常有用。ant-design-vue 的 Input 组件是一个典型的受控组件——value 完全由外部控制:
// 核心思路:value 从 props 读,写入时 emit 事件通知父组件
// 类似 customRef 的思想:读和写分离控制
const triggerChange = (e: Event) => {
emit('update:value', (e.target as HTMLInputElement).value)
emit('change', e)
formItemContext.onFieldChange() // 通知表单联动验证
}四、markRaw + shallowRef:别让 Vue 把你的对象"搞坏了"
一个真实踩坑场景
你在组件里用 ECharts:
import * as echarts from 'echarts'
const chartRef = ref<HTMLDivElement>()
let chartInstance: echarts.ECharts // ← 这里如果用 ref 就出问题了
onMounted(() => {
chartInstance = echarts.init(chartRef.value!)
// 如果你写成 const chartInstance = ref(echarts.init(chartRef.value!))
// Vue 会尝试把 ECharts 实例变成 Proxy,然后... 可能报错或性能极差
})ECharts 实例、Three.js 的 Scene、WebSocket 对象——这些对象不能被代理,被代理之后要么报错,要么性能惨不忍睹。
markRaw:贴上"此物不可代理"的标签
import { markRaw, reactive } from 'vue'
import * as echarts from 'echarts'
const state = reactive({
// 正确:用 markRaw 保护第三方实例
chart: markRaw(echarts.init(document.getElementById('chart')!)),
// 或者保护一个大型数据集
rawData: markRaw(largeDataArray)
})
// state.chart 的修改不会触发响应式,但这正是我们想要的
// 我们不需要 Vue 帮我们追踪 ECharts 实例的内部变化shallowRef:只追踪"外壳"变化
import { shallowRef, triggerRef } from 'vue'
// 存储大型数组,只追踪整体替换,不追踪内部每个元素
const bigList = shallowRef<Item[]>([])
// 只有整体替换才触发更新
bigList.value = [...newItems] // 触发
// 直接修改内部不触发(这是 shallowRef 的特性)
bigList.value.push(newItem) // 不触发更新
// 如果非要在直接修改后触发,用 triggerRef
bigList.value.push(newItem)
triggerRef(bigList) // 手动通知 Vue:我变了!ant-design-vue 的真实案例
在 ant-design-vue 的 vc-select/hooks/useCache.ts 中,大量用到了 shallowRef:
// ant-design-vue 源码(简化)
import { shallowRef, computed } from 'vue'
// 用 shallowRef 存储 Map 缓存,不需要深层响应式
const cacheRef = shallowRef({
values: new Map(), // 值缓存
options: new Map(), // 选项缓存
})
// 整体替换触发更新,而不是追踪 Map 内部每个 key 的变化
cacheRef.value.values = newValueCache
cacheRef.value.options = newOptionCache对于一个下拉框来说,选项可能有几百上千条,用 shallowRef 而不是 ref 可以避免 Vue 把每个 option 对象都代理一遍,性能提升相当明显。
五、provide + InjectionKey:类型安全的"跨层传值"
传统 provide/inject 的问题
// 父组件
provide('formConfig', { labelAlign: 'right', colon: true })
// 子组件(某个很深的后代)
const config = inject('formConfig')
// config 的类型是 unknown!用起来要各种断言,烦死了InjectionKey:让类型随着数据一起传递
import type { InjectionKey, ComputedRef } from 'vue'
import { provide, inject, computed } from 'vue'
// 1. 定义数据结构
interface FormContextProps {
labelAlign: ComputedRef<'left' | 'right'>
colon: ComputedRef<boolean>
disabled: ComputedRef<boolean>
addField: (key: string, field: any) => void
removeField: (key: string) => void
}
// 2. 创建 Symbol key,泛型标记了这个 key 对应的值类型
const FormContextKey: InjectionKey<FormContextProps> = Symbol('FormContextKey')
// 3. 在父组件中 provide
const useProvideForm = (state: FormContextProps) => {
provide(FormContextKey, state)
}
// 4. 在后代组件中 inject(类型自动推断!)
const useInjectForm = () => {
return inject(FormContextKey, {
// 提供安全默认值,防止在 Form 组件外面用时崩溃
labelAlign: computed(() => 'right' as const),
colon: computed(() => true),
disabled: computed(() => false),
addField: () => {},
removeField: () => {}
})
}
// 使用的时候
const formCtx = useInjectForm()
formCtx.labelAlign.value // 类型是 'left' | 'right',有完整提示ant-design-vue 满眼都是这个模式
打开 ant-design-vue 源码的 components/form/context.ts:
// 实际源码
export const FormContextKey: InjectionKey<FormContextProps> = Symbol('formContextKey')
export const FormItemPrefixContextKey: InjectionKey<FormItemPrefixContextProps> = Symbol('formItemPrefixContextKey')
export const useProvideForm = (state: FormContextProps) => {
provide(FormContextKey, state)
}
export const useInjectForm = () => {
return inject(FormContextKey, {
labelAlign: computed(() => 'right' as FormLabelAlign),
vertical: computed(() => false),
addField: () => {},
removeField: () => {},
onValidate: () => {},
validateMessages: computed(() => ({})),
})
}Form 组件通过 useProvideForm 向下传递表单配置,深层嵌套的 FormItem 通过 useInjectForm 拿到这些配置,不用一层层 props 传递,代码干净多了。
Select、Table、ConfigProvider 全都是同一个套路:每个复合组件一个 context.ts,一个 InjectionKey,两个封装好的 useProvide/useInject 函数。
六、app.runWithContext:在 setup 外面偷偷用 inject
问题背景
inject 只能在 setup() 里调用,因为 Vue 需要知道"当前是哪个组件在调用"。出了 setup,currentInstance 就是 null,inject 就会报错。
但有些场景偏偏就需要在 setup 外面拿注入值:
Axios 请求拦截器:想拿 app.provide 注入的全局 token、用户信息
路由守卫:想在 beforeEach 里拿注入的权限配置
定时器回调:setup 完了之后的异步操作
app.runWithContext 的魔法
// main.ts
const app = createApp(App)
app.provide('userType', 'admin')
app.mount('#app')
// axios 拦截器里(不在任何组件 setup 中)
axiosInstance.interceptors.request.use(config => {
// 直接 inject 会报错:inject() can only be used inside setup()
// const userType = inject('userType')
// 用 runWithContext 临时模拟一个 app 上下文
const userType = app.runWithContext(() => inject('userType'))
config.headers['X-User-Type'] = userType
return config
})它是怎么做到的?
Vue 源码里,inject 会检查两个全局变量:
currentInstance(当前组件实例)
currentApp(当前应用实例)
app.runWithContext 就是临时把 currentApp 设置为当前 app,让 inject 从 app._context.provides 里取值,执行完立刻清空。
// Vue 源码(简化)
runWithContext(fn) {
currentApp = app // 临时挂上 app
try {
return fn() // 执行你的回调
} finally {
currentApp = null // 清掉,干干净净
}
}注意:回调必须同步执行,async 函数里 await 之后 context 就丢了。
七、defineOptions:在 script setup 里偷设组件选项
痛点
用了 <script setup> 的同学有没有遇到过:想给组件设个 name,或者设置 inheritAttrs: false,但 <script setup> 里没有地方写这些选项?
以前的方案是加一个额外的 <script> 块:
<script>
export default { name: 'MyButton', inheritAttrs: false }
</script>
<script setup>
// 主要逻辑
</script>两个 script 块并存,看着就别扭。
defineOptions:Vue 3.3+ 的救星
<script setup>
// 直接在 setup 里设置组件选项!
defineOptions({
name: 'MyButton',
inheritAttrs: false,
})
// 其他正常代码
const props = defineProps({ size: String })
</script>一个 script 块搞定,干净利落。
八、onRenderTracked / onRenderTriggered:调试响应式的"侦探工具"
"为什么我的组件一直在重渲染?"这是每个 Vue 开发者都会遇到的灵魂拷问。
onRenderTracked 和 onRenderTriggered 就是专门回答这个问题的:
import { onRenderTracked, onRenderTriggered } from 'vue'
// 组件每次"追踪到依赖"时触发(渲染过程中读取了哪些响应式数据)
onRenderTracked((event) => {
console.log('追踪到依赖:', event)
// event.target: 被追踪的对象
// event.key: 被追踪的属性
// event.type: 'get'/'has'/'iterate'
})
// 组件因为某个依赖变化而"触发重渲染"时触发
onRenderTriggered((event) => {
console.log('触发重渲染的原因:', {
对象: event.target,
属性: event.key,
旧值: event.oldValue,
新值: event.newValue,
操作: event.type, // 'set'/'add'/'delete'
})
})调试完删掉就行,只在开发模式下有效,生产环境不运行。
九、useAttrs + useSlots:封装"透明组件"的利器
场景:你想对第三方组件加一层包装
假设你要封装 ant-design-vue 的 Input,加一些公司的业务逻辑,但又希望所有 Input 的原有功能(事件、属性)都能透传:
<script setup>
import { useAttrs, useSlots, h } from 'vue'
import { Input } from 'ant-design-vue'
defineOptions({ inheritAttrs: false }) // 禁止自动继承 attrs
const attrs = useAttrs() // 拿到所有透传进来的属性和事件
const slots = useSlots() // 拿到所有透传进来的插槽
// 可以在这里做业务逻辑
// 然后把 attrs 和 slots 全部透传给 Input
</script>
<template>
<!-- v-bind="$attrs" 等价于 v-bind="attrs" -->
<Input v-bind="attrs">
<!-- 透传所有插槽 -->
<template v-for="(slot, name) in slots" #[name]="slotProps">
<slot :name="name" v-bind="slotProps ?? {}" />
</template>
</Input>
</template>useAttrs 的好处是在 setup 里拿到 attrs,可以在 JS 逻辑里对其进行处理,比如拦截某些属性、修改某些值,然后再透传出去。
十、MaybeRef + MaybeRefOrGetter:写出更灵活的 composable
写 composable 的通用性问题
你写了一个 useRequest(url) 函数,url 是字符串。但调用方想传一个 ref(url) 进来,这样 url 变了请求就自动重发。
怎么同时支持两种写法?
import type { MaybeRef, MaybeRefOrGetter } from 'vue'
import { toRef, toValue } from 'vue'
function useRequest(url: MaybeRefOrGetter<string>) {
// toValue 可以处理:普通值、ref、getter 函数 这三种情况
watchEffect(() => {
const actualUrl = toValue(url)
// 发请求...
fetch(actualUrl)
})
}
// 以下三种调用方式都支持!
useRequest('/api/user') // 普通字符串
useRequest(ref('/api/user')) // ref
useRequest(() => `/api/user/${userId.value}`) // getter 函数(响应式计算)MaybeRef<T> = T | Ref<T>
MaybeRefOrGetter<T> = T | Ref<T> | (() => T)
toValue(x) = 自动解包上面三种情况
这是 VueUse 大量使用的模式,写出来的 composable 极其灵活。
汇总表
| API | 一句话用途 | 使用频率(开源库) |
|---|---|---|
| ExtractPropTypes<T> | 从运行时 props 提取内部 TS 类型 | ant-design-vue 满地用 |
| ExtractPublicPropTypes<T> | 从运行时 props 提取外部使用者的 TS 类型 | ant-design-vue 满地用 |
| effectScope | 批量管理一组响应式副作用,可统一销毁 | VueUse 大量使用 |
| onScopeDispose | 在 effectScope 中注册清理函数 | 配合 effectScope |
| customRef | 手动控制依赖追踪和更新触发时机 | 防抖场景 |
| markRaw | 标记对象永远不被响应式代理 | 第三方实例保护 |
| shallowRef | 只追踪 .value 替换,不深层代理内部 | 大型数据性能优化 |
| triggerRef | 手动触发 shallowRef 的更新 | 配合 shallowRef |
| InjectionKey<T> | 类型安全的 provide/inject key | ant-design-vue 复合组件 |
| app.runWithContext | 在 setup 外使用 inject | 拦截器、路由守卫 |
| defineOptions | 在 script setup 中设置组件选项 | 需要设 name/inheritAttrs |
| onRenderTracked | 追踪组件渲染时读取了哪些响应式数据 | 调试重渲染问题 |
| onRenderTriggered | 追踪是哪个数据变化触发了重渲染 | 调试重渲染问题 |
| MaybeRefOrGetter<T> | 支持值/ref/getter 三种输入的类型 | 写通用 composable |
| toValue | 统一解包值/ref/getter | 配合上面的类型 |
最后说几句
这些 API 大部分人不知道,不是因为不重要,是因为它们藏在文档的"进阶"或者"TypeScript 工具类型"章节,很少出现在教程里。
但你去翻一翻 ant-design-vue、VueUse、Pinia 的源码,这些 API 无处不在。大型组件库之所以"健壮、类型安全、性能好",这些 API 功不可没。
下次遇到以下场景,记得想起来:
封装组件库,需要暴露 props 类型 → ExtractPropTypes
写全局共享 composable,担心内存泄漏 → effectScope
存 ECharts/Three.js 实例到响应式对象 → markRaw
表单/表格这种复合组件的跨层通信 → InjectionKey
在 axios 拦截器里需要用注入值 → app.runWithContext
本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!