TypeScript 入门第一周完整记录:知识点总结 + 踩坑实录

TypeScript 入门第一周完整记录:知识点总结 + 踩坑实录

「Vue前端转全栈实战」系列第三篇。这周执行 12 个月学习计划的第一阶段,用五天把 TypeScript 核心语法过了一遍。这篇把所有知识点、练习代码、踩过的坑全部记录下来------不只是总结,是完整的学习过程还原。


目录


学习环境搭建

创建项目

用 pnpm 创建 Vue3 + TypeScript 项目:

bash 复制代码
pnpm create vite@latest mail-client -- --template vue-ts
cd mail-client
pnpm install
code .

第一个坑:Node.js 版本不兼容

运行命令直接报错:

bash 复制代码
warn: This version of pnpm requires at least Node.js v22.13
warn: The current version of Node.js is v20.18.0
Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite

原因是 pnpm 最新版要求 Node.js v22 以上,本地还是 v20。解决方式是去 nodejs.org 下载最新 LTS 版本(v22.x)覆盖安装,升级完重新运行就好了。

项目结构

Vite 生成的项目比预期多几个文件,说明一下每个的作用:

css 复制代码
mail-client/
  src/
    types/        ← 手动新建,放类型定义
    composables/  ← 手动新建,放 Composable 函数
    components/   ← 放 Vue 组件
    views/        ← 放页面
    App.vue
    main.ts
  tsconfig.json         ← 主配置,引用下面两个
  tsconfig.app.json     ← 针对 src 源码的 TS 配置
  tsconfig.node.json    ← 针对 vite.config.ts 的 TS 配置
  pnpm-lock.yaml        ← pnpm 自动生成,不要手动改
  vite.config.ts        ← Vite 构建配置

tsconfig 配置

tsconfig.app.jsoncompilerOptions 里加上严格模式:

jsonc 复制代码
{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

strict: true 在继承的基础配置里其实已经开启了,手动写出来是为了让配置更直观。


Day1:基础类型 + 类型推断

知识点

八种基础类型:

typescript 复制代码
const name: string = 'Tom'
const age: number = 32
const isActive: boolean = true
const nothing: null = null
const notDefined: undefined = undefined

// 数组类型(两种写法等价)
const list1: string[] = ['a', 'b', 'c']
const list2: Array<string> = ['a', 'b', 'c']

类型推断(重点):

大多数时候不需要手动标注,TS 自动推断:

typescript 复制代码
const sender = 'boss@company.com'  // 自动推断 string
const timestamp = new Date()        // 自动推断 Date
const count = ref(0)                // 自动推断 Ref<number>

// 什么时候需要手动标注?初始值是 null 的时候
const user = ref<User | null>(null)  // 必须手动标注

any / unknown / never 的区别(重点):

typescript 复制代码
// any:跳过类型检查,危险
let data: any = '123'
data.foo.bar  // 不报错,但运行时崩溃

// unknown:不确定类型,但强制先判断再使用(安全)
let input: unknown = getUserInput()
if (typeof input === 'string') {
  input.toUpperCase()  // 收窄后才能用
}

// never:永远不会发生的类型
// 场景一:函数永远不返回
function throwError(msg: string): never {
  throw new Error(msg)
}
// 场景二:穷举检查,漏掉某个 case 时报错
type MailStatus = 'inbox' | 'sent' | 'draft' | 'trash'
function getLabel(status: MailStatus): string {
  switch (status) {
    case 'inbox': return '收件箱'
    case 'sent':  return '已发送'
    case 'draft': return '草稿箱'
    case 'trash': return '垃圾箱'
    default:
      const exhausted: never = status  // 全覆盖则不报错
      return exhausted
  }
}

三者对比:

any unknown never
能赋什么值 任何值 任何值 没有值
能直接使用 ❌ 先判断 ---
安全性 最低 ---
使用场景 尽量不用 类型不确定时 不可能执行到时

今天的踩坑

坑:noUnusedLocals 导致满屏报红

定义了一堆练习变量,VSCode 全部报红「已经声明定义但未使用」。

原因是 tsconfig 里的 noUnusedLocals: true------声明了但没有使用的变量会报错。

解决:在文件底部加 console.log 引用所有变量:

typescript 复制代码
console.log(name, age, isActive, sender, timestamp...)

这个配置本身是好习惯,真实项目里帮你发现冗余代码。


Day2:Interface + Type

知识点

Interface 基础写法:

typescript 复制代码
/**
 * 功能:邮件数据模型
 * 场景:收件箱、发件箱、草稿箱的邮件数据结构
 */
interface Mail {
  id: number
  subject: string
  from: string
  to: string[]
  body: string
  isRead: boolean
  createdAt: Date
  attachments?: string[]   // ? 表示可选
  readonly owner: string   // readonly 表示只读
}

Interface 继承(重点):

typescript 复制代码
/**
 * 功能:所有数据模型的基础结构
 * 场景:统一管理 id 和时间字段,避免重复定义
 *       新增字段只改这一处,所有继承它的类型自动更新
 */
interface BaseItem {
  id: number
  createdAt: Date
  updatedAt?: Date
}

// Mail 继承 BaseItem,不需要重复写 id 和 createdAt
interface MailV2 extends BaseItem {
  subject: string
  from: string
  to: string[]
  body: string
  isRead: boolean
  attachments?: string[]
}

// Contact 也继承 BaseItem
interface Contact extends BaseItem {
  name: string
  email: string
  avatar?: string
  isFavorite: boolean
}

为什么要用继承?

不用继承,每个 interface 都要重复写 idcreatedAtupdatedAt。某天后端说「所有数据都要加 isDeleted 软删除字段」,要改 N 个地方,漏改一个就出 bug。用继承只改 BaseItem 一处,所有类型自动更新。这就是「单一数据源」的思想。

Type 写法:

typescript 复制代码
// 联合类型(interface 做不到)
type MailStatus = 'inbox' | 'sent' | 'draft' | 'trash'
type Priority = 'high' | 'normal' | 'low'

// 函数类型
type MailFilter = (mail: MailV2) => boolean
type EventHandler = (event: MouseEvent) => void

Interface vs Type 选择原则:

typescript 复制代码
// 定义对象结构 → interface
interface UserCardProps {
  name: string
  avatar?: string
}

// 定义联合类型 → type(interface 做不到)
type ButtonVariant = 'primary' | 'secondary' | 'ghost'

Interface 和 Java class 的区别:

Java class TS interface TS class
能 new
运行时存在 ❌ 编译后消失
只描述结构
有方法实现

TS 的 interface 是纯类型定义,编译成 JS 后完全消失,不能 new

Type 和 const 的区别:

typescript 复制代码
// const 是「装数据的盒子」,运行时真实存在
const mailStatus = 'inbox'   // 值固定是 'inbox'

// type 是「贴在盒子上的标签」,运行时不存在
type MailStatus = 'inbox' | 'sent' | 'draft' | 'trash'

// type 用来约束变量可以是哪些值
const current: MailStatus = 'inbox'    // ✅
const wrong: MailStatus = 'deleted'    // ❌ 不在联合类型里,报错

今天的踩坑

坑一:缺少必填字段

创建 testContact 对象时漏掉了 name 字段,报错:

arduino 复制代码
类型中缺少属性 "name",但类型 "Contact" 中需要该属性

这是 ts(2741) 错误,固定含义是「对象缺少某个必填字段」,补上就解决了。

坑二:updatedAt 忘记加 ?

BaseItem 里的 updatedAt 没有加 ?,导致所有继承它的对象都必须传这个字段,报同样的错。加上 ? 变成可选字段就解决了。


Day3:联合类型 + 类型收窄

知识点

字面量联合类型:

typescript 复制代码
type RequestStatus = 'idle' | 'loading' | 'success' | 'error'
type SortOrder = 'asc' | 'desc'

// 泛型 + 联合类型:通用 API 响应结构
interface ApiResponse<T> {
  data: T | null
  error: string | null
  status: RequestStatus
}

可选链 ?.:

typescript 复制代码
const user: User | null = getUser()

// 直接访问会报错
// user.name  // ❌

// 可选链:为 null 时返回 undefined,不报错
const name = user?.name          // string | undefined
const email = user?.email        // string | undefined

空值合并 ?? vs 逻辑或 ||:

typescript 复制代码
// || 遇到所有「假值」都用默认值(0、''、false 都算假值)
// || 遇到所有「假值」都会用默认值,?? 只有遇到 null 和 undefined 才用默认值。
// 假值包括:null、undefined、0、''、false、NaN
const pageSize = config.pageSize || 20   // pageSize 是 0 时返回 20,用户设置失效!

// ?? 只有遇到 null 或 undefined 才用默认值
const pageSize = config.pageSize ?? 20   // pageSize 是 0 时返回 0,正确

// 判断原则:
// 0、空字符串、false 对你有意义 → 用 ??
// 纯字符串显示、用户名等不应该为空 → 用 || 也没问题
// 实际项目里默认用 ??,更安全

非空断言 !. vs 可选链 ?.:

typescript 复制代码
const user: User | null = getUser()

// ?. 安全:为 null 时返回 undefined
const name = user?.name

// !. 危险:强制告诉 TS「我保证不是 null」
// 如果真的是 null,运行时直接崩溃
const name = user!.name

// 实际项目里尽量少用 !.,用 ?. 配合 ?? 更安全
const name = user?.name ?? '匿名用户'

类型收窄:

typescript 复制代码
// typeof 收窄基础类型
function getMailInfo(id: string | number) {
  if (typeof id === 'string') {
    return id.toUpperCase()  // 这里 TS 知道是 string
  }
  return id.toFixed(0)       // 这里 TS 知道是 number
}

// instanceof 收窄对象类型
catch (e: unknown) {
  if (e instanceof Error) {
    console.log(e.message)   // 收窄后才能安全访问
  }
}

// null 判断收窄
function handleResponse(response: ApiResponse<MailV2>) {
  if (response.data !== null) {
    console.log(response.data.subject)  // 收窄后安全访问
  }
}

typeof vs instanceof:

typescript 复制代码
// typeof:判断基础类型
typeof 'hello' === 'string'   // true
typeof 123 === 'number'       // true

// instanceof:判断对象是哪个类创建的
new Date() instanceof Date    // true
new Error() instanceof Error  // true
[] instanceof Array           // true

// typeof 判断对象只返回 'object',没法细分
typeof new Date() === 'object'  // true,但不知道是 Date 还是别的

今天的踩坑

坑:const 赋值 null 导致类型变成 never

这是这周踩得最深的坑:

typescript 复制代码
const maybeContact: Contact | null = null
const contactName = maybeContact?.name  // 报错!
// 类型"never"上不存在属性"name"

明明标注了 Contact | null,为什么变成了 never

原因:const 声明的变量值不可变,TS 看到直接赋值了 null,就把类型收窄成了 null------它知道这个变量永远只会是 nullContact 那条路永远走不到,所以联合类型里的 Contact 被排除了,剩下就是 never

解决:用函数返回值模拟,函数返回值 TS 不会提前收窄:

typescript 复制代码
// ❌ const 直接赋 null,类型被收窄成 never
const maybeContact: Contact | null = null

// ✅ 用函数返回值,类型保持 Contact | null
function findContact(id: number): Contact | null {
  return null
}
const maybeContact = findContact(999)  // 类型是 Contact | null
const contactName = maybeContact?.name  // ✅ 正常

Day4:函数类型 + 泛型

知识点

函数类型标注:

typescript 复制代码
/**
 * 功能:格式化邮件日期
 * 场景:收件箱列表每封邮件右侧显示日期,如「2025/1/1」
 */
function formatMailDate(date: Date): string {
  return date.toLocaleDateString('zh-CN')
}

/**
 * 功能:统计未读邮件数量
 * 场景:左侧导航栏「收件箱」旁边显示未读数量
 */
function countUnread(mails: MailV2[]): number {
  return mails.filter(mail => !mail.isRead).length
}

// 返回 void:没有返回值
function markAsRead(mail: MailV2): void {
  mail.isRead = true
}

函数类型作为参数(回调函数):

typescript 复制代码
// 定义函数类型
type MailFilter = (mail: MailV2) => boolean

/**
 * 功能:筛选邮件列表
 * 场景:收件箱工具栏的筛选功能,接受任意筛选条件函数
 */
function filterMails(mails: MailV2[], filter: MailFilter): MailV2[] {
  return mails.filter(filter)
}

// 未读筛选
const filterUnread: MailFilter = (mail) => !mail.isRead
// 关键词筛选
const filterByKeyword: MailFilter = (mail) => mail.subject.includes('会议')

// 使用
filterMails(mails, filterUnread)
filterMails(mails, filterByKeyword)
// 这就是「把函数作为参数」的核心价值:把「遍历逻辑」和「判断条件」分开,遍历逻辑只写一次,判断条件可以无限扩展,互不影响。
// 这个设计模式在 Vue 项目里非常高频------组件只负责渲染,具体的业务逻辑通过函数参数传进来,组件本身不需要改

泛型(重点):

typescript 复制代码
// 不用泛型:只能处理一种类型
function getFirstMail(items: MailV2[]): MailV2 | null {
  return items[0] ?? null
}

// 用泛型:一个函数处理所有类型
// 而且返回值类型和输入类型自动关联
function getFirst<T>(items: T[]): T | null {
  return items[0] ?? null
}

// 传 MailV2[],返回值自动是 MailV2 | null(不需要手动标注)
const mail = getFirst(mockMails)
// 传 Contact[],返回值自动是 Contact | null
const contact = getFirst(contacts)

泛型约束:

typescript 复制代码
/**
 * 功能:根据 id 查找列表元素
 * 场景:点击邮件列表某一行,通过 id 找到完整数据
 * T extends { id: number } 确保传入的数组元素一定有 id 字段
 */
function findById<T extends { id: number }>(items: T[], id: number): T | null {
  return items.find(item => item.id === id) ?? null
}

// 故意传没有 id 字段的类型,立刻报错
// findById<{ name: string }>([{ name: 'test' }], 1)  // ❌ 报错

泛型 Composable(最实际的应用):

typescript 复制代码
// src/composables/useFetch.ts
import { ref } from 'vue'

/**
 * 功能:通用 API 请求封装
 * 场景:所有接口请求复用同一套逻辑(loading、error、data)
 *       泛型 T 让返回的 data 自动有正确的类型
 */
export function useFetch<T>(url: string) {
  const data = ref<T | null>(null)
  const loading = ref<boolean>(false)
  const error = ref<string | null>(null)

  async function execute() {
    loading.value = true
    error.value = null
    try {
      const res = await fetch(url)
      if (!res.ok) throw new Error(`请求失败: ${res.status}`)
      data.value = await res.json() as T
    } catch (e: unknown) {
      // 用 unknown 而不是 any,强制先判断类型再使用
      if (e instanceof Error) {
        error.value = e.message
      }
    } finally {
      loading.value = false
    }
  }

  return { data, loading, error, execute }
}

// 使用时指定类型,data 自动有正确的类型
const { data: mailData } = useFetch<MailV2[]>('/api/mails')
// mailData.value 的类型是 MailV2[] | null,不需要手动标注

catch 里用 unknown 而不是 any 的原因:

JS 里可以 throw 任何东西,不只是 Error 对象:

typescript 复制代码
throw new Error('标准错误')
throw '字符串也能 throw'
throw 404
throw { code: 'NETWORK_ERROR' }

所以 catch 到的 e 真的可能是任何类型,用 unknown 强制你先判断,比 any 假装没问题要安全得多。

今天的踩坑

坑:useFetch 写在 .ts 文件里报错

useFetch 写在 mail.ts 里,报错:

arduino 复制代码
找不到名称"ref"

原因:mail.ts 是纯 TypeScript 文件,不是 Vue 组件,ref 需要从 vue 导入,但纯 .ts 文件在 Node.js 环境下无法使用 Vue 的响应式 API。

解决:import { ref } from 'vue'


Day5:工具类型

知识点

六个最常用的工具类型:

typescript 复制代码
interface MailV2 extends BaseItem {
  subject: string
  from: string
  to: string[]
  body: string
  isRead: boolean
  attachments?: string[]
}

Omit:排除指定字段(要去掉的字段少时用)

typescript 复制代码
/**
 * 功能:创建邮件的表单类型
 * 场景:写新邮件时不需要 id 和时间(后端自动生成)
 */
type CreateMail = Omit<MailV2, 'id' | 'createdAt' | 'updatedAt'>

Partial:所有字段变可选(编辑表单常用)

typescript 复制代码
/**
 * 功能:编辑邮件的表单类型
 * 场景:编辑草稿时只传修改的字段,其他保持原样
 */
type EditMail = Partial<MailV2>

function editMail(original: MailV2, changes: EditMail): MailV2 {
  return {
    ...original,  // 保留原始数据
    ...changes,   // 只覆盖传入的字段
    updatedAt: new Date()
  }
}

// 只改主题,其他字段不传
editMail(testMailV2, { subject: '新主题' })

Pick:只保留指定字段(要保留的字段少时用)

typescript 复制代码
/**
 * 功能:邮件列表预览类型
 * 场景:收件箱列表只展示部分字段,不传 body 等大字段
 */
type MailPreview = Pick<MailV2, 'id' | 'subject' | 'from' | 'isRead' | 'createdAt'>

Omit vs Pick 怎么选:

  • 要去掉的字段少 → 用 Omit
  • 要保留的字段少 → 用 Pick
  • Omit 会自动继承原类型新增的字段,Pick 不会

Record:键值对字典类型

typescript 复制代码
/**
 * 功能:文件夹未读数量映射
 * 场景:左侧导航栏显示每个文件夹的未读数量
 */
type FolderUnreadCount = Record<MailStatus, number>
// 等价于:{ inbox: number; sent: number; draft: number; trash: number }

const unreadCount: FolderUnreadCount = {
  inbox: 3,
  sent: 0,
  draft: 2,
  trash: 0
}

ReturnType:获取函数返回值类型

typescript 复制代码
// 复用函数的返回类型,不需要手动重复写
/**
 * 功能:获取函数返回值类型
 * 场景:复用 findById 的返回类型,不需要手动重复写
 */
type FindByIdReturn = ReturnType<typeof findById>
// 等价于:MailV2 | null

Partial 的三个典型场景:

typescript 复制代码
// Partial<T> 就是给 T 的所有字段加上 ?,最常用在编辑表单、搜索条件、配置项这三个场景------这些场景的共同特点是「不需要填写所有字段,填哪个算哪个」。

// 场景一:编辑表单(最常用)
type EditMail = Partial<MailV2>

// 场景二:搜索条件(条件可选填)
type MailSearchParams = Partial<{
  keyword: string
  from: string
  isRead: boolean
  startDate: Date
}>

// 场景三:配置项初始化(有默认值)
function initConfig(userConfig: Partial<MailClientConfig>): MailClientConfig {
  return { ...defaultConfig, ...userConfig }
}

Required

Partial 和 Required 是一对反义词:

typescript 复制代码
// Partial:所有字段变可选
type EditMail = Partial<MailV2>      // 所有字段加 ?

// Required:所有字段变必填
type StrictMail = Required<MailV2>   // 所有字段去掉 ?

今天的踩坑

坑:拼写错误但 TS 立刻提示

写邮件文件夹统计时,'inbox' 拼成了 'index'

typescript 复制代码
const folderMap: Record<number, MailStatus> = {
  [testMailV2.id]: 'index',  // 拼错了
}

TS 立刻报错:

arduino 复制代码
不能将类型 "index" 分配给类型 "MailStatus"

如果用纯 JS,'index' 完全不会有任何提示,运行时统计数据悄悄出错,排查起来非常麻烦。TS 在编译阶段就把这个问题暴露出来了------这就是 TypeScript 最核心的价值:把运行时才能发现的错误提前到编译阶段发现。


项目目前的代码结构

所有练习代码都写在 mail-client 项目里,对应真实的邮件客户端功能:

css 复制代码
src/
  types/
    mail.ts        ← 所有类型定义
  composables/
    useFetch.ts    ← 泛型请求封装

类型和功能的对应关系:

类型 工具类型 对应功能
CreateMail Omit 写邮件表单,去掉 id 和时间
EditMail Partial 编辑草稿,所有字段可选
MailPreview Pick 收件箱列表,只取必要字段
FolderUnreadCount Record 导航栏未读数量统计
ApiResponse<T> 泛型 所有 API 请求的通用响应结构
useFetch<T> 泛型 类型安全的通用请求 Composable

第一周总结

五天学完 TypeScript 核心语法,用一句话总结这周最大的感受:

TS 不是在给自己找麻烦,而是在提前暴露问题。

每一次报红,都是编译器在替你发现潜在的 bug。习惯了之后看到报红反而有安全感------比起运行时崩溃,编译时报错的修复成本低太多了。

这周掌握的知识点自检:

  • interface vs type 怎么选
  • any / unknown / never 的区别和使用场景
  • 可选链 ?. 和非空断言 !. 的区别
  • ?? 和 || 的区别,什么时候用哪个
  • 类型收窄:typeof / instanceof / null 判断
  • 泛型 <T> 的概念:输入输出类型自动关联
  • 六个工具类型:Partial / Required / Pick / Omit / Record / ReturnType

下周计划

进入第二阶段:Pinia 状态管理 + Vite 工程化配置

  • 用 Pinia 建 mailStore,管理收件箱列表、未读数量、当前选中邮件
  • 配置 Vite 路径别名、环境变量、构建分析
  • 把本周定义的类型用到真实的 Vue 组件里

「Vue前端转全栈实战」系列持续更新,欢迎关注。 有问题欢迎评论区交流,我会把遇到的问题和解决过程都记录下来。

相关推荐
月光刺眼3 小时前
用LangChain 打造第一个 Agent:LLM 大脑与 Tool 手脚的完整闭环
javascript·人工智能·全栈
甜味弥漫7 小时前
React Router:从单页应用到前端路由
javascript·node.js
甜味弥漫8 小时前
后端开发入门:业务能力、数据库与程序员的核心竞争力
javascript·node.js·全栈
workbuddy小能手10 小时前
用 WorkBuddy 部署 Node.js 项目实战:基金导航站与 Markdown 编辑器同机上线
人工智能·ai·node.js·编辑器·workbuddy
不想说话的麋鹿1 天前
「项目实战」我用 Codex + GPT-5.5 + Trellis "氛围编程",独立做了一个情侣厨房小程序
前端·agent·全栈
阿里云云原生1 天前
Node.js 监控的终极补完:一次接入,全链路打通 APM、AI 观测与运行时健康
云原生·node.js
木西1 天前
告别机械重复:使用 Node.js + Playwright 构建 24 小时全自动测试网领水脚本
前端·javascript·node.js
xiaofeichaichai1 天前
Vite原理与Webpack对比
前端·webpack·node.js