维度 | Options API | Composition API |
---|---|---|
代码组织 | 按选项类型分组(data/methods等) | 按逻辑功能组织 |
复用性 | Mixins/继承 | 自定义Hook函数 |
TypeScript支持 | 有限 | 完整支持 |
逻辑抽象 | 困难 | 灵活 |
学习曲线 | 平缓 | 较陡峭 |
import { ref, reactive, shallowRef, shallowReactive } from 'vue'
// 完全响应式
const state = reactive({
count: 0,
user: { name: 'Alice' } // 嵌套对象也是响应式的
})
const num = ref(0) // 需要.value访问
// 浅层响应式
const shallowState = shallowReactive({
nested: { count: 0 } // nested不会自动解包
})
const shallowNum = shallowRef(0) // 不追踪.value内部变化
源码级响应式流程:
reactive()
→ createReactiveObject()
get
→ track
依赖收集set
→ trigger
触发更新targetMap
中手动依赖控制:
import { effect, stop } from '@vue/reactivity'
const runner = effect(() => {
console.log(state.count)
})
// 手动停止响应
stop(runner)
// hooks/useFetch.ts
import { ref, watchEffect, type Ref } from 'vue'
interface UseFetchOptions<T> {
immediate?: boolean
initialData?: T
onSuccess?: (data: T) => void
onError?: (error: Error) => void
}
export function useFetch<T>(
url: Ref<string> | string,
options: UseFetchOptions<T> = {}
) {
const data = ref<T | undefined>(options.initialData)
const error = ref<Error | null>(null)
const isFetching = ref(false)
const isFinished = ref(false)
async function execute() {
isFetching.value = true
error.value = null
try {
const response = await fetch(unref(url))
if (!response.ok) throw new Error(response.statusText)
data.value = await response.json()
options.onSuccess?.(data.value)
} catch (err) {
error.value = err as Error
options.onError?.(error.value)
} finally {
isFetching.value = false
isFinished.value = true
}
}
watchEffect(() => {
if (options.immediate !== false) {
execute()
}
})
return {
data,
error,
isFetching,
isFinished,
execute,
refresh: execute
}
}
// hooks/usePagination.ts
import { ref, computed } from 'vue'
import { useFetch } from './useFetch'
interface UsePaginationOptions<T> {
pageSize?: number
initialPage?: number
fetchOptions?: Omit<UseFetchOptions<T[]>, 'initialData'>
}
export function usePagination<T>(
url: string,
options: UsePaginationOptions<T> = {}
) {
const currentPage = ref(options.initialPage || 1)
const pageSize = ref(options.pageSize || 10)
const {
data: items,
error,
isFetching,
execute: refresh
} = useFetch<T[]>(
computed(() => `${url}?page=${currentPage.value}&size=${pageSize.value}`),
{
...options.fetchOptions,
immediate: true
}
)
const total = ref(0)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
async function loadPage(page: number) {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
await refresh()
}
return {
items,
currentPage,
pageSize,
total,
totalPages,
isFetching,
error,
loadPage,
refresh
}
}
// contexts/themeContext.ts
import { inject, provide, type InjectionKey, type Ref } from 'vue'
interface Theme {
primaryColor: string
secondaryColor: string
darkMode: boolean
}
const ThemeSymbol: InjectionKey<Ref<Theme>> = Symbol.for('theme')
export function provideTheme(theme: Ref<Theme>) {
provide(ThemeSymbol, theme)
}
export function useTheme() {
const theme = inject(ThemeSymbol)
if (!theme) throw new Error('Theme not provided!')
return theme
}
// composables/useSharedState.ts
import { reactive, readonly } from 'vue'
interface SharedState {
user: {
id: string
name: string
} | null
permissions: string[]
}
const state = reactive<SharedState>({
user: null,
permissions: []
})
export function setUser(user: SharedState['user']) {
state.user = user
}
export function setPermissions(permissions: string[]) {
state.permissions = permissions
}
export function useSharedState() {
return readonly(state) // 返回只读副本
}
// components/ComponentFactory.tsx
import { defineComponent, type VNode } from 'vue'
interface ComponentDefinition {
type: string
props?: Record<string, any>
children?: ComponentDefinition[]
}
export const ComponentFactory = defineComponent({
props: {
schema: {
type: Object as () => ComponentDefinition,
required: true
}
},
setup(props) {
const resolveComponent = (def: ComponentDefinition): VNode => {
const { type, props: componentProps = {}, children = [] } = def
return h(
type,
componentProps,
children.map(child => resolveComponent(child))
)
}
return () => resolveComponent(props.schema)
}
})
// components/withLoading.tsx
import { defineComponent, h, ref } from 'vue'
export function withLoading(component: any) {
return defineComponent({
setup(props, { slots }) {
const isLoading = ref(false)
const setLoading = (value: boolean) => {
isLoading.value = value
}
return () => [
h(component, {
...props,
setLoading
}, slots),
isLoading.value && h('div', { class: 'loading-overlay' }, 'Loading...')
]
}
})
}
import { ref, watch, type WatchSource } from 'vue'
export function useDebounce<T>(
source: WatchSource<T>,
delay: number = 200
) {
const debouncedValue = ref<T>()
let timeout: number
watch(source, (newValue) => {
clearTimeout(timeout)
timeout = window.setTimeout(() => {
debouncedValue.value = newValue
}, delay)
}, { immediate: true })
return debouncedValue
}
import { ref, watch, type WatchSource } from 'vue'
export function useThrottle<T>(
source: WatchSource<T>,
interval: number = 200
) {
const throttledValue = ref<T>()
let lastExecuted = 0
watch(source, (newValue) => {
const now = Date.now()
if (now - lastExecuted >= interval) {
throttledValue.value = newValue
lastExecuted = now
}
}, { immediate: true })
return throttledValue
}
// tests/hooks/useCounter.spec.ts
import { renderHook } from '@testing-library/vue'
import { useCounter } from '@/hooks/useCounter'
describe('useCounter', () => {
it('should increment count', () => {
const { result } = renderHook(() => useCounter(0))
expect(result.current.count.value).toBe(0)
result.current.increment()
expect(result.current.count.value).toBe(1)
})
it('should reset count', async () => {
const { result } = renderHook(() => useCounter(10))
result.current.reset()
expect(result.current.count.value).toBe(0)
})
})
// tests/components/ThemeProvider.spec.ts
import { render, h } from '@testing-library/vue'
import { provideTheme, useTheme } from '@/contexts/themeContext'
const TestComponent = {
setup() {
const theme = useTheme()
return () => h('div', theme.value.primaryColor)
}
}
test('provides theme context', () => {
const theme = { primaryColor: '#ff0000' }
const { container } = render({
setup() {
provideTheme(ref(theme))
return () => h(TestComponent)
}
})
expect(container.textContent).toBe(theme.primaryColor)
})
src/
├── composables/ # 自定义Hook
│ ├── useFetch.ts # API请求Hook
│ ├── usePagination.ts # 分页逻辑
│ └── index.ts # 统一导出
├── contexts/ # 依赖注入上下文
│ ├── themeContext.ts # 主题上下文
│ └── authContext.ts # 认证上下文
├── lib/ # 工具库
│ ├── debounce.ts # 防抖函数
│ └── throttle.ts # 节流函数
├── factories/ # 组件工厂
│ ├── FormFactory.tsx # 动态表单
│ └── TableFactory.tsx # 动态表格
└── hooks/ # 业务Hook
├── useUser.ts # 用户相关
└── useProducts.ts # 商品相关
// types/hooks.d.ts
declare module '@/composables/useFetch' {
export interface UseFetchOptions<T> {
immediate?: boolean
initialData?: T
onSuccess?: (data: T) => void
onError?: (error: Error) => void
}
export function useFetch<T>(
url: Ref<string> | string,
options?: UseFetchOptions<T>
): {
data: Ref<T | undefined>
error: Ref<Error | null>
isFetching: Ref<boolean>
isFinished: Ref<boolean>
execute: () => Promise<void>
refresh: () => Promise<void>
}
}
解决方案:
// composables/useA.ts
import { useB } from './useB'
export function useA() {
const { b } = useB()
// ...
}
// composables/useB.ts
import { useA } from './useA'
let a: ReturnType<typeof useA>
export function useB() {
if (!a) a = useA() // 延迟初始化
return {
// ...
}
}
自动清理Hook:
import { onUnmounted } from 'vue'
export function useAutoCleanup() {
const cleanupCallbacks = new Set<() => void>()
function autoCleanup(cb: () => void) {
cleanupCallbacks.add(cb)
}
onUnmounted(() => {
cleanupCallbacks.forEach(cb => cb())
cleanupCallbacks.clear()
})
return { autoCleanup }
}
// vite.config.js
export default {
plugins: [
vue({
reactivityTransform: true
})
]
}
// 使用$ref语法糖
let count = $ref(0) // 自动解包,无需.value
通过深入理解和应用这些高级特性,开发者可以构建出更健壮、更易维护的Vue 3应用程序。Composition API与自定义Hooks的结合,为复杂前端应用提供了全新的开发范式,使得逻辑复用和代码组织达到了前所未有的灵活程度。