Vue3 + TypeScript Enterprise Development Guide

前端工程

Why Vue3 + TypeScript Is the Enterprise Standard in 2026

In 2026, Vue3 + TypeScript has become the dominant choice for mid-to-large frontend projects. Vue3's Composition API provides more flexible logic reuse, while TypeScript brings type safety and better refactoring experience. Together, they significantly improve team collaboration and code quality.

Core Advantages

  • Type Safety: Catch 80%+ of low-level errors at compile time, reducing production bugs
  • Better IDE Support: VSCode + Volar provides precise IntelliSense and navigation
  • Logic Reuse: Composition API + custom hooks replace Mixins, no more naming conflicts
  • Performance: Vue3's Proxy reactivity + Tree-shaking reduces bundle size by 40%+
  • Mature Ecosystem: Pinia, Vue Router 4, and Vite all have first-class TypeScript support

Comparison with Vue2 + Options API

// Vue2 Options API - logic scattered across data/methods/computed
export default {
  data() {
    return { searchQuery: '', results: [] }
  },
  methods: {
    async fetchResults() {
      this.results = await api.search(this.searchQuery)
    }
  },
  computed: {
    filteredResults() {
      return this.results.filter(r => r.active)
    }
  }
}

// Vue3 Composition API - cohesive logic, reusable
function useSearch() {
  const searchQuery = ref('')
  const results = ref<SearchResult[]>([])

  async function fetchResults() {
    results.value = await api.search(searchQuery.value)
  }

  const filteredResults = computed(() =>
    results.value.filter(r => r.active)
  )

  return { searchQuery, results, fetchResults, filteredResults }
}

Project Scaffolding with Vite

Vite is the standard build tool for Vue3 projects in 2026, with cold starts 10-20x faster than Webpack.

Initializing a Project

# Using create-vue (official recommended)
npm create vue@latest my-enterprise-app

# Select these features:
# ✅ TypeScript
# ✅ Vue Router
# ✅ Pinia
# ✅ Vitest
# ✅ ESLint + Prettier

cd my-enterprise-app
npm install

Enterprise Directory Structure

src/
├── assets/              # Static assets
├── components/          # Shared components
│   ├── base/            # Base components (Button, Input, Modal)
│   └── business/        # Business components
├── composables/         # Composable functions (useXxx)
├── layouts/             # Layout components
├── pages/               # Page components (organized by route)
├── router/              # Router configuration
├── stores/              # Pinia state management
├── types/               # Global type definitions
│   ├── api.d.ts         # API response types
│   ├── env.d.ts         # Environment variable types
│   └── global.d.ts      # Global type extensions
├── utils/               # Utility functions
├── api/                 # API request layer
├── App.vue
└── main.ts

Key Configuration Files

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  },
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true
      }
    }
  },
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          'ui-vendor': ['element-plus']
        }
      }
    }
  }
})

💡 Use the JSON Formatter to verify your configuration file syntax.


Composition API Core Patterns

Setup Syntax Sugar

<script setup> is the standard approach in 2026 — the compiler handles it automatically, no manual return needed.

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'

const count = ref(0)
const doubled = computed(() => count.value * 2)

function increment() {
  count.value++
}

onMounted(() => {
  console.log('Component mounted, initial value:', count.value)
})
</script>

<template>
  <div class="flex items-center gap-4">
    <span class="text-2xl font-bold">{{ count }}</span>
    <span class="text-gray-500">×2 = {{ doubled }}</span>
    <button
      class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      @click="increment"
    >
      +1
    </button>
  </div>
</template>

Custom Composables

This is the core pattern for logic reuse in Vue3, replacing Vue2 Mixins.

// composables/usePagination.ts
import { ref, computed, type Ref } from 'vue'

interface UsePaginationOptions {
  total: Ref<number>
  pageSize?: number
}

export function usePagination(options: UsePaginationOptions) {
  const { total, pageSize = 10 } = options
  const currentPage = ref(1)

  const totalPages = computed(() =>
    Math.ceil(total.value / pageSize)
  )

  const paginatedRange = computed(() => ({
    start: (currentPage.value - 1) * pageSize,
    end: currentPage.value * pageSize
  }))

  function goToPage(page: number) {
    if (page >= 1 && page <= totalPages.value) {
      currentPage.value = page
    }
  }

  function nextPage() {
    goToPage(currentPage.value + 1)
  }

  function prevPage() {
    goToPage(currentPage.value - 1)
  }

  return {
    currentPage,
    totalPages,
    paginatedRange,
    goToPage,
    nextPage,
    prevPage
  }
}

Reactive System Internals

ref vs reactive vs computed

import { ref, reactive, computed } from 'vue'

// ref - preferred for primitives, accessed via .value
const username = ref('John')
console.log(username.value) // 'John'

// reactive - for objects, no .value needed, but can't replace entire object
const userForm = reactive({
  name: 'John',
  age: 25,
  address: {
    city: 'New York'
  }
})
userForm.name = 'Jane' // direct assignment

// ❌ Error: cannot replace the entire reactive object
// userForm = { name: 'Bob', age: 30 }

// computed - derived state with automatic caching
const fullName = computed(() =>
  `${userForm.name} - ${userForm.address.city}`
)

When to Use What

Scenario Recommendation Reason
Primitive values ref reactive doesn't support primitives
Form objects reactive No .value needed, cleaner syntax
Returned from functions ref Can replace entire value, destructuring preserves reactivity
Derived calculations computed Auto-cached with dependency tracking
Collection types (Map/Set) reactive Native support

Common Pitfalls with Reactivity Loss

// ❌ Destructuring reactive loses reactivity
const state = reactive({ count: 0, name: 'test' })
const { count } = state // count is no longer reactive!

// ✅ Use toRefs to preserve reactivity
import { toRefs } from 'vue'
const { count, name } = toRefs(state) // Now reactive

// ❌ Returning a reactive property from a function
function getUser() {
  const state = reactive({ user: null as User | null })
  return state.user // Loses reactivity
}

// ✅ Return a ref instead
function getUser() {
  const user = ref<User | null>(null)
  return { user } // Preserves reactivity
}

TypeScript Integration Best Practices

defineProps and defineEmits Type Inference

<script setup lang="ts">
// Method 1: Generic declaration (recommended)
interface Props {
  title: string
  count?: number
  items: string[]
  status: 'active' | 'inactive'
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  status: 'active'
})

// Method 2: Runtime declaration (not recommended, incomplete type inference)
// const props = defineProps({
//   title: { type: String, required: true },
//   count: { type: Number, default: 0 }
// })

// defineEmits type declaration
interface Emits {
  (e: 'update', value: string): void
  (e: 'delete', id: number): void
  (e: 'change', payload: { field: string; value: unknown }): void
}

const emit = defineEmits<Emits>()

emit('update', 'new value')
emit('delete', 42)
emit('change', { field: 'name', value: 'John' })
</script>

Generic Components

<script setup lang="ts" generic="T extends { id: number }">
interface Props {
  items: T[]
  selectedId?: number
}

const props = defineProps<Props>()
const emit = defineEmits<{
  (e: 'select', item: T): void
}>()
</script>

<template>
  <div
    v-for="item in items"
    :key="item.id"
    :class="[
      'p-2 cursor-pointer rounded',
      item.id === selectedId
        ? 'bg-blue-100 border-blue-500'
        : 'hover:bg-gray-100'
    ]"
    @click="emit('select', item)"
  >
    <slot :item="item" />
  </div>
</template>

💡 Use the JSON to TypeScript tool to quickly generate type definitions from API responses.


Pinia State Management

Defining a Store

// stores/useUserStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo, LoginParams } from '@/types/api'

export const useUserStore = defineStore('user', () => {
  // state
  const userInfo = ref<UserInfo | null>(null)
  const token = ref<string>('')

  // getters
  const isLoggedIn = computed(() => !!token.value)
  const displayName = computed(() =>
    userInfo.value?.nickname ?? userInfo.value?.username ?? 'Not logged in'
  )

  // actions
  async function login(params: LoginParams) {
    const res = await api.login(params)
    token.value = res.token
    userInfo.value = res.user
    localStorage.setItem('token', res.token)
  }

  function logout() {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
  }

  async function fetchProfile() {
    if (!token.value) return
    userInfo.value = await api.getProfile()
  }

  return {
    userInfo,
    token,
    isLoggedIn,
    displayName,
    login,
    logout,
    fetchProfile
  }
})

Using in Components

<script setup lang="ts">
import { useUserStore } from '@/stores/useUserStore'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()

// ✅ Use storeToRefs to preserve reactivity
const { userInfo, isLoggedIn, displayName } = storeToRefs(userStore)

// ❌ Direct destructuring loses reactivity
// const { userInfo } = userStore // Not reactive!

// Actions can be called directly
async function handleLogin() {
  await userStore.login({ username: 'admin', password: '123456' })
}
</script>

Composing Stores

// stores/useCartStore.ts
import { defineStore } from 'pinia'
import { useUserStore } from './useUserStore'

export const useCartStore = defineStore('cart', () => {
  const userStore = useUserStore()
  const items = ref<CartItem[]>([])

  const totalPrice = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  async function addToCart(productId: number) {
    if (!userStore.isLoggedIn) {
      throw new Error('Please log in first')
    }
    const product = await api.getProduct(productId)
    items.value.push({ ...product, quantity: 1 })
  }

  return { items, totalPrice, addToCart }
})

Vue Router 4 with Type-Safe Routing

Typed Routes

// router/routes.ts
import type { RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    component: () => import('@/layouts/DefaultLayout.vue'),
    children: [
      {
        path: '',
        name: 'Home',
        component: () => import('@/pages/HomePage.vue'),
        meta: { title: 'Home', requiresAuth: false }
      },
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/pages/DashboardPage.vue'),
        meta: { title: 'Dashboard', requiresAuth: true }
      },
      {
        path: 'user/:id',
        name: 'UserProfile',
        component: () => import('@/pages/UserProfilePage.vue'),
        props: true,
        meta: { title: 'User Profile', requiresAuth: true }
      }
    ]
  }
]

export default routes

Augmenting Route Types

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { Router, RouteMeta } from 'vue-router'
import routes from './routes'

// Extend RouteMeta type
declare module 'vue-router' {
  interface RouteMeta {
    title?: string
    requiresAuth?: boolean
    permissions?: string[]
  }
}

const router: Router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

// Global navigation guard
router.beforeEach((to, _from, next) => {
  document.title = to.meta.title ?? 'ToolsKu'

  if (to.meta.requiresAuth) {
    const userStore = useUserStore()
    if (!userStore.isLoggedIn) {
      next({ name: 'Home', query: { redirect: to.fullPath } })
      return
    }
  }

  next()
})

export default router

Type-Safe Routing in Components

<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

// route.params.id is string | string[]
const userId = computed(() => {
  const id = route.params.id
  return Array.isArray(id) ? id[0] : id
})

function navigateToDashboard() {
  router.push({ name: 'Dashboard' })
}
</script>

Component Design Patterns

Compound Components Pattern

<!-- components/base/Tabs.vue -->
<script setup lang="ts">
import { ref, provide, type InjectionKey, type Ref } from 'vue'

interface TabsContext {
  activeTab: Ref<string>
  setActiveTab: (id: string) => void
}

export const TabsInjectionKey: InjectionKey<TabsContext> = Symbol('tabs')

const activeTab = defineModel<string>({ required: true })

provide(TabsInjectionKey, {
  activeTab,
  setActiveTab: (id: string) => { activeTab.value = id }
})
</script>

<template>
  <div class="tabs">
    <slot />
  </div>
</template>
<!-- components/base/TabItem.vue -->
<script setup lang="ts">
import { inject, computed } from 'vue'
import { TabsInjectionKey } from './Tabs.vue'

const props = defineProps<{ id: string; label: string }>()

const tabsContext = inject(TabsInjectionKey)
if (!tabsContext) {
  throw new Error('TabItem must be used inside Tabs')
}

const isActive = computed(() => tabsContext.activeTab.value === props.id)
</script>

<template>
  <button
    :class="[
      'px-4 py-2 border-b-2 transition-colors',
      isActive
        ? 'border-blue-500 text-blue-600'
        : 'border-transparent text-gray-500 hover:text-gray-700'
    ]"
    @click="tabsContext!.setActiveTab(id)"
  >
    {{ label }}
  </button>
</template>

Provide / Inject Pattern

// composables/useTheme.ts
import { ref, provide, inject, type InjectionKey, type Ref } from 'vue'

interface ThemeContext {
  isDark: Ref<boolean>
  toggleTheme: () => void
}

export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')

export function provideTheme() {
  const isDark = ref(false)

  function toggleTheme() {
    isDark.value = !isDark.value
    document.documentElement.classList.toggle('dark', isDark.value)
  }

  provide(ThemeKey, { isDark, toggleTheme })
  return { isDark, toggleTheme }
}

export function useTheme() {
  const ctx = inject(ThemeKey)
  if (!ctx) throw new Error('useTheme must be used inside ThemeProvider')
  return ctx
}

Performance Optimization

Route Lazy Loading

// All route components use dynamic imports
const routes: RouteRecordRaw[] = [
  {
    path: '/dashboard',
    component: () => import('@/pages/DashboardPage.vue') // Lazy loaded
  },
  {
    path: '/heavy-page',
    component: () => import(
      /* webpackChunkName: "heavy" */
      '@/pages/HeavyPage.vue'
    )
  }
]

Virtual Scrolling

<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualList } from '@vueuse/core'

const allItems = ref(Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `Item ${i + 1}`
})))

const { list, containerProps, wrapperProps } = useVirtualList(
  allItems,
  { itemHeight: 48, overscan: 10 }
)
</script>

<template>
  <div v-bind="containerProps" class="h-[600px] overflow-auto">
    <div v-bind="wrapperProps">
      <div
        v-for="{ data, index } in list"
        :key="data.id"
        class="h-12 flex items-center px-4 border-b"
      >
        {{ index }}: {{ data.name }}
      </div>
    </div>
  </div>
</template>

KeepAlive Caching

<script setup lang="ts">
import { ref } from 'vue'

const includeList = ref(['DashboardPage', 'UserProfilePage'])
</script>

<template>
  <router-view v-slot="{ Component }">
    <keep-alive :include="includeList">
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

Common TypeScript Errors and Fixes

Error 1: ref .value in Templates

// ❌ Templates don't need .value, but type checking can mislead
const count = ref(0)
// In template: {{ count }}, NOT {{ count.value }}

// ✅ In <script>, you must use .value
function increment() {
  count.value++ // Correct
}

Error 2: Component Event Type Mismatch

// ❌ emit parameter type mismatch
const emit = defineEmits<{
  (e: 'change', value: string): void
}>()
emit('change', 123) // TS error: number not assignable to string

// ✅ Pass the correct type
emit('change', 'hello')

Error 3: Async Component Type Inference Failure

// ❌ Async imported component loses type
const AsyncComp = defineAsyncComponent(() =>
  import('./MyComponent.vue')
) // Type is any

// ✅ Explicitly declare with DefineComponent
import type { DefineComponent } from 'vue'
const AsyncComp = defineAsyncComponent<DefineComponent<...>>(
  () => import('./MyComponent.vue')
)

Error 4: Props Default Value Type Mismatch

// ❌ withDefaults default value type errors
interface Props {
  items: string[]
  count: number
}
const props = withDefaults(defineProps<Props>(), {
  items: '', // TS error: string not assignable to string[]
  count: '0' // TS error: string not assignable to number
})

// ✅ Correct default values
const props = withDefaults(defineProps<Props>(), {
  items: () => [], // Reference types must use factory functions
  count: 0
})

Unit Testing with Vitest

Configuring Vitest

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: { '@': resolve(__dirname, 'src') }
  },
  test: {
    globals: true,
    environment: 'jsdom',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html']
    }
  }
})

Testing Composables

// composables/__tests__/usePagination.test.ts
import { describe, it, expect } from 'vitest'
import { ref } from 'vue'
import { usePagination } from '../usePagination'

describe('usePagination', () => {
  it('should calculate total pages correctly', () => {
    const total = ref(100)
    const { totalPages } = usePagination({ total, pageSize: 10 })
    expect(totalPages.value).toBe(10)
  })

  it('should navigate pages correctly', () => {
    const total = ref(50)
    const { currentPage, nextPage, prevPage, goToPage } = usePagination({
      total,
      pageSize: 10
    })

    expect(currentPage.value).toBe(1)
    nextPage()
    expect(currentPage.value).toBe(2)
    goToPage(5)
    expect(currentPage.value).toBe(5)
    prevPage()
    expect(currentPage.value).toBe(4)
  })

  it('should not exceed page range', () => {
    const total = ref(30)
    const { currentPage, goToPage } = usePagination({ total, pageSize: 10 })

    goToPage(0)
    expect(currentPage.value).toBe(1)
    goToPage(100)
    expect(currentPage.value).toBe(1)
  })
})

Testing Components

// components/__tests__/Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '../Counter.vue'

describe('Counter', () => {
  it('should render initial value', () => {
    const wrapper = mount(Counter, {
      props: { initialValue: 10 }
    })
    expect(wrapper.text()).toContain('10')
  })

  it('should increment count on button click', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('button').trigger('click')
    expect(wrapper.text()).toContain('1')
  })
})

FAQ

Q1: Should I use ref or reactive?

Simple rule: Use ref for primitives, reactive for objects. If you need to return from a function or destructure, always use ref + toRefs. In 2026 practice, more teams are standardizing on ref for its more consistent behavior.

Q2: Do I still need Vuex for Vue3 projects?

No. Pinia is now the officially recommended state management solution for Vue3. It's written entirely in TypeScript, has a simpler API, no mutations, and supports Composition API style.

Q3: How to handle type-safe API requests?

Define unified API response types with axios interceptors:

interface ApiResponse<T> {
  code: number
  data: T
  message: string
}

async function getUser(id: number): Promise<ApiResponse<UserInfo>> {
  return http.get(`/api/users/${id}`)
}

Q4: How to elegantly handle cross-level component communication?

  • Parent-Child: props + emits
  • Cross-level: provide / inject + InjectionKey
  • Global state: Pinia
  • Temporary state: defineModel (Vue 3.4+)

Q5: How to optimize first-screen loading speed?

  1. Route lazy loading (mandatory)
  2. Async component imports (defineAsyncComponent)
  3. Image lazy loading (loading="lazy")
  4. Code splitting (Vite's manualChunks)
  5. Enable gzip/brotli compression
  6. Use CDN for large third-party libraries

💡 Use the Base64 Encode tool to inline small images and reduce HTTP requests.

Try these browser-local tools — no sign-up required →

#Vue3#TypeScript#企业级#前端#教程