关键词:Vue3 教程、TypeScript 新手、Vue 组件开发、script setup、v-model、props emits、前端入门实战
这篇是给新手的「能马上用起来」版本:少讲概念黑话,多给直接可抄的模板。 掌握这些之后可以独立写出一个类型安全、可复用、可维护的 Vue3 组件。
适读人群
- Vue3 刚入门,想系统掌握组件开发
- 会一点 TS,但写组件总报错
- 想从"能跑"进阶到"规范可维护"
你将收获
- 一套可复用的组件开发骨架
- 高频报错的定位和修复思路
- 发布前可自检的速查方法
建议阅读时长:15-20 分钟
目录
- [1. 开局就别踩坑:最小推荐配置](#1. 开局就别踩坑:最小推荐配置)
- [2. 一个组件最常见的骨架](#2. 一个组件最常见的骨架)
- [3. Props:新手最容易写错的 4 点](#3. Props:新手最容易写错的 4 点)
- [4. Emits:把事件写成可检查的接口](#4. Emits:把事件写成可检查的接口)
- [5. ref / reactive / computed 怎么选](#5. ref / reactive / computed 怎么选)
- [6. watch 与 watchEffect 一眼分清](#6. watch 与 watchEffect 一眼分清)
- [7. v-model 组件写法(必会)](#7. v-model 组件写法(必会))
- [8. 插槽(slot)先会用,再谈高级](#8. 插槽(slot)先会用,再谈高级)
- [9. 模板 ref 获取 DOM(高频)](#9. 模板 ref 获取 DOM(高频))
- [10. 父调子:defineExpose](#10. 父调子:defineExpose)
- [11. 一个完整小案例:可复用分页组件](#11. 一个完整小案例:可复用分页组件)
- [12. 新手高频报错对照](#12. 新手高频报错对照)
- [13. 组件开发速查表(收藏)](#13. 组件开发速查表(收藏))
- [14. 学习顺序建议(7 天版本)](#14. 学习顺序建议(7 天版本))
- [15. 结语](#15. 结语)
1. 开局就别踩坑:最小推荐配置
1.1 tsconfig.json 关键项
json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"types": ["vite/client"]
}
}
strict: true 一定开。新手早期会觉得烦,但它能帮你在运行前发现一堆 bug。
1.2 env.d.ts
ts
/// <reference types="vite/client" />
没它就容易遇到 .vue 模块识别问题。
2. 一个组件最常见的骨架
vue
<script setup lang="ts">
interface Props {
title: string
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false
})
const emit = defineEmits<{
click: [id: number]
}>()
function handleClick() {
if (props.disabled) return
emit('click', 1)
}
</script>
<template>
<button :disabled="disabled" @click="handleClick">
{{ title }}
</button>
</template>
你要先形成这个肌肉记忆:
- 入参 :
defineProps - 出参 :
defineEmits - 默认值 :
withDefaults - 逻辑:函数 + 组合式 API
3. Props:新手最容易写错的 4 点
3.1 可选属性 + 默认值
ts
interface Props {
size?: 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
size: 'md'
})
有默认值就通常把该字段写成可选,类型更顺手。
3.2 对象/数组默认值
ts
interface Props {
tags?: string[]
}
const props = withDefaults(defineProps<Props>(), {
tags: () => []
})
对象和数组建议用工厂函数,避免引用共享。
3.3 不要改 props
props 是只读的。要改就拷贝一份:
ts
const localTags = ref([...(props.tags ?? [])])
3.4 string | undefined 处理
ts
const safeTitle = computed(() => props.title ?? '默认标题')
4. Emits:把事件写成"可检查"的接口
ts
const emit = defineEmits<{
submit: [form: { name: string; age: number }]
cancel: []
}>()
使用:
ts
emit('submit', { name: 'Tom', age: 18 })
emit('cancel')
好处:事件名拼错、参数错类型会立刻报错,不用等联调。
5. ref / reactive / computed 怎么选
5.1 ref
单值优先。
ts
const count = ref(0)
count.value++
5.2 reactive
对象聚合状态。
ts
const form = reactive({
name: '',
age: 0
})
5.3 computed
派生值,不要手动同步。
ts
const canSubmit = computed(() => form.name.trim().length > 0 && form.age > 0)
口诀:源数据用 ref/reactive,计算结果用 computed。
6. watch 与 watchEffect 一眼分清
6.1 watch:监听谁,你说了算
ts
watch(
() => form.name,
(newName, oldName) => {
console.log(newName, oldName)
}
)
6.2 watchEffect:函数里用到谁,就监听谁
ts
watchEffect(() => {
console.log(form.name, form.age)
})
新手建议先用 watch,更可控。
7. v-model 组件写法(必会)
子组件:
vue
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<input
:value="props.modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>
父组件:
vue
<MyInput v-model="keyword" />
8. 插槽(slot)先会用,再谈高级
子组件:
vue
<template>
<section>
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>
</section>
</template>
父组件:
vue
<BaseCard>
<template #header>标题</template>
正文内容
<template #footer>底部按钮</template>
</BaseCard>
先掌握命名插槽,已经覆盖大多数业务场景。
9. 模板 ref 获取 DOM(高频)
vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const inputRef = ref<HTMLInputElement | null>(null)
onMounted(() => {
inputRef.value?.focus()
})
</script>
<template>
<input ref="inputRef" />
</template>
关键点:模板 ref 一般要在 onMounted 后访问。
10. 父调子:defineExpose
子组件:
ts
function reset() {
// ...
}
defineExpose({ reset })
父组件:
ts
const childRef = ref<{ reset: () => void } | null>(null)
childRef.value?.reset()
只暴露必要 API,别把整个内部状态暴露出去。
11. 一个完整小案例:可复用分页组件
vue
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
total: number
page: number
pageSize?: number
}
const props = withDefaults(defineProps<Props>(), {
pageSize: 10
})
const emit = defineEmits<{
'update:page': [page: number]
}>()
const totalPages = computed(() =>
Math.max(1, Math.ceil(props.total / props.pageSize))
)
function prev() {
if (props.page > 1) emit('update:page', props.page - 1)
}
function next() {
if (props.page < totalPages.value) emit('update:page', props.page + 1)
}
</script>
<template>
<div>
<button :disabled="page <= 1" @click="prev">上一页</button>
<span>{{ page }} / {{ totalPages }}</span>
<button :disabled="page >= totalPages" @click="next">下一页</button>
</div>
</template>
这个组件同时练到了:
- props + 默认值
- emit 类型
- computed
- 边界判断
12. 新手高频报错对照
12.1 Property xxx does not exist on type
通常是变量没声明、拼写错、或推断不到类型。先看定义,再看名字。
12.2 Object is possibly 'null'
模板 ref 或接口可空字段未判空。加 ?. 或先 if 收窄。
12.3 Cannot find module '*.vue'
检查 env.d.ts 和 tsconfig include。
12.4 Type 'string | undefined' is not assignable to type 'string'
给默认值:x ?? '',或者目标类型改联合类型。
13. 组件开发速查表(收藏)
| 需求 | 首选写法 |
|---|---|
| 接收参数 | defineProps<T>() |
| 参数默认值 | withDefaults(defineProps<T>(), defaults) |
| 抛出事件 | defineEmits<{ event: [payload] }>() |
| 双向绑定 | modelValue + update:modelValue |
| 单值状态 | ref<T>() |
| 对象状态 | reactive() |
| 派生状态 | computed() |
| 监听变化 | watch() |
| 自动收集依赖 | watchEffect() |
| 暴露子组件方法 | defineExpose() |
| 拿 DOM | 模板 ref + onMounted |
14. 学习顺序建议(7 天版本)
- Day1:
script setup+props/emits - Day2:
ref/reactive/computed - Day3:
watch+ 生命周期 - Day4:
v-model组件 - Day5: 插槽 +
defineExpose - Day6: 做一个表单组件
- Day7: 做一个列表 + 分页 + 搜索组件
每天 1 小时,边写边改,进步最快。
15. 结语
Vue3 + TypeScript 难的不是 API 多,而是「组件边界和类型边界」要清晰。
只要你把 props、emits、v-model 这三件事写标准,80% 的组件问题都会少很多。
如果你愿意,我可以下一篇直接给你写一套「后台管理系统常用 10 个组件模板」(弹窗、表格、表单、上传、抽屉、树选择等),复制即用。
---)
