Vue 3 + TypeScript 完全指南
相关文档:Vue3入门(/vue-3-ru-men-zhi-nan/) | TypeScript完全指南(/typescript-wan-quan-zhi-nan/) | TypeScript最佳实践(/typescript-zui-jia-shi-jian/) | Pinia完全指南(/pinia-wan-quan-zhi-nan/) | Vue Router完全指南(/vue-router-wan-quan-zhi-nan/) 1. 概述(#%E6%A6%82%E8%BF%B0) 2. 项目搭建与工具链(#%E4%B8%80%E3%80%81%E9%
官方文档:https://vuejs.org/guide/typescript/overview.html
Composition API 类型化:https://vuejs.org/guide/typescript/composition-api.html
SFC<script setup>参考:https://vuejs.org/api/sfc-script-setup.html
适用版本:Vue 3.5.x(2026-05-23 核实)
相关文档:Vue3入门 | TypeScript完全指南 | TypeScript最佳实践 | Pinia完全指南 | Vue Router完全指南
目录
- 概述
- 项目搭建与工具链
- script setup 编译宏完整参考
- 响应式 API 与类型
- 泛型组件
- provide / inject 类型安全
- 模板引用与组件引用
- Composables 类型设计
- Pinia + TypeScript
- Vue Router + TypeScript
- 自定义指令、插件与全局类型扩展
- 模板内的类型推断
- 最佳实践
- 常见陷阱
- 参见
概述
What:本文是 Vue 3 + TypeScript 在 <script setup> 风格下的完整集成指南。覆盖所有编译宏(defineProps / defineEmits / defineModel / defineSlots / defineExpose / defineOptions / useTemplateRef)、响应式 API 类型化、泛型组件、provide/inject 类型安全、Pinia 与 Vue Router 的 TS 集成、自定义指令与插件类型、模板内类型推断、最佳实践与陷阱。
Why:Vue 3 是少数把 TS 当成一等公民设计的框架——编译宏不是函数调用而是编译时擦除的标记,类型从模板到 store 端到端可推断。但同时也意味着规则与普通 TS 项目不完全一样:defineProps<Props>() 不是泛型函数调用、<script setup generic="T"> 是 SFC 特有语法、InjectionKey 是类型安全的核心、Vue 3.5 的 useTemplateRef 又改变了模板 ref 的写法。这些细节决定了"能用"与"用得对"的差距。
When:所有用 Vue 3 + Vite + TypeScript 的新项目;从 Vue 2 + TS 迁移到 Vue 3 的项目;写公共组件库的团队(必须用泛型组件与 defineSlots)。不适合:纯 Options API 项目(继续看官方 Options 章节)、Vue 2(用 vue-class-component 或 @vue/composition-api)。
与 TypeScript完全指南 · 十一Vue 3 + TypeScript 最佳实践 的关系:那里是基础速览,本文是深入参考。
一、项目搭建与工具链
1.1 创建项目
npm create vue@latest
# 在交互菜单中:
# ✔ Add TypeScript? Yes
# ✔ Add JSX Support? 视需要
# ✔ Add Pinia for state management? Yes
# ✔ Add Vue Router for SPA? Yes
# ✔ Add Vitest for Unit Testing? Yes
# ✔ Add ESLint? Yes
生成的项目自带:vue-tsc(类型检查器)、vite-plugin-vue(SFC 编译器)、vue/tsconfig 预设。
1.2 推荐 tsconfig.json 拆分
create-vue 默认生成三个 tsconfig:
| 文件 | 用途 | 关键差异 |
|---|---|---|
tsconfig.json |
入口聚合,含 references |
不编译任何文件 |
tsconfig.app.json |
应用源码(src/**/*) |
DOM lib,包含 .vue |
tsconfig.node.json |
配置文件(vite.config.ts 等) |
Node lib,无 DOM |
关键选项:
| 选项 | 推荐值 | 说明 |
|---|---|---|
extends |
@vue/tsconfig/tsconfig.dom.json |
Vue 官方预设(已开 strict、useDefineForClassFields 等) |
compilerOptions.types |
["vite/client"] |
引入 Vite 的 import.meta.env 类型 |
compilerOptions.paths |
{ "@/*": ["./src/*"] } |
配合 vite-tsconfig-paths 使用 |
include |
["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"] |
.vue 必须在 include 中 |
compilerOptions.jsx |
"preserve" |
JSX 由 Vue 编译器处理(如使用) |
1.3 vue-tsc 类型检查
tsc 不认识 .vue 文件,必须用 vue-tsc。
// package.json
{
"scripts": {
"dev": "vite",
"build": "vue-tsc --build && vite build",
"type-check": "vue-tsc --build"
}
}
为何用 --build: 启用 Project References 增量编译,CI 第二次以后只编译变更文件,比 --noEmit 全量快 3-10 倍。
1.4 Volar Takeover Mode 与 vue-tsc 版本
- VSCode 安装
Vue (Official)(原 Volar)扩展,禁用 内置TypeScript and JavaScript Language Features在 Vue workspace 中的工作(Hybrid Mode 已是默认,无需手动 takeover) vue-tsc与@vue/language-tools大版本必须一致,否则会有奇怪的类型错误- Vue 3.5 +
vue-tsc2.1+ 才支持useTemplateRef的自动类型推断
1.5 env.d.ts 必备声明
create-vue 自动生成,关键内容:
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
// 扩展 Vite 环境变量类型
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_APP_TITLE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
二、script setup 编译宏完整参考
<script setup> 中的宏(defineXxx)不是普通函数——它们在编译期被擦除,运行时不存在。因此:
- 不可从
vue中import { defineProps }(除非显式开启) - 不可在条件分支中调用
- 不可解构后传递
2.1 defineProps
声明组件 props。
三种声明方式
| 方式 | 语法 | 何时用 |
|---|---|---|
| 运行时声明 | defineProps({ name: { type: String, required: true } }) |
简单组件、需要 prop 校验函数 |
| 类型声明 | defineProps<{ name: string }>() |
推荐,类型表达力最强 |
| 类型 + 默认值 | withDefaults(defineProps<Props>(), { ... }) |
类型声明时设默认值 |
类型声明示例
<script setup lang="ts">
interface Props {
name: string;
age?: number;
role: "admin" | "user" | "guest";
tags: string[];
user: User;
onClick?: (e: MouseEvent) => void;
}
const props = defineProps<Props>();
// 访问
console.log(props.name);
</script>
withDefaults 设默认值
<script setup lang="ts">
interface Props {
name?: string;
age?: number;
tags?: string[];
config?: { theme: string };
}
const props = withDefaults(defineProps<Props>(), {
name: "Anonymous",
age: 18,
tags: () => [], // 数组/对象用工厂函数
config: () => ({ theme: "light" }),
});
</script>
为什么数组对象要工厂函数: 防止所有实例共享同一引用(与 Vue 2 同理)。
响应式 Props 解构(Vue 3.5+ 稳定)
3.5 之前解构 props 会丢失响应式,3.5 起编译器自动处理:
<script setup lang="ts">
// 3.5+ 解构后仍是响应式
const { name = "Anon", age = 0 } = defineProps<{
name?: string;
age?: number;
}>();
// 在 watch 中需要用 getter,因为 name 是普通绑定不是 ref
watch(() => name, (v) => console.log(v));
</script>
警告: 不能在 setTimeout / setInterval 等异步回调中直接使用解构后的 prop——会读到旧值。需要传入 getter 或在使用时引用。
参数表
| 参数(类型声明位置) | 类型 | 说明 |
|---|---|---|
| 类型字面量字段 | string | number | boolean | ... |
任意 TS 类型 |
字段加 ? |
任意 | 标记可选,组件内类型为 T | undefined |
withDefaults 第二参字段 |
对应类型或工厂函数 | 字面量默认;对象/数组必须工厂函数返回 |
反例:常见错误
<script setup lang="ts">
// 错误:泛型外部引用类型在 3.2 前不支持,3.3+ 才允许
import type { User } from "./types";
defineProps<{ user: User }>(); // 3.3+ 可以
// 错误:在变量定义中调用 defineProps
const p = makeProps();
defineProps(p); // 报错:必须是字面量参数
// 错误:尝试 import defineProps
import { defineProps } from "vue"; // 不必要,宏自动可用
</script>
2.2 defineEmits
声明组件可触发的事件。
四种声明方式
<script setup lang="ts">
// 1) 运行时数组声明(弱类型)
const emit = defineEmits(["change", "delete"]);
// 2) 运行时对象声明(带校验函数)
const emit = defineEmits({
change: (id: number) => typeof id === "number",
delete: (id: number) => id > 0,
});
// 3) 类型声明:调用签名风格(旧)
const emit = defineEmits<{
(e: "change", id: number): void;
(e: "delete", id: number, force?: boolean): void;
}>();
// 4) 类型声明:命名元组风格(Vue 3.3+,推荐)
const emit = defineEmits<{
change: [id: number];
delete: [id: number, force?: boolean];
"status-change": [from: string, to: string];
}>();
</script>
命名元组的优势: 更短、更易读、IDE 提示更好。
触发与父组件接收
<!-- 子组件 -->
<script setup lang="ts">
const emit = defineEmits<{ submit: [data: FormData] }>();
function handleSubmit() {
emit("submit", new FormData());
}
</script>
<!-- 父组件 -->
<template>
<Form @submit="(data: FormData) => handle(data)" />
</template>
2.3 defineModel(Vue 3.4+)
为组件提供 v-model 双向绑定,省去手写 prop + emit 的样板。
基础用法
<!-- TextInput.vue -->
<script setup lang="ts">
const model = defineModel<string>();
function clear() {
model.value = ""; // 自动触发 emit("update:modelValue", "")
}
</script>
<template>
<input v-model="model" />
<button @click="clear">清空</button>
</template>
<!-- 父组件 -->
<TextInput v-model="text" />
命名 v-model
<script setup lang="ts">
const title = defineModel<string>("title");
const count = defineModel<number>("count", { default: 0 });
</script>
<!-- 父组件 -->
<MyComp v-model:title="t" v-model:count="n" />
修饰符(modifier transformer)
<script setup lang="ts">
const [model, modifiers] = defineModel<string>({
set(value) {
if (modifiers.trim) return value.trim();
if (modifiers.upper) return value.toUpperCase();
return value;
},
});
</script>
<!-- 父组件 -->
<MyInput v-model.trim.upper="text" />
参数表
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
name(第 1 参) |
string |
"modelValue" |
v-model 的名字,决定 emit 事件名(update:<name>) |
options.default |
同泛型 T | 无 | 父组件未提供时的默认值 |
options.required |
boolean |
false |
是否必传 |
options.type |
Constructor | Constructor[] |
无 | 运行时校验(与类型参数互斥) |
options.validator |
(v) => boolean |
无 | 校验函数 |
options.set |
(v) => v |
无 | 写入前的转换器(可访问 modifiers) |
options.get |
(v) => v |
无 | 读取时的转换器 |
触发条件
model.value = x → emit update:<name> → 父组件 v-model 绑定值更新。
2.4 defineSlots(Vue 3.3+)
为插槽提供类型签名,给父组件用 slot 时带类型提示。
<!-- DataList.vue -->
<script setup lang="ts" generic="T">
defineProps<{ items: T[] }>();
const slots = defineSlots<{
default(props: { item: T; index: number }): any;
header?(): any;
empty?(): any;
}>();
</script>
<template>
<slot name="header" />
<slot v-for="(item, index) in items" :item="item" :index="index" />
<slot v-if="!items.length" name="empty" />
</template>
<!-- 父组件 -->
<DataList :items="users">
<template #header>
<h2>用户列表</h2>
</template>
<template #default="{ item, index }">
<!-- item 类型自动推断为 User -->
{{ index }}: {{ item.name }}
</template>
<template #empty>
无数据
</template>
</DataList>
参数表:
| 类型字段 | 含义 |
|---|---|
<slotName>(props: T): any |
必选 slot |
<slotName>?(props: T): any |
可选 slot |
返回类型 any |
固定写 any(Vue 内部约定,没有更精确的类型) |
2.5 defineExpose
<script setup> 默认所有变量私有,父组件通过 ref 无法访问。defineExpose 显式暴露。
<!-- Modal.vue -->
<script setup lang="ts">
import { ref } from "vue";
const visible = ref(false);
function open() { visible.value = true; }
function close() { visible.value = false; }
defineExpose({
open,
close,
visible, // 暴露 ref 后,父组件拿到的是 .value 已解包的值
});
</script>
<!-- 父组件 -->
<script setup lang="ts">
import Modal from "./Modal.vue";
import { useTemplateRef } from "vue";
const modal = useTemplateRef<InstanceType<typeof Modal>>("modal");
function show() {
modal.value?.open(); // 类型安全
}
</script>
<template>
<Modal ref="modal" />
</template>
2.6 defineOptions(Vue 3.3+)
在 <script setup> 中声明额外的组件选项,省去额外的 <script> 块。
<script setup lang="ts">
defineOptions({
name: "MyComponent",
inheritAttrs: false,
customOptions: { foo: 1 },
});
</script>
常用选项:
| 选项 | 类型 | 说明 |
|---|---|---|
name |
string |
组件名(DevTools、KeepAlive、递归调用) |
inheritAttrs |
boolean |
是否自动继承非 prop attribute |
compatConfig |
object |
Vue 2 → 3 兼容模式配置 |
限制: 不能引用 <script setup> 中的变量,因为编译后这些选项会被提到外层。
2.7 withDefaults(与 defineProps 配合)
见 §2.1。Vue 3.5+ 后,由于响应式 props 解构稳定,withDefaults 可被解构默认值替代,但类型层面仍有差异:
<script setup lang="ts">
// withDefaults:props.name 类型为 string(默认值消除 undefined)
const props = withDefaults(defineProps<{ name?: string }>(), { name: "anon" });
// props.name: string
// 解构默认值:name 类型仍是 string | undefined(编译器层面)
const { name = "anon" } = defineProps<{ name?: string }>();
// name: string | undefined(运行时不会是 undefined,但类型保留)
</script>
按团队偏好选一种风格保持一致。
2.8 useTemplateRef(Vue 3.5+)
取代旧的"在 setup 中定义同名 ref"模式,类型推断更准确。
基础用法
<script setup lang="ts">
import { useTemplateRef, onMounted } from "vue";
// 显式类型
const inputRef = useTemplateRef<HTMLInputElement>("input");
// 自动类型推断(vue-tsc 2.1+,静态模板 ref 自动识别)
const buttonRef = useTemplateRef("button");
onMounted(() => {
inputRef.value?.focus();
});
</script>
<template>
<input ref="input" />
<button ref="button">click</button>
</template>
引用组件实例
<script setup lang="ts">
import { useTemplateRef } from "vue";
import Modal from "./Modal.vue";
const modal = useTemplateRef<InstanceType<typeof Modal>>("modal");
function open() {
modal.value?.open(); // 调用 defineExpose 暴露的方法
}
</script>
<template>
<Modal ref="modal" />
</template>
引用泛型组件
<script setup lang="ts">
import type { ComponentExposed } from "vue-component-type-helpers";
import { useTemplateRef } from "vue";
import GenericList from "./GenericList.vue";
const list = useTemplateRef<ComponentExposed<typeof GenericList>>("list");
</script>
旧用法(Vue 3.4 及以下)
<script setup lang="ts">
import { ref, onMounted } from "vue";
const inputRef = ref<HTMLInputElement | null>(null);
onMounted(() => inputRef.value?.focus());
</script>
<template>
<input ref="inputRef" />
</template>
对比: useTemplateRef 通过字符串名而非变量名引用,避免变量名/属性名耦合,对动态 ref 列表(v-for 中)也有更好支持。
参数表
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
key(第 1 参) |
string |
无 | 对应模板中 ref="key" 的字符串名 |
泛型 T |
任意 | 自动推断或 unknown |
元素/实例类型 |
返回:Readonly<ShallowRef<T \| null>>——初始为 null,组件挂载后赋值。
三、响应式 API 与类型
3.1 ref / shallowRef
import { ref, shallowRef } from "vue";
import type { Ref, ShallowRef } from "vue";
// 类型推断
const count = ref(0); // Ref<number>
const name = ref(""); // Ref<string>
// 显式类型
const user = ref<User | null>(null); // Ref<User | null>
const list = ref<User[]>([]); // Ref<User[]>
// 不带初值时为 Ref<T | undefined>
const x = ref<number>(); // Ref<number | undefined>
// shallowRef:不深度响应,仅 .value 替换时触发
const big = shallowRef<BigObject>({ /* ... */ });
big.value.x = 1; // 不触发
big.value = { ...big.value, x: 1 }; // 触发
何时用 shallowRef: 大对象(>1000 字段或多层嵌套)、第三方实例(如 ECharts、Leaflet Map)、只整体替换不深度变更的状态。
3.2 reactive / shallowReactive
import { reactive } from "vue";
interface AppState {
users: User[];
loading: boolean;
page: number;
}
// 显式类型(推荐)
const state = reactive<AppState>({
users: [],
loading: false,
page: 1,
});
// 类型推断(数组初值需要断言)
const s = reactive({
users: [] as User[],
loading: false,
});
注意: reactive 返回的是 Reactive<T>,与 T 等价但传递时会触发 Proxy 跟踪。不能用 const newState = state 后赋值给非响应式变量——会断开响应链。
3.3 computed
import { computed } from "vue";
import type { ComputedRef } from "vue";
// 类型推断
const double = computed(() => count.value * 2); // ComputedRef<number>
// 显式类型(少用)
const total = computed<number>(() => items.value.reduce((s, x) => s + x, 0));
// 可写 computed
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (v) => {
const [f, l] = v.split(" ");
firstName.value = f;
lastName.value = l;
},
});
3.4 watch / watchEffect
import { watch, watchEffect } from "vue";
import type { WatchStopHandle } from "vue";
// 监听 ref
watch(count, (newVal: number, oldVal: number) => {
console.log(`${oldVal} → ${newVal}`);
});
// 监听 getter(监听 reactive 属性必须用 getter)
watch(
() => state.page,
(newPage) => fetchUsers(newPage),
);
// 监听多个源
watch([count, name], ([c, n], [oc, on]) => {});
// 选项
watch(source, cb, {
immediate: true, // 创建时立即触发
deep: true, // 深度监听对象
flush: "post", // 在 DOM 更新后触发(默认 "pre")
once: true, // 仅触发一次(3.4+)
});
// 停止监听
const stop: WatchStopHandle = watch(...);
stop();
// watchEffect:自动追踪依赖
watchEffect(() => {
document.title = `${name.value} (${count.value})`;
});
完整参数表(watch)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
source |
Ref | Reactive | () => T | Array<...> |
必填 | 监听源 |
callback |
(newV, oldV, onCleanup) => void |
必填 | 变化回调 |
options.immediate |
boolean |
false |
创建时立即用初值触发一次 |
options.deep |
boolean |
false |
深度监听(对象/数组) |
options.flush |
"pre" | "post" | "sync" |
"pre" |
触发时机:DOM 更新前/后/同步 |
options.once |
boolean(3.4+) |
false |
仅触发一次后自动停止 |
options.onTrack / onTrigger |
(event) => void |
无 | 调试用,开发环境观察依赖追踪 |
3.5 toRef / toRefs
import { toRef, toRefs } from "vue";
const state = reactive({ x: 1, y: 2 });
// toRef:把单个字段变成 ref,保持响应式连接
const x = toRef(state, "x");
x.value = 10;
console.log(state.x); // 10
// toRefs:把整个 reactive 对象拆解为 ref
const { x: xr, y: yr } = toRefs(state);
xr.value = 100;
console.log(state.x); // 100
// 常见用途:在 composable 中返回 reactive,让调用方能解构
function useCounter() {
const state = reactive({ count: 0 });
return toRefs(state);
}
const { count } = useCounter(); // count 仍是 ref
四、泛型组件
写复杂表格、列表、表单时,子组件需要根据传入数据类型推断回调参数类型。Vue 3.3+ 支持 <script setup generic="T">。
4.1 generic 语法
<!-- TypedList.vue -->
<script setup lang="ts" generic="T">
defineProps<{
items: T[];
getKey: (item: T) => string | number;
}>();
const emit = defineEmits<{
select: [item: T];
}>();
defineSlots<{
default(props: { item: T; index: number }): any;
}>();
</script>
<template>
<div v-for="(item, i) in items" :key="getKey(item)" @click="emit('select', item)">
<slot :item="item" :index="i" />
</div>
</template>
父组件使用
<script setup lang="ts">
import TypedList from "./TypedList.vue";
interface User { id: number; name: string; }
const users: User[] = [{ id: 1, name: "alice" }];
function onSelect(u: User) {} // u 类型自动为 User
</script>
<template>
<!-- 子组件的 T 自动推断为 User -->
<TypedList
:items="users"
:get-key="u => u.id"
@select="onSelect"
>
<template #default="{ item, index }">
{{ index }}: {{ item.name }}
</template>
</TypedList>
</template>
4.2 多泛型参数与约束
<script setup lang="ts" generic="T extends { id: string | number }, K extends keyof T">
defineProps<{
items: T[];
groupBy: K;
}>();
</script>
支持的语法:
- 多个泛型用逗号分隔:
generic="T, U" - 约束用
extends:generic="T extends Item" - 引用外部类型需在
<script setup>内import type:
<script setup lang="ts" generic="T extends Item">
import type { Item } from "./types";
</script>
4.3 限制
- 不能在
generic中用别名(必须就地写extends ...) - 不能用
default = SomeType(无泛型默认值) - 仅
<script setup>支持;普通<script>写不出
五、provide / inject 类型安全
普通 provide("key", value) + inject("key") 的返回类型是 unknown,必须用 InjectionKey 串联。
5.1 InjectionKey<T> 模式
// keys.ts
import type { InjectionKey, Ref } from "vue";
export interface UserContext {
user: Ref<User | null>;
login: (u: User) => void;
logout: () => void;
}
export const userContextKey: InjectionKey<UserContext> = Symbol("user-context");
Provider
<!-- App.vue -->
<script setup lang="ts">
import { ref, provide } from "vue";
import { userContextKey } from "./keys";
const user = ref<User | null>(null);
provide(userContextKey, {
user,
login: (u) => (user.value = u),
logout: () => (user.value = null),
});
</script>
Consumer
<script setup lang="ts">
import { inject } from "vue";
import { userContextKey } from "./keys";
// 默认行为:返回 UserContext | undefined
const ctx = inject(userContextKey);
ctx?.login(someUser);
// 提供默认值:返回 UserContext
const ctx2 = inject(userContextKey, {
user: ref(null),
login: () => {},
logout: () => {},
});
// 强制非空(找不到时抛错)
function useUserContext(): UserContext {
const ctx = inject(userContextKey);
if (!ctx) throw new Error("userContext 未 provide");
return ctx;
}
5.2 inject 完整参数表
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
key |
InjectionKey<T> | string |
必填 | 注入键 |
defaultValue |
T 或工厂 |
undefined |
未找到时的默认值 |
treatDefaultAsFactory |
boolean |
false |
第 2 参为函数时是否当工厂调用 |
// 工厂模式(避免共享引用)
const ctx = inject(key, () => createCtx(), true);
5.3 工厂式 composable 包装
把 provide / inject 封装成 hooks,调用方不直接接触 key:
// useUserContext.ts
import { ref, provide, inject } from "vue";
import type { InjectionKey, Ref } from "vue";
const key: InjectionKey<{
user: Ref<User | null>;
setUser: (u: User | null) => void;
}> = Symbol();
export function provideUserContext() {
const user = ref<User | null>(null);
const ctx = { user, setUser: (u: User | null) => (user.value = u) };
provide(key, ctx);
return ctx;
}
export function useUserContext() {
const ctx = inject(key);
if (!ctx) throw new Error("provideUserContext 未在祖先组件调用");
return ctx;
}
六、模板引用与组件引用
6.1 引用 DOM 元素
<script setup lang="ts">
import { useTemplateRef, onMounted } from "vue";
const input = useTemplateRef<HTMLInputElement>("input");
const canvas = useTemplateRef<HTMLCanvasElement>("canvas");
onMounted(() => {
input.value?.focus();
const ctx = canvas.value?.getContext("2d");
});
</script>
<template>
<input ref="input" />
<canvas ref="canvas" />
</template>
6.2 引用普通子组件
<script setup lang="ts">
import { useTemplateRef } from "vue";
import Modal from "./Modal.vue";
const modal = useTemplateRef<InstanceType<typeof Modal>>("modal");
modal.value?.open();
</script>
InstanceType<typeof Modal> 解析为该组件的实例类型,包含 defineExpose 暴露的所有字段。
6.3 引用泛型组件
InstanceType 在泛型组件上需要具体化泛型参数:
<script setup lang="ts">
import { useTemplateRef } from "vue";
import type { ComponentExposed } from "vue-component-type-helpers";
import GenericList from "./GenericList.vue";
// 用 ComponentExposed 拿到 defineExpose 内容
const list = useTemplateRef<ComponentExposed<typeof GenericList>>("list");
</script>
需要安装 vue-component-type-helpers(pnpm add -D vue-component-type-helpers)。
6.4 v-for 中的 ref 列表
<script setup lang="ts">
import { useTemplateRef } from "vue";
const items = useTemplateRef<HTMLDivElement[]>("items");
</script>
<template>
<div v-for="i in 10" :key="i" ref="items">{{ i }}</div>
</template>
useTemplateRef 在 v-for 中自动收集为数组。
七、Composables 类型设计
Composable(组合式函数)是 Vue 3 复用逻辑的核心单元,等价于 React Hooks。命名以 use 开头。
7.1 标准返回签名
// composables/useUser.ts
import { ref, computed, readonly } from "vue";
import type { Ref, ComputedRef, DeepReadonly } from "vue";
interface UseUserReturn {
user: DeepReadonly<Ref<User | null>>;
loading: Readonly<Ref<boolean>>;
error: Readonly<Ref<string | null>>;
isLoggedIn: ComputedRef<boolean>;
fetchUser: (id: number) => Promise<void>;
logout: () => void;
}
export function useUser(): UseUserReturn {
const user = ref<User | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const isLoggedIn = computed(() => user.value !== null);
async function fetchUser(id: number): Promise<void> {
loading.value = true;
error.value = null;
try {
const r = await fetch(`/api/users/${id}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
user.value = await r.json();
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown";
} finally {
loading.value = false;
}
}
function logout(): void {
user.value = null;
}
return {
user: readonly(user),
loading: readonly(loading),
error: readonly(error),
isLoggedIn,
fetchUser,
logout,
};
}
设计要点:
- 显式声明
UseUserReturn,防止内部重构改变签名 - 内部状态用
readonly()包裹后返回,避免调用方直接 mutate - 修改状态通过返回的函数,单向数据流
7.2 接受 options 与 AbortSignal
interface UseFetchOptions {
immediate?: boolean;
signal?: AbortSignal;
}
export function useFetch<T>(url: MaybeRefOrGetter<string>, opts: UseFetchOptions = {}) {
const data = ref<T | null>(null);
const error = ref<Error | null>(null);
const loading = ref(false);
async function execute() {
loading.value = true;
try {
const r = await fetch(toValue(url), { signal: opts.signal });
data.value = await r.json();
} catch (e) {
if ((e as Error).name === "AbortError") return;
error.value = e as Error;
} finally {
loading.value = false;
}
}
if (opts.immediate) execute();
return { data, error, loading, execute };
}
参考 TypeScript最佳实践 · 3.5 异步函数接受 AbortSignal 实现可取消。
7.3 MaybeRefOrGetter 兼容三种入参
import { toValue } from "vue";
import type { MaybeRefOrGetter } from "vue";
// 同时支持 ref / getter / 原始值
function useDouble(source: MaybeRefOrGetter<number>) {
return computed(() => toValue(source) * 2);
}
useDouble(10); // 原始值
useDouble(ref(10)); // ref
useDouble(() => state.x); // getter
VueUse 库的大量 composable 都遵循此约定。
八、Pinia + TypeScript
Pinia 是 Vue 3 推荐的状态管理库,类型推断设计为零配置。
8.1 Setup Store 风格(推荐)
// stores/user.ts
import { defineStore } from "pinia";
import { ref, computed } from "vue";
interface User {
id: number;
name: string;
role: "admin" | "user";
}
export const useUserStore = defineStore("user", () => {
// state
const user = ref<User | null>(null);
const loading = ref(false);
// getters
const isAdmin = computed(() => user.value?.role === "admin");
// actions
async function login(email: string, pass: string): Promise<void> {
loading.value = true;
try {
const r = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ email, pass }),
});
user.value = await r.json();
} finally {
loading.value = false;
}
}
function logout(): void {
user.value = null;
}
return { user, loading, isAdmin, login, logout };
});
在组件中
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useUserStore } from "@/stores/user";
const userStore = useUserStore();
// 必须用 storeToRefs 解构,否则丢失响应式
const { user, isAdmin } = storeToRefs(userStore);
// 方法直接解构(不是响应式数据)
const { login, logout } = userStore;
</script>
<template>
<div v-if="user">{{ user.name }} {{ isAdmin ? "(admin)" : "" }}</div>
</template>
8.2 Options Store 风格
export const useCounterStore = defineStore("counter", {
state: (): { count: number; history: number[] } => ({
count: 0,
history: [],
}),
getters: {
double: (state): number => state.count * 2,
// 引用其他 getter 需要标注 this
quadruple(): number { return this.double * 2; },
},
actions: {
increment(n: number = 1): void {
this.count += n;
this.history.push(this.count);
},
},
});
Setup 风格 vs Options 风格: Setup 类型推断更精准(特别是返回类型)、与 composable 形式一致;Options 风格更接近 Vuex 习惯。新项目用 Setup 风格。
8.3 跨 store 类型引用
import { useUserStore } from "./user";
export const useOrderStore = defineStore("order", () => {
const userStore = useUserStore();
const orders = ref<Order[]>([]);
async function loadMyOrders() {
if (!userStore.user) return;
orders.value = await fetch(`/api/orders?user=${userStore.user.id}`).then(r => r.json());
}
return { orders, loadMyOrders };
});
类型自动推断,无需手动声明。
8.4 Pinia 插件全局类型扩展
// stores/persistent.d.ts
import "pinia";
declare module "pinia" {
export interface DefineStoreOptionsBase<S, Store> {
persist?: boolean | { paths?: string[] };
}
}
之后所有 defineStore 都能识别 persist 选项。
参见 Pinia完全指南。
九、Vue Router + TypeScript
9.1 基础路由类型
// router/index.ts
import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
const routes: RouteRecordRaw[] = [
{ path: "/", name: "home", component: () => import("@/pages/Home.vue") },
{
path: "/user/:id",
name: "user",
component: () => import("@/pages/User.vue"),
props: true,
meta: { requiresAuth: true, title: "用户" },
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});
9.2 扩展 RouteMeta 类型
// router/types.d.ts
import "vue-router";
declare module "vue-router" {
interface RouteMeta {
requiresAuth?: boolean;
title?: string;
roles?: Array<"admin" | "user" | "guest">;
keepAlive?: boolean;
}
}
全局有效——所有 to.meta.requiresAuth 都有类型提示。
9.3 路由守卫类型
import type { NavigationGuardWithThis } from "vue-router";
const beforeAuth: NavigationGuardWithThis<undefined> = (to, from) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
return { name: "login", query: { redirect: to.fullPath } };
}
// 不返回值或返回 true → 放行
};
router.beforeEach(beforeAuth);
9.4 路由参数类型
route.params / route.query 默认值为 string | string[]。需要类型时显式断言或封装:
import { useRoute } from "vue-router";
const route = useRoute();
const id = String(route.params.id); // 总是 string
const tags = ([] as string[]).concat(route.query.tags ?? []);
端到端类型安全: unplugin-vue-router 或 vue-router 的 typed routes(v4.5+ 实验性)能生成全类型化的 router.push({ name: "user", params: { id: "1" } })。
参见 Vue Router完全指南。
十、自定义指令、插件与全局类型扩展
10.1 自定义指令的类型
// directives/v-focus.ts
import type { Directive, DirectiveBinding } from "vue";
interface FocusValue {
delay?: number;
select?: boolean;
}
export const vFocus: Directive<HTMLInputElement, FocusValue> = {
mounted(el, binding) {
// binding.value 类型为 FocusValue
const { delay = 0, select = false } = binding.value ?? {};
setTimeout(() => {
el.focus();
if (select) el.select();
}, delay);
},
};
使用
<script setup lang="ts">
import { vFocus } from "@/directives/v-focus";
</script>
<template>
<input v-focus="{ delay: 100, select: true }" />
</template>
Directive 与 ObjectDirective 完整钩子
| 钩子 | 触发时机 |
|---|---|
created(el, binding, vnode, prevVnode) |
元素 vnode 创建后(DOM 之前) |
beforeMount(el, ...) |
挂载前 |
mounted(el, ...) |
挂载后 |
beforeUpdate(el, ...) |
包含组件更新前 |
updated(el, ...) |
更新后 |
beforeUnmount(el, ...) |
卸载前 |
unmounted(el, ...) |
卸载后 |
10.2 插件类型
// plugins/i18n.ts
import type { App, Plugin } from "vue";
interface I18nOptions {
locale: string;
messages: Record<string, Record<string, string>>;
}
const i18n: Plugin<[I18nOptions]> = {
install(app: App, options: I18nOptions) {
const t = (key: string) => options.messages[options.locale]?.[key] ?? key;
app.config.globalProperties.$t = t;
app.provide("i18n", { t, locale: options.locale });
},
};
export default i18n;
// main.ts
import i18n from "./plugins/i18n";
app.use(i18n, { locale: "zh", messages: { zh: { hello: "你好" } } });
10.3 扩展 ComponentCustomProperties(this.$xxx)
// plugins/i18n.d.ts
import "vue";
declare module "vue" {
interface ComponentCustomProperties {
$t: (key: string) => string;
}
}
模板与 Options API 中 $t("hello") 类型安全。
10.4 扩展全局组件类型
// global-components.d.ts
import "vue";
import MyButton from "@/components/MyButton.vue";
declare module "vue" {
interface GlobalComponents {
MyButton: typeof MyButton;
}
}
全局注册的组件在模板中有类型提示。
十一、模板内的类型推断
Vue 模板由 vue-tsc 编译时检查类型,规则与 JS 略有差异。
11.1 模板中的表达式
<script setup lang="ts">
const user = ref<{ name: string; age?: number }>({ name: "a" });
</script>
<template>
<!-- 可选属性安全访问 -->
<div>{{ user.age?.toFixed(0) ?? "未知" }}</div>
<!-- 模板中的非空断言用 ! 同样有效 -->
<div>{{ user.name!.toUpperCase() }}</div>
<!-- as 断言不支持,需在 script 中转换 -->
<!-- 错误:{{ user.age as number }} -->
</template>
11.2 v-for 类型推断
<script setup lang="ts">
const users = ref<User[]>([]);
const map = ref<Map<string, User>>(new Map());
</script>
<template>
<!-- 数组:user 自动推断为 User -->
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
<!-- Map:value 是 User,key 是 string -->
<li v-for="[id, user] in map" :key="id">{{ user.name }}</li>
<!-- 数字范围:n 是 number -->
<li v-for="n in 10" :key="n">{{ n }}</li>
</template>
11.3 事件处理器参数类型
<template>
<!-- 内联函数:参数类型必须显式声明 -->
<button @click="(e: MouseEvent) => handle(e)">click</button>
<!-- 引用函数:参数从函数签名推断 -->
<button @click="handle">click</button>
<!-- 仅 $event 写法(不推荐,类型为 any) -->
<button @click="handle($event)">click</button>
</template>
11.4 slot props 推断
<!-- 子组件用 defineSlots 声明 -->
<script setup lang="ts">
defineSlots<{
default(props: { item: User; index: number }): any;
}>();
</script>
<!-- 父组件 -->
<template>
<Child>
<template #default="{ item, index }">
<!-- item 自动推断为 User -->
{{ item.name }}
</template>
</Child>
</template>
十二、最佳实践
1. <script setup> + 类型声明 props/emits 作为默认风格。
运行时声明仅在需要校验函数或动态 props 时使用。
<!-- 错误:现代项目仍用 PropType -->
<script setup lang="ts">
import { type PropType } from "vue";
const props = defineProps({
user: { type: Object as PropType<User>, required: true },
});
</script>
<!-- 正确 -->
<script setup lang="ts">
defineProps<{ user: User }>();
</script>
2. defineModel 替代手写 prop + emit。
3.4+ 的 defineModel 让 v-model 组件实现从 20 行降到 1 行,且类型完全推断。
3. Composable 显式声明返回类型。
防止内部重构悄悄破坏调用方的解构与类型提示。
// 正确
export function useUser(): UseUserReturn { /* ... */ }
// 错误:类型靠推断,重构时调用方先报错才发现
export function useUser() { /* ... */ }
4. 跨组件共享数据用 InjectionKey + useXxx 包装。
不要在组件中直接 inject("string-key")——类型为 unknown 且没有 IDE 提示。
5. 模板 ref 优先用 useTemplateRef(3.5+)。
旧的同名 ref 模式仍可用,但 useTemplateRef 在 v-for、动态 key、TypeScript 推断上更稳定。
6. Pinia 用 Setup Store 风格。
类型推断最准;与 composable 形式一致;不需要为 this 写复杂的类型注解。
7. RouteMeta / ComponentCustomProperties 在 d.ts 中扩展。
避免散落的 (route.meta as any).requiresAuth。
8. 公共组件库必须用泛型组件 + defineSlots。
让消费者能拿到完整类型推断。否则用户每次都要手动 cast。
9. shallow 系列 API 用于大对象或第三方实例。
ECharts、Three.js、地图实例等——shallowRef 避免 Proxy 包裹导致的性能损耗。
10. vue-tsc --build 在 CI 中跑严格类型检查。
Vite 开发模式不做完整类型检查(只走 esbuild 转译),必须靠 vue-tsc 兜底。
十三、常见陷阱
陷阱 1:解构 props 后丢失响应式(Vue 3.5 之前)
现象: 在 <script setup> 中解构 defineProps 返回值,组件不更新。
原因: Vue 3.5 之前,const { name } = defineProps<Props>() 解构后 name 是普通变量,与父组件传值断开。
解决:
- 升级到 Vue 3.5+,编译器自动处理响应式解构
- 或不解构,直接用
props.name - 或在使用处用
toRefs/toRef显式转换
<script setup lang="ts">
const props = defineProps<{ name: string }>();
// 3.5 之前的正确做法
const { name } = toRefs(props); // name: Ref<string>
// 3.5+ 直接解构即可
const { name } = defineProps<{ name: string }>();
</script>
陷阱 2:忘记 storeToRefs 解构 Pinia store
现象: const { count } = useCounterStore() 后 count 不响应。
原因: Pinia store 返回的对象的 state 与 getters 是响应式的,但普通解构会破坏这种响应式。storeToRefs 把它们转成 Ref 后再解构。
解决: state 与 getter 用 storeToRefs,actions 直接解构。
// 错误
const { count, double, increment } = useCounterStore();
// count、double 不响应
// 正确
const store = useCounterStore();
const { count, double } = storeToRefs(store);
const { increment } = store;
陷阱 3:reactive 整体替换断开响应
现象: 用 state = newObj 替换整个 reactive 对象后,组件不更新。
原因: reactive 的响应式追踪绑定在原始 Proxy 上,整个变量重新赋值丢失追踪。
解决: 用 Object.assign 合并字段,或改用 ref + .value 替换。
// 错误
let state = reactive({ x: 1, y: 2 });
state = reactive({ x: 10, y: 20 }); // 模板不更新
// 正确(reactive)
Object.assign(state, { x: 10, y: 20 });
// 正确(ref)
const state = ref({ x: 1, y: 2 });
state.value = { x: 10, y: 20 };
陷阱 4:InstanceType 用于泛型组件失效
现象: useTemplateRef<InstanceType<typeof GenericList>>("list") 类型推断不出 defineExpose 的字段。
原因: InstanceType 提取类的实例类型,但泛型组件的实例签名不带具体泛型,导致 expose 内容是 unknown。
解决: 用 ComponentExposed(vue-component-type-helpers 包)。
<script setup lang="ts">
import type { ComponentExposed } from "vue-component-type-helpers";
import GenericList from "./GenericList.vue";
const list = useTemplateRef<ComponentExposed<typeof GenericList>>("list");
list.value?.scrollToBottom(); // OK
</script>
陷阱 5:宏在变量中传递
现象: const opts = { foo: String }; defineProps(opts); 编译报错。
原因: defineProps / defineEmits 是编译期宏,参数必须是字面量;编译器静态分析需要看到完整声明。
解决: 直接传字面量;如果要复用,把整个组件抽成函数返回。
<!-- 错误 -->
<script setup lang="ts">
const commonProps = { name: String };
defineProps(commonProps);
</script>
<!-- 正确 -->
<script setup lang="ts">
defineProps({ name: String });
</script>
陷阱 6:provide / inject 在 setup 之外调用
现象: 在异步回调或事件处理器中调用 inject,返回 undefined。
原因: inject 必须在 setup 同步阶段调用——它依赖当前激活的组件实例上下文。
解决: 在 setup 顶层 inject 一次,后续使用结果。
// 错误
async function load() {
const ctx = inject(key); // undefined
}
// 正确
const ctx = inject(key);
async function load() {
ctx?.doSomething();
}
陷阱 7:watch 监听 reactive 属性丢失响应
现象: watch(state.x, cb) 不触发。
原因: 监听源必须是 ref、reactive 对象、getter 函数或它们的数组。直接传 state.x(值)等价于传一个普通数字。
解决: 用 getter 包裹。
// 错误
watch(state.x, cb);
// 正确
watch(() => state.x, cb);
// reactive 整体监听(自动 deep)
watch(state, cb, { deep: true });
陷阱 8:导入 .vue 文件无类型
现象: import Comp from "./Comp.vue" 报错或类型为 any。
原因: TS 默认不识别 .vue 扩展。
解决: 确保 env.d.ts(或同等文件)声明了 .vue 模块。
declare module "*.vue" {
import type { DefineComponent } from "vue";
const c: DefineComponent<{}, {}, any>;
export default c;
}
并确认 tsconfig.json 的 include 包含该 d.ts 文件。
陷阱 9:组件 emit 事件名大小写不一致
现象: 子组件 emit 的事件父组件接收不到。
原因: 模板中事件名用 kebab-case(@user-update),JS 中用 camelCase(emit("userUpdate")),Vue 会自动转换——但 defineEmits 类型字段要与 emit 调用一致。
解决: defineEmits 类型字段用 camelCase,模板用 kebab-case。
<!-- 子组件 -->
<script setup lang="ts">
const emit = defineEmits<{ userUpdate: [u: User] }>();
emit("userUpdate", user);
</script>
<!-- 父组件 -->
<template>
<Child @user-update="handle" />
</template>
陷阱 10:defineModel 修饰符解构后类型为 unknown
现象: const [model, mods] = defineModel<string>(); mods.trim 报错。
原因: mods 默认类型是 Record<string, true | undefined>,不限制具体修饰符名。
解决: 用第二个泛型参数声明可识别的修饰符。
<script setup lang="ts">
const [model, mods] = defineModel<string, "trim" | "upper">();
if (mods.trim) { /* OK */ }
if (mods.unknown) { /* 报错 */ }
</script>
参见
- Vue3入门 — Vue 3 基础(JS 风格,本文的前置)
- TypeScript完全指南 — TS 语法手册,含 Vue 集成速览
- TypeScript最佳实践 — TS 通用最佳实践(严格配置、类型设计、AbortSignal、Zod、类型测试)
- Pinia完全指南 — Pinia 状态管理完整 API
- Vue Router完全指南 — 路由配置、守卫、动态路由
- Nuxt3完全指南 — Vue 全栈框架(自带 TS 配置与类型化)
- PrimeVue完全指南 — Vue 组件库(与 TS 集成示例)
- Vite初级指南 — Vue + TS 项目的默认构建工具
- JavaScript Promise 完全指南 — async/await 与错误处理基础