Vue3 进阶 API 全解析:ant-design-vue、VueUse、Pinia 源码高频使用的十个宝藏功能

更新日期: 2026-04-13 阅读: 20 标签: Vue3

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 会检查两个全局变量:

  1. currentInstance(当前组件实例)

  2. 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 keyant-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


本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!

链接: https://fly63.com/article/detial/13618

相关推荐

Vue3 Hook 到底是啥黑魔法?

早就听说,React社区,已经全面拥抱Hook。Vue3的发布也支持了自定义Hook,作为只会Vue的前端小码农自然要去看看Vue3 Hook到底是啥黑魔法?

快速进阶Vue3.0

在2019.10.5日发布了Vue3.0预览版源码,但是预计最早需要等到 2020 年第一季度才有可能发布 3.0 正式版。新版Vue 3.0计划并已实现的主要架构改进和新功能:

vue3在setup中通过$ref获取dom元素

在使用vue2的时候,我们需要获取dom元素,或者获取组件的相关方法属性,一般都是通过this.$refs[domName]的方式,但是在vue3的setup中是没有this的,那么如何获取$refs呢?

vue3对比vue2使用,代码解释最直观

对于大多数组件,Vue2和Vue3中的代码即使不完全相同,也是非常相似的。但是,Vue3支持片段,这意味着组件可以有多个根节点。这在呈现列表中组件以删除不必要的包装器div元素时特别有用。但是,在本例中,表单组件的两个版本都将只保留一个根节点

浅谈Vue3的watchEffect用途

vue2里面的 watch api 大家应该都挺熟悉的了, vue2中vue实例里面有一个 $watch 方法 在sfc(sigle file component)里面有一个 watch 选项。他可以实现在一个属性变更的时候,去执行我们想要的行为

从 Proxy 到 Vue 源码,深入理解 Vue 3.0 响应系统

10 月 5 日,尤雨溪在 GitHub 开放了 Vue 3.0 处于 pre-alpha 状态的源码,这次 Vue 3.0 Updates 版本的更新,将带来五项重大改进:速度体积、可维护性、面向原生、易用性

Vue3数据响应系统

Vue3 就是基于 Proxy 对其数据响应系统进行了重写,现在这部分可以作为独立的模块配合其他框架使用。数据响应可分为三个阶段: 初始化阶段 --> 依赖收集阶段 --> 数据响应阶段

在Vue2与Vue3中构建相同的组件

Vue 开发团队终于在今天发布了 3.0-beta.1 版本,也就是测试版。通常来说,从测试版到正式版,只会修复 bug,不会引入新功能,或者删改老功能。所以,如果你对新版本非常感兴趣,或者有新项目即将上马,不妨尝试一下新版本

Vue3.5 新特性有哪些?

2024年9月3日,Vue 官方团队发布了 Vue.js 3.5 稳定版,这个小版本不包含任何破坏性变更,为服务器端渲染(SSR)带来了一些期待已久的改进,同时包括了内部改进和实用的新功能。

Vue 的数据响应式(Vue2 及 Vue3)

从一开始使用 Vue 时,对于之前的 jq 开发而言,一个很大的区别就是基本不用手动操作 dom,data 中声明的数据状态改变后会自动重新渲染相关的 dom。换句话说就是 Vue 自己知道哪个数据状态发生了变化及哪里有用到这个数据需要随之修改。

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!