Skip to content

Vue 3 组合式 API 进阶

1. 深入响应式

1.1 ref vs reactive 内部原理

javascript
// ref 内部实现 (简化)
class RefImpl {
  constructor(value) {
    this._value = value
    this._dep = new Dep()
  }
  
  get value() {
    this._dep.depend()  // 收集依赖
    return this._value
  }
  
  set value(newVal) {
    this._value = newVal
    this._dep.notify()  // 触发更新
  }
}

// reactive 内部实现 (简化)
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      const dep = getDep(target, key)
      dep.depend()
      const value = Reflect.get(target, key)
      // 深层响应式
      if (typeof value === 'object' && value !== null) {
        return reactive(value)
      }
      return value
    },
    set(target, key, value) {
      const dep = getDep(target, key)
      target[key] = value
      dep.notify()
      return true
    }
  })
}

1.2 shallowRef 和 triggerRef

vue
<script setup>
import { shallowRef, triggerRef, ref } from 'vue'

// shallowRef: 只有 .value 变化才触发更新
const shallow = shallowRef({ count: 0 })
shallow.value.count++  // 不会触发更新!
shallow.value = { count: 1 }  // 才会触发

// triggerRef: 手动触发 shallowRef 更新
const state = shallowRef({ items: [] })

function updateItems() {
  // 直接修改内部属性不会触发
  state.value.items.push({ id: 1 })
  // 需要手动触发
  triggerRef(state)
}

// ref: 深层响应式
const deep = ref({ items: [] })
deep.value.items.push({ id: 1 })  // 会自动触发
</script>

1.3 readonly 和 shallowReadonly

javascript
import { readonly, shallowReadonly, ref, reactive } from 'vue'

const count = ref(0)
const obj = reactive({ count: 0 })

// readonly: 深层只读
const readOnlyCount = readonly(count)
readOnlyCount.value = 1  // 警告: Cannot set readonly property

// shallowReadonly: 只有第一层只读
const shallowReadOnly = shallowReadonly({
  count: 0,
  nested: { value: 1 }
})
shallowReadOnly.nested.value = 2  // 可以修改!
shallowReadOnly.count = 1  // 警告

// 应用场景
const state = reactive({
  user: { name: '张三' },
  settings: { theme: 'dark' }
})

// 暴露给外部前设置为只读
function useStore() {
  return {
    // 只读的用户信息
    user: readonly(state.user),
    // 可修改的设置
    settings: state.settings
  }
}

1.4 toRaw 和 markRaw

javascript
import { toRaw, markRaw, isRef, isProxy } from 'vue'

const obj = reactive({ name: '张三' })

// toRaw: 获取原始对象
const raw = toRaw(obj)
console.log(obj === raw)  // true

// 应用: 性能优化,避免重复代理
function processData(data) {
  const raw = toRaw(data)
  // 直接操作原始对象,不触发响应式
  raw.processed = true
  return raw
}

// markRaw: 标记为非响应式
const nonReactive = markRaw({
  name: '张三',
  methods: {
    greet() { console.log('Hello') }
  }
})

// 放入 reactive 中不会变成响应式
const state = reactive({
  items: [],
  meta: nonReactive  // meta 不会响应式
})

state.meta.greet()  // 可以调用
state.items.push(nonReactive)  // 可以,但 reactive 检测不到变化

// 注意事项
state.meta.name = '李四'  // 修改不会触发响应式更新

2. 高级组合式函数

2.1 useAsync

javascript
// composables/useAsync.js
import { ref, shallowRef } from 'vue'

export function useAsync(asyncFn, options = {}) {
  const { immediate = false, onSuccess, onError } = options
  
  const data = shallowRef(null)
  const error = shallowRef(null)
  const isLoading = ref(false)
  const isSuccess = ref(false)
  const isError = ref(false)
  
  async function execute(...args) {
    isLoading.value = true
    error.value = null
    isSuccess.value = false
    isError.value = false
    
    try {
      const result = await asyncFn(...args)
      data.value = result
      isSuccess.value = true
      onSuccess?.(result)
      return result
    } catch (e) {
      error.value = e
      isError.value = true
      onError?.(e)
      throw e
    } finally {
      isLoading.value = false
    }
  }
  
  if (immediate) {
    execute()
  }
  
  return {
    data,
    error,
    isLoading,
    isSuccess,
    isError,
    execute
  }
}

// 使用
const { data: users, isLoading, error, execute } = useAsync(
  (id) => fetch(`/api/users/${id}`).then(r => r.json()),
  { immediate: true }
)

// 手动触发
const { execute } = useAsync(
  (payload) => fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify(payload)
  }).then(r => r.json())
)

async function createUser() {
  try {
    await execute({ name: '张三', age: 25 })
    console.log('创建成功')
  } catch (e) {
    console.error('创建失败:', e)
  }
}

2.2 useDebounce

javascript
// composables/useDebounce.js
import { ref, watch } from 'vue'

export function useDebounce(value, delay = 300) {
  const debouncedValue = ref(value.value)
  let timeout
  
  watch(value, (newVal) => {
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      debouncedValue.value = newVal
    }, delay)
  })
  
  return debouncedValue
}

// 完整版
export function useDebounceFn(fn, delay = 300) {
  let timeout
  
  function debounced(...args) {
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      fn(...args)
    }, delay)
  }
  
  function cancel() {
    clearTimeout(timeout)
  }
  
  function flush(...args) {
    cancel()
    fn(...args)
  }
  
  return { debounced, cancel, flush }
}

// 使用
const searchQuery = ref('')
const debouncedQuery = useDebounce(searchQuery, 500)

watch(debouncedQuery, (query) => {
  // 搜索
  searchAPI(query)
})

2.3 useLocalStorage

javascript
// composables/useLocalStorage.js
import { ref, watch } from 'vue'

export function useLocalStorage(key, defaultValue) {
  const stored = localStorage.getItem(key)
  const data = ref(stored ? JSON.parse(stored) : defaultValue)
  
  watch(data, (newVal) => {
    if (newVal === null || newVal === undefined) {
      localStorage.removeItem(key)
    } else {
      localStorage.setItem(key, JSON.stringify(newVal))
    }
  }, { deep: true })
  
  return data
}

// 使用
const theme = useLocalStorage('theme', 'light')
const userPrefs = useLocalStorage('prefs', { sidebar: true, fontSize: 14 })

2.4 useMediaQuery

javascript
// composables/useMediaQuery.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useMediaQuery(query) {
  const matches = ref(false)
  let mediaQuery
  
  function update() {
    matches.value = mediaQuery.matches
  }
  
  onMounted(() => {
    mediaQuery = window.matchMedia(query)
    update()
    mediaQuery.addEventListener('change', update)
  })
  
  onUnmounted(() => {
    mediaQuery?.removeEventListener('change', update)
  })
  
  return matches
}

// 使用
const isMobile = useMediaQuery('(max-width: 768px)')
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
const isLandscape = useMediaQuery('(orientation: landscape)')

3. 组件进阶

3.1 异步组件

vue
<!-- AsyncComponent.vue -->
<script setup>
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import ErrorBoundary from './ErrorBoundary.vue'

// 基础异步组件
const AsyncUser = defineAsyncComponent(() => 
  import('./User.vue')
)

// 带选项的异步组件
const AsyncUserWithOptions = defineAsyncComponent({
  loader: () => import('./User.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorBoundary,
  delay: 200,
  timeout: 3000,
  onError(error, retry, fail, attempts) {
    // 错误处理
    if (attempts < 3) {
      retry()  // 重试
    } else {
      fail()   // 放弃
    }
  }
})
</script>

<template>
  <Suspense>
    <template #default>
      <AsyncUser />
    </template>
    <template #fallback>
      <LoadingSpinner />
    </template>
  </Suspense>
</template>

3.2 依赖注入进阶

javascript
// provide key
const INJECTION_KEY = Symbol('myPlugin')
const MY_STRING_KEY = 'myString'

export function providePlugin(app) {
  app.provide(INJECTION_KEY, {
    version: '1.0.0',
    config: { apiUrl: 'https://api.example.com' }
  })
}

// 组件中
import { inject } from 'vue'

export default {
  setup() {
    // 使用 Symbol key
    const plugin = inject(INJECTION_KEY)
    
    // 使用字符串 key + 默认值
    const myString = inject(MY_STRING_KEY, 'default value')
    
    // 注入响应式数据
    const { state, update } = inject('sharedState')
    
    return { plugin, myString, state, update }
  }
}

3.3 渲染函数

vue
<script setup>
import { h, ref } from 'vue'

const count = ref(0)

// 方式1: 使用 render 函数
const vnode = h('div', { class: 'container' }, [
  h('h1', '计数器: ' + count.value),
  h('button', { onClick: () => count.value++ }, '+1')
])

// 方式2: 结合模板
// 需要在 setup 中返回 render 函数
function render() {
  return h('div', { class: 'wrapper' }, [
    h('p', `计数: ${count.value}`),
    h('button', { onClick: () => count.value-- }, '-1')
  ])
}

export default { render }
</script>

4. 性能优化

4.1 v-memo

vue
<template>
  <!-- 只有 title 或 deep === 3 时才重新渲染 -->
  <div v-memo="[title, deep === 3]">
    <h2>{{ title }}</h2>
    <p>{{ content }}</p>
    <span>{{ timestamp }}</span>
  </div>
</template>

<script setup>
const title = ref('标题')
const content = ref('内容')
const timestamp = ref(Date.now())
const deep = ref(3)
</script>

4.2 v-once

vue
<template>
  <!-- 只渲染一次,不会改变 -->
  <div v-once>
    <StaticContent />
  </div>
  
  <!-- 结合 v-if -->
  <div v-if="show">
    <ExpensiveComponent v-once />
  </div>
</template>

4.3 路由懒加载优化

javascript
// 路由配置
const routes = [
  // 方式1: 直接 import
  { path: '/', component: import('./Home.vue') },
  
  // 方式2: 带注释控制 chunk 名称
  { 
    path: '/about', 
    component: () => import(/* webpackChunkName: "about" */ './About.vue')
  },
  
  // 方式3: 预加载
  { 
    path: '/user', 
    component: () => import(/* webpackPrefetch: true */ './User.vue')
  }
]

// 组件中预加载
import { preloadRouteComponents } from 'vue-router/auto'

// 用户悬停时预加载
function onHoverLink(to) {
  preloadRouteComponents(to).catch(() => {})
}

4.4 虚拟列表

vue
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'

const props = defineProps({
  items: Array,
  itemHeight: { type: Number, default: 50 },
  visibleCount: { type: Number, default: 10 }
})

const containerRef = ref(null)
const scrollTop = ref(0)

const totalHeight = computed(() => props.items.length * props.itemHeight)

const visibleData = computed(() => {
  const start = Math.floor(scrollTop.value / props.itemHeight)
  const end = start + props.visibleCount + 2
  return props.items.slice(start, end).map((item, index) => ({
    ...item,
    top: (start + index) * props.itemHeight
  }))
})

function onScroll(e) {
  scrollTop.value = e.target.scrollTop
}
</script>

<template>
  <div 
    ref="containerRef" 
    class="virtual-list" 
    @scroll="onScroll"
  >
    <div :style="{ height: totalHeight + 'px' }">
      <div 
        v-for="item in visibleData" 
        :key="item.id"
        class="virtual-item"
        :style="{ transform: `translateY(${item.top}px)` }"
      >
        {{ item.name }}
      </div>
    </div>
  </div>
</template>

5. TypeScript 支持

5.1 组件类型定义

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

interface User {
  id: number
  name: string
  email: string
}

interface Props {
  title: string
  users: User[]
  type: 'info' | 'warning' | 'error'
  onClick?: (user: User) => void
}

const props = withDefaults(defineProps<Props>(), {
  title: '默认标题',
  users: () => [],
  type: 'info'
})

const emit = defineEmits<{
  (e: 'update', value: string): void
  (e: 'delete', id: number): void
}>()

// ref 类型推导
const count = ref(0)  // Ref<number>
const name = ref('')  // Ref<string>

// 手动类型
const count2 = ref<number | null>(null)

// computed 类型推导
const doubleCount = computed(() => count.value * 2)

// 函数类型
const handleClick = (user: User) => {
  emit('click', user)
}
</script>

5.2 组合式函数类型定义

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

interface User {
  id: number
  name: string
}

interface UseUserReturn {
  user: Ref<User | null>
  isLoading: Ref<boolean>
  error: Ref<Error | null>
  fetchUser: (id: number) => Promise<void>
  updateUser: (data: Partial<User>) => Promise<void>
}

export function useUser(): UseUserReturn {
  const user = ref<User | null>(null)
  const isLoading = ref(false)
  const error = ref<Error | null>(null)
  
  async function fetchUser(id: number) {
    isLoading.value = true
    error.value = null
    try {
      const res = await fetch(`/api/users/${id}`)
      user.value = await res.json()
    } catch (e) {
      error.value = e as Error
    } finally {
      isLoading.value = false
    }
  }
  
  async function updateUser(data: Partial<User>) {
    if (!user.value) return
    // ...
  }
  
  return {
    user,
    isLoading,
    error,
    fetchUser,
    updateUser
  }
}

5.3 模板引用类型

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

// 获取子组件实例类型
interface ChildComponentExposed {
  open: (id: string) => void
  close: () => void
  value: string
}

const childRef = ref<ComponentPublicInstance<ChildComponentExposed> | null>(null)

function handleClick() {
  if (childRef.value) {
    childRef.value.open('123')
  }
}

// 获取原生元素类型
const inputRef = ref<HTMLInputElement | null>(null)

function focusInput() {
  inputRef.value?.focus()
}

// 获取组件实例
const componentRef = ref<InstanceType<typeof MyComponent> | null>(null)
</script>

<template>
  <MyComponent ref="childRef" />
  <input ref="inputRef" />
</template>

6. 面试要点

6.1 响应式原理

Vue 3 使用 Proxy 实现响应式:

ref:
- 基本类型: 包装成 { value: xxx }
- 对象类型: reactive(value)

reactive:
- 直接返回 Proxy
- 深层代理,所有嵌套属性都响应式

对比 Vue 2:
- Vue 2 使用 Object.defineProperty
- 无法监听新增属性 (需要 $set)
- 无法监听数组索引变化
- 需要递归遍历,性能差

6.2 组合式函数设计原则

1. 单一职责
   - 每个 composable 只做一件事
   - useUser, useAuth, usePermission 分开

2. 显式依赖
   - 参数明确,副作用可控

3. 返回一致
   - 统一返回 { state, actions, ... }

4. 便于测试
   - composable 逻辑可以单独测试

5. 命名规范
   - useXxx 开头
   - 返回的 ref 命名清晰

6.3 性能优化技巧

1. 路由懒加载
   - component: () => import('./view.vue')

2. v-memo
   - 大量列表渲染时,控制不必要的更新

3. v-once
   - 不变的静态内容

4. shallowRef
   - 大数据结构,不需要深层响应式

5. localStorage 封装
   - 减少持久化开销

6. 虚拟列表
   - 长列表必备

6.4 nextTick 原理

javascript
// 简单实现
class Scheduler {
  callbacks = []
  pending = false
  
  flush() {
    this.pending = false
    const cbs = [...this.callbacks]
    this.callbacks = []
    cbs.forEach(cb => cb())
  }
  
  nextTick(cb) {
    return new Promise(resolve => {
      this.callbacks.push(() => {
        cb()
        resolve()
      })
      if (!this.pending) {
        this.pending = true
        Promise.resolve().then(() => this.flush())
      }
    })
  }
}

// 使用
await nextTick()
// DOM 已经更新

基于 MIT 许可发布