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?
- Route lazy loading (mandatory)
- Async component imports (
defineAsyncComponent) - Image lazy loading (
loading="lazy") - Code splitting (Vite's
manualChunks) - Enable gzip/brotli compression
- 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 →