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 通常包含四类内容:

  1. 响应式状态:例如 refreactivecomputed
  2. 派生逻辑:例如根据筛选条件计算列表。
  3. 副作用:例如监听窗口大小、发起请求、订阅事件。
  4. 生命周期管理:例如组件卸载时移除监听器、取消请求。

可以把一个 Composable 抽象成:

C(I,S)(O,E)C(I, S) \rightarrow (O, E)

其中:

  • II 是调用者传入的参数;
  • SS 是当前组件或作用域;
  • OO 是返回的状态和操作;
  • EE 是由 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;
  • 参数变化时是否自动重新执行;
  • 是否允许空值;
  • 异步操作失败时如何暴露错误;
  • 调用者是否可以修改返回的状态。

参数契约不清晰,通常比代码重复更容易制造缺陷。


二、refreactive 与返回值设计

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 变化不会自动更新。要获得响应式行为,读取必须发生在 computedwatchwatchEffect 的追踪函数中:

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()

effectScopeonScopeDispose

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:请求、取消和竞态

异步请求比同步状态更复杂,因为它包含并发和故障路径。

假设用户快速切换查询条件:

  1. 请求 A 开始;
  2. 用户切换条件,请求 B 开始;
  3. B 先返回;
  4. 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 仍可能在之后进入 thencatch。但它保存的 currentSequence1,此时全局 sequence2,所以:

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 状态码为 404500
  • 响应 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 状态,只暴露明确的 refreshreset 等操作。

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 })
  })
})

这个测试验证的是不变量:

只有 requestId=latestRequestId 的请求才能写入状态\text{只有 } requestId = latestRequestId \text{ 的请求才能写入状态}

它比单纯测试“请求成功后有数据”更接近生产中的故障路径。


九、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 }
}

但这会导致服务端首次渲染时 widthnull,模板必须能接受这个状态,否则客户端挂载时可能出现内容不一致。

对于时间、随机数、浏览器存储、媒体查询等环境相关输入,应明确区分:

  • 服务端和客户端都可执行的纯逻辑;
  • 仅客户端可执行的副作用;
  • 需要通过依赖注入或服务端数据预取提供的初始值。

如果 Composable 依赖路由、状态管理或国际化上下文,也不应隐式假设任何组件都具备这些注入。可以直接导入全局服务,也可以把依赖作为参数传入;后者通常更容易测试和复用。


十一、如何判断一个 Composable 是否设计完整

可以通过以下因果链检查实现,而不是只检查函数是否“能运行”:

  1. 调用两次是否产生独立状态?
    如果不独立,是否明确声明为共享单例?

  2. 每个副作用是否有创建和销毁路径?
    事件、计时器、订阅、请求都应能回答“什么时候停止”。

  3. 参数变化是否符合契约?
    普通值应不会假装响应式;ref 或 getter 应在文档和类型中明确支持。

  4. 异步结果是否有当前性判断?
    如果多个请求可能并发,就必须处理取消或过期结果。

  5. 错误、取消和卸载是否区分?
    取消不应无条件展示为业务失败;卸载后的结果不应修改状态。

  6. 返回对象是否暴露了不必要的写权限?
    内部状态可用 readonly 保护,修改通过命令函数完成。

  7. 测试是否覆盖生命周期和故障路径?
    至少应验证成功、失败、参数变化、销毁后的停止更新;涉及并发时还要验证竞态。

Composable 的质量不由函数长度决定,而由它是否建立了清晰的状态边界、输入契约和副作用所有权决定。一个小型的纯状态 Composable 可以只包含 ref 和操作函数;一个包含网络请求的 Composable 则必须进一步处理作用域、取消、过期结果、错误分类和测试隔离。这样封装出来的逻辑才真正具备可复用性,而不是把组件中的复杂代码换了一个文件名。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。