Skip to content

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/tsconfig

3.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.js

5.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
useAuthStore

5.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 更好
- 需要复用复杂逻辑:用 Composition

6.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.scrollHeight

6.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 中

基于 MIT 许可发布