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完全指南


目录

  1. 概述
  2. 项目搭建与工具链
  3. script setup 编译宏完整参考
  4. 响应式 API 与类型
  5. 泛型组件
  6. provide / inject 类型安全
  7. 模板引用与组件引用
  8. Composables 类型设计
  9. Pinia + TypeScript
  10. Vue Router + TypeScript
  11. 自定义指令、插件与全局类型扩展
  12. 模板内的类型推断
  13. 最佳实践
  14. 常见陷阱
  15. 参见

概述

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 官方预设(已开 strictuseDefineForClassFields 等)
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-tsc 2.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不是普通函数——它们在编译期被擦除,运行时不存在。因此:

  • 不可从 vueimport { 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"
  • 约束用 extendsgeneric="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-helperspnpm 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>

DirectiveObjectDirective 完整钩子

钩子 触发时机
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

解决:ComponentExposedvue-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.jsoninclude 包含该 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>

参见

阅读更多

Web 安全基础

1. HTML 转义(服务端渲染必须): 2. CSP(Content Security Policy): 3. HttpOnly Cookie:防止 JS 读取会话 Cookie: 4. 前端框架防护: 攻击者在第三方网站构造一个表单,诱导已登录用户提交,浏览器会自动携带目标站的 Cookie。 触发条件: 1. 用户已登录目标网站(Cookie 有效) 2. 目标 API 仅凭 Cookie 识别用户身份 3. 请求来源未验证 1. CSRF Token(推荐): 2. SameSite Cookie: 3. 验证 Origin/Referer 头:

By yellowdog

HTTP 协议深度指南

HTTP(HyperText Transfer Protocol)是 Web 的基础传输协议,基于 TCP/IP,采用请求/响应模型。 相关文档:Web安全基础(/web-an-quan-ji-chu/) FastAPI完全指南(/fastapi-wan-quan-zhi-nan/) Nginx完全指南(/nginx-wan-quan-zhi-nan/) 幂等性:多次执行相同请求,服务器状态结果相同。PUT /users/1 多次执行结果一致;POST /users 每次创建新资源,非幂等。 浏览器直接从本地缓存读取,不向服务器发送请求。 缓存命中时,状

By yellowdog

系统设计基础

SLA 对照表: 选择建议:无状态服务(Web 层、API 层)优先水平扩展;数据库初期垂直扩展,达到瓶颈后考虑分库分表或读写分离。 缓存穿透(查询不存在的 key,每次都打到 DB): 缓存击穿(热点 key 过期,瞬间大量请求打到 DB): 缓存雪崩(大量 key 同时过期,或缓存服务宕机): 令牌桶 Python 实现: Redis 实现分布式限流(滑动窗口): URL 命名规则: Cursor 分页响应格式: 雪花算法结构(64 bit): 定义:分布式系统不能同时满足以下三个特性: 在分布式环境中 P 是必须保证的,所以实际是 CP vs AP

By yellowdog

算法思路与模板

二分查找要求序列有序,每次将搜索范围缩减一半,时间复杂度 O(log n)。 两个指针从两端向中间收缩,常用于有序数组。 滑动窗口维护一个满足条件的区间 left, right,right 不断向右扩张,条件不满足时收缩 left。 滑动窗口通用框架: 1. 确定"子问题":原问题可以分解为哪些规模更小的同类问题 2. 定义 dpi 或 dpij 的含义,要足够清晰 3. 推导状态转移方程 4. 确定初始状态(边界条件) 5. 确定计算顺序(确保依赖的子问题先计算) 每件物品最多选一次。dpj = 容量为 j 时的最大价值,逆序遍历容量防止重复选取。 每

By yellowdog