Vue Best Practices
Contents
Quick Start
Installation & Version
Current version is Vue 3.5.32.
Vue 3 requires Node.js 20+.
Using create-vue (Recommended)
# npm
npm create vue@latest
cd <your-project-name>
npm install
npm run dev
# pnpm
pnpm create vue@latest
cd <your-project-name>
pnpm install
pnpm run dev
# yarn
yarn create vue@latest
cd <your-project-name>
yarn
yarn devUsing Vite
# npm
npm create vite@latest my-vue-app -- --template vue-ts
cd my-vue-app
npm install
npm run dev
# pnpm
pnpm create vite my-vue-app --template vue-ts
cd my-vue-app
pnpm install
pnpm run devCDN (No Build Step)
<!-- Using ESM build -->
<script type="module">
import { createApp } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
createApp({ data() { return { message: 'Hello Vue!' } } }).mount('#app')
</script>Vue 3 vs Vue 2 Key Changes
| Feature | Vue 2 | Vue 3 |
|---|---|---|
| Reactivity | defineProperty |
Proxy |
| Components | Options API | Composition API |
| TypeScript | Via decorators | Native support |
| Slot components | slot |
v-slot |
| Global state | Vuex | Pinia |
Composition API
Core feature of Vue 3, using <script setup> syntax:
Basic Structure
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
// Reactive state
const count = ref(0)
const message = ref('Hello')
// Computed
const doubled = computed(() => count.value * 2)
// Methods
function increment() {
count.value++
}
// Lifecycle
onMounted(() => {
console.log('Component mounted')
})
</script>
<template>
<div>
<p>{{ message }} {{ count }}</p>
<p>Doubled: {{ doubled }}</p>
<button @click="increment">Increment</button>
</div>
</template>reactive
import { reactive } from 'vue'
// Object reactivity
const state = reactive({
count: 0,
user: {
name: 'Alice',
age: 30
}
})
// Modify
state.count++
state.user.name = 'Bob'Destructuring and Reactivity
import { reactive, toRefs } from 'vue'
const state = reactive({
count: 0,
message: 'Hello'
})
// Destructure while maintaining reactivity
const { count, message } = toRefs(state)TypeScript Integration
Defining Props
import { defineProps, PropType } from 'vue'
interface User {
id: number
name: string
email: string
}
const props = defineProps<{
title: string
users: User[]
onSave: (user: User) => void
}>()Defining Emits
const emit = defineEmits<{
(e: 'update', value: string): void
(e: 'delete', id: number): void
}>()
// Usage
emit('update', 'new value')
emit('delete', 1)Template Refs
import { ref, onMounted } from 'vue'
const inputRef = ref<HTMLInputElement | null>(null)
onMounted(() => {
inputRef.value?.focus()
})Component Structure
Single File Component Structure
<script setup lang="ts">
// 1. Imports
import { ref } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
// 2. Props and Emits
const props = defineProps<{ title: string }>()
const emit = defineEmits<{ (e: 'submit'): void }>()
// 3. Reactive state
const loading = ref(false)
// 4. Computed
const isValid = computed(() => props.title.length > 0)
// 5. Methods
async function handleSubmit() {
loading.value = true
try {
emit('submit')
} finally {
loading.value = false
}
}
</script>
<template>
<!-- Template -->
</template>
<style scoped>
/* Component styles */
</style>Pinia State Management
Installation
npm install piniaCreating a Store
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// State
const users = ref<User[]>([])
const currentUser = ref<User | null>(null)
// Computed
const userCount = computed(() => users.value.length)
const isLoggedIn = computed(() => currentUser.value !== null)
// Actions
async function fetchUsers() {
const response = await api.get('/users')
users.value = response.data
}
async function login(credentials: Credentials) {
const response = await api.post('/login', credentials)
currentUser.value = response.data
}
function logout() {
currentUser.value = null
}
return {
users,
currentUser,
userCount,
isLoggedIn,
fetchUsers,
login,
logout
}
})Usage in Components
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// Usage
console.log(userStore.userCount)
userStore.login({ email: '[email protected]', password: '123' })
</script>Vue Router
Configuration
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue')
},
{
path: '/users/:id',
name: 'UserDetail',
component: () => import('@/views/UserDetail.vue'),
props: true
},
{
path: '/admin',
component: () => import('@/views/Admin.vue'),
meta: { requiresAuth: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// Navigation guard
router.beforeEach((to) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
return '/login'
}
})
export default routerUsage in Components
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
// Get params
const userId = route.params.id
// Navigate
router.push('/dashboard')
</script>Composables
Encapsulate reusable logic:
Creating
// composables/useFetch.ts
import { ref, onMounted } from 'vue'
export function useFetch<T>(url: string) {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
async function fetchData() {
loading.value = true
error.value = null
try {
const response = await fetch(url)
data.value = await response.json()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
onMounted(fetchData)
return { data, loading, error, refetch: fetchData }
}Usage
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'
interface User {
id: number
name: string
}
const { data: users, loading, error, refetch } = useFetch<User[]>('/api/users')
</script>Performance Optimization
Lazy Loading Components
<!-- Static import -->
<script setup>
import HeavyComponent from './HeavyComponent.vue'
</script>
<!-- Dynamic import (lazy) -->
<script setup>
import { defineAsyncComponent } from 'vue'
const HeavyComponent = defineAsyncComponent(() =>
import('./HeavyComponent.vue')
)
</script>v-memo
<template>
<div v-for="item in list" :key="item.id" v-memo="[item.id === selectedId]">
<!-- Only updates when selectedId changes -->
</div>
</template>KeepAlive
<template>
<keep-alive include="UserList,ProductList">
<router-view />
</keep-alive>
</template>Style Guide
Scoped CSS
<style scoped>
.button {
padding: 8px 16px;
border-radius: 4px;
}
/* Deep selector */
:deep(.el-input) {
border: none;
}
</style>CSS Variables
<style scoped>
:root {
--primary-color: #42b983;
--button-padding: 8px 16px;
}
.button {
background: var(--primary-color);
padding: var(--button-padding);
}
</style>Testing
Vitest
npm install -D vitest @vue/test-utils jsdomComponent Testing
// components/Button.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from './Button.vue'
describe('Button', () => {
it('renders with correct text', () => {
const wrapper = mount(Button, {
props: { label: 'Click me' }
})
expect(wrapper.text()).toContain('Click me')
})
it('emits click event', async () => {
const wrapper = mount(Button, {
props: { label: 'Click me' }
})
await wrapper.trigger('click')
expect(wrapper.emitted()).toHaveProperty('click')
})
})