L37 · Composition API 设计哲学
🎯 本节目标:理解 Composition API 的设计动机、核心理念和最佳实践
📦 本节产出:对 Vue 3 设计决策的深层理解 + Options vs Composition 对比
🔗 前置钩子:L36 的组件渲染流程(理解 setup 的执行时机)
🔗 后续钩子:L38 在独立项目体验 Vue 3.6 RC 的 Vapor Mode1. Options API 的痛点
1.1 代码按选项组织 vs 按功能组织
下面是组织结构示意,省略具体算法;§2 给出可运行的组合版本。
<!-- 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 如何组织有状态逻辑:
// ❌ 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
}| 问题 | Mixins | Composables |
|---|---|---|
| 命名冲突 | ❌ data/methods 可能同名 | ✅ 函数返回值,调用者命名 |
| 来源不明 | ❌ this.xxx 不知道来自哪里 | ✅ const { xxx } = useXxx() |
| 隐式依赖 | ❌ mixin 可能依赖宿主组件 | ✅ 参数显式传入 |
| TypeScript | 多 mixin 合并时,来源与类型关系较难表达 | 普通函数的参数/返回值更容易推导,仍需要恰当的类型声明 |
2. Composition API 的解法
2.1 按功能内聚
把“搜索 → 排序 → 分页”写成明确的数据流。下方完整实现保存到 L31 的独立实验目录;这是内存列表实验,不替换 L23 的服务端搜索与 URL 同步:
// 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,组件再把搜索结果交给排序和分页。每个返回值都能追溯到具体调用,参数则说明了依赖;这仍需要作者设计好接口,并不意味着任意组合都不会冲突。
// 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 API | React 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 使用限制
// 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))
},
})// 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 的依赖与所读数据不一致:
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 复制出的普通数字不会跟着变。下例同时展示两种读取,也补上定时器清理:
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:
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 的普通对象
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 创建资源时登记清理
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 中用 this | setup 没有组件 this,普通函数也不会自动绑定组件 | 用 ref/reactive 管理状态 |
| composable 不清理副作用 | setInterval/addEventListener 泄漏 | 在 onUnmounted 中清理 |
| 混淆复用逻辑与共享状态 | 函数内创建的 ref 通常每次调用各一份 | 先决定状态归属,再选择独立实例或共享 store |
| 过早抽取 composable | 功能还没稳定就抽取 → 频繁改接口 | 先在组件内写清楚,稳定后再抽取 |
📐 最佳实践
- 输入灵活:用
MaybeRefOrGetter<T>接受 ref、getter 或纯值 - 命名一致:composable 用
use前缀,内部 ref 不再加Ref后缀 - 单一职责:一个 composable 只处理一个关注点(搜索/排序/分页分开)
- 可测试性:纯计算可直接测试;含 watcher/清理的函数用 effectScope,含生命周期/DOM/inject 的函数需要相应组件环境
Git 提交
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 组件混用的条件。