> ## Content Index
> Fetch the complete content index at: https://blog.vercanti.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Vue 3 + TypeScript 完全指南
- URL: https://blog.vercanti.com/vue-3-typescript-wan-quan-zhi-nan/
- Published: 2026-08-28T14:35:27.000Z
- Updated: 2026-08-28T14:58:50.000Z
- Description: 相关文档：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%
- Author: yellowdog
- Tags: 前端开发, Vue生态

> 官方文档：<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入门](https://blog.vercanti.com/vue-3-ru-men-zhi-nan/) | [TypeScript完全指南](https://blog.vercanti.com/typescript-wan-quan-zhi-nan/) | [TypeScript最佳实践](https://blog.vercanti.com/typescript-zui-jia-shi-jian/) | [Pinia完全指南](https://blog.vercanti.com/pinia-wan-quan-zhi-nan/) | [Vue Router完全指南](https://blog.vercanti.com/vue-router-wan-quan-zhi-nan/)

---

## 目录

1. [概述](#%E6%A6%82%E8%BF%B0)
2. [项目搭建与工具链](#%E4%B8%80%E3%80%81%E9%A1%B9%E7%9B%AE%E6%90%AD%E5%BB%BA%E4%B8%8E%E5%B7%A5%E5%85%B7%E9%93%BE)
3. [script setup 编译宏完整参考](#%E4%BA%8C%E3%80%81script-setup-%E7%BC%96%E8%AF%91%E5%AE%8F%E5%AE%8C%E6%95%B4%E5%8F%82%E8%80%83)
4. [响应式 API 与类型](#%E4%B8%89%E3%80%81%E5%93%8D%E5%BA%94%E5%BC%8F-api-%E4%B8%8E%E7%B1%BB%E5%9E%8B)
5. [泛型组件](#%E5%9B%9B%E3%80%81%E6%B3%9B%E5%9E%8B%E7%BB%84%E4%BB%B6)
6. [provide / inject 类型安全](#%E4%BA%94%E3%80%81provide-inject-%E7%B1%BB%E5%9E%8B%E5%AE%89%E5%85%A8)
7. [模板引用与组件引用](#%E5%85%AD%E3%80%81%E6%A8%A1%E6%9D%BF%E5%BC%95%E7%94%A8%E4%B8%8E%E7%BB%84%E4%BB%B6%E5%BC%95%E7%94%A8)
8. [Composables 类型设计](#%E4%B8%83%E3%80%81composables-%E7%B1%BB%E5%9E%8B%E8%AE%BE%E8%AE%A1)
9. [Pinia + TypeScript](#%E5%85%AB%E3%80%81pinia-typescript)
10. [Vue Router + TypeScript](#%E4%B9%9D%E3%80%81vue-router-typescript)
11. [自定义指令、插件与全局类型扩展](#%E5%8D%81%E3%80%81%E8%87%AA%E5%AE%9A%E4%B9%89%E6%8C%87%E4%BB%A4%E3%80%81%E6%8F%92%E4%BB%B6%E4%B8%8E%E5%85%A8%E5%B1%80%E7%B1%BB%E5%9E%8B%E6%89%A9%E5%B1%95)
12. [模板内的类型推断](#%E5%8D%81%E4%B8%80%E3%80%81%E6%A8%A1%E6%9D%BF%E5%86%85%E7%9A%84%E7%B1%BB%E5%9E%8B%E6%8E%A8%E6%96%AD)
13. [最佳实践](#%E5%8D%81%E4%BA%8C%E3%80%81%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5)
14. [常见陷阱](#%E5%8D%81%E4%B8%89%E3%80%81%E5%B8%B8%E8%A7%81%E9%99%B7%E9%98%B1)
15. [参见](#%E5%8F%82%E8%A7%81)

---

## 概述

**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 最佳实践](https://blog.vercanti.com/typescript-wan-quan-zhi-nan/#%E5%8D%81%E4%B8%80vue-3-%2B-typescript-%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5) 的关系：那里是基础速览，本文是深入参考。

---

## 一、项目搭建与工具链

### 1.1 创建项目

```bash
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`。

```json
// 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-tsc` 2.1+ 才支持 `useTemplateRef` 的自动类型推断

### 1.5 `env.d.ts` 必备声明

`create-vue` 自动生成，关键内容：

```ts
/// <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>(), { ... })             | 类型声明时设默认值         |

#### 类型声明示例

```vue
<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` 设默认值

```vue
<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 起编译器自动处理：

```vue
<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 第二参字段 | 对应类型或工厂函数        | 字面量默认；对象/数组必须工厂函数返回        |     |          |

#### 反例：常见错误

```vue
<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`

声明组件可触发的事件。

#### 四种声明方式

```vue
<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 提示更好。

#### 触发与父组件接收

```vue
<!-- 子组件 -->
<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 的样板。

#### 基础用法

```vue
<!-- 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

```vue
<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）

```vue
<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 时带类型提示。

```vue
<!-- 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` 显式暴露。

```vue
<!-- 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>` 块。

```vue
<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` 可被解构默认值替代，但**类型层面**仍有差异：

```vue
<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"模式，类型推断更准确。

#### 基础用法

```vue
<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>

```

#### 引用组件实例

```vue
<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>

```

#### 引用泛型组件

```vue
<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 及以下）

```vue
<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`

```ts
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`

```ts
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`

```ts
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`

```ts
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`

```ts
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` 语法

```vue
<!-- 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>

```

#### 父组件使用

```vue
<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 多泛型参数与约束

```vue
<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`：

```vue
<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>` 模式

```ts
// 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

```vue
<!-- 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

```vue
<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 参为函数时是否当工厂调用 |

```ts
// 工厂模式（避免共享引用）
const ctx = inject(key, () => createCtx(), true);

```

### 5.3 工厂式 composable 包装

把 `provide` / `inject` 封装成 hooks，调用方不直接接触 key：

```ts
// 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 元素

```vue
<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 引用普通子组件

```vue
<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` 在泛型组件上需要具体化泛型参数：

```vue
<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 列表

```vue
<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 标准返回签名

```ts
// 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

```ts
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 实现可取消](https://blog.vercanti.com/typescript-zui-jia-shi-jian/#3.5-%E5%BC%82%E6%AD%A5%E5%87%BD%E6%95%B0%E6%8E%A5%E5%8F%97-abortsignal-%E5%AE%9E%E7%8E%B0%E5%8F%AF%E5%8F%96%E6%B6%88)。

### 7.3 `MaybeRefOrGetter` 兼容三种入参

```ts
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 风格（推荐）

```ts
// 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 };
});

```

#### 在组件中

```vue
<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 风格

```ts
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 类型引用

```ts
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 插件全局类型扩展

```ts
// stores/persistent.d.ts
import "pinia";

declare module "pinia" {
  export interface DefineStoreOptionsBase<S, Store> {
    persist?: boolean | { paths?: string[] };
  }
}

```

之后所有 `defineStore` 都能识别 `persist` 选项。

参见 [Pinia完全指南](https://blog.vercanti.com/pinia-wan-quan-zhi-nan/)。

---

## 九、Vue Router + TypeScript

### 9.1 基础路由类型

```ts
// 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` 类型

```ts
// 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 路由守卫类型

```ts
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[]`。需要类型时显式断言或封装：

```ts
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完全指南](https://blog.vercanti.com/vue-router-wan-quan-zhi-nan/)。

---

## 十、自定义指令、插件与全局类型扩展

### 10.1 自定义指令的类型

```ts
// 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);
  },
};

```

#### 使用

```vue
<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 插件类型

```ts
// 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）

```ts
// plugins/i18n.d.ts
import "vue";

declare module "vue" {
  interface ComponentCustomProperties {
    $t: (key: string) => string;
  }
}

```

模板与 Options API 中 `$t("hello")` 类型安全。

### 10.4 扩展全局组件类型

```ts
// 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 模板中的表达式

```vue
<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` 类型推断

```vue
<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 事件处理器参数类型

```vue
<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 推断

```vue
<!-- 子组件用 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 时使用。

```vue
<!-- 错误：现代项目仍用 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 显式声明返回类型。**

防止内部重构悄悄破坏调用方的解构与类型提示。

```ts
// 正确
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` 显式转换

```vue
<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 直接解构。

```ts
// 错误
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` 替换。

```ts
// 错误
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` 包）。

```vue
<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` 是编译期宏，参数必须是字面量；编译器静态分析需要看到完整声明。

**解决：** 直接传字面量；如果要复用，把整个组件抽成函数返回。

```vue
<!-- 错误 -->
<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 一次，后续使用结果。

```ts
// 错误
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 包裹。

```ts
// 错误
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` 模块。

```ts
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。

```vue
<!-- 子组件 -->
<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>`，不限制具体修饰符名。

**解决：** 用第二个泛型参数声明可识别的修饰符。

```vue
<script setup lang="ts">
const [model, mods] = defineModel<string, "trim" | "upper">();

if (mods.trim) { /* OK */ }
if (mods.unknown) { /* 报错 */ }
</script>

```

---

## 参见

- [Vue3入门](https://blog.vercanti.com/vue-3-ru-men-zhi-nan/) — Vue 3 基础（JS 风格，本文的前置）
- [TypeScript完全指南](https://blog.vercanti.com/typescript-wan-quan-zhi-nan/) — TS 语法手册，含 Vue 集成速览
- [TypeScript最佳实践](https://blog.vercanti.com/typescript-zui-jia-shi-jian/) — TS 通用最佳实践（严格配置、类型设计、AbortSignal、Zod、类型测试）
- [Pinia完全指南](https://blog.vercanti.com/pinia-wan-quan-zhi-nan/) — Pinia 状态管理完整 API
- [Vue Router完全指南](https://blog.vercanti.com/vue-router-wan-quan-zhi-nan/) — 路由配置、守卫、动态路由
- [Nuxt3完全指南](https://blog.vercanti.com/nuxt-3-wan-quan-zhi-nan/) — Vue 全栈框架（自带 TS 配置与类型化）
- [PrimeVue完全指南](https://blog.vercanti.com/primevue-wan-quan-zhi-nan/) — Vue 组件库（与 TS 集成示例）
- [Vite初级指南](https://blog.vercanti.com/vite-chu-ji-zhi-nan/) — Vue + TS 项目的默认构建工具
- [JavaScript Promise 完全指南](https://blog.vercanti.com/javascript-promise-wan-quan-zhi-nan/) — async/await 与错误处理基础