目录
TypeScript 集成
Q1: 如何在 Vue 3 中使用 TypeScript?
详细解答:
1. 基础配置
安装依赖:
bash
npm install -D typescript @vue/tsconfig
tsconfig.json 配置:
json
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"]
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"exclude": ["node_modules"]
}
vite-env.d.ts:
typescript
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
2. 组件类型定义
基础组件:
vue
<script setup lang="ts">
import { ref, computed, type Ref } from 'vue'
// 基本类型
const count: Ref<number> = ref(0)
const message: Ref<string> = ref('Hello')
// 对象类型
interface User {
id: number
name: string
email: string
age?: number
}
const user: Ref<User | null> = ref(null)
// 计算属性类型推导
const doubled = computed(() => count.value * 2) // 自动推导为 ComputedRef<number>
// 函数类型
function increment(): void {
count.value++
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`)
return response.json()
}
</script>
Props 类型:
vue
<script setup lang="ts">
// 方法 1: 运行时声明
const props = defineProps({
name: {
type: String,
required: true
},
age: {
type: Number,
default: 0
}
})
// 方法 2: 类型声明(推荐)
interface Props {
name: string
age?: number
tags?: string[]
user?: User
}
const props = defineProps<Props>()
// 方法 3: 带默认值的类型声明
interface PropsWithDefaults {
name: string
age?: number
tags?: string[]
}
const props = withDefaults(defineProps<PropsWithDefaults>(), {
age: 0,
tags: () => []
})
// 方法 4: 复杂类型
interface ComplexProps {
// 联合类型
status: 'pending' | 'success' | 'error'
// 函数类型
onClick: (event: MouseEvent) => void
// 泛型
items: Array<{ id: number; name: string }>
// 可选回调
onUpdate?: (value: string) => void
}
const props = defineProps<ComplexProps>()
</script>
Emits 类型:
vue
<script setup lang="ts">
// 方法 1: 运行时声明
const emit = defineEmits(['update', 'delete'])
// 方法 2: 类型声明(推荐)
interface Emits {
(e: 'update', id: number): void
(e: 'delete', id: number): void
(e: 'change', value: string): void
}
const emit = defineEmits<Emits>()
// 使用
emit('update', 123)
emit('delete', 456)
emit('change', 'new value')
// 方法 3: 带验证的声明
const emit = defineEmits({
update: (payload: number) => {
return payload > 0
},
delete: (payload: number) => {
return payload > 0
}
})
</script>
完整组件示例:
vue
<!-- UserProfile.vue -->
<template>
<div class="user-profile">
<h2>{{ user?.name ?? 'Loading...' }}</h2>
<p v-if="user">Email: {{ user.email }}</p>
<p v-if="user && user.age">Age: {{ user.age }}</p>
<button @click="handleUpdate">更新</button>
<button @click="handleDelete">删除</button>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, type Ref } from 'vue'
// 类型定义
interface User {
id: number
name: string
email: string
age?: number
}
interface Props {
userId: number
canEdit?: boolean
}
interface Emits {
(e: 'update', user: User): void
(e: 'delete', id: number): void
}
// Props & Emits
const props = withDefaults(defineProps<Props>(), {
canEdit: false
})
const emit = defineEmits<Emits>()
// State
const user: Ref<User | null> = ref(null)
const loading: Ref<boolean> = ref(false)
const error: Ref<Error | null> = ref(null)
// Methods
async function fetchUser(): Promise<void> {
loading.value = true
error.value = null
try {
const response = await fetch(`/api/users/${props.userId}`)
if (!response.ok) {
throw new Error('Failed to fetch user')
}
user.value = await response.json()
} catch (err) {
error.value = err as Error
} finally {
loading.value = false
}
}
function handleUpdate(): void {
if (user.value) {
emit('update', user.value)
}
}
function handleDelete(): void {
emit('delete', props.userId)
}
// Lifecycle
onMounted(() => {
fetchUser()
})
// Expose
defineExpose({
fetchUser,
user
})
</script>
3. Refs 类型
vue
<script setup lang="ts">
import { ref, type Ref } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import ChildComponent from './ChildComponent.vue'
// DOM 元素 ref
const inputRef = ref<HTMLInputElement | null>(null)
const divRef = ref<HTMLDivElement | null>(null)
// 组件实例 ref
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null)
// 或者使用 ComponentPublicInstance
const childRef2 = ref<ComponentPublicInstance | null>(null)
onMounted(() => {
// 访问 DOM 元素
inputRef.value?.focus()
// 访问组件方法
childRef.value?.someMethod()
})
</script>
<template>
<input ref="inputRef" />
<div ref="divRef"></div>
<ChildComponent ref="childRef" />
</template>
4. Composable 类型
typescript
// composables/useFetch.ts
import { ref, type Ref } from 'vue'
interface UseFetchOptions {
immediate?: boolean
onSuccess?: (data: any) => void
onError?: (error: Error) => void
}
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
loading: Ref<boolean>
execute: () => Promise<void>
}
export function useFetch<T = any>(
url: string,
options: UseFetchOptions = {}
): UseFetchReturn<T> {
const { immediate = true, onSuccess, onError } = options
const data: Ref<T | null> = ref(null)
const error: Ref<Error | null> = ref(null)
const loading: Ref<boolean> = ref(false)
async function execute(): Promise<void> {
loading.value = true
error.value = null
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result = await response.json()
data.value = result
if (onSuccess) {
onSuccess(result)
}
} catch (err) {
error.value = err as Error
if (onError) {
onError(err as Error)
}
} finally {
loading.value = false
}
}
if (immediate) {
execute()
}
return {
data,
error,
loading,
execute
}
}
// 使用
interface User {
id: number
name: string
email: string
}
const { data, loading, error } = useFetch<User[]>('/api/users')
5. Pinia Store 类型
typescript
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed, type Ref, type ComputedRef } from 'vue'
export interface User {
id: number
name: string
email: string
role: 'admin' | 'user' | 'guest'
}
interface LoginCredentials {
email: string
password: string
}
export const useUserStore = defineStore('user', () => {
// State
const user: Ref<User | null> = ref(null)
const token: Ref<string> = ref('')
const loading: Ref<boolean> = ref(false)
// Getters
const isLoggedIn: ComputedRef<boolean> = computed(() => !!token.value)
const isAdmin: ComputedRef<boolean> = computed(() => user.value?.role === 'admin')
// Actions
async function login(credentials: LoginCredentials): Promise<boolean> {
loading.value = true
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
})
if (!response.ok) {
throw new Error('Login failed')
}
const data = await response.json()
token.value = data.token
user.value = data.user
return true
} catch (error) {
console.error('Login error:', error)
return false
} finally {
loading.value = false
}
}
function logout(): void {
user.value = null
token.value = ''
}
return {
user,
token,
loading,
isLoggedIn,
isAdmin,
login,
logout
}
})
// 在组件中使用
import { useUserStore } from '@/stores/user'
import type { User } from '@/stores/user'
const userStore = useUserStore()
// 类型推导
const user: User | null = userStore.user
const isLoggedIn: boolean = userStore.isLoggedIn
6. 路由类型
typescript
// router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
// 路由元信息类型
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
roles?: string[]
title?: string
}
}
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: {
title: 'Home'
}
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: {
requiresAuth: true,
roles: ['admin'],
title: 'Dashboard'
}
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 导航守卫类型
router.beforeEach((to, from, next) => {
// to, from, next 都有完整的类型推导
if (to.meta.requiresAuth) {
// 检查认证
}
next()
})
export default router
项目配置
Q2: Vue 3 项目的 Vite 配置有哪些重要选项?
详细解答:
1. 基础 Vite 配置
typescript
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
// 插件
plugins: [
vue({
// Vue 插件选项
script: {
defineModel: true,
propsDestructure: true
},
template: {
compilerOptions: {
// 自定义元素
isCustomElement: (tag) => tag.startsWith('ion-')
}
}
})
],
// 路径解析
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@components': resolve(__dirname, 'src/components'),
'@utils': resolve(__dirname, 'src/utils'),
'@assets': resolve(__dirname, 'src/assets')
},
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
},
// CSS 配置
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/styles/variables.scss";`
}
},
modules: {
localsConvention: 'camelCase'
}
},
// 开发服务器
server: {
port: 3000,
open: true,
cors: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
// 构建选项
build: {
target: 'es2015',
outDir: 'dist',
assetsDir: 'assets',
sourcemap: false,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
},
rollupOptions: {
output: {
// 分包策略
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'ui-vendor': ['element-plus'],
'utils': ['axios', 'lodash-es']
},
// 文件名格式
chunkFileNames: 'js/[name]-[hash].js',
entryFileNames: 'js/[name]-[hash].js',
assetFileNames: '[ext]/[name]-[hash].[ext]'
}
},
chunkSizeWarningLimit: 1000
},
// 环境变量
envPrefix: 'VITE_',
// 优化选项
optimizeDeps: {
include: ['vue', 'vue-router', 'pinia'],
exclude: ['some-large-library']
}
})
2. 环境变量配置
bash
# .env.development
VITE_APP_TITLE=My App (Dev)
VITE_API_BASE_URL=http://localhost:8080/api
VITE_APP_ENV=development
# .env.production
VITE_APP_TITLE=My App
VITE_API_BASE_URL=https://api.example.com
VITE_APP_ENV=production
# .env.staging
VITE_APP_TITLE=My App (Staging)
VITE_API_BASE_URL=https://staging-api.example.com
VITE_APP_ENV=staging
typescript
// env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_API_BASE_URL: string
readonly VITE_APP_ENV: 'development' | 'production' | 'staging'
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
typescript
// 使用环境变量
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
const appTitle = import.meta.env.VITE_APP_TITLE
3. 常用插件配置
typescript
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { visualizer } from 'rollup-plugin-visualizer'
import viteCompression from 'vite-plugin-compression'
export default defineConfig({
plugins: [
vue(),
// 自动导入 API
AutoImport({
imports: [
'vue',
'vue-router',
'pinia',
{
'@/utils': ['formatDate', 'debounce']
}
],
dts: 'src/auto-imports.d.ts',
eslintrc: {
enabled: true
}
}),
// 自动导入组件
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts',
dirs: ['src/components'],
extensions: ['vue']
}),
// Gzip 压缩
viteCompression({
verbose: true,
disable: false,
threshold: 10240,
algorithm: 'gzip',
ext: '.gz'
}),
// 打包分析
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
filename: 'dist/stats.html'
})
]
})
测试
Q3: 如何在 Vue 3 项目中进行单元测试和集成测试?
详细解答:
1. Vitest 配置
bash
npm install -D vitest @vue/test-utils happy-dom
typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'happy-dom',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'**/*.spec.ts',
'**/*.test.ts'
]
}
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})
2. 组件测试
typescript
// Counter.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'
describe('Counter.vue', () => {
it('renders initial count', () => {
const wrapper = mount(Counter, {
props: {
initialCount: 5
}
})
expect(wrapper.text()).toContain('5')
})
it('increments count when button clicked', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('1')
})
it('emits update event', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('update')).toBeTruthy()
expect(wrapper.emitted('update')?.[0]).toEqual([1])
})
})
3. Composable 测试
typescript
// useFetch.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useFetch } from '@/composables/useFetch'
global.fetch = vi.fn()
describe('useFetch', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches data successfully', async () => {
const mockData = { id: 1, name: 'Test' }
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => mockData
} as Response)
const { data, loading, error, execute } = useFetch('/api/test')
await execute()
expect(data.value).toEqual(mockData)
expect(loading.value).toBe(false)
expect(error.value).toBeNull()
})
it('handles errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'))
const { data, error, execute } = useFetch('/api/test')
await execute()
expect(data.value).toBeNull()
expect(error.value).toBeInstanceOf(Error)
})
})
4. Pinia Store 测试
typescript
// user.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
describe('User Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('logs in successfully', async () => {
const store = useUserStore()
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
token: 'test-token',
user: { id: 1, name: 'Test User' }
})
})
const result = await store.login({
email: 'test@example.com',
password: 'password'
})
expect(result).toBe(true)
expect(store.isLoggedIn).toBe(true)
expect(store.user).toEqual({ id: 1, name: 'Test User' })
})
})
构建工具
Q4: 如何优化 Vue 3 项目的构建配置?
详细解答:
1. 代码分割策略
typescript
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: (id) => {
// node_modules 分包
if (id.includes('node_modules')) {
// Vue 全家桶单独打包
if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
return 'vue-vendor'
}
// UI 框架单独打包
if (id.includes('element-plus')) {
return 'element-plus'
}
// 图表库单独打包
if (id.includes('echarts')) {
return 'echarts'
}
// 其他第三方库
return 'vendor'
}
// 业务代码分包
if (id.includes('src/views')) {
const paths = id.split('/')
const viewName = paths[paths.indexOf('views') + 1]
return `view-${viewName}`
}
}
}
}
}
})
2. 性能优化配置
typescript
export default defineConfig({
build: {
// 启用 CSS 代码分割
cssCodeSplit: true,
// 生成 sourcemap
sourcemap: process.env.NODE_ENV === 'development',
// 压缩配置
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log']
}
},
// 警告阈值
chunkSizeWarningLimit: 1000,
// 关闭 brotli 压缩大小报告
reportCompressedSize: false
},
// 依赖优化
optimizeDeps: {
// 强制包含
include: ['vue', 'vue-router', 'pinia'],
// 排除不需要优化的包
exclude: ['some-esm-package']
}
})
总结:
TypeScript 与工程化的关键点:
- 类型系统:完善的类型定义和类型推导
- 项目配置:合理的 Vite 配置和环境变量管理
- 代码质量:完善的测试覆盖和代码规范
- 构建优化:有效的代码分割和打包策略
- 开发效率:自动导入、热更新等开发体验优化