在 Vue3 里写 TS:setup、props、emit、「ref、reactive」 的类型注解

0 阅读6分钟

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、开篇:Vue3 为什么值得上 TypeScript?

日常开发里常会遇到:

  • 重构不敢动:改了 props 名字,不知道有没有地方漏改
  • 调用传参靠猜:emit 的事件参数类型不清楚,写完才发现传错
  • ref / reactive 混用:什么时候用泛型、什么时候用 as,心里没数

在 Vue3 + TS 里,把这些都加上类型注解,既能获得智能提示,也能在编译期就发现错误,减少线上 bug。

下面从基础概念 → 具体用法 → 完整示例 → 选型与踩坑,把 setup、props、emit、ref、reactive 的类型写法讲清楚。

二、概念扫盲:Vue3 里的类型体系

先把几个核心类型分清:

概念作用
defineProps定义组件的 props 及类型
defineEmits定义组件触发的自定义事件及参数类型
ref基本类型/单值响应式
reactive对象响应式
computed计算属性
watch监听器

一句话记:props 和 emit 用 defineXxx 配合类型,ref/reactive 用泛型或 as 标注类型

三、setup 与 <script setup> 的关系

Vue3 推荐用 <script setup>,和传统 setup 的对应关系是:

<!-- 传统 setup 写法 -->
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
  setup(props, { emit }) {
    // ...
  }
})
</script>

<!-- 推荐:<script setup> -->
<script setup lang="ts">
// props、emit 等由编译器宏自动推导,无需显式返回
</script>

<script setup> 下,definePropsdefineEmits编译器宏,会在编译时处理,无需 return

四、props 的类型注解

4.1 两种写法

方式一:运行时声明(同时满足 TS 类型)

<script setup lang="ts">
const props = defineProps<{
  title: string
  count?: number
  tags: string[]
}>()

// 使用
console.log(props.title)  // string
console.log(props.count)  // number | undefined
</script>

方式二:用接口抽离(更易复用)

<script setup lang="ts">
interface Props {
  title: string
  count?: number
  tags: string[]
}

const props = defineProps<Props>()
</script>

4.2 带默认值的 props

泛型写法本身不直接支持 default,需要用 withDefaults

<script setup lang="ts">
interface Props {
  title: string
  count?: number
  tags?: string[]
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  tags: () => ['default']
})
</script>

注意:tags 用工厂函数 () => ['default'],避免数组在多个实例间共享引用。

4.3 解构 props 会丢响应式

// ❌ 解构后不再是响应式
const { title } = defineProps<{ title: string }>()

// ✅ 先完整拿到 props,再解构或使用
const props = defineProps<{ title: string }>()
const title = computed(() => props.title)

五、emit 的类型注解

5.1 基本写法

<script setup lang="ts">
// 写法一:对象形式,带校验
const emit = defineEmits<{
  change: [value: string]
  update: [id: number, name: string]
}>()

// 调用
emit('change', 'hello')
emit('update', 1, 'vue')
</script>

[value: string] 表示该事件接收一个 string 参数,[id: number, name: string] 表示两个参数。

5.2 可选参数

const emit = defineEmits<{
  submit: [payload?: { name: string; age: number }]
}>()

emit('submit')                    // ✅
emit('submit', { name: 'Tom', age: 18 })  // ✅

5.3 常见误用

// ❌ 事件名写错,类型检查不会报错
emit('chang', 'hello')  // 应该是 'change'

// ✅ 建议统一用对象形式,方便以后扩展校验
const emit = defineEmits<{
  change: [value: string]
}>()

六、ref 的类型注解

6.1 基本类型

const count = ref(0)           // 自动推断为 Ref<number>
const name = ref<string>('')   // 显式指定 Ref<string>
const list = ref<string[]>([]) // 数组

6.2 对象类型

interface User {
  id: number
  name: string
}

// 方式一:泛型
const user = ref<User | null>(null)

// 方式二:as(在初始化时断言)
const user = ref(null) as Ref<User | null>

6.3 取值时注意 .value

const count = ref(0)
count.value++        // ✅
const n = count.value  // ✅

// 模板里会自动解包,不需要 .value
// {{ count }}

七、reactive 的类型注解

7.1 基本用法

interface FormState {
  name: string
  age: number
}

const form = reactive<FormState>({
  name: '',
  age: 0
})

7.2 和 ref 的差异

特性refreactive
适用对象任意类型对象
赋值整体替换需改 .value不能整体替换,会丢响应式
解构解构后仍保留响应式解构后丢失响应式
// reactive 不能整体替换
let state = reactive({ count: 0 })
state = { count: 1 }  // ❌ 响应式丢失

// ✅ 应逐个属性修改
state.count = 1

// 或改用 ref 包对象
const state = ref({ count: 0 })
state.value = { count: 1 }  // ✅

7.3 解构用 toRefs

const state = reactive({ name: 'vue', count: 0 })

// ❌ 解构后丢失响应式
const { name } = state

// ✅ 用 toRefs 保持响应式
const { name, count } = toRefs(state)

八、完整示例:一个带类型的小组件

<script setup lang="ts">
import { ref, reactive, computed } from 'vue'

// 1. Props 类型
interface Props {
  title: string
  maxCount?: number
}

const props = withDefaults(defineProps<Props>(), {
  maxCount: 10
})

// 2. Emit 类型
const emit = defineEmits<{
  submit: [result: { count: number }]
}>()

// 3. ref
const count = ref(0)

// 4. reactive
const form = reactive({
  name: '',
  valid: false
})

// 5. computed
const canSubmit = computed(() => count.value <= props.maxCount)

function handleSubmit() {
  if (!canSubmit.value) return
  emit('submit', { count: count.value })
}
</script>

<template>
  <div>
    <h2>{{ title }}</h2>
    <p>当前: {{ count }} / {{ maxCount }}</p>
    <button @click="count++" :disabled="!canSubmit">增加</button>
    <button @click="handleSubmit">提交</button>
  </div>
</template>

父组件使用示例:

<script setup lang="ts">
import MyComponent from './MyComponent.vue'

function onSubmit(result: { count: number }) {
  console.log('提交数量:', result.count)
}
</script>

<template>
  <MyComponent title="计数器" :max-count="5" @submit="onSubmit" />
</template>

九、选型与踩坑

场景推荐说明
简单 propsdefineProps<{...}>()直接用泛型,简洁
有默认值withDefaults(defineProps<Props>(), {...})必须配合 withDefaults
基础类型/单值ref需要 .value
表单等对象reactive不要整体替换
需要整体替换对象ref 包对象ref<Obj>({...})
解构 reactivetoRefs否则会丢响应式

常见坑

  1. defineProps 解构丢响应式:不要直接解构,需要时用 computedtoRef
  2. reactive 整体替换:只能改属性,不能 state = newState
  3. ref 在 template 里不用 .value:只在 script 里需要。
  4. emit 参数和类型不一致:用对象形式定义好参数类型,避免传错。

十、小结

概念类型写法注意点
propsdefineProps<Props>() + withDefaults有默认值必须用 withDefaults
emitdefineEmits<{ event: [args] }>()用元组定义参数
refref<T>(initial)ref(initial).value 访问
reactivereactive<T>(obj)不整体替换、解构用 toRefs

记住三点:

  1. props 和 emitdefineXxx + 泛型/接口,把参数类型写清楚。
  2. ref 适合任意类型,reactive 只适合对象,且不能整体替换。
  3. 有默认值用 withDefaults,解构 reactive 用 toRefs

把类型写对,IDE 提示会更好用,重构也更放心。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~