Vue 基础体系 · 第 9/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue Composable 设计:复用状态、清理副作用、参数契约和测试
Composable 是 Vue Composition API 中用于封装和复用“有状态逻辑”的函数。它通常是一个以 use 开头的普通 TypeScript 函数,内部可以创建响应式状态、注册侦听器、调用生命周期钩子,并返回供组件使用的状态和操作函数。
例如:
// useCounter.ts
import { ref } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
function increment() {
count.value += 1
}
return {
count,
increment,
}
}
组件只需要关心状态和行为:
<script setup lang="ts">
import { useCounter } from './useCounter'
const { count, increment } = useCounter(10)
</script>
<template>
<button @click="increment">
{{ count }}
</button>
</template>
这里复用的不是某个组件的模板,而是“计数状态 + 修改状态的行为”。这正是 Composable 与组件复用的边界:组件负责视图和交互组合,Composable 负责可独立组织的逻辑。
一、Composable 到底复用了什么
一个完整的 Composable 通常包含四类内容:
- 响应式状态:例如
ref、reactive、computed。 - 派生逻辑:例如根据筛选条件计算列表。
- 副作用:例如监听窗口大小、发起请求、订阅事件。
- 生命周期管理:例如组件卸载时移除监听器、取消请求。
可以把一个 Composable 抽象成:
其中:
- 是调用者传入的参数;
- 是当前组件或作用域;
- 是返回的状态和操作;
- 是由 Composable 创建的副作用。
一个合理的 Composable 至少应满足以下条件:
1. 状态隔离
每次调用都应默认创建独立状态:
const first = useCounter()
const second = useCounter()
first.increment()
console.log(first.count.value) // 1
console.log(second.count.value) // 0
如果把状态放在模块顶层,则所有调用者会共享它:
// 错误或需要明确说明的共享设计
const count = ref(0)
export function useSharedCounter() {
return { count }
}
模块级单例并非绝对错误。它适合全局会话、权限、配置缓存等明确需要共享的状态,但这时应把“共享”作为 API 契约,而不是调用者的意外结果。
2. 副作用有明确所有者
副作用必须知道自己属于哪个组件或作用域。组件卸载后,属于该组件的事件监听、定时器、订阅和请求都不能继续修改已失效的状态。
3. 输入语义稳定
Composable 的参数应明确说明:
- 接受普通值、
ref,还是 getter; - 参数变化时是否自动重新执行;
- 是否允许空值;
- 异步操作失败时如何暴露错误;
- 调用者是否可以修改返回的状态。
参数契约不清晰,通常比代码重复更容易制造缺陷。
二、ref、reactive 与返回值设计
ref 适合表达单值状态
const loading = ref(false)
const error = ref<Error | null>(null)
const data = ref<User | null>(null)
在 JavaScript 和 TypeScript 中访问 .value:
loading.value = true
在 Vue 模板中会自动解包:
<span v-if="loading">加载中</span>
reactive 适合表达对象状态
const state = reactive({
loading: false,
error: null as Error | null,
data: null as User | null,
})
但 reactive 有一个常见边界:直接解构会失去响应式连接。
const state = reactive({
loading: false,
count: 0,
})
const { loading, count } = state
// loading 和 count 是普通值,不再随 state 的变化更新
如果必须拆分,应使用 toRefs:
import { reactive, toRefs } from 'vue'
const state = reactive({
loading: false,
count: 0,
})
const { loading, count } = toRefs(state)
对于面向调用者的 Composable,多个 ref 往往更容易表达状态边界:
return {
data: readonly(data),
loading: readonly(loading),
error: readonly(error),
refresh,
}
readonly 可以防止调用者直接修改返回状态:
const result = useSomething()
result.loading.value = false
// TypeScript 会报告错误;运行时也会阻止对只读代理的写入
这并不等于安全边界。readonly 只约束 Vue 响应式对象的写入,不能阻止调用者修改对象内部仍可变的嵌套结构,也不能替代业务权限控制。
三、参数契约:普通值、Ref 和 Getter
Composable 常见的参数需求是:调用者既可以传入固定字符串,也可以传入会变化的 ref 或 getter。
Vue 提供了 MaybeRefOrGetter<T> 和 toValue 来表达和读取这种输入。下面的类型在现代 Vue 3 中可直接使用:
import type { MaybeRefOrGetter } from 'vue'
import { toValue } from 'vue'
function useGreeting(name: MaybeRefOrGetter<string>) {
const greeting = computed(() => `Hello, ${toValue(name)}`)
return { greeting }
}
三种调用方式都成立:
useGreeting('Ada')
const name = ref('Grace')
useGreeting(name)
useGreeting(() => route.params.name as string)
但是“支持 ref”不等于“会自动响应变化”。如果只在函数初始化时读取一次:
function useGreeting(name: MaybeRefOrGetter<string>) {
const initialName = toValue(name)
return {
name: ref(initialName),
}
}
后续 name.value 变化不会自动更新。要获得响应式行为,读取必须发生在 computed、watch 或 watchEffect 的追踪函数中:
function useGreeting(name: MaybeRefOrGetter<string>) {
const greeting = computed(() => `Hello, ${toValue(name)}`)
return { greeting }
}
对于异步逻辑,通常把参数包装成 watch 的 getter:
watch(
() => toValue(url),
(newUrl) => {
// newUrl 变化后重新执行
},
{ immediate: true },
)
toValue(url) 在 watch 的源函数内部执行,因此:
- 普通字符串不会变化;
ref的变化会被追踪;- getter 内部访问的响应式依赖会被追踪。
如果参数只允许固定值,应直接声明为 string,不要无条件扩大成 MaybeRefOrGetter<string>。支持越多输入形式,契约、测试和边界处理就越复杂。
四、副作用与作用域生命周期
副作用是指会影响当前函数返回值之外的操作,例如:
window.addEventListener;setInterval;watch;fetch;- WebSocket 或第三方事件订阅;
- 修改浏览器标题、存储或外部库实例。
Composable 最重要的生命周期原则是:
副作用创建在哪里,就必须能在对应的作用域销毁时清理。
在组件的 setup() 或 <script setup> 中调用 Composable 时,Vue 会把其中创建的生命周期钩子、侦听器和部分响应式副作用关联到当前组件作用域。
import { onMounted, onUnmounted } from 'vue'
export function useWindowWidth() {
const width = ref(window.innerWidth)
function update() {
width.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', update)
})
onUnmounted(() => {
window.removeEventListener('resize', update)
})
return { width }
}
事件处理函数必须使用同一个函数引用。下面的代码无法正确移除监听器:
window.addEventListener('resize', () => update())
window.removeEventListener('resize', () => update())
// 两个箭头函数不是同一个对象
生命周期钩子还存在调用时机限制:应在同步执行的 setup() 过程中注册。
// 不推荐
await loadConfig()
onUnmounted(cleanup)
异步等待之后,当前组件实例上下文可能不再可用,钩子注册不能依赖这种写法。应先注册清理逻辑,再执行异步工作:
onUnmounted(cleanup)
void loadConfig()
effectScope 与 onScopeDispose
Composable 不只可能在组件中使用,也可能在一个独立的响应式作用域中使用。effectScope 可以把一组响应式副作用组织在一起:
import { effectScope } from 'vue'
const scope = effectScope()
scope.run(() => {
useWindowWidth()
})
// 不再需要这组逻辑时统一停止
scope.stop()
Composable 可以用 onScopeDispose 注册与当前作用域绑定的清理函数:
import { getCurrentScope, onScopeDispose } from 'vue'
export function useResource() {
const dispose = () => {
// 关闭订阅、停止计时器等
}
if (getCurrentScope()) {
onScopeDispose(dispose)
}
return { dispose }
}
getCurrentScope() 的判断很重要:如果该函数在组件或 effectScope 外部被直接调用,直接使用 onScopeDispose 可能产生警告,而且没有可自动销毁的作用域。此时应提供显式的 dispose,或在 API 文档中明确要求调用者管理生命周期。
下面是典型的生命周期关系:
sequenceDiagram
participant C as 组件 setup
participant U as Composable
participant E as 副作用
participant V as Vue 作用域
C->>U: useSomething()
U->>V: 注册 watch / onScopeDispose
U->>E: 创建事件监听或请求
V-->>U: 组件卸载或 scope.stop()
U->>E: cleanup()
E-->>U: 停止更新
如果清理缺失,组件虽然已经卸载,外部事件仍可能继续触发回调。这会造成重复监听、内存增长、无效网络请求,甚至把结果写入已经不再显示的状态。
五、一个完整的异步 Composable:请求、取消和竞态
异步请求比同步状态更复杂,因为它包含并发和故障路径。
假设用户快速切换查询条件:
- 请求 A 开始;
- 用户切换条件,请求 B 开始;
- B 先返回;
- A 后返回。
如果 A 返回后直接写入 data,旧结果就会覆盖新结果。这是典型的异步竞态。
可以用两个机制共同处理:
AbortController:尽量取消旧请求;- 请求序号:即使旧请求无法真正停止,也禁止旧结果写入状态。
下面是一个可运行的 useFetchJson 示例:
// useFetchJson.ts
import {
getCurrentScope,
onScopeDispose,
readonly,
ref,
toValue,
watch,
} from 'vue'
import type { MaybeRefOrGetter, Ref } from 'vue'
export interface FetchState<T> {
data: Readonly<Ref<T | null>>
loading: Readonly<Ref<boolean>>
error: Readonly<Ref<Error | null>>
refresh: () => Promise<void>
dispose: () => void
}
export function useFetchJson<T>(
url: MaybeRefOrGetter<string>,
): FetchState<T> {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
let sequence = 0
let controller: AbortController | null = null
let stopped = false
let stopWatch: (() => void) | undefined
async function load(targetUrl: string): Promise<void> {
const currentSequence = ++sequence
controller?.abort()
const currentController = new AbortController()
controller = currentController
loading.value = true
error.value = null
try {
const response = await fetch(targetUrl, {
signal: currentController.signal,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const result = (await response.json()) as T
// 旧请求、已销毁作用域中的请求都不能写入状态
if (stopped || currentSequence !== sequence) {
return
}
data.value = result
} catch (cause) {
// 主动取消不是业务错误
if (cause instanceof DOMException && cause.name === 'AbortError') {
return
}
if (stopped || currentSequence !== sequence) {
return
}
error.value = cause instanceof Error
? cause
: new Error(String(cause))
} finally {
if (!stopped && currentSequence === sequence) {
loading.value = false
}
}
}
async function refresh(): Promise<void> {
await load(toValue(url))
}
stopWatch = watch(
() => toValue(url),
(targetUrl) => {
void load(targetUrl)
},
{ immediate: true },
)
function dispose(): void {
if (stopped) {
return
}
stopped = true
sequence += 1
controller?.abort()
stopWatch?.()
}
if (getCurrentScope()) {
onScopeDispose(dispose)
}
return {
data: readonly(data),
loading: readonly(loading),
error: readonly(error),
refresh,
dispose,
}
}
这个实现的状态变化
初始调用 useFetchJson('/api/users') 时:
data = null
loading = false
error = null
watch 设置了 immediate: true,因此立即执行:
sequence = 1
loading = true
请求 A 开始
A 成功返回:
currentSequence === sequence
data = A 的结果
loading = false
如果 URL 在 A 返回前变化:
sequence = 2
A 被 abort
请求 B 开始
如果 A 的底层实现没有立即响应取消,A 仍可能在之后进入 then 或 catch。但它保存的 currentSequence 是 1,此时全局 sequence 是 2,所以:
currentSequence !== sequence
条件成立,A 无法覆盖 B 的状态。
AbortController 不能保证服务器端已经停止处理请求。它主要取消浏览器侧的等待和传输;服务端是否停止执行,取决于服务器和网络协议。因此请求序号检查仍然必要。
组件使用方式
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useFetchJson } from './useFetchJson'
interface User {
id: number
name: string
}
const userId = ref(1)
const url = computed(() => `/api/users/${userId.value}`)
const { data, loading, error, refresh } =
useFetchJson<User>(url)
</script>
<template>
<button @click="userId++">下一个用户</button>
<button @click="refresh">重新加载</button>
<p v-if="loading">加载中……</p>
<p v-else-if="error">请求失败:{{ error.message }}</p>
<pre v-else>{{ data }}</pre>
</template>
这里 url 是一个 computed。URL 变化会触发 watch,从而启动新请求。模板不需要知道请求取消和竞态处理细节。
六、错误处理必须区分“失败”和“取消”
异步 Composable 至少应区分三种情况:
网络或业务请求失败
例如:
- DNS、连接或超时失败;
- HTTP 状态码为
404、500; - 响应 JSON 格式错误。
这些通常应写入 error,供组件展示或重试。
主动取消
组件卸载、参数变化或显式刷新时,旧请求被主动取消。取消通常不是用户需要看到的错误,否则页面切换时会频繁显示“请求失败”。
旧请求完成
旧请求即使成功,也不能覆盖新请求。它不是当前业务状态的一部分,应被丢弃。
不要把所有异常都简单处理成:
catch {
error.value = new Error('请求失败')
}
这样会把取消误报为失败,也会丢失 HTTP 状态和原始错误,降低诊断能力。
七、常见错误设计及其失败表现
1. 把副作用放在模块顶层
// 模块一导入就执行
window.addEventListener('resize', update)
后果是:
- 与组件生命周期无关;
- 测试导入模块就产生副作用;
- 多次导入或热更新时可能产生重复监听;
- SSR 环境没有
window,服务端导入可能直接失败。
应把浏览器相关操作放入 Composable 的生命周期逻辑中,并在需要时延迟到 onMounted。
2. 返回可被任意修改的内部状态
return {
loading,
data,
}
这不一定错误,但调用者可以绕过 Composable 的状态转换:
loading.value = false
如果状态必须由内部请求流程维护,应返回 readonly 状态,只暴露明确的 refresh、reset 等操作。
3. 使用深度监听替代明确的数据源
watch(config, reload, { deep: true })
深度监听会递归追踪对象内部变化,配置对象较大时会增加维护和执行成本,还可能因为内部无关字段变化而重复请求。
更精确的方式是监听真正影响请求的字段:
watch(
() => [config.url, config.method],
reload,
)
如果使用数组源,回调会在数组中任一被追踪值变化时触发。
4. 在 Composable 中偷偷修改输入对象
function usePagination(options: { page: number }) {
options.page++
}
这会让调用者难以判断状态变化来自哪里。除非参数明确设计为双向状态,否则应把输入视为只读,返回新的状态或操作函数。
5. 误以为 watch 会立即执行
watch(source, callback)
默认只在源发生变化后执行,不会在创建时执行。需要初始化加载时,必须显式使用:
watch(source, callback, { immediate: true })
八、Composable 的测试边界
Composable 测试的重点不是验证 Vue 模板,而是验证:
- 输入参数是否按契约工作;
- 返回状态是否正确变化;
- 副作用是否创建;
- 副作用是否清理;
- 异步错误和取消是否正确处理;
- 竞态是否不会覆盖新结果。
1. 纯逻辑优先直接测试
不依赖生命周期的 Composable 可以直接调用:
import { describe, expect, it } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('increments its own state', () => {
const first = useCounter(10)
const second = useCounter(10)
first.increment()
expect(first.count.value).toBe(11)
expect(second.count.value).toBe(10)
})
})
这个测试同时验证了状态隔离:两次调用不会共享 count。
2. 使用 effectScope 测试作用域清理
如果 Composable 使用了 onScopeDispose,可以用 effectScope 模拟组件作用域:
import { effectScope, nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { useWindowWidth } from './useWindowWidth'
describe('useWindowWidth', () => {
it('stops reacting after the scope is stopped', async () => {
const scope = effectScope()
const add = vi.spyOn(window, 'addEventListener')
const remove = vi.spyOn(window, 'removeEventListener')
let result!: ReturnType<typeof useWindowWidth>
scope.run(() => {
result = useWindowWidth()
})
expect(add).toHaveBeenCalledWith(
'resize',
expect.any(Function),
)
scope.stop()
await nextTick()
expect(remove).toHaveBeenCalledWith(
'resize',
expect.any(Function),
)
add.mockRestore()
remove.mockRestore()
})
})
需要注意:如果实现把监听注册在 onMounted 中,单独使用 effectScope 不会触发组件挂载生命周期。这种实现更适合通过 Vue Test Utils 挂载一个测试组件来验证。
3. 测试异步请求
下面测试 useFetchJson 的成功和 HTTP 错误路径:
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { nextTick, ref } from 'vue'
import { useFetchJson } from './useFetchJson'
describe('useFetchJson', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('loads JSON data', async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ id: 1, name: 'Ada' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
const result = useFetchJson<{ id: number; name: string }>(
'/api/users/1',
)
expect(result.loading.value).toBe(true)
await vi.waitFor(() => {
expect(result.data.value).toEqual({
id: 1,
name: 'Ada',
})
})
expect(result.loading.value).toBe(false)
expect(result.error.value).toBeNull()
})
it('exposes HTTP failures', async () => {
vi.mocked(fetch).mockResolvedValue(
new Response('not found', { status: 404 }),
)
const result = useFetchJson('/missing')
await vi.waitFor(() => {
expect(result.error.value?.message).toBe('HTTP 404')
})
expect(result.loading.value).toBe(false)
})
it('reloads when a ref input changes', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(
new Response(JSON.stringify({ value: 'first' }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ value: 'second' }), {
status: 200,
}),
)
const id = ref(1)
const result = useFetchJson<{ value: string }>(
() => `/api/items/${id.value}`,
)
await vi.waitFor(() => {
expect(result.data.value).toEqual({ value: 'first' })
})
id.value = 2
await vi.waitFor(() => {
expect(result.data.value).toEqual({ value: 'second' })
})
expect(fetch).toHaveBeenCalledTimes(2)
})
})
测试中 vi.stubGlobal('fetch', ...) 替换浏览器的 fetch,避免访问真实网络。vi.waitFor 用于等待响应式状态和异步 Promise 完成;它比固定等待一段时间更稳定,因为测试等待的是断言条件而不是任意时间。
4. 用受控 Promise 测试竞态
为了证明旧请求不能覆盖新请求,需要让两个请求按指定顺序完成:
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useFetchJson } from './useFetchJson'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('useFetchJson race condition', () => {
it('does not let an old response overwrite a new one', async () => {
const first = deferred<Response>()
const second = deferred<Response>()
vi.stubGlobal(
'fetch',
vi.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise),
)
const id = ref(1)
const result = useFetchJson<{ id: number }>(
() => `/api/items/${id.value}`,
)
id.value = 2
await Promise.resolve()
second.resolve(
new Response(JSON.stringify({ id: 2 }), { status: 200 }),
)
await vi.waitFor(() => {
expect(result.data.value).toEqual({ id: 2 })
})
first.resolve(
new Response(JSON.stringify({ id: 1 }), { status: 200 }),
)
await Promise.resolve()
expect(result.data.value).toEqual({ id: 2 })
})
})
这个测试验证的是不变量:
它比单纯测试“请求成功后有数据”更接近生产中的故障路径。
九、Composable 测试与组件测试的分工
Vue Test Utils 适合测试 Composable 与组件生命周期、模板和用户交互之间的连接。
例如:
<!-- TestHost.vue -->
<script setup lang="ts">
import { useCounter } from './useCounter'
const { count, increment } = useCounter()
</script>
<template>
<button data-test="increment" @click="increment">
{{ count }}
</button>
</template>
测试:
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import TestHost from './TestHost.vue'
describe('TestHost', () => {
it('updates the rendered count', async () => {
const wrapper = mount(TestHost)
expect(wrapper.get('[data-test="increment"]').text())
.toBe('0')
await wrapper.get('[data-test="increment"]').trigger('click')
expect(wrapper.get('[data-test="increment"]').text())
.toBe('1')
})
})
两层测试关注点不同:
- Composable 单元测试:状态转换、参数变化、错误、取消、清理;
- 组件测试:模板是否正确展示状态,事件是否正确调用操作;
- 端到端测试:真实浏览器中的路由、网络、用户流程和部署环境集成。
如果一个请求 Composable 已经充分覆盖了 fetch、竞态和错误路径,组件测试不必再次穷举所有网络细节;组件测试应重点确认“加载中、成功、失败”三种状态如何呈现。
十、SSR、浏览器 API 和注入依赖
Composable 可能在服务端渲染环境执行。服务端没有浏览器全局对象,因此以下代码不能在模块顶层或无条件的初始化阶段执行:
const width = window.innerWidth
const storage = localStorage.getItem('token')
更安全的写法是延迟到客户端生命周期:
import { onMounted, ref } from 'vue'
export function useClientWidth() {
const width = ref<number | null>(null)
onMounted(() => {
width.value = window.innerWidth
})
return { width }
}
但这会导致服务端首次渲染时 width 为 null,模板必须能接受这个状态,否则客户端挂载时可能出现内容不一致。
对于时间、随机数、浏览器存储、媒体查询等环境相关输入,应明确区分:
- 服务端和客户端都可执行的纯逻辑;
- 仅客户端可执行的副作用;
- 需要通过依赖注入或服务端数据预取提供的初始值。
如果 Composable 依赖路由、状态管理或国际化上下文,也不应隐式假设任何组件都具备这些注入。可以直接导入全局服务,也可以把依赖作为参数传入;后者通常更容易测试和复用。
十一、如何判断一个 Composable 是否设计完整
可以通过以下因果链检查实现,而不是只检查函数是否“能运行”:
-
调用两次是否产生独立状态?
如果不独立,是否明确声明为共享单例? -
每个副作用是否有创建和销毁路径?
事件、计时器、订阅、请求都应能回答“什么时候停止”。 -
参数变化是否符合契约?
普通值应不会假装响应式;ref或 getter 应在文档和类型中明确支持。 -
异步结果是否有当前性判断?
如果多个请求可能并发,就必须处理取消或过期结果。 -
错误、取消和卸载是否区分?
取消不应无条件展示为业务失败;卸载后的结果不应修改状态。 -
返回对象是否暴露了不必要的写权限?
内部状态可用readonly保护,修改通过命令函数完成。 -
测试是否覆盖生命周期和故障路径?
至少应验证成功、失败、参数变化、销毁后的停止更新;涉及并发时还要验证竞态。
Composable 的质量不由函数长度决定,而由它是否建立了清晰的状态边界、输入契约和副作用所有权决定。一个小型的纯状态 Composable 可以只包含 ref 和操作函数;一个包含网络请求的 Composable 则必须进一步处理作用域、取消、过期结果、错误分类和测试隔离。这样封装出来的逻辑才真正具备可复用性,而不是把组件中的复杂代码换了一个文件名。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 表单工程:受控输入、校验、异步提交、错误与可访问性
- 下一篇:Vue Router 完整指南:路由匹配、守卫、懒加载和滚动行为
- 延伸:Vue Composition API:setup、生命周期、作用域和逻辑组织
- 延伸:Vue 测试体系:Vitest、Vue Test Utils、组件测试和端到端测试
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论