1. 从 Vue2 迁移到 Vue3,两种 API 到底怎么选
Vue3 同时保留了选项式 API 和组合式 API,这件事对从 Vue2 过来的开发者其实挺友好——你不用一次性把老代码全推翻。但真正落到 TypeScript 项目里,问题就来了:tsconfig.json该怎么配?vite.config.ts里要不要加额外插件?defineComponent和<script setup>的类型推导为什么表现不一样?props 和 emits 的校验写法差在哪?
这篇就围绕 Vue3 + TypeScript 的工程化落地,把两套 API 的配置骨架、组件模板、类型校验动作完整走一遍。适合两类人:一是手上有一批 Vue2 项目准备渐进迁移,二是新起项目想直接上组合式 API 但不确定配置细节。读完之后,你能拿到可直接复制的tsconfig.json、vite.config.ts,以及选项式和组合式两套组件骨架,并且知道怎么用类型报错来验证配置是否真的生效。
我试过在同一个项目里混用两种风格,结论是:配置层完全共用,差异只在组件写法。所以下面先讲公共配置,再分别给两套模板。
2. 前置准备:TaoToken 接入与项目初始化
在开始写组件之前,先把模型调用这条链路打通,因为后面验证类型推导时,我会用一个真实的接口请求来演示 props 和 emits 的类型校验。这里用 TaoToken 作为模型服务入口,它的 API 地址是https://taotoken.net/api,兼容常见的对话补全格式,接入成本低。
你需要先去控制台创建一个 API Key。打开https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite,登录后新建一个 Key,复制出来存到本地环境变量里,别硬编码进代码。
项目初始化用 Vite 官方模板:
npm create vite@latest vue3-ts-demo -- --template vue-ts cd vue3-ts-demo npm install模板自带 TypeScript 支持,但默认的tsconfig.json比较宽松,类型检查不够严格。下面我会替换成更工程化的版本。
如果你打算长期用组合式 API 写业务组件、甚至接 Agent 类工具,可以顺带了解下 Coding Plan,地址是https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite,它面向的是持续编码场景,和本篇的组件复用思路能对上。
3. 可复制配置:tsconfig.json 与 vite.config.ts
3.1 tsconfig.json 严格模式骨架
Vue3 项目推荐用「项目引用」结构,把应用代码和 Node 侧配置分开。根目录tsconfig.json:
{ "files": [], "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" } ] }tsconfig.app.json负责src下的业务代码:
{ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "jsx": "preserve", "resolveJsonModule": true, "isolatedModules": true, "esModuleInterop": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"] }tsconfig.node.json负责vite.config.ts:
{ "compilerOptions": { "composite": true, "module": "ESNext", "moduleResolution": "Bundler", "allowSyntheticDefaultImports": true, "strict": true, "types": ["node"] }, "include": ["vite.config.ts"] }关键点有三个:strict: true打开全部严格检查;moduleResolution: "Bundler"适配 Vite 的解析方式;paths里配了@/*别名,后面组件里可以直接用。
3.2 vite.config.ts 与类型声明
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { fileURLToPath, URL } from 'node:url' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, server: { port: 5173, proxy: { '/api': { target: 'https://taotoken.net', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '/api') } } } })代理这段是为了让前端请求走同源,避免本地调试时的跨域问题。src/env.d.ts里补上 Vue 单文件组件的类型声明:
/// <reference types="vite/client" /> declare module '*.vue' { import type { DefineComponent } from 'vue' const component: DefineComponent<{}, {}, any> export default component }到这里配置层就绪。接下来分别看两套 API 的组件骨架。
4. 两套组件骨架与类型校验动作
4.1 选项式 API:defineComponent + 类型推导
选项式 API 在 Vue3 里通过defineComponent保留,类型推导主要靠data、props、emits的显式声明。
<template> <div> <p>计数:{{ count.toFixed(2) }}</p> <button @click="increment">加一</button> <p>模型回复:{{ reply }}</p> </div> </template> <script lang="ts"> import { defineComponent } from 'vue' interface Props { initial: number modelName?: string } export default defineComponent({ name: 'CounterOptions', props: { initial: { type: Number, required: true }, modelName: { type: String, default: 'default-model' } }, emits: { change: (value: number) => typeof value === 'number' }, data() { return { count: this.initial, reply: '' } }, mounted() { this.fetchReply() }, methods: { increment() { this.count += 1 this.$emit('change', this.count) }, async fetchReply() { const res = await fetch('/api/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${import.meta.env.VITE_TAOTOKEN_KEY}` }, body: JSON.stringify({ model: this.modelName, messages: [{ role: 'user', content: '用一句话介绍 Vue3' }] }) }) const data = await res.json() this.reply = data.choices?.[0]?.message?.content ?? '' } } }) </script>验证动作:把initial传成字符串,比如<CounterOptions initial="1" />,Vue 会在控制台给出类型不匹配的警告;把modelName传成数字,同样会报。emits用对象形式声明后,this.$emit('change', 'abc')会触发类型错误,因为校验函数要求number。
4.2 组合式 API:script setup + 泛型 props
组合式 API 的<script setup>写法更紧凑,类型推导也更直接。
<template> <div> <p>计数:{{ count.toFixed(2) }}</p> <button @click="increment">加一</button> <p>模型回复:{{ reply }}</p> </div> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue' interface Props { initial: number modelName?: string } const props = withDefaults(defineProps<Props>(), { modelName: 'default-model' }) const emit = defineEmits<{ change: [value: number] }>() const count = ref(props.initial) const reply = ref('') function increment() { count.value += 1 emit('change', count.value) } async function fetchReply() { const res = await fetch('/api/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${import.meta.env.VITE_TAOTOKEN_KEY}` }, body: JSON.stringify({ model: props.modelName, messages: [{ role: 'user', content: '用一句话介绍 Vue3' }] }) }) const data = await res.json() reply.value = data.choices?.[0]?.message?.content ?? '' } onMounted(fetchReply) </script>验证动作:defineProps<Props>()里把initial改成string,父组件传数字就会报错;defineEmits用元组语法后,emit('change', 'abc')直接编译不过。这就是组合式 API 在类型上的优势——校验发生在编译期,而不是运行时警告。
4.3 组合式函数复用:把请求逻辑抽出来
组合式 API 真正的价值在逻辑复用。把上面的请求逻辑抽成useChat:
// src/composables/useChat.ts import { ref } from 'vue' export function useChat(modelName: string) { const reply = ref('') const loading = ref(false) async function send(content: string) { loading.value = true try { const res = await fetch('/api/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${import.meta.env.VITE_TAOTOKEN_KEY}` }, body: JSON.stringify({ model: modelName, messages: [{ role: 'user', content }] }) }) const data = await res.json() reply.value = data.choices?.[0]?.message?.content ?? '' } finally { loading.value = false } } return { reply, loading, send } }组件里直接const { reply, loading, send } = useChat('default-model'),类型全部自动推导。选项式 API 想达到同样效果,得靠 mixin,但 mixin 的类型推导一直是痛点,这也是新项目更推荐组合式的原因。
5. 本篇常见错排查
报错一:Cannot find module '@/xxx'。检查tsconfig.app.json的paths和vite.config.ts的resolve.alias是否都配了,两边缺一不可。IDE 里如果还飘红,重启 TS 服务。
报错二:Property 'xxx' does not exist on type。选项式 API 里常见于this推断失败,确认用了defineComponent而不是裸对象导出。组合式里常见于ref忘了.value。
报错三:props 默认值不生效。组合式必须用withDefaults包裹defineProps,直接写defineProps<Props>()不会应用默认值。
报错四:emits类型不校验。选项式要用对象形式声明,数组形式emits: ['change']不做类型检查。组合式用元组语法defineEmits<{ change: [value: number] }>()。
报错五:请求 401。检查VITE_TAOTOKEN_KEY是否写进了.env.local,且变量名以VITE_开头,否则 Vite 不会注入。Key 可以在https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite重新生成。
报错六:代理不生效。vite.config.ts改动后要重启 dev server,热更新不会重载配置。
6. 继续验证与接入文档
配置和组件骨架跑通后,建议做两件事:一是用模型对话页面手动发一条请求,确认 Key 和模型名都对得上,地址是https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite;二是把接口参数、错误码对照接入文档过一遍,地址是https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite,里面列了请求体和响应字段的完整说明。
如果你在迁移过程中遇到类型推导对不上的情况,优先怀疑tsconfig的strict和moduleResolution两项,这两个是 Vue3 + TS 项目里最容易踩的配置坑。把这两项对齐官方模板,剩下的组件写法差异就只是风格选择了。