Vue 生态(四):组合式艺术 · 打造可复用、可测试的逻辑单元 (Composables)

第四章:组合式艺术 · 打造可复用、可测试的逻辑单元 (Composables)

摘要: 如果说组件化是对 UI 的封装,那么组合式函数 (Composables) 就是对 逻辑 的封装。本章,我们将迎来一次思维上的重要跃遷:从“在组件里写逻辑”到“将逻辑抽离给组件用”。我们将深入理解 Composables 如何从根本上超越传统 Mixins,并亲手编写简洁、可独立测试的逻辑单元。最重要的是,你将学会如何拥抱 VueUse 这一“终极武器”,将社区的智慧融入日常开发,真正掌握 Vue 3 中最强大、最优雅的代码组织与复用模式。


4.1. 为什么需要新范式?Mixins 的“光环”与“阴影”

在软件开发的演进中,每一个新范式的出现,都是为了解决旧范式的固有问题。在 Vue 2 时代,当我们想在多个组件之间共享相似的逻辑(例如,一个列表组件和一个表格组件都需要分页逻辑),Mixins 曾是我们的首选方案。它看起来很美:将可复用的代码块混入到不同的组件中,避免了复制粘贴。

然而,随着项目复杂度的提升,这杯“方便的良药”却常常伴随着难以忍受的“副作用”,这些副作用正是我们 为什么 迫切需要 Composables 的根本原因。

Mixins 的三大“原罪”

  1. 黑盒里的“幽灵属性” (数据来源不清晰):当一个组件混入多个 Mixin 时,其 datamethods 中的属性究竟来自哪里?这就像一个神秘的黑盒,属性凭空出现,你无法一眼追溯其来源。
    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();
    }
    }
  2. “静默的战争” (命名空间冲突):如果两个不同的 Mixin 恰好定义了同名的 data 属性或 method,后混入的会悄无声息地覆盖前者。这种冲突不会报错,但在运行时会导致难以追踪的 Bug,成为大型项目维护的噩梦。
  3. “类型推断的迷雾” (TypeScript 支持不佳)Mixins 的动态合并特性,让 TypeScript 很难精确推断出最终组件实例的类型。我们常常需要繁琐的手动类型断言,失去了静态类型检查带来的安全感。

这些痛点指向了一个核心问题:Mixins 破坏了代码的 可预测性可追溯性。我们需要一种更清晰、更安全的逻辑复用模式。

4.2. 解决方案:到底什么是 Composable?

为了解决 Mixins 的根本缺陷,Vue 3 组合式 API 带来了一种回归编程本源的优雅方案——Composables (组合式函数)。

那么,什么 是 Composable?

一个 Composable,本质上就是一个利用 Vue 响应式 API 来封装和复用有状态逻辑的函数。

让我们拆解这个定义:

  • 它是一个函数:这是最关键的一点。它不像 Mixin 那样是一个需要“混入”的配置对象,而是一个可以被正常调用的 JavaScript 函数。
  • 封装有状态逻辑:它不仅仅是一个纯粹的工具函数(如 formatDate),其内部通常包含由 refreactive 创建的、会随时间变化的状态。
  • 利用响应式 API:这是它“活”起来的关键。它通过 ref, computed, watch 等 API 创造出响应式的状态和副作用。
  • 清晰的契约
    • 输入明确:所有外部依赖都通过函数参数明确传入。
    • 输出可控:所有暴露给外部的状态和方法,都通过 return 语句显式返回。

这种“纯函数”般的模式,天然地解决了 Mixins 的所有问题:数据来源一目了然(来自函数返回值),绝不会有命名冲突(因为你可以对返回的对象进行解构和重命名),并且与 TypeScript 的类型系统完美契合。

4.3. 如何编写?从重复代码到你的第一个 Composable

理论是灰色的,生命之树常青。让我们遵循最真实的开发路径——“先在组件内实现 -> 发现重复 -> 抽离为 Composable”——来亲手打造一个逻辑单元。

4.3.1. 场景:组件内的重复逻辑

假设我们需要在 App.vue 中控制两个独立元素的显示/隐藏。

文件路径: src/App.vue (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<script setup lang="ts">
import { ref } from 'vue';

// --- 第一个切换逻辑 ---
const isModalVisible = ref(false);
function toggleModal() {
isModalVisible.value = !isModalVisible.value;
}

// --- 第二个切换逻辑 (代码几乎完全一样!) ---
const isSidebarVisible = ref(true);
function toggleSidebar() {
isSidebarVisible.value = !isSidebarVisible.value;
}
</script>

<template>
<div>
<button @click="toggleModal">切换模态框</button>
<div v-if="isModalVisible">这是一个模态框内容</div>
<hr />
<button @click="toggleSidebar">切换侧边栏</button>
<div v-if="isSidebarVisible">这是一个侧边栏内容</div>
</div>
</template>

我们立即发现了问题:代码重复。管理一个布尔状态并提供一个切换它的方法,这个逻辑模式是通用的。是时候将它抽离出来了。

4.3.2. 抽离逻辑:创建 useToggle.ts

现在,我们来回答 如何 创建一个 Composable。

文件路径: src/composables/useToggle.ts (新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. 导入 Vue 的响应式 API,这是 Composable 的“魔力”来源
import { ref } from 'vue';
import type { Ref } from 'vue'; // (最佳实践) 显式导入类型,让代码更清晰

// 2. 定义函数。函数名约定以 "use" 开头,这是社区共识,也便于工具识别
// 通过参数接收外部依赖 (初始状态),实现清晰的“输入”
export function useToggle(initialValue: boolean) {
// 3. 在函数内部封装响应式状态。这个状态是独立的,每次调用 useToggle 都会创建新的实例
const value: Ref<boolean> = ref(initialValue);

// 4. 封装改变状态的方法。所有逻辑都内聚在函数内部
const toggle = () => {
value.value = !value.value;
};

// 5. 将需要暴露给外部的状态和方法,通过 return 语句显式返回。构成其清晰的“输出”契约
return {
value,
toggle,
};
}

这个 useToggle 函数完美体现了 Composable 的所有特征:它是一个函数,内部有 ref 状态,并且输入(initialValue)和输出({ value, toggle })都极为清晰。

4.3.3. 重构组件:享用 Composable 的成果

现在,我们可以用这个简洁、可复用的 Composable 来重构 App.vue

文件路径: src/App.vue (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script setup lang="ts">
// 1. 像导入普通函数一样导入我们的 Composable
import { useToggle } from '@/composables/useToggle';

// 2. 调用函数,创建两个完全独立的逻辑实例
// 使用解构和重命名来避免命名冲突,代码意图一目了然
const { value: isModalVisible, toggle: toggleModal } = useToggle(false);
const { value: isSidebarVisible, toggle: toggleSidebar } = useToggle(true);
</script>

<template>
<div>
<button @click="toggleModal">切换模态框</button>
<div v-if="isModalVisible">这是一个模态框内容</div>
<hr />
<button @click="toggleSidebar">切换侧边栏</button>
<div v-if="isSidebarVisible">这是一个侧边栏内容</div>
</div>
</template>

看,组件变得无比清爽!它不再关心“如何”切换状态,只关心“使用”这个切换能力。我们成功地将逻辑与视图表现分离,实现了关注点分离,这就是组合式开发的魅力。


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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<script setup lang="ts">
import { useStorage } from '@vueuse/core';

// 为我们的表单数据定义一个类型,享受 TypeScript 带来的类型安全
interface UserProfile {
name: string;
email: string;
receiveNewsletter: boolean;
}

// 调用 useStorage,它返回一个与 LocalStorage 双向绑定的 ref
// 1. 'user-profile': 这是 localStorage 中的 key
// 2. { ... }: 这是默认值,如果 localStorage 中没有,则使用此值
// 3. 泛型 <UserProfile> 确保了 state 的类型安全
const state = useStorage<UserProfile>('user-profile', {
name: '',
email: '',
receiveNewsletter: false,
});
</script>

<template>
<div class="form">
<h3>个人资料</h3>
<label>姓名: <input v-model="state.name" type="text" /></label>
<label>邮箱: <input v-model="state.email" type="email" /></label>
<label>
<input v-model="state.receiveNewsletter" type="checkbox" />
订阅我们的资讯
</label>
<pre>{{ state }}</pre>
</div>
</template>

一行 useStorage 就代替了手动实现的所有步骤!它更简洁、更健壮,并且经过了社区的充分测试。打开开发者工具,你会看到数据在 localStorage 中实时更新。


4.4.2. VueUse 的专业思维:成为能力调度者

VueUse 函数如此之多,我们无需记忆。而是要建立一套“意图驱动”的工作流:

  1. 明确意图:用一句话描述你的需求。例如:“我需要 在用户滚动到页面底部时 加载更多数据。”
  2. 触发检索:打开 VueUse 官网,在搜索框输入你意图的关键词。例如:scroll bottom
  3. 快速应用:搜索结果会立即指向 useScroll 及其 arrivedState 属性。参考其交互式 Demo,快速将代码应用到你的项目中。

4.5. 进阶之道:解剖专业级 Composable 的内部构造

到目前为止,我们已经学会了如何使用 VueUse 来解决问题。但要成为一名真正的专家,我们必须更进一步:像 VueUse 的作者一样思考。本节,我们将不再仅仅是“看”代码,而是要“解剖”三个构成专业级 Composable 的核心设计模式,并学习如何在你自己的逻辑单元中实现它们,从而理解其强大功能背后的精妙构造。

设计模式一:无懈可击的灵活性 · 响应式参数契约

想象一下,你封装了一个用于日志记录的 Composable useMyLogger,一个简单的版本可能长这样:

1
2
3
4
5
6
7
8
9
10
// 一个天真的实现
import type { Ref } from "vue";
import { watch } from "vue";

export function useMyLogger(message:Ref<String>) {
// 监听message的变化
watch(message,(newMessage) => {
console.log(newMessage)
})
}

这个实现能用,但它给使用者带来了 不必要的负担。它强制使用者必须传入一个 ref。如果我想记录一个静态字符串,或者一个 computed 属性的值呢?我不得不为了满足这个函数,去额外创建一个 refcomputed,这显然不够优雅。

专业级的 Composable 通过一种更灵活的参数契约解决了这个问题。它能优雅地接受 普通值、一个 ref,或者一个 返回值的函数 (getter)。在 VueUse 的世界里,这种模式被类型 MaybeRefOrGetter<T> 所定义。

这个“魔法”的核心,是一个名为 toValue 的小巧而强大的工具函数(Vue 3.3+ 已内置)。它的职责就是将上述三种不同形态的输入,统一“解包”成最原始的值。让我们亲手揭开它的面纱:

这是一个 toValue 的底层实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { unref } from 'vue';
import type { Ref } from 'vue';

type MaybeRefOrGetter<T> = T | Ref<T> | (() => T);

// 这就是 toValue 的核心逻辑
export function toValue<T>(r: MaybeRefOrGetter<T>): T {
// 如果是函数,就执行它并返回结果
if (typeof r === 'function') {
return (r as () => T)();
}
// 否则,使用 Vue 的 unref。
// unref 对于普通值会直接返回,对于 ref 会返回其 .value
return unref(r);
}

原理一目了然。现在,我们可以用这个模式来升级我们的 useMyLogger,使其变得无比灵活:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { ref, toValue, watch, type MaybeRefOrGetter } from 'vue';

// 将泛型参数从 String 改为 unknown 或 any,允许任意类型
function useMyLogger_Pro(message: MaybeRefOrGetter<unknown>) {
watch(
() => toValue(message),
(newMessage) => {
console.log(`Logging: ${newMessage}`)
},
{ immediate: true }
)
}

const count = ref(0);
useMyLogger_Pro('这是一个静态日志'); // OK
useMyLogger_Pro(count); // OK
useMyLogger_Pro(() => `当前计数值是: ${count.value}`); // OK,也是最高效的方式

架构师思维: 在封装你自己的 Composable 时,务必让你的参数接受 MaybeRefOrGetter 类型,并在内部使用 watch(() => toValue(param), ...) 的模式。这能让你的逻辑单元获得无与伦比的灵活性和可组合性,成为一个真正专业的工具。

设计模式二:后顾无忧的健壮性 · 自动化副作用管理

在 JavaScript 中,副作用(如事件监听、定时器、WebSocket 连接)是内存泄漏的重灾区。传统的做法是在组件挂载时创建,在卸载时销毁,代码分散且极易遗忘。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 传统、易出错的模式
import { onMounted, onUnmounted } from 'vue';

onMounted(() => {
window.addEventListener('mousemove', onMove);
const timer = setInterval(tick, 1000);

onUnmounted(() => {
// 开发者必须时刻记着在这里清理,否则就会造成内存泄漏!
window.removeEventListener('mousemove', onMove);
clearInterval(timer);
});
});

这种模式将清理的责任 推给了使用者,这违背了封装的初衷。一个真正健壮的 Composable,应该自己管理好自己的“身后事”。

Vue 3 的 onScopeDispose API 为此提供了完美的解决方案。它允许我们在当前组件的生命周期作用域中注册一个销毁时的回调函数。当组件被卸载时,这个回调就会被自动执行。VueUse 在此基础上封装了一个更友好的 tryOnScopeDispose 工具。

让我们从零开始,构建一个具备自动清理能力的 useIntervalFn Composable,来体会这个模式的威力:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { tryOnScopeDispose } from '@vueuse/core'; // 实际项目中直接从 VueUse 导入

export function useMyIntervalFn(callback: () => void, interval: number) {
let timer: ReturnType<typeof setInterval> | null = null;

const stop = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};

const start = () => {
stop(); // 先停止已有的,确保安全
timer = setInterval(callback, interval);
};

// --- 自动清理的核心所在 ---
// 当 useMyIntervalFn 在一个组件的 setup 中被调用时,
// tryOnScopeDispose 会将 stop 函数“挂载”到该组件的生命周期上。
// 当组件被卸载时,这个 stop 函数就会被自动调用!
tryOnScopeDispose(stop);

// 默认启动定时器
start();

return { stop, start };
}

这个模式是 VueUse 中所有副作用函数(useEventListener, useWebSocket 等)的基石。它将复杂的生命周期管理完美地封装在内,为使用者提供了极致简洁和安全的 API。

我们可以在组建中使用他

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<script lang="ts" setup>
import { ref, shallowRef, type Component } from 'vue';
import ProfileSettings from '@/components/tabs/ProfileSettings.vue';
import SecuritySettings from '@/components/tabs/SecuritySettings.vue';
// 导入我们之前的动态标签页即可实现组建的卸载
import { useMyIntervalFn } from '@/composables/useMyIntervalFn';

interface Tab {
name: string,
component: Component
}

// 在我们切换页面的时候,tick会被重新打印输出
const user = useMyIntervalFn(() => console.log('tick'), 1000);


const tabs: Tab[] = [
{ name: '个人资料', component: ProfileSettings as Component },
{ name: '账户安全', component: SecuritySettings as Component },
];

// 使用 shallowRef 存储当前激活的组件。
// 对于组件这种不应被深度代理的复杂对象,shallowRef 是性能更优的选择。
const currentTabComponent = shallowRef<Component>(ProfileSettings);


</script>

<template>
<div class="tabs-container">
<div class="tab-buttons">
<button v-for="tab in tabs" :key="tab.name" @click="currentTabComponent = tab.component">
{{ tab.name }}
</button>
</div>

<!--
<component :is="..."> 是动态组件的核心。
'is' 属性绑定到一个组件对象或组件名字符串。
当 currentTabComponent 变化时,Vue 会自动卸载旧组件,挂载新组件。
-->
<main class="tab-content">
<component :is="currentTabComponent" />
</main>
</div>
</template>

设计模式三:拥抱复杂场景 · 可配置的 options 对象

一个好的工具,既要能开箱即用,也要能应对复杂的需求。如果一个 Composable 的行为被硬编码(例如,总是监听 window 对象,总是立即执行),那么当遇到特殊场景(如在 iframe 中操作、在 SSR 环境下,或需要防抖)时,它就会变得毫无用处。

为了解决这个问题,专业的 Composable 几乎总是接受一个可选的 options 对象作为最后一个参数,它就像一个“控制面板”,允许使用者深度定制其行为。

实现这个模式的关键在于 默认值合并逻辑分支。让我们创建一个 useClickCoordinates 来演示如何构建这样一个可配置的系统。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { ref } from 'vue';
import { useEventListener } from '@vueuse/core'; // 借用已实现的事件监听

// 1. 定义清晰的 Options 类型,这是与使用者沟通的“契约”
interface UseClickCoordinatesOptions {
// 允许使用者注入依赖,而不是硬编码
target?: Window | Document | HTMLElement;
}

export function useClickCoordinates(options: UseClickCoordinatesOptions = {}) {
// 2. 使用解构和默认值,优雅地合并用户配置与默认配置
const {
target = window, // 如果用户没提供 target,我们就默认使用 window
} = options;

const x = ref(0);
const y = ref(0);

const handler = (event: MouseEvent) => {
x.value = event.clientX;
y.value = event.clientY;
};

// 3. 在核心逻辑中使用这些配置变量,而不是硬编码的值
// 这里的 target 是经过配置选择的,可能是 window,也可能是用户传入的某个 div
useEventListener(target, 'click', handler);

return { x, y };
}

在组件中使用这个 composable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup lang="ts">
import { useClickCoordinates } from './composables/useClickCoordinates';

const { x, y } = useClickCoordinates();

// 假设我们需要在iframe中来使用这个composable
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const iframeWindow = iframe.contentWindow;
const { x: iframeX, y: iframeY } = useClickCoordinates({ target: iframeWindow });

</script>

<template>
<div>
<p>X: {{ x }}</p>
<p>Y: {{ y }}</p>
<p>iframeX: {{ iframeX }}</p>
<p>iframeY: {{ iframeY }}</p>
</div>
</template>

通过这种模式,我们可以轻松扩展出各种强大的功能:

  • 依赖注入: 如上面的 target,可以传入 iframe.contentWindow 来监听 iframe 内部的点击,或在测试中传入一个 mock 对象。
  • 行为标志: 增加一个 isListening ref,并提供 pauseresume 方法,通过 options 中的 immediate: false 来决定是否初始就启动监听。
  • 功能增强: 增加一个 debouncethrottle 选项,在 useEventListener 内部对 handler 进行包装。

通过学习并应用这三大设计模式,你将不仅能高效地使用 VueUse,更能亲手打造出同样专业、健壮、灵活的组合式函数,让你的代码质量和架构能力提升到一个新的层次。