1. 为什么需要自定义组合式函数
在Vue3中,组合式API的出现彻底改变了我们组织组件逻辑的方式。记得我刚从Vue2迁移到Vue3时,最让我头疼的就是如何把那些散落在各个生命周期钩子里的逻辑重新组织起来。组合式函数(Composable)就是解决这个问题的利器。
与Vue2的Mixins相比,组合式函数有几个明显的优势。首先,Mixins会导致属性来源不清晰 - 当一个组件使用多个Mixins时,你很难一眼看出某个属性来自哪个Mixin。其次,Mixins之间存在命名冲突的风险。最重要的是,Mixins无法形成良好的类型推导,这在TypeScript项目中尤为明显。
实际项目中,我曾遇到一个使用了5个Mixins的组件,调试时花了整整一天才理清各个属性的来源。这正是组合式函数要解决的问题。
2. 组合式函数的核心设计原则
2.1 单一职责原则
一个好的组合式函数应该像瑞士军刀中的一个工具 - 专注做好一件事。比如处理鼠标位置、管理表单状态、或者控制异步请求。我见过一些开发者把太多不相关的逻辑塞进一个组合式函数,结果又造出了一个"超级Mixin"。
2.2 响应式数据隔离
组合式函数内部应该使用ref或reactive来封装自己的状态,并通过返回值暴露给组件。这样可以确保多个组件实例使用同一个组合式函数时,各自拥有独立的状态副本。
// 不好的写法 - 直接修改外部状态 function useCounter() { const count = ref(0) window.count = count // 错误!会污染全局作用域 return { count } } // 正确的写法 - 完全自包含 function useCounter() { const count = ref(0) const increment = () => count.value++ return { count, increment } }2.3 明确的输入输出
组合式函数应该通过参数接收配置,通过返回值提供功能。这种明确的接口使得函数更容易被理解和重用。
3. 实战:编写一个useMouse跟踪器
让我们通过一个具体的例子来演示如何编写一个自定义组合式函数。我们将创建一个跟踪鼠标位置的useMouse函数。
3.1 基础实现
import { ref, onMounted, onUnmounted } from 'vue' export function useMouse() { const x = ref(0) const y = ref(0) function update(event) { x.value = event.pageX y.value = event.pageY } onMounted(() => window.addEventListener('mousemove', update)) onUnmounted(() => window.removeEventListener('mousemove', update)) return { x, y } }在组件中使用:
import { useMouse } from './useMouse' export default { setup() { const { x, y } = useMouse() return { x, y } } }3.2 添加配置选项
让我们增强这个函数,使其可以配置是否跟踪鼠标移动:
export function useMouse({ trackMovement = true } = {}) { const x = ref(0) const y = ref(0) let update = () => {} if (trackMovement) { update = (event) => { x.value = event.pageX y.value = event.pageY } onMounted(() => window.addEventListener('mousemove', update)) onUnmounted(() => window.removeEventListener('mousemove', update)) } return { x, y } }3.3 性能优化版本
对于高频触发的事件,我们可以添加节流控制:
import { throttle } from 'lodash-es' export function useMouse({ throttleDelay = 100 } = {}) { const x = ref(0) const y = ref(0) const update = throttle((event) => { x.value = event.pageX y.value = event.pageY }, throttleDelay) onMounted(() => window.addEventListener('mousemove', update)) onUnmounted(() => { window.removeEventListener('mousemove', update) update.cancel() // 记得取消节流函数的pending调用 }) return { x, y } }4. 组合式函数与Mixins的对比
4.1 代码组织对比
Mixins方式:
const mouseMixin = { data() { return { mixinX: 0, mixinY: 0 } }, mounted() { window.addEventListener('mousemove', this.handleMouseMove) }, beforeUnmount() { window.removeEventListener('mousemove', this.handleMouseMove) }, methods: { handleMouseMove(event) { this.mixinX = event.pageX this.mixinY = event.pageY } } }组合式函数方式:
// 使用前面定义的useMouse const { x, y } = useMouse()4.2 类型支持对比
Mixins在TypeScript中几乎无法获得良好的类型推断,而组合式函数可以完美支持:
interface MousePosition { x: Ref<number> y: Ref<number> } export function useMouse(): MousePosition { // 实现... }4.3 组合能力对比
Mixins的合并策略可能导致意外行为,而组合式函数可以显式组合:
// 组合多个功能 const { x, y } = useMouse() const { width, height } = useWindowSize() const user = useUser()5. 高级模式与最佳实践
5.1 异步组合式函数
组合式函数也可以处理异步逻辑:
export function useFetch(url) { const data = ref(null) const error = ref(null) const loading = ref(false) async function fetchData() { loading.value = true try { const response = await fetch(url) data.value = await response.json() } catch (err) { error.value = err } finally { loading.value = false } } onMounted(fetchData) return { data, error, loading, retry: fetchData } }5.2 共享状态组合式函数
有时我们需要在组件间共享状态:
// shared.js import { reactive } from 'vue' const state = reactive({ count: 0 }) export function useSharedCounter() { const increment = () => { state.count++ } return { count: readonly(state.count), increment } }5.3 测试组合式函数
组合式函数很容易测试,因为它们不依赖组件实例:
import { useMouse } from './useMouse' import { nextTick } from 'vue' test('useMouse', async () => { const { x, y } = useMouse() window.dispatchEvent(new MouseEvent('mousemove', { pageX: 100, pageY: 200 })) await nextTick() expect(x.value).toBe(100) expect(y.value).toBe(200) })6. 常见问题与解决方案
6.1 生命周期钩子的执行顺序
当组合式函数和组件都使用了相同的生命周期钩子时,执行顺序是:
- 组合式函数的onMounted
- 组件的mounted
我在实际项目中遇到过因为执行顺序导致的bug,建议在组合式函数文档中明确说明它使用的生命周期钩子。
6.2 SSR兼容性
在服务端渲染时,浏览器API如window不可用:
import { ref, onMounted, onUnmounted } from 'vue' import { inBrowser } from 'vitepress' export function useMouse() { const x = ref(0) const y = ref(0) if (inBrowser) { // 只在客户端执行 onMounted(() => { window.addEventListener('mousemove', update) }) } // ... }6.3 内存泄漏
忘记清理副作用是常见错误:
// 错误的写法 - 没有清理定时器 function usePolling(callback, interval) { onMounted(() => { setInterval(callback, interval) }) } // 正确的写法 function usePolling(callback, interval) { let timer onMounted(() => { timer = setInterval(callback, interval) }) onUnmounted(() => { clearInterval(timer) }) }7. 组合式函数设计模式
7.1 工厂函数模式
当需要创建多个独立实例时:
export function createCounter(initialValue = 0) { const count = ref(initialValue) const increment = () => count.value++ const decrement = () => count.value-- return { count, increment, decrement } } // 使用 const counter1 = createCounter() const counter2 = createCounter(10)7.2 中间件模式
通过函数组合增强功能:
function withLogger(composable) { return function(...args) { const result = composable(...args) // 添加日志功能 console.log('Composable called with:', args) return result } } const useMouseWithLog = withLogger(useMouse)7.3 状态共享模式
使用provide/inject在组件树中共享状态:
// provider.js import { provide, inject, ref } from 'vue' const AuthSymbol = Symbol() export function provideAuth() { const user = ref(null) const login = async (creds) => { // 登录逻辑 } provide(AuthSymbol, { user, login }) } export function useAuth() { const auth = inject(AuthSymbol) if (!auth) { throw new Error('No auth provided') } return auth }8. 从Mixins迁移到组合式函数
8.1 迁移策略
- 识别Mixins中的逻辑关注点
- 将每个关注点提取为独立的组合式函数
- 在组件中按需组合这些函数
8.2 示例迁移
迁移前(Mixins):
// userMixin.js export default { data() { return { user: null, loading: false } }, methods: { async fetchUser(id) { this.loading = true this.user = await fetchUser(id) this.loading = false } } }迁移后(组合式函数):
// useUser.js export function useUser() { const user = ref(null) const loading = ref(false) async function fetchUser(id) { loading.value = true user.value = await fetchUser(id) loading.value = false } return { user, loading, fetchUser } }8.3 混合使用策略
在大型项目中,可以逐步迁移:
export default { mixins: [legacyMixin], setup() { // 新功能使用组合式API const { x, y } = useMouse() return { x, y } } }9. 组合式函数在大型项目中的应用
9.1 目录结构组织
src/ composables/ useMouse.js useFetch.js useAuth/ index.js types.js mock.js dom/ useScroll.js useResize.js form/ useField.js useForm.js9.2 类型定义最佳实践
使用TypeScript时,为组合式函数添加完整类型:
interface UseMouseOptions { throttleDelay?: number trackMovement?: boolean } interface UseMouseReturn { x: Ref<number> y: Ref<number> } export function useMouse(options?: UseMouseOptions): UseMouseReturn { // 实现... }9.3 文档规范
每个组合式函数应该有清晰的文档:
/** * 跟踪鼠标位置 * @param {Object} options - 配置选项 * @param {number} [options.throttleDelay=100] - 节流延迟(ms) * @param {boolean} [options.trackMovement=true] - 是否跟踪鼠标移动 * @returns {Object} 包含x和y坐标的对象 * @example * const { x, y } = useMouse() */ export function useMouse(options = {}) { // 实现... }10. 性能优化技巧
10.1 惰性计算
使用computed属性避免不必要的计算:
export function useFilteredList(list, filterFn) { const filtered = computed(() => list.value.filter(filterFn)) return { filtered } }10.2 响应式转换优化
避免深层响应式转换:
// 不好的写法 - 整个大对象都被转换为响应式 const data = reactive(bigDataObject) // 好的写法 - 只转换需要的部分 const data = { key1: ref(value1), key2: ref(value2) }10.3 事件监听优化
对于高频事件,使用passive事件监听:
onMounted(() => { window.addEventListener('scroll', onScroll, { passive: true }) })11. 组合式函数生态系统
11.1 流行组合式函数库
- VueUse:最全面的Vue组合式函数集合
- @vue/composition-api:Vue2的兼容版本
- vue-concurrency:处理异步操作的组合式函数
11.2 如何选择现有解决方案
- 评估功能需求
- 检查维护活跃度
- 考虑包体积大小
- 验证TypeScript支持
11.3 发布自己的组合式函数
创建可发布的组合式函数包:
# 项目结构 my-composable/ src/ index.js # 主入口 utils.js # 工具函数 package.json README.mdpackage.json关键配置:
{ "name": "use-awesome", "version": "1.0.0", "main": "dist/use-awesome.cjs.js", "module": "dist/use-awesome.esm.js", "types": "dist/index.d.ts", "peerDependencies": { "vue": "^3.0.0" } }12. 组合式函数的未来演进
12.1 Vue3.3+的新特性
- 改进的响应性系统
- 更好的TypeScript支持
- 新的defineOptions API
12.2 组合式API与Signal的比较
虽然Solid.js等框架的Signal概念与Vue的ref类似,但组合式API提供了更丰富的生态系统和工具支持。
12.3 组合式函数在微前端中的应用
组合式函数特别适合微前端架构,可以在不同子应用间共享逻辑:
// 主应用 import { useAuth } from '@shared/composables' // 子应用同样可以复用 const { user } = useAuth()13. 实战案例:构建一个完整的usePaginatedFetch
让我们通过一个更复杂的例子来总结 - 构建一个支持分页的请求组合式函数:
import { ref, computed } from 'vue' export function usePaginatedFetch(fetcher, options = {}) { const { initialPage = 1, pageSize = 10, initialData = [], immediate = true } = options const data = ref(initialData) const error = ref(null) const loading = ref(false) const page = ref(initialPage) const total = ref(0) const totalPages = computed(() => Math.ceil(total.value / pageSize)) const hasNext = computed(() => page.value < totalPages.value) const hasPrev = computed(() => page.value > 1) async function fetch() { try { loading.value = true const result = await fetcher({ page: page.value, pageSize }) data.value = result.data total.value = result.total } catch (err) { error.value = err } finally { loading.value = false } } function next() { if (hasNext.value) { page.value++ fetch() } } function prev() { if (hasPrev.value) { page.value-- fetch() } } function goTo(p) { if (p >= 1 && p <= totalPages.value) { page.value = p fetch() } } if (immediate) { fetch() } return { data, error, loading, page, total, totalPages, hasNext, hasPrev, fetch, next, prev, goTo } }使用示例:
const { data: posts, page, totalPages, next, prev } = usePaginatedFetch(({ page, pageSize }) => { return fetchPosts({ page, limit: pageSize }) })这个例子展示了组合式函数如何封装复杂逻辑,同时保持代码的清晰和可维护性。在实际项目中,这种模式可以显著减少重复代码,特别是处理常见但复杂的交互模式时。