Vue 生态(四):组合式艺术 · 打造可复用、可测试的逻辑单元 (Composables)
Vue 生态(四):组合式艺术 · 打造可复用、可测试的逻辑单元 (Composables)
Prorise第四章:组合式艺术 · 打造可复用、可测试的逻辑单元 (Composables)
摘要: 如果说组件化是对 UI 的封装,那么组合式函数 (Composables) 就是对 逻辑 的封装。本章,我们将迎来一次思维上的重要跃遷:从“在组件里写逻辑”到“将逻辑抽离给组件用”。我们将深入理解 Composables 如何从根本上超越传统 Mixins,并亲手编写简洁、可独立测试的逻辑单元。最重要的是,你将学会如何拥抱 VueUse 这一“终极武器”,将社区的智慧融入日常开发,真正掌握 Vue 3 中最强大、最优雅的代码组织与复用模式。
4.1. 为什么需要新范式?Mixins 的“光环”与“阴影”
在软件开发的演进中,每一个新范式的出现,都是为了解决旧范式的固有问题。在 Vue 2 时代,当我们想在多个组件之间共享相似的逻辑(例如,一个列表组件和一个表格组件都需要分页逻辑),Mixins
曾是我们的首选方案。它看起来很美:将可复用的代码块混入到不同的组件中,避免了复制粘贴。
然而,随着项目复杂度的提升,这杯“方便的良药”却常常伴随着难以忍受的“副作用”,这些副作用正是我们 为什么 迫切需要 Composables 的根本原因。
Mixins 的三大“原罪”
- 黑盒里的“幽灵属性” (数据来源不清晰):当一个组件混入多个 Mixin 时,其
data
或methods
中的属性究竟来自哪里?这就像一个神秘的黑盒,属性凭空出现,你无法一眼追溯其来源。1
2
3
4
5
6
7
8
9
10
11// MyComponent.js
export default {
mixins: [SearchMixin, PaginationMixin],
created() {
// this.items 是 SearchMixin 还是 PaginationMixin 提供的?
// this.loadData() 又是哪个 Mixin 的方法?
// 如果两个 Mixin 都有 loadData(),会发生什么?(答案是:覆盖)
console.log(this.items);
this.loadData();
}
} - “静默的战争” (命名空间冲突):如果两个不同的 Mixin 恰好定义了同名的
data
属性或method
,后混入的会悄无声息地覆盖前者。这种冲突不会报错,但在运行时会导致难以追踪的 Bug,成为大型项目维护的噩梦。 - “类型推断的迷雾” (TypeScript 支持不佳):
Mixins
的动态合并特性,让 TypeScript 很难精确推断出最终组件实例的类型。我们常常需要繁琐的手动类型断言,失去了静态类型检查带来的安全感。
这些痛点指向了一个核心问题:Mixins 破坏了代码的 可预测性 和 可追溯性。我们需要一种更清晰、更安全的逻辑复用模式。
4.2. 解决方案:到底什么是 Composable?
为了解决 Mixins 的根本缺陷,Vue 3 组合式 API 带来了一种回归编程本源的优雅方案——Composables
(组合式函数)。
那么,什么 是 Composable?
一个 Composable,本质上就是一个利用 Vue 响应式 API 来封装和复用有状态逻辑的函数。
让我们拆解这个定义:
- 它是一个函数:这是最关键的一点。它不像 Mixin 那样是一个需要“混入”的配置对象,而是一个可以被正常调用的 JavaScript 函数。
- 封装有状态逻辑:它不仅仅是一个纯粹的工具函数(如
formatDate
),其内部通常包含由ref
或reactive
创建的、会随时间变化的状态。 - 利用响应式 API:这是它“活”起来的关键。它通过
ref
,computed
,watch
等 API 创造出响应式的状态和副作用。 - 清晰的契约:
- 输入明确:所有外部依赖都通过函数参数明确传入。
- 输出可控:所有暴露给外部的状态和方法,都通过
return
语句显式返回。
这种“纯函数”般的模式,天然地解决了 Mixins 的所有问题:数据来源一目了然(来自函数返回值),绝不会有命名冲突(因为你可以对返回的对象进行解构和重命名),并且与 TypeScript 的类型系统完美契合。
4.3. 如何编写?从重复代码到你的第一个 Composable
理论是灰色的,生命之树常青。让我们遵循最真实的开发路径——“先在组件内实现 -> 发现重复 -> 抽离为 Composable”——来亲手打造一个逻辑单元。
4.3.1. 场景:组件内的重复逻辑
假设我们需要在 App.vue
中控制两个独立元素的显示/隐藏。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
我们立即发现了问题:代码重复。管理一个布尔状态并提供一个切换它的方法,这个逻辑模式是通用的。是时候将它抽离出来了。
4.3.2. 抽离逻辑:创建 useToggle.ts
现在,我们来回答 如何 创建一个 Composable。
文件路径: src/composables/useToggle.ts
(新增)
1 | // 1. 导入 Vue 的响应式 API,这是 Composable 的“魔力”来源 |
这个 useToggle
函数完美体现了 Composable 的所有特征:它是一个函数,内部有 ref
状态,并且输入(initialValue
)和输出({ value, toggle }
)都极为清晰。
4.3.3. 重构组件:享用 Composable 的成果
现在,我们可以用这个简洁、可复用的 Composable 来重构 App.vue
。
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
看,组件变得无比清爽!它不再关心“如何”切换状态,只关心“使用”这个切换能力。我们成功地将逻辑与视图表现分离,实现了关注点分离,这就是组合式开发的魅力。
4.4. 站在巨人肩上:如何更专业地使用 Composable
useToggle
是一个很好的入门练习,但对于更复杂的场景,比如“跟踪鼠标位置”、“与 LocalStorage 同步”或“实现输入防抖”,我们是否需要自己从头开始写呢?
答案是:绝对不要!
在你准备动手编写自己的 Composable 之前,请记住一个黄金法则:“先查阅 VueUse,再造轮子”。
VueUse
是一个包含海量高质量、可摇树(tree-shakable)、类型友好的组合式函数的集合库。它几乎涵盖了你在日常开发中可能遇到的所有通用逻辑封装场景。
4.4.1. 场景升级:同步表单数据到 LocalStorage
让我们看一个极其常见的需求:用户填写表单时,我们希望将内容实时保存到 LocalStorage,这样即使用户刷新了页面,已填写的数据也不会丢失。
如果手动实现:你需要关心 onMounted
读取、watch
深度监听、JSON 序列化与解析、错误处理等一系列繁琐细节。这套流程不仅容易出错,而且每个需要此功能的组件都得重复一遍。
现在,看专业的做法:
安装 VueUse:
1 | pnpm add @vueuse/core |
用 VueUse 一行代码解决问题:
文件路径: src/App.vue
(修改)
1 | <script setup lang="ts"> |
一行 useStorage
就代替了手动实现的所有步骤!它更简洁、更健壮,并且经过了社区的充分测试。打开开发者工具,你会看到数据在 localStorage
中实时更新。
4.4.2. VueUse 的专业思维:成为能力调度者
VueUse 函数如此之多,我们无需记忆。而是要建立一套“意图驱动”的工作流:
- 明确意图:用一句话描述你的需求。例如:“我需要 在用户滚动到页面底部时 加载更多数据。”
- 触发检索:打开 VueUse 官网,在搜索框输入你意图的关键词。例如:
scroll bottom
。 - 快速应用:搜索结果会立即指向
useScroll
及其arrivedState
属性。参考其交互式 Demo,快速将代码应用到你的项目中。
4.5. 进阶之道:解剖专业级 Composable 的内部构造
到目前为止,我们已经学会了如何使用 VueUse 来解决问题。但要成为一名真正的专家,我们必须更进一步:像 VueUse 的作者一样思考。本节,我们将不再仅仅是“看”代码,而是要“解剖”三个构成专业级 Composable 的核心设计模式,并学习如何在你自己的逻辑单元中实现它们,从而理解其强大功能背后的精妙构造。
设计模式一:无懈可击的灵活性 · 响应式参数契约
想象一下,你封装了一个用于日志记录的 Composable useMyLogger
,一个简单的版本可能长这样:
1 | // 一个天真的实现 |
这个实现能用,但它给使用者带来了 不必要的负担。它强制使用者必须传入一个 ref
。如果我想记录一个静态字符串,或者一个 computed 属性的值呢?我不得不为了满足这个函数,去额外创建一个 ref
或 computed
,这显然不够优雅。
专业级的 Composable 通过一种更灵活的参数契约解决了这个问题。它能优雅地接受 普通值、一个 ref
,或者一个 返回值的函数 (getter)。在 VueUse 的世界里,这种模式被类型 MaybeRefOrGetter<T>
所定义。
这个“魔法”的核心,是一个名为 toValue
的小巧而强大的工具函数(Vue 3.3+ 已内置)。它的职责就是将上述三种不同形态的输入,统一“解包”成最原始的值。让我们亲手揭开它的面纱:
这是一个 toValue 的底层实现
1 | import { unref } from 'vue'; |
原理一目了然。现在,我们可以用这个模式来升级我们的 useMyLogger
,使其变得无比灵活:
1 | import { ref, toValue, watch, type MaybeRefOrGetter } from 'vue'; |
架构师思维: 在封装你自己的 Composable 时,务必让你的参数接受 MaybeRefOrGetter
类型,并在内部使用 watch(() => toValue(param), ...)
的模式。这能让你的逻辑单元获得无与伦比的灵活性和可组合性,成为一个真正专业的工具。
设计模式二:后顾无忧的健壮性 · 自动化副作用管理
在 JavaScript 中,副作用(如事件监听、定时器、WebSocket 连接)是内存泄漏的重灾区。传统的做法是在组件挂载时创建,在卸载时销毁,代码分散且极易遗忘。
1 | // 传统、易出错的模式 |
这种模式将清理的责任 推给了使用者,这违背了封装的初衷。一个真正健壮的 Composable,应该自己管理好自己的“身后事”。
Vue 3 的 onScopeDispose
API 为此提供了完美的解决方案。它允许我们在当前组件的生命周期作用域中注册一个销毁时的回调函数。当组件被卸载时,这个回调就会被自动执行。VueUse 在此基础上封装了一个更友好的 tryOnScopeDispose
工具。
让我们从零开始,构建一个具备自动清理能力的 useIntervalFn
Composable,来体会这个模式的威力:
1 | import { tryOnScopeDispose } from '@vueuse/core'; // 实际项目中直接从 VueUse 导入 |
这个模式是 VueUse 中所有副作用函数(useEventListener
, useWebSocket
等)的基石。它将复杂的生命周期管理完美地封装在内,为使用者提供了极致简洁和安全的 API。
我们可以在组建中使用他
1 | <script lang="ts" setup> |
设计模式三:拥抱复杂场景 · 可配置的 options
对象
一个好的工具,既要能开箱即用,也要能应对复杂的需求。如果一个 Composable 的行为被硬编码(例如,总是监听 window
对象,总是立即执行),那么当遇到特殊场景(如在 iframe
中操作、在 SSR 环境下,或需要防抖)时,它就会变得毫无用处。
为了解决这个问题,专业的 Composable 几乎总是接受一个可选的 options
对象作为最后一个参数,它就像一个“控制面板”,允许使用者深度定制其行为。
实现这个模式的关键在于 默认值合并 和 逻辑分支。让我们创建一个 useClickCoordinates
来演示如何构建这样一个可配置的系统。
1 | import { ref } from 'vue'; |
在组件中使用这个 composable
1 | <script setup lang="ts"> |
通过这种模式,我们可以轻松扩展出各种强大的功能:
- 依赖注入: 如上面的
target
,可以传入iframe.contentWindow
来监听iframe
内部的点击,或在测试中传入一个 mock 对象。 - 行为标志: 增加一个
isListening
ref,并提供pause
和resume
方法,通过options
中的immediate: false
来决定是否初始就启动监听。 - 功能增强: 增加一个
debounce
或throttle
选项,在useEventListener
内部对handler
进行包装。
通过学习并应用这三大设计模式,你将不仅能高效地使用 VueUse,更能亲手打造出同样专业、健壮、灵活的组合式函数,让你的代码质量和架构能力提升到一个新的层次。