Skip to content

L37 · Composition API 设计哲学 ​

🎯 本节目标:理解 Composition API 的设计动机、核心理念和最佳实践
📦 本节产出:对 Vue 3 设计决策的深层理解 + Options vs Composition 对比
🔗 前置钩子:L36 的组件渲染流程(理解 setup 的执行时机)
🔗 后续钩子:L38 在独立项目体验 Vue 3.6 RC 的 Vapor Mode

1. Options API 的痛点 ​

1.1 代码按选项组织 vs 按功能组织 ​

下面是组织结构示意,省略具体算法;§2 给出可运行的组合版本。

vue
<!-- Options API:按选项类型分组 -->
<script>
export default {
  data() {
    return {
      // 功能 A 的状态
      searchQuery: '',
      searchResults: [],
      // 功能 B 的状态
      sortBy: 'name',
      sortOrder: 'asc',
      // 功能 C 的状态
      currentPage: 1,
      pageSize: 20,
    }
  },
  computed: {
    // 功能 A 的计算属性
    filteredResults() { /* ... */ },
    // 功能 B 的计算属性
    sortedResults() { /* ... */ },
    // 功能 C 的计算属性
    paginatedResults() { /* ... */ },
  },
  methods: {
    // 功能 A 的方法
    handleSearch() { /* ... */ },
    // 功能 B 的方法
    handleSort() { /* ... */ },
    // 功能 C 的方法
    handlePageChange() { /* ... */ },
  },
  watch: {
    // 功能 A 的副作用
    searchQuery() { /* ... */ },
    // 功能 C 的副作用
    currentPage() { /* ... */ },
  },
}
</script>

问题: 相关功能的代码被打散到 data/computed/methods/watch 四个地方。组件变大后,理解一个功能需要在代码中反复跳转。

1.2 有状态逻辑的复用 ​

Mixins 是 Options API 常见的复用方式,但不是唯一方式:普通工具函数、组件组合、无渲染组件也能复用不同层次的逻辑;Options 组件还可以通过 setup 调用 composable。这里比较的是 mixin 与 composable 如何组织有状态逻辑:

javascript
// ❌ Mixin 的问题
const searchMixin = {
  data() { return { searchQuery: '' } },
  methods: { handleSearch() { /* ... */ } },
}

const sortMixin = {
  data() { return { sortBy: 'name' } },  // 如果和另一个 mixin 同名 → 冲突!
  methods: { handleSort() { /* ... */ } },
}

export default {
  mixins: [searchMixin, sortMixin],
  // 问题 1: 命名冲突 → 谁覆盖谁?
  // 问题 2: 来源不明 → this.searchQuery 来自哪个 mixin?
  // 问题 3: 隐式依赖 → mixin 可能依赖组件的 data
}
问题MixinsComposables
命名冲突❌ data/methods 可能同名✅ 函数返回值,调用者命名
来源不明❌ this.xxx 不知道来自哪里✅ const { xxx } = useXxx()
隐式依赖❌ mixin 可能依赖宿主组件✅ 参数显式传入
TypeScript多 mixin 合并时,来源与类型关系较难表达普通函数的参数/返回值更容易推导,仍需要恰当的类型声明

2. Composition API 的解法 ​

2.1 按功能内聚 ​

把“搜索 → 排序 → 分页”写成明确的数据流。下方完整实现保存到 L31 的独立实验目录;这是内存列表实验,不替换 L23 的服务端搜索与 URL 同步:

typescript
// labs/mini-reactivity/list-composables.ts
import {
  computed, onScopeDispose, ref, shallowRef, toValue, watch,
  type MaybeRefOrGetter,
} from 'vue'

export interface NamedItem { id: number; name: string }

export function useDebouncedValue<T>(source: MaybeRefOrGetter<T>, delay = 300) {
  if (!Number.isFinite(delay) || delay < 0) throw new Error('delay 必须为非负有限数')
  const value = shallowRef<T>(toValue(source))
  let timer: ReturnType<typeof setTimeout> | undefined
  watch(() => toValue(source), next => {
    clearTimeout(timer)
    timer = setTimeout(() => { value.value = next }, delay)
  })
  onScopeDispose(() => clearTimeout(timer))
  return value
}

export function useSearch(source: MaybeRefOrGetter<readonly NamedItem[]>) {
  const searchQuery = ref('')
  const query = useDebouncedValue(searchQuery)
  const filteredResults = computed(() => {
    const keyword = query.value.trim().toLocaleLowerCase()
    return toValue(source).filter(item => item.name.toLocaleLowerCase().includes(keyword))
  })
  return { searchQuery, filteredResults }
}

export function useSort(source: MaybeRefOrGetter<readonly NamedItem[]>) {
  const sortOrder = ref<'asc' | 'desc'>('asc')
  const sortedResults = computed(() => [...toValue(source)].sort((a, b) => {
    const order = a.name.localeCompare(b.name) || a.id - b.id
    return sortOrder.value === 'asc' ? order : -order
  }))
  return { sortOrder, sortedResults }
}

export function usePagination<T>(source: MaybeRefOrGetter<readonly T[]>, pageSize = 2) {
  if (!Number.isSafeInteger(pageSize) || pageSize < 1) throw new Error('pageSize 必须为正整数')
  const currentPage = ref(1)
  const totalPages = computed(() => Math.max(1, Math.ceil(toValue(source).length / pageSize)))
  const paginatedResults = computed(() => {
    const page = Math.min(currentPage.value, totalPages.value)
    return toValue(source).slice((page - 1) * pageSize, page * pageSize)
  })
  // 上游搜索或排序改变时,从第一页查看新结果。
  watch(() => toValue(source), () => { currentPage.value = 1 })
  function handlePageChange(page: number) {
    if (!Number.isSafeInteger(page)) return
    currentPage.value = Math.min(Math.max(1, page), totalPages.value)
  }
  return { currentPage, totalPages, paginatedResults, handlePageChange }
}

2.2 Composable 的组合能力 ​

useSearch 调用 useDebouncedValue,组件再把搜索结果交给排序和分页。每个返回值都能追溯到具体调用,参数则说明了依赖;这仍需要作者设计好接口,并不意味着任意组合都不会冲突。

typescript
// labs/mini-reactivity/composition-demo.ts
import { createApp, defineComponent, h, ref } from 'vue'
import { useSearch, useSort, usePagination, type NamedItem } from './list-composables'

createApp(defineComponent({
  setup() {
    const rawData = ref<NamedItem[]>([
      { id: 1, name: 'Apple' }, { id: 2, name: 'Apricot' },
      { id: 3, name: 'Banana' }, { id: 4, name: 'Blueberry' },
    ])
    const { searchQuery, filteredResults } = useSearch(rawData)
    const { sortOrder, sortedResults } = useSort(filteredResults)
    const { currentPage, totalPages, paginatedResults, handlePageChange } = usePagination(sortedResults)
    return () => h('main', [
      h('h1', '组合搜索、排序与分页'),
      h('input', {
        'aria-label': '搜索名称', value: searchQuery.value,
        onInput: (event: Event) => { searchQuery.value = (event.target as HTMLInputElement).value },
      }),
      h('button', {
        onClick: () => { sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc' },
      }, `排序:${sortOrder.value}`),
      h('ul', paginatedResults.value.map(item => h('li', { key: item.id }, item.name))),
      h('p', `第 ${currentPage.value} / ${totalPages.value} 页`),
      h('button', { disabled: currentPage.value === 1, onClick: () => handlePageChange(currentPage.value - 1) }, '上一页'),
      h('button', { disabled: currentPage.value === totalPages.value, onClick: () => handlePageChange(currentPage.value + 1) }, '下一页'),
    ])
  },
})).mount('#app')

保留 L35 的 vite.config.ts,把 index.html 的 script src 改为 /composition-demo.ts,使用 L33 的 Vite 命令。翻到第二页后输入 ap:300ms 防抖结束后,列表应回到第一页并只显示 Apple、Apricot;反转排序后顺序交换。这个例子不含请求,远程数据的取消、失败与竞态仍按 L21 处理。


3. 与 React Hooks 的区别 ​

下表比较 Vue 3.5 的 Composition API 与 React 19 的普通 Hooks,不把某一种组织方式当成另一种的替代品:

Vue Composition APIReact Hooks
执行范围setup 每个组件实例执行一次,更新时运行 render/effect组件函数随 render 执行,Hook 状态由 React 跨渲染保存
依赖computed、watchEffect 追踪读取;watch 显式给出来源useEffect 等通过依赖数组描述何时重新同步
闭包读取回调中读 ref.value 得到当时值,提前复制也会留下快照回调保留创建它的那次 render 的 props/state 快照
条件调用不依赖固定调用顺序,但受活动实例/作用域限制useState/useEffect 等须在组件或自定义 Hook 顶层调用
状态模型响应式对象与 ref每次 render 的状态快照

Vue 的 ref 通过 .value 访问器追踪,reactive 对象使用 Proxy,不能把两者都简写成 Proxy。React 的普通 Hook 规则也有明确例外:React 19 的 use 可出现在条件和循环中,仍只能在组件或 Hook 内使用,且不能放在 try/catch 中。Rules of Hooks、use

Vue 含生命周期、inject 或需要自动随组件停止的 watcher 的 composable,通常应在 setup / script setup 中同步调用。没有固定次序要求,不等于可以随时在事件或计时器里调用;只用独立响应式 API 的函数可在组件外使用,但作用域及清理由调用者负责。<script setup> 顶层 await 有编译器恢复上下文的特殊支持,普通 async helper 不自动获得它。Composable 使用限制

javascript
// Vue:同一实例内保留 ref,更新不重跑 setup。
import { defineComponent, h, ref } from 'vue'
export const VueCounter = defineComponent({
  setup() {
    const count = ref(0)
    return () => h('button', { onClick: () => { count.value++ } }, String(count.value))
  },
})
jsx
// React:每次 render 读取该次状态;这样一次点击加一也是正确的。
import { useState } from 'react'
export function ReactCounter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

React 若要基于排队中的前一次状态连续累加,可以用 setCount(c => c + 1)。这不是每个事件处理器都必须使用的“防闭包”模板;count * 2 这样的廉价计算也不必加 useMemo。

回调读到的是哪一份值 ​

这个 React 例子故意遗漏依赖:interval 留住首次 render 的 count,之后点击增加也不会更新它。问题是 Effect 的依赖与所读数据不一致:

jsx
import { useEffect, useState } from 'react'
export function Timer() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const id = setInterval(() => console.log(count), 1000)
    return () => clearInterval(id)
  }, []) // 演示错误;改为 [count] 会在值变化时清理旧定时器,再创建新定时器。
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

useEffect 在 commit 后与外部系统同步,不是在 render 中执行;开发环境 StrictMode 还可能额外执行一次 setup/cleanup 检查,空依赖不能解释为“绝对只运行一次”。useEffect

Vue 若每次回调都读取 ref.value,可以读取当时的值;但 const snapshot = count.value 复制出的普通数字不会跟着变。下例同时展示两种读取,也补上定时器清理:

typescript
import { defineComponent, h, ref, onMounted, onUnmounted } from 'vue'
export const VueTimer = defineComponent({
  setup() {
    const count = ref(0)
    const snapshot = count.value
    let timer: ReturnType<typeof setInterval> | undefined
    onMounted(() => {
      timer = setInterval(() => console.log(count.value, snapshot), 1000)
    })
    onUnmounted(() => clearInterval(timer))
    return () => h('button', { onClick: () => { count.value++ } }, String(count.value))
  },
})

闭包保存变量绑定,并不会自动把所有读取变成“最新状态”。应先分清保存的是稳定 ref、普通值,还是某次 render 的状态快照。React 的状态快照


4. Composable 设计原则 ​

4.1 说明输入是否需要持续响应 ​

需要持续读取变化的参数,可以接受 Ref 或 getter,并在跟踪期间调用 toValue;如果只需初始化一次,普通值已经足够。useTitle 是副作用函数,没有必要为了形式而返回一个 ref:

typescript
import { onMounted, toValue, watchEffect, type MaybeRefOrGetter } from 'vue'
export function useTitle(title: MaybeRefOrGetter<string>) {
  // SSR 不访问 document;同步 mounted 回调中的 watcher 归属当前组件。
  onMounted(() => {
    watchEffect(() => { document.title = toValue(title) })
  })
}

可传入 '固定标题'、标题 ref 或 () => String(route.meta.title ?? '商城')。这里由一个页面组件负责标题;多个调用同时写 document.title 时,仍需确定谁负责最终值。toValue

4.2 约定 use 前缀 ​

useSearch、useLocalStorage 这样的命名用于提示“这个函数封装了有状态逻辑”。普通格式化或排序工具保持普通函数名即可;命名不是框架注册机制。

4.3 通常返回包含 ref 的普通对象 ​

typescript
import { ref } from 'vue'
function useCounter() {
  const count = ref(0)
  const increment = () => count.value++
  return { count, increment }
}

const { count, increment } = useCounter()
const { count: count2, increment: inc2 } = useCounter()

对象便于按名称解构和重命名;返回 ref 能保留响应式连接。元组、单个 ref 或无返回值也可以是合理接口。问题不在“数组不适合 Vue”,而在调用者能否理解返回值,以及解构 reactive 对象的普通属性时是否丢失连接。

4.4 创建资源时登记清理 ​

typescript
import { onMounted, onUnmounted } from 'vue'
export function useEventListener(
  getTarget: () => EventTarget | null,
  event: string,
  handler: EventListener,
) {
  let target: EventTarget | null = null
  onMounted(() => {
    target = getTarget()
    target?.addEventListener(event, handler)
  })
  onUnmounted(() => target?.removeEventListener(event, handler))
}

在 setup 同步调用,例如 useEventListener(() => window, 'resize', () => console.log('resize'));getter 延后到 mounted 访问 window,适用于 SSR。这个简版只绑定挂载时的目标,不跟踪后续目标替换。自动发生的是卸载时调用已登记的清理函数,Vue 不会替你发现并删除任意监听器、计时器或外部连接。


5. 何时用 Options API ​

Vue 3 的 Options API 仍受支持,没有弃用计划;它也可以通过 defineComponent 获得 TypeScript 推断。选择应看逻辑复杂度和团队约定,不按行数划分。Composition API FAQ、Options API 的 TypeScript 支持

场景推荐
状态与交互较少的组件两者都可以,遵循项目约定
多个逻辑关注点交织的组件Composition API 便于按功能组织与提取
需要复用逻辑✅ Composable
已熟悉 Options 的团队和现有代码可以继续使用,按具体收益渐进引入 setup
TypeScript 项目✅ Composition API(类型推断好)
复用逻辑 / 共享状态Composable 复用逻辑;需要共享状态时再决定是否用 Pinia

6. 本节总结 ​

检查清单 ​

  • [ ] 能说明按选项组织、mixin 复用与类型合并各有什么取舍
  • [ ] 理解 Composition API 如何按功能内聚代码
  • [ ] 理解 Composable 的组合能力 vs Mixin 的缺陷
  • [ ] 能对比 Vue Composition API 和 React Hooks 的核心差异
  • [ ] 能区分回调读取 ref.value 与提前复制普通值的行为
  • [ ] 能设计明确的输入、返回值和副作用清理责任
  • [ ] 知道何时 Options API 仍然合适

🐞 防坑指南 ​

坑说明正确做法
composable 中用 thissetup 没有组件 this,普通函数也不会自动绑定组件用 ref/reactive 管理状态
composable 不清理副作用setInterval/addEventListener 泄漏在 onUnmounted 中清理
混淆复用逻辑与共享状态函数内创建的 ref 通常每次调用各一份先决定状态归属,再选择独立实例或共享 store
过早抽取 composable功能还没稳定就抽取 → 频繁改接口先在组件内写清楚,稳定后再抽取

📐 最佳实践 ​

  1. 输入灵活:用 MaybeRefOrGetter<T> 接受 ref、getter 或纯值
  2. 命名一致:composable 用 use 前缀,内部 ref 不再加 Ref 后缀
  3. 单一职责:一个 composable 只处理一个关注点(搜索/排序/分页分开)
  4. 可测试性:纯计算可直接测试;含 watcher/清理的函数用 effectScope,含生命周期/DOM/inject 的函数需要相应组件环境

Git 提交 ​

bash
git add .
git commit -m "L37: Composition API 设计哲学 + Composable 设计原则"

🔬 深度专题 ​

📖 D01 · Options API vs Composition API — 两种范式的设计哲学对比 📖 D09 · Composables vs React Hooks — 同样是"钩子",心智模型为何不同? 📖 D12 · 闭包陷阱 — setTimeout 里为什么拿到旧值?

🔗 → 下一节 ​

L38 将在独立项目中体验 Vue 3.6 RC 的 Vapor Mode:它把支持的模板编译为直接驱动 DOM 的代码,并说明与现有 VDOM 组件混用的条件。