Vue 3 实用技巧与生态
1. 组件设计模式
1.1 Container/Presentational 模式
vue
<!-- Presentational Component (展示组件) -->
<!-- UserList.vue - 只负责 UI -->
<template>
<ul class="user-list">
<li
v-for="user in users"
:key="user.id"
class="user-item"
>
<span class="name">{{ user.name }}</span>
<span class="email">{{ user.email }}</span>
</li>
</ul>
</template>
<script setup>
defineProps({
users: {
type: Array,
required: true
}
})
</script>
<!-- Container Component (容器组件) -->
<!-- UserListContainer.vue - 负责数据和逻辑 -->
<script setup>
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
import UserList from './UserList.vue'
const userStore = useUserStore()
const activeUsers = computed(() =>
userStore.users.filter(u => u.isActive)
)
function handleUserClick(user) {
userStore.selectUser(user.id)
}
</script>
<template>
<div>
<h2>活跃用户</h2>
<UserList :users="activeUsers" @click="handleUserClick" />
</div>
</template>1.2 Render Props 模式
vue
<!-- MouseTracker.vue -->
<script setup>
import { ref } from 'vue'
const x = ref(0)
const y = ref(0)
function handleMouseMove(e) {
x.value = e.clientX
y.value = e.clientY
}
</script>
<template>
<div @mousemove="handleMouseMove">
<!-- 使用 slot prop 传递数据 -->
<slot :x="x" :y="y" />
</div>
</template>
<!-- 使用 -->
<MouseTracker v-slot="{ x, y }">
<div class="mouse-pos">
鼠标位置: {{ x }}, {{ y }}
</div>
</MouseTracker>1.3 Provider/Consumer 模式
javascript
// composables/useForm.js
import { provide, inject, reactive, computed } from 'vue'
const FORM_KEY = Symbol('form')
export function createFormStore(initialValues = {}) {
const state = reactive({
values: { ...initialValues },
errors: {},
touched: {}
})
const isValid = computed(() => Object.keys(state.errors).length === 0)
function setFieldValue(name, value) {
state.values[name] = value
}
function setFieldError(name, error) {
state.errors[name] = error
}
function setFieldTouched(name) {
state.touched[name] = true
}
function validate() {
// 验证逻辑
}
function reset() {
Object.keys(state.values).forEach(key => {
state.values[key] = initialValues[key]
state.errors[key] = ''
state.touched[key] = false
})
}
return {
state,
isValid,
setFieldValue,
setFieldError,
setFieldTouched,
validate,
reset
}
}
export function provideForm(store) {
provide(FORM_KEY, store)
}
export function useForm() {
const form = inject(FORM_KEY)
if (!form) {
throw new Error('useForm must be used within FormProvider')
}
return form
}2. 实用工具函数
2.1 防抖/节流
javascript
// utils/debounce.js
export function debounce(fn, delay = 300) {
let timeoutId
return function (...args) {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
export function throttle(fn, delay = 300) {
let lastCall = 0
return function (...args) {
const now = Date.now()
if (now - lastCall >= delay) {
lastCall = now
fn.apply(this, args)
}
}
}
// 使用
import { debounce, throttle } from '@/utils'
const debouncedSearch = debounce(searchAPI, 500)
const throttledScroll = throttle(handleScroll, 100)2.2 格式化函数
javascript
// utils/format.js
// 日期格式化
export function formatDate(date, format = 'YYYY-MM-DD') {
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
}
// 金额格式化
export function formatMoney(amount, decimals = 2, separator = ',') {
const num = Number(amount).toFixed(decimals)
const parts = num.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator)
return parts.join('.')
}
// 文件大小格式化
export function formatFileSize(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
}
// 字符串截断
export function truncate(str, maxLen = 50, suffix = '...') {
if (str.length <= maxLen) return str
return str.slice(0, maxLen - suffix.length) + suffix
}2.3 类型判断
javascript
// utils/is.js
export function isString(val) {
return Object.prototype.toString.call(val) === '[object String]'
}
export function isNumber(val) {
return typeof val === 'number' && !isNaN(val)
}
export function isBoolean(val) {
return typeof val === 'boolean'
}
export function isArray(val) {
return Array.isArray(val)
}
export function isObject(val) {
return val !== null && typeof val === 'object'
}
export function isFunction(val) {
return typeof val === 'function'
}
export function isEmpty(val) {
if (isArray(val) || isString(val)) {
return val.length === 0
}
if (isObject(val)) {
return Object.keys(val).length === 0
}
return true
}
export function isEqual(a, b) {
if (a === b) return true
if (isArray(a) && isArray(b)) {
return a.length === b.length && a.every((item, i) => isEqual(item, b[i]))
}
if (isObject(a) && isObject(b)) {
const keysA = Object.keys(a)
const keysB = Object.keys(b)
return keysA.length === keysB.length &&
keysA.every(key => isEqual(a[key], b[key]))
}
return false
}3. Vue 3 生态
3.1 Vite 配置
javascript
// vite.config.js
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: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
},
build: {
target: 'es2015',
cssCodeSplit: true,
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'element-plus': ['element-plus']
}
}
}
}
})3.2 常用插件
bash
# UI 框架
npm install element-plus # PC 端
npm install ant-design-vue # PC 端
npm install vuetify # Material Design
npm install quasar # 跨平台
npm install headlessui # 无样式组件库
# 生态
npm install vue-router@4
npm install pinia
npm install @vueuse/core # Vue 组合式工具集
# 开发工具
npm install -D vite
npm install -D @vitejs/plugin-vue
npm install -D eslint-plugin-vue
npm install -D @vue/tsconfig3.3 VueUse 常用函数
javascript
import {
// 响应式
ref, reactive, computed, watch,
// DOM
useElementBounding,
useWindowSize,
useMouse,
useScroll,
useIntersectionObserver,
// 事件
useEventListener,
onClickOutside,
// 异步
useFetch,
useAsyncState,
// 工具
useDebounceFn,
useThrottleFn,
useLocalStorage,
useStorage,
// 生命周期
onMounted,
onUnmounted
} from '@vueuse/core'
// useMouse - 鼠标位置
const { x, y } = useMouse()
// useWindowSize - 窗口大小
const { width, height } = useWindowSize()
// useElementBounding - 元素边界
const el = ref(null)
const { left, top, width, height } = useElementBounding(el)
// useScroll - 滚动状态
const { x, y, isScrolling } = useScroll(window)
// useDebounceFn - 防抖
const debouncedFn = useDebounceFn(() => {
// ...
}, 1000)
// useLocalStorage - 响应式 localStorage
const token = useLocalStorage('token', '')
// useFetch - 响应式请求
const { data, isFetching, error } = useFetch('/api/user')
// onClickOutside - 点击外部
const dialogRef = ref()
onClickOutside(dialogRef, () => {
showDialog.value = false
})4. 常见问题解决方案
4.1 深拷贝响应式对象
javascript
import { reactive } from 'vue'
// 方式1: JSON 方法(简单但有局限)
const copy = JSON.parse(JSON.stringify(state))
// 方式2: structuredClone(原生,更好的性能)
const copy = structuredClone(reactiveObj)
// 方式3: 手动拷贝
function deepClone(obj) {
if (typeof obj !== 'object' || obj === null) return obj
if (Array.isArray(obj)) return obj.map(deepClone)
const cloned = {}
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloned[key] = deepClone(obj[key])
}
}
return cloned
}
// 方式4: toRaw + 拷贝
import { toRaw, reactive } from 'vue'
const raw = toRaw(reactiveObj)
const copy = reactive({ ...raw })4.2 动态组件
vue
<template>
<!-- 方式1: component :is -->
<component :is="currentComponent" />
<!-- 方式2: 条件渲染 -->
<KeepAlive>
<Home v-if="route.name === 'Home'" />
<About v-else-if="route.name === 'About'" />
</KeepAlive>
</template>
<script setup>
import { computed } from 'vue'
const componentMap = {
Home: () => import('./views/Home.vue'),
About: () => import('./views/About.vue')
}
const currentComponent = computed(() => componentMap[route.name])
</script>4.3 权限控制
javascript
// 路由守卫权限控制
router.beforeEach((to, from) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return {
name: 'Login',
query: { redirect: to.fullPath }
}
}
if (to.meta.roles && !hasRole(to.meta.roles)) {
return { name: 'Forbidden' }
}
})
// v-if 权限指令
export function usePermission() {
const auth = useAuthStore()
function can(role) {
return auth.permissions.includes(role)
}
function canAny(roles) {
return roles.some(role => can(role))
}
function canAll(roles) {
return roles.every(role => can(role))
}
return { can, canAny, canAll }
}
// 模板中使用
<script setup>
const { can } = usePermission()
</script>
<template>
<button v-if="can('admin')">删除</button>
<button v-if="canAny(['admin', 'editor'])">编辑</button>
</template>4.4 表单验证
vue
<!-- 简单表单验证 -->
<script setup>
import { reactive, ref } from 'vue'
const form = reactive({
email: '',
password: ''
})
const errors = reactive({
email: '',
password: ''
})
const rules = {
email: [
v => !!v || '邮箱不能为空',
v => /.+@.+\..+/.test(v) || '邮箱格式不正确'
],
password: [
v => !!v || '密码不能为空',
v => v.length >= 6 || '密码至少6位'
]
}
function validate() {
let isValid = true
for (const field in rules) {
for (const rule of rules[field]) {
const result = rule(form[field])
if (result !== true) {
errors[field] = result
isValid = false
break
}
errors[field] = ''
}
}
return isValid
}
function handleSubmit() {
if (validate()) {
// 提交
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<input v-model="form.email" />
<span v-if="errors.email">{{ errors.email }}</span>
</div>
<div>
<input v-model="form.password" type="password" />
<span v-if="errors.password">{{ errors.password }}</span>
</div>
<button type="submit">提交</button>
</form>
</template>5. 最佳实践
5.1 目录结构
src/
├── assets/ # 静态资源
├── components/ # 通用组件
│ ├── common/ # 基础组件 (Button, Input...)
│ └── layout/ # 布局组件 (Header, Sidebar...)
├── composables/ # 组合式函数
│ ├── useAuth.js
│ ├── useForm.js
│ └── usePagination.js
├── layouts/ # 页面布局
├── router/ # 路由配置
├── stores/ # Pinia stores
├── styles/ # 全局样式
├── utils/ # 工具函数
├── views/ # 页面组件
├── App.vue
└── main.js5.2 命名规范
javascript
// 组件命名 (PascalCase)
MyComponent.vue
UserCard.vue
IconBase.vue
// 组合式函数 (use 前缀)
useUser.js
useCounter.js
useFetchData.js
// 工具函数 (camelCase)
debounce.js
formatDate.js
isValidEmail.js
// 常量 (SCREAMING_SNAKE_CASE)
const MAX_FILE_SIZE = 10 * 1024 * 1024
const API_BASE_URL = '/api/v1'
// Store (use 前缀 + Store 后缀)
useUserStore
useCartStore
useAuthStore5.3 TypeScript 最佳实践
typescript
// 类型定义文件
// types/user.ts
export interface User {
id: number
name: string
email: string
avatar?: string
createdAt: Date
}
export interface UserState {
users: User[]
currentUser: User | null
isLoading: boolean
}
// Props 类型
export interface Props {
title: string
items?: string[]
onChange?: (value: string) => void
}
// composable 返回值
export interface UseUserReturn {
user: Ref<User | null>
isLoading: Ref<boolean>
error: Ref<Error | null>
fetchUser: (id: number) => Promise<void>
updateUser: (data: Partial<User>) => Promise<void>
}6. 面试要点
6.1 Vue 3 新特性
1. Composition API
- setup()
- ref, reactive, computed
- 生命周期钩子
- 依赖注入
2. 响应式系统
- Proxy 替代 Object.defineProperty
- 深层响应式
- shallowRef, shallowReactive
3. 新增内置组件
- Fragment (多根节点)
- Teleport (传送到 DOM)
- Suspense (异步组件)
4. 性能优化
- 编译时优化 (静态提升)
- Block tree
- v-memo
5. TypeScript 支持
- 全部用 TS 重写
- 更好的类型推导6.2 Composition vs Options
Options API:
- 逻辑分散(data/methods/computed 按类别分)
- 大组件难以维护
- mixins 有命名冲突
Composition API:
- 相关逻辑放一起
- 易于逻辑复用 (composables)
- 更好的 TypeScript 支持
- 代码组织更灵活
选择:
- 小型项目:Options 够用
- 中大型项目:Composition 更好
- 需要复用复杂逻辑:用 Composition6.3 nextTick 场景
1. DOM 更新后获取元素尺寸
await nextTick()
const height = el.value.offsetHeight
2. 表单验证后聚焦
this.$refs.form.validate(async (valid) => {
if (valid) {
await nextTick()
this.$refs.submitBtn.focus()
}
})
3. 列表渲染后滚动到底部
items.value.push(newItem)
await nextTick()
container.value.scrollTop = container.value.scrollHeight6.4 性能优化清单
1. 路由懒加载
component: () => import('./view.vue')
2. 大列表使用虚拟滚动
vue-virtual-scroller
3. v-memo 减少不必要的渲染
v-memo="[stableId]"
4. 使用 shallowRef
对于大型数据结构
5. 合理使用 keep-alive
缓存不常更新的组件
6. 事件监听记得移除
onUnmounted(() => removeEventListener)
7. 避免响应式数据过大
非必要数据不放在 state 中