Skip to content

玄学放置 · 从 0 到 1 接入 TypeScript

起因

项目走到第 8 篇时,前端已经有 8 个 Vue 单文件组件、3 个 Pinia store、3 份 mock 数据、1 个像素图标库、外加一个 Node 端的 SVG 导出脚本。代码不算多,但已经到了"重构时心里没底"的临界点——改一个 store 字段,模板里读它的地方全靠搜索而不是类型;给一个组件加 prop,调用方有没有都得手动盯 review。

放置游戏的玩法迭代是长跑,状态模型未来一定会继续生长(商店、副本、技能树、转生),现在不上 TS,半年后再迁会更痛。所以这次专门抽一个上午,把 TypeScript 工具链从零接进来。

第一道选择题:渐进式还是颠覆式

通用建议大多是"渐进式迁移:先开 allowJs,新写的用 TS、旧的慢慢改"。这种方式风险低,但坏处是项目长期处于 JS / TS 二态共存——团队里每写一个文件都得停下来想"这个该用哪种",长期下来反而成为持续的小摩擦。

考虑到本项目代码量可控(十几个源文件),我直接选了颠覆式

  • 全部 .js / .mjs 改成 .ts,旧文件删除而不是保留
  • 全部 Vue 组件 <script setup>lang="ts"
  • 不开 allowJs,让 TS 直接接管整棵源码树

颠覆式的好处是上完之后就没有"还没改完的部分"——没有渐进式那种"项目长期处于半态"的尾巴。代价就是这个上午得把所有文件一次过完,但反正本来就要做。

tsconfig 拆三份

Vue 3 + Vite 项目接 TS 的标准做法是 tsconfig 拆三份,沿用 create-vue 脚手架的形态:

  • tsconfig.json — 项目引用根,本身不编译任何文件,只做 references 转发
  • tsconfig.app.json — 应用层(浏览器、含 DOM 库、src/**
  • tsconfig.node.json — Node 侧(vite.config.tsscripts/**包含 DOM 库)

为什么非要拆?因为 vite.config.ts 这种文件运行在 Node 里,写 process.argv 是合法的;但应用代码运行在浏览器里,写 Node API 应该被拒绝。两个环境的全局类型互斥,混在一份 tsconfig 里就会出现"应用代码也能不报错地用 Node API"这种漏网情况。

每份 tsconfig 都开了 strict: true,但noUncheckedIndexedAccess。这点是有意为之——

noUncheckedIndexedAccess 关了,因为索引访问太多

像素图标库和物品数据库都长这样:

ts
export const ICONS = { chinese: { ... }, math: { ... }, ... }
export const ITEMS_DB: Record<string, ItemDef> = { pencil: { ... }, ... }

模板里 ICONS[name] / ITEMS_DB[itemId] 这种索引访问随处可见。如果开 noUncheckedIndexedAccess,每一处都会被推成 T | undefined,需要补非空守护。对一个明知 key 一定存在的查表操作来说,这种保护带来的改造量远大于实际收益。

权衡之后保留宽松索引访问,但在每个查表函数内部加 ?? null 兜底,对外暴露统一的"找不到时返回 null"语义。这样一来即使 ICONS 缺 key,调用方也只会拿到 null 而不是 undefined。

Pinia options API 的几个 TS 坑

项目里 store 都是 options API 写法,迁 TS 时遇到几个非显而易见的问题。

第一个坑:getter 里依赖 this 时,TS 推不出返回类型。

ts
getters: {
  activeSubject(state): Subject | null { ... },
  // 下面这个会失败:activeTask 调用 this.activeSubject 时
  // TS 还在推断 getters 类型,不知道 this.activeSubject 是什么
  activeTask() {
    const subject = this.activeSubject
    // ...
  }
}

解法是给依赖 this 的 getter 显式声明返回类型,把循环依赖打断:

ts
activeTask(): Task | null {
  const subject = this.activeSubject
  const entry = this.activeEntry
  if (!subject || !entry) return null
  return subject.tasks.find((t) => t.id === entry.taskId) ?? null
}

不依赖 this 的 getter(只用 state 参数)就不需要这一步,TS 能自己推。

第二个坑:定时器句柄类型。

chat.ts 里有个 mock 推送定时器:

ts
_mockTimer: NodeJS.Timeout | null  // ❌ 浏览器代码引 Node 类型

直接写 NodeJS.Timeout 会从 @types/node 借类型,但应用层 tsconfig 故意没引 node 这个 lib。正确写法是用 ReturnType 反查:

ts
type TimerHandle = ReturnType<typeof setTimeout>

这样不管浏览器还是 Node 实现,都拿到对应平台的句柄类型。

模板侧的空安全

vue-tsc 在 strict 模式下会校验模板表达式。原来的 ActiveTaskBar 模板里有这样一段:

vue
<template v-if="store.isRunning && activeSubject && activeTask">
  <strong v-pre>activeEntry.completedCount</strong>

业务逻辑上 isRunning 为真就意味着 queue[0] 存在、activeEntry 也一定不为 null。但 TS 推不出这层因果——它只看 v-if 表达式里有没有 activeEntry。修法很机械:

vue
<template v-if="store.isRunning && activeSubject && activeTask && activeEntry">

加一个看似多余的判断,但对 TS 来说是"在模板这一帧把 activeEntry 收敛为非空"。

QueuePopover 那边踩了另一个相关的坑。原版代码:

js
function getSubject(entry) {
  return store.list.find((s) => s.id === entry.subjectId) || {}
}

迁到 TS 时第一反应是写 Partial<Subject>,但模板里 getSubject(entry).iconKey<PixelIcon :name="..."> 时,string | undefinedname: string 类型对不上。

最后改成"显式 fallback 对象":

ts
const FALLBACK_SUBJECT: Subject = {
  id: '', name: '?', iconKey: '', color: '#666',
  tasks: [], level: 0, exp: 0, expMax: 0, completedCount: 0
}
function getSubject(entry: QueueEntry): Subject {
  return store.list.find((s) => s.id === entry.subjectId) ?? FALLBACK_SUBJECT
}

这种写法在运行时也更稳——找不到时模板拿到的是 ? 而不是页面崩溃,对降级体验更友好。

工具链:vue-tsc 做类型检查,esbuild 做转译

Vue 3 + Vite 接 TS 的标准链路是分工明确的:

  • 类型检查vue-tsc --noEmit,专门处理 .vue 模板里的表达式类型
  • 代码转译 → Vite 内置的 esbuild,速度比 tsc 快一个量级

所以 package.jsonbuild 脚本写成串行的:

json
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit"

vue-tsc 不输出文件,只做检查;vite 也不做类型检查,只做构建。两边互不挡道。dev 模式下 vite 直接跑、不调 vue-tsc,热更新速度不受影响。

Node 脚本那边,原本 scripts/export-icons.mjsnode 直接跑。迁到 TS 后用 tsx

json
"export-icons": "tsx scripts/export-icons.ts"

tsx 是 esbuild 包的一层 CLI 封装,能直接跑 ts 文件不需要预编译。比 ts-node 快很多,比"先 tsc 再 node"省一个步骤。脚本侧不需要纳入主构建产物,所以这种执行时编译完全够用。

顺带把类型当文档用

迁完之后一个意料之外的收获:很多 mock 文件加了类型导出之后,本身就成了一份 API 文档。

比如原本 mock/chat.js 里写 WELCOME_MESSAGES = [...],看不出消息长什么样、字段是不是必填。迁完后:

ts
export type ChatMessageType = 'system' | 'user' | 'self'

export interface ChatMessage {
  id: number
  user: string
  content: string
  time: number
  type: ChatMessageType
}

export interface ChatMessageDraft {
  user: string
  content: string
  type?: ChatMessageType  // 缺省时 store 默认 'user'
}

ChatMessage 是 store 内部的完整态,ChatMessageDraft 是外部推消息时允许的简化态——idtime 由 store push 时补全。两个类型摆在一起,读代码的人立刻看出"我从外面调 push 时哪些字段是必给、哪些能省"。比注释清楚得多。

类似地 mock/inventory.tsSlotKey 收成字面量联合:

ts
export type SlotKey =
  | 'head' | 'neck' | 'chest' | 'legs'
  | 'feet' | 'mainHand' | 'offHand' | 'accessory'

调用 unequip('head') 时 IDE 直接补全槽位名,写错任意一个都立刻红线。

收获

这次接入花了不到一个上午,比预估快——主要是因为前几轮迭代攒下的 store 模型已经收敛得差不多,类型设计基本就是把脑子里的隐式约束写下来。

  • typecheck 一次过 —— 主要受益于颠覆式:所有文件一次性同时迁,互相之间不存在新旧不匹配
  • 大部分类型来自 mock 数据 —— 把 SUBJECTS_SEED / ITEMS_DB 这些字面量加上类型注解,store / 组件那边几乎不用单独写类型,全靠类型流推下去
  • Pinia options API 需要更多手写注解 —— 如果项目更大或新立项,会优先选 setup store 写法,能省不少 this 推断的坑

类型当文档用是这次最大的体感收益。原来读代码经常要在 mock 文件、store 文件、组件文件三边来回跳,现在大部分理解直接发生在 IDE 的 hover 提示里。

下一篇大概率聚焦 Element Plus 的按需引入——build 完 1.17 MB(gzip 379 KB)的 bundle 已经到了不能装作没看见的程度。