重构工作坊的桌上并排放着订单列表、退款队列和发货看板。三个组件早已按照第 8 章的界面边界拆开,各自负责自己的展示与交互;可逐份翻开脚本,熟悉的痕迹几乎复印了三遍:用 ref 保存状态,用 watcher 启动请求,注册快捷键监听,再在条件变化或组件离场时取消请求、移除监听。第 11 章讨论生命周期时揭示的“建立与清理必须成对”,在这里已经不只是一份组件的收尾习惯,而是三份组件共同重复的完整工作。
所以这次要解决的并不是“文件太长”。为了缩短文件继续拆界面,只会得到更多组件,却不会消除三份几乎相同的请求状态和副作用。真正的问题是:同一条有状态功能被复制到了不同组件里,而且它的开始、变化与收尾仍散落在各处。
拿笔在其中一份代码上圈一圈:ref 保存的是这条功能的状态,computed 给出它的派生结果,watch 决定它何时跟着输入变化,生命周期清理则负责把监听、计时器和请求完整收回。把这几个位置圈进同一条边界,再给这条边界一个能说明用途的名字,我们得到的就是 composable。它不是又一种拆文件技巧,而是一段可以连同响应式关系和清理责任一起搬走的有状态能力。
订单工作台先被推到评审台中央:沿着搜索动作向下追,筛选条件圈成一组,异步查询圈成一组,键盘快捷键再圈成一组,每一组都以用途命名为独立的 composable。重构是否完成,不看文件里多出了多少个 useXxx,只看一条功能能否从输入怎样进入、变化怎样触发,一直顺读到监听、计时器和请求怎样清理,中途不必再跳回组件寻找失散的半段逻辑。
回看前 11 章,ref、computed、watch 和生命周期钩子早已陆续出现。那时我们用 ref 留住界面状态,用 computed 表达派生关系,用 watch 接住数据变化,再在组件离场时清理外部副作用。每个 API 都在解决一个具体问题,并不需要先凑成一套宏大的方法论。
直到同一项能力要跨组件复用,它们才必须一起移动。只搬走请求函数,却把 loading 留在组件里,请求的状态边界仍然是断的;只复用快捷键处理函数,却忘了挂载时注册、离场时移除,复用的就是一处泄漏。Composition API 在这里真正派上用场:前面学过的状态、计算、监听和生命周期可以按“订单查询”“筛选条件”“键盘快捷键”重新组合,每组代码都带着自己的完整因果链。
这也解释了为什么课程没有在开篇就急着把每段逻辑都抽成 useXxx。功能还很小时,直接留在 <script setup> 里更容易读;边界经过前面章节的组件拆分、响应式数据流和生命周期检验之后,我们现在才有足够具体的依据判断哪些东西应当一起抽走。
在旧项目里仍会遇到 Options API:状态位于 data,派生值位于 computed,动作位于 methods,生命周期又在相应选项中。它并没有失效,只是按 API 类型组织代码。本章保留两套写法的对照,是为了让你能读懂和渐进迁移旧组件;这次重构关注的则是另一条组织轴——围绕一项功能,把前 11 章已经掌握的能力放回同一个上下文。
走到 Composition API 也不代表每个组件都必须拆出许多 useXxx。一个只有少量状态和两个按钮的组件,直接把逻辑留在 <script setup> 中通常更清楚。抽取的理由应该是形成了可命名的功能边界、需要复用、需要独立测试,或者一条功能的因果链已经难以顺着读完,而不是“组合式函数看起来更高级”。

下面的 Options API 计数器没有任何问题:
<script>
export default {
data() {
return {
count: 0
}
},
computed: {
doubled() {
return this.count * 2
}
},
methods: {
increment() {
this.count += 1
}
}
}
</script>
<template>
<button @click="increment">
{{ count }},两倍是 {{ doubled }}
</button>
</template>换成 Composition API 后,同一条“计数”功能线自然地靠在一起:
<script setup>
import { computed, ref } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() {
count.value += 1
}
</script>
<template>
<button
这个例子太小,还看不出谁更合适。等到一份组件同时维护三四条功能线时,差异才真正出现。因此我们不在这里争论哪套语法“更优雅”,直接进入一个会变复杂的页面。
<script setup> 到底替我们省掉了什么<script setup> 不是浏览器认识的新标签,也不是运行时把脚本和模板临时拼起来。它是单文件组件在编译阶段使用的语法。块里的内容会成为组件 setup() 的主体,并且每创建一个组件实例都会执行一次。
顶层声明可以直接给模板使用,所以不必再写 return { count, increment }。导入的组件也能直接出现在模板里,defineProps、defineEmits 等则是编译器宏,不需要从 vue 导入。
<script setup>
import { computed, ref } from 'vue'
import OrderBadge from './OrderBadge.vue'
const props = defineProps({
customerName: {
type: String,
required: true
}
})
const emit = defineEmits(['confirm'])
const quantity = ref(1)
const summary
这里最容易让新手困惑的是 .value:
quantity 是一个 ref 盒子,读写其中的值要用 quantity.value。quantity,不用写 quantity.value。<script setup> 就自动变成响应式。let quantity = 1 改了以后,模板没有理由跟着更新。
<script setup> 省掉的是组件选项和暴露绑定的样板代码,响应式状态仍然需要显式创建。顶层可以写 await,但它会让组件形成异步 setup()。这样的组件需要放在 Suspense 边界下,而 Suspense 仍然不是我们这类普通数据列表的默认方案。
订单列表更适合先同步返回响应式状态,再由内部请求更新状态:
// 不推荐把常规数据加载写成这种形状
export async function useOrders() {
const orders = await fetch('/api/orders').then(response => response.json())
return { orders }
}上面这个函数返回的是 Promise。组件必须先等待,loading、error、取消请求和重新加载都没有自然的位置。
更适合页面交互的接口是:
export function useOrders() {
const orders = ref([])
const loading = ref(false)
const error = ref(null)
async function refresh() {
// 请求完成后更新这些 ref
}
refresh()
return { orders, loading, error, refresh }
}调用者立刻拿到稳定的状态盒子,模板先显示“加载中”,以后也能刷新或重试。异步发生在操作内部,而不是改变整个 composable 的返回类型。
订单工作台有三个需求:按门店和筛选条件查询订单;用 / 聚焦搜索框、用 R 刷新;显示当前筛选摘要。下面的组件可以工作,请先留意同一功能散落的位置。
<!-- OrderWorkbench.vue:重构前 -->
<script setup>
import {
computed,
nextTick,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
const props = defineProps({
shopId: {
type: String,
required: true
}
})
const keyword = ref('')
const status
页面可能呈现为:
订单工作台 [刷新]
[搜索订单号或客户名] [待处理 ▼] [重置筛选]
当前范围:关键词:林;状态:pending
SO-1032 · 林晓 · 待处理
SO-1027 · 林川 · 待处理问题不是“这段代码写错了”。它甚至已经处理了防抖、取消请求和卸载清理。问题是修改一条功能线时需要跨过许多无关声明:
keyword、filterSummary、两个 watch 和 resetFilters 之间跳转。orders、三个控制变量、loadOrders、scheduleLoad 以及卸载清理。这正是“按功能组织”开始有价值的时刻。下一步不是随意把代码切成小块,而是先给三条边界命名:useOrderFilters、useOrderQuery、useOrderShortcuts。
普通工具函数通常拿到输入,立刻算出输出,然后结束。例如:
export function formatOrderCode(id) {
return `SO-${String(id).padStart(4, '0')}`
}composable 往往会持有随时间变化的状态,建立 watcher,注册生命周期行为,或者组合其他 composable:
import { computed, ref } from 'vue'
export function useOrderFilters() {
const keyword = ref('')
const status = ref('all')
const page = ref(1)
const summary = computed(() => {
const parts = []
const normalizedKeyword
命名以 use 开头是一项约定。它提醒调用者:这个函数可能创建响应式状态或副作用,应当在 setup() 或 <script setup> 的同步执行阶段调用。useOrderFilters() 每调用一次都会创建一套独立筛选状态;它不会因为放进单独文件就自动变成全局单例。
const leftPanel = useOrderFilters()
const rightPanel = useOrderFilters()
leftPanel.keyword.value = '林'
console.log(rightPanel.keyword.value) // ''
组合式函数常用的返回形状是普通对象,其中每个状态是独立 ref:
const { keyword, status, summary, reset } = useOrderFilters()这样解构后,keyword 和 status 仍然是 ref,响应式连接不会断。反过来,如果 composable 返回一个 reactive 对象,直接解构属性就会拿到当时的普通值:
function useBadFilters() {
const state = reactive({
keyword: '',
status: 'all'
})
return state
}
const { keyword } = useBadFilters()
// keyword 只是解构那一刻的字符串,不再跟着 state.keyword 更新如果内部确实更适合使用 reactive,返回前可以用 toRefs:
function useFiltersWithReactive() {
const state = reactive({
keyword: '',
status: 'all'
})
return {
...toRefs(state),
reset() {
state.keyword = ''
state.status = 'all'
}
}
}data1、flag、doIt 会迫使每个调用者重新读实现。更好的名称会说明状态和动作:
orders、loading、error 是可观察状态。refresh、reset、cancel 是调用者可执行的动作。hasActiveFilters、summary 是派生结果。controller、requestVersion、debounceTimer 是实现细节,不该返回。对外只读的状态可以用 readonly 包住,把修改入口收敛到动作:
const orders = ref([])
return {
orders: readonly(orders),
replaceOrders
}这并不会冻结原始数据。composable 内部仍然能更新 orders.value,调用者通过只读视图观察变化;如果调用者尝试直接赋值,开发环境会给出警告。这个边界特别适合“列表只能通过加载和刷新改变”的场景。
组件可以把固定门店编号传给 composable:
useOrderQuery('shop-a', filters)也可以传一个会变化的 ref:
const shopId = ref('shop-a')
useOrderQuery(shopId, filters)还可以传 getter,尤其适合 props:
useOrderQuery(() => props.shopId, filters)如果 composable 希望同时支持这三种输入,可以在真正读取的地方使用 toValue:
import { toValue, watchEffect } from 'vue'
export function useShopTitle(shopId) {
const title = ref('')
watchEffect(() => {
title.value = `门店:${toValue(shopId)}`
})
return { title }
}toValue 会按输入类型处理:普通值原样返回,ref 返回 .value,getter 会被调用。它必须放在 watchEffect 或 watcher 的追踪过程中读取,getter 里面访问到的响应式依赖才会被收集。
unref 也能统一“普通值或 ref”,但它不会把普通函数当 getter 执行:
const count = ref(3)
unref(count) // 3
unref(3) // 3
unref(() => 3) // 返回这个函数本身
toValue(() => 3) // 3所以,公共 composable 如果声明支持 值 | ref | getter,通常用 toValue;只处理 值 | ref 时,unref 足够直接。
isRef 适合分支判断,不要用它重复造 unrefisRef(value) 能判断输入是不是 ref,在 TypeScript 中也能帮助收窄类型:
function describeInput(input) {
if (isRef(input)) {
return `当前 ref 值:${input.value}`
}
return `当前普通值:${input}`
}如果目的只是取值,不需要自己写 isRef(input) ? input.value : input,直接使用 unref 或 toValue。isRef 更适合确实要针对 ref 做不同操作的场景,例如保留同一个 ref 身份、显示调试信息,或根据输入是否可写决定接口行为。
组合式 API 里的“组合”并不是把许多 useXxx() 逐行调用就结束了。真正有用的地方在于:一条功能线的公开结果,可以成为另一条功能线的明确输入。
订单工作台里,筛选功能先创建状态:
const filters = useOrderFilters()查询功能需要关键词、状态和页码,所以调用者把 filters 交给它:
const query = useOrderQuery(() => props.shopId, filters)快捷键功能需要刷新能力,于是接收查询功能公开的 refresh:
useOrderShortcuts({
searchInput,
onRefresh: query.refresh
})这三行本身就在说明页面结构:先有筛选,再由筛选驱动查询,快捷键可以触发查询。维护者不用进入三个文件,也能看清依赖方向。
一种看似省事、实际很危险的做法,是让不同 composable 从某个模块变量偷偷取数据:
// orderContext.js
export let currentFilters
export function useOrderFilters() {
currentFilters = reactive({ keyword: '', status: 'all' })
return currentFilters
}
export function useOrderQuery() {
// 隐式依赖另一个函数是否已经先调用
console.log(currentFilters.keyword)
}这样的代码依赖调用顺序,也会让两个订单面板互相覆盖状态。单元测试若只调用 useOrderQuery(),还会因为前置状态不存在而失败。把依赖变成参数以后,顺序、来源和测试输入都摆在明面上。
useOrderShortcuts 只需要“刷新”和“聚焦搜索框”,它不需要知道订单数组、请求地址或错误状态。传入一个 onRefresh 函数,比把完整 query 对象交给它更稳妥:
// 范围过宽:快捷键能碰到所有查询内部公开项
useOrderShortcuts({ query, filters, searchInput })
// 范围明确:只交付快捷键实际需要的能力
useOrderShortcuts({
searchInput,
onRefresh: query.refresh
})这和组件 props 的设计很像。子组件需要标题,就传标题;不要为了少写两个参数,把整个页面对象塞给它。composable 的输入越小,越容易复用,也越不容易在不知不觉中形成环形依赖。
summary 依赖 keyword 和 status,所以它属于 useOrderFilters。订单总金额依赖 orders,就更适合放在 useOrderQuery 或更专门的订单统计 composable。不要仅仅因为某个派生值最终显示在组件模板里,就一律留在组件顶层。
可以问一句:“谁最了解生成这个值所需要的规则?”答案通常就是它应该待的边界。这样修改状态枚举、过滤规则或金额计算时,相关代码会一起移动,不必跨文件追踪。
下面这种写法会制造两个“关键词”:
const pageKeyword = ref('')
const filters = useOrderFilters()
watch(pageKeyword, value => {
filters.keyword.value = value
})
watch(filters.keyword, value => {
pageKeyword.value = value
})它们需要互相同步,还可能因为格式化差异形成循环。更简单的办法是选择唯一数据源:模板直接绑定 filters.keyword,其他功能也接收同一个 ref。真的需要对外转换时,用带 getter 与 setter 的 computed 明确映射,不要维护两份含义相同的可写状态。
把 composable 放进单独文件,不会自动让状态共享。决定状态是否共享的是状态在哪里创建。
下面的状态在函数内部创建,所以每次调用都独立:
export function useDraft() {
const text = ref('')
return { text }
}两个编辑器分别调用 useDraft(),一边输入不会影响另一边。这是组件局部状态最安全的默认值,也能自然随着各自组件卸载。
如果把 ref 放到模块顶层,所有调用者会拿到同一个实例:
const text = ref('')
export function useSharedDraft() {
return { text }
}这种共享并不一定错。例如同一浏览器标签页里,全站只应有一份网络在线状态,模块级状态可能很合适。但它改变了生命周期:最后一个组件卸载以后,模块状态仍然存在;服务端渲染时,如果服务端进程复用模块,还可能把一次请求的状态带到另一次请求。
因此,不要通过“把 ref 挪到函数外面”偶然得到共享。先把共享范围说清楚:
stop()。两处都调用 useOrderQuery('shop-a', filters),默认会发起两套请求并拥有两套 loading。如果产品要求它们共享缓存,需要明确设计缓存键、过期时间、并发合并和失效策略。这已经超出“抽一个 composable”的简单重构。
不要偷偷用一个模块级 Map 就宣布解决缓存。你至少需要回答:筛选对象怎样生成稳定键;离开页面后缓存保留多久;刷新一处是否通知另一处;失败是否缓存;服务端渲染时如何隔离不同用户。composable 可以包装缓存能力,但共享规则必须是它公开契约的一部分。
如果两个功能确实要围绕同一状态协作,应传递 ref 本身:
const selectedOrderId = ref(null)
const details = useOrderDetails(selectedOrderId)
const keyboard = useSelectionKeyboard(selectedOrderId)两个 composable 都读取同一个响应式身份。若改成传 selectedOrderId.value,它们只得到创建时的快照;若各自再创建一份 ref,则又回到双向同步的问题。
“props 一解构就失去响应性”曾经是一条常见口诀,但在当前 Vue 里,它需要加上条件。
在 Vue 3.5 及以上版本的 <script setup> 中,直接从 defineProps() 返回值解构出来的变量,在同一个 <script setup> 代码块内会保持响应式。编译器会把相应访问转换成对 props.xxx 的读取:
<script setup>
import { watchEffect } from 'vue'
const { shopId, pageSize = 20 } = defineProps({
shopId: {
type: String,
required: true
},
pageSize: Number
})
watchEffect(() => {
console.log('当前门店:', shopId)
})
</script>父组件改变 shopId 时,这个 watchEffect 会重新运行。默认值也可以直接使用 JavaScript 的解构默认值语法。
不过,编译器能保持的是当前代码块里的访问。如果把解构变量作为普通实参传出去,传递的仍然是那一刻的值:
// 这里只传入当前字符串,composable 无法观察后续变化
useOrderQuery(shopId, filters)
// getter 每次读取最新的 shopId
useOrderQuery(() => shopId, filters)如果项目仍在 Vue 3.4 或更早版本,或者你写的是显式 setup(props),普通解构仍会断开连接:
export default {
props: {
shopId: String
},
setup(props) {
const { shopId } = props
// shopId 是当前字符串
}
}需要单个属性时,用 toRef 建立连接:
setup(props) {
const shopId = toRef(props, 'shopId')
// shopId.value 始终读取当前 props.shopId
}也可以使用 getter 归一化形式得到只读 ref:
const shopId = toRef(() => props.shopId)需要多个已存在的属性时,toRefs(props) 可以一次转换:
setup(props) {
const { shopId, pageSize } = toRefs(props)
}toRefs 只处理调用时可枚举、已经存在的属性。某个键以后才会出现时,应使用 toRef(object, key) 单独建立连接。还有一点要记住:props 本身是只读边界。toRef(props, 'shopId') 让你持续读取它,不代表子组件获得了修改父组件 props 的权限。

现实项目很少允许你停下需求,把几十个组件一次性翻写。好消息是,Composition API 可以在 Options API 组件的 setup() 中使用。这提供了一条渐进路线:先抽最痛、最容易验证的功能,不必为了统一外观重写整个页面。
假设旧组件已经有大量表格展示和弹窗方法,只有“监听窗口尺寸”被多个页面复制。可以先抽一个 composable:
// composables/useWindowSize.js
import { onMounted, onScopeDispose, readonly, ref } from 'vue'
export function useWindowSize() {
const width = ref(0)
const height = ref(0)
let listening = false
function update() {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted
然后在原 Options API 组件中增加 setup:
<script>
import { useWindowSize } from './composables/useWindowSize'
export default {
props: {
title: String
},
setup() {
const { width, height } = useWindowSize()
return {
viewportWidth: width,
viewportHeight: height
}
},
data() {
return {
dialogOpen:
setup() 返回的 ref 会在模板以及组件实例的 this 上进行浅层解包,所以 computed 中可以读取 this.viewportWidth。这让旧选项继续工作,同时新逻辑已经能被其他组件复用。
迁移时要注意一个方向限制:Options API 可以读取 setup() 返回的绑定,但 setup() 不能通过 this 读取稍后才初始化的 data、computed 和 methods。如果新 composable 需要旧状态,不要试图从 setup 里抓组件实例,而应把那一小块状态也迁到 setup,或者暂时让旧逻辑保持原样。
比较稳妥的顺序是:
setup() 中调用并返回必要绑定。不要先把 data 全部搬成 ref,再把 methods 全部改成函数,最后才寻找边界。那只是语法翻译,期间文件会同时存在大量半迁移状态,也最难审查。
<script setup> 与 Options API 之间来回穿透一个单文件组件可以同时存在普通 <script> 和 <script setup>,但这通常用于模块级选项或少数特殊能力,不适合把一半业务放在 Options API、另一半放在 <script setup>,再期待两边通过组件实例任意互访。
迁移期如果需要混用,优先使用普通 setup() 桥接;新建组件则直接采用 <script setup>。这样每个组件内部仍有明确主风格,维护者不用判断同一个 count 究竟来自 data、setup 还是模块顶层。
老项目常用 mixin 复用逻辑。mixin 的问题之一是来源不透明:模板出现 loading,你可能需要检查多个 mixin 才知道它从哪里来;多个 mixin 还可能写入同名键。
迁移时不要把整个 mixin 原封不动塞进 useLegacyMixin()。先按照真正功能拆开,例如 usePermission()、usePagination()、usePolling()。调用处通过解构和重命名能明确处理冲突:
const { loading: permissionLoading } = usePermission()
const { loading: ordersLoading } = useOrderQuery(...)这个显式来源正是 composable 相比 mixin 更好调试的地方。
下面是订单查询的完整 composable。它接受门店编号以及三个筛选 ref,立即返回订单、加载状态、错误和刷新操作。查询条件变化时会等待 250 毫秒;新请求开始前取消旧请求;作用域结束时清除定时器并取消仍在进行的请求。
// composables/useOrderQuery.js
import {
onScopeDispose,
readonly,
ref,
toValue,
watch
} from 'vue'
function getErrorMessage(cause) {
if (cause instanceof Error && cause.message) {
return cause.message
}
return '暂时无法加载订单,请稍后重试'
}
export function useOrderQuery(shopId, filters) {

AbortController 能取消 fetch,但“取消”并不等于所有异步步骤都会立刻消失。真实项目里,网络层可能被封装,响应之后还可能有解析、转换或缓存步骤。给每次请求一个递增版本号,可以确保只有最新请求有权写入公开状态。
假设用户快速输入“林”“林晓”:
请求 1:keyword=林 —— 较早发出,响应较慢
请求 2:keyword=林晓 —— 较晚发出,响应较快
请求 2 先完成:显示“林晓”的结果
请求 1 后完成:版本已过期,不允许覆盖页面这个检查也保护了 loading。如果旧请求先进入 finally 就随手设成 false,页面可能在新请求仍然进行时提前隐藏加载提示。
筛选变化导致的取消是正常控制流,不应该出现红色报错。所以上面的实现识别 AbortError 并忽略它。真正的网络失败、权限错误或服务端错误才进入 error。
onScopeDispose普通组件的 setup() 本身就在一个响应式作用域中。组件卸载时,这个作用域会结束。onScopeDispose 把清理绑定到 composable 所在的作用域,因此它既适用于组件卸载,也适用于稍后会看到的手动 effectScope。
如果你的 composable 明确只服务于组件,使用 onUnmounted 也完全可以。这里选择 onScopeDispose,是因为“清理查询副作用”属于 composable 自己的职责,而且这份逻辑将来可能在独立作用域中使用。
不要在 setTimeout、点击回调或请求完成后才首次调用带生命周期注册的 composable。Vue 需要在同步执行的 <script setup> 或 setup() 期间确认它属于哪个组件实例和作用域。可以延后执行 composable 返回的 refresh(),不要延后创建 composable 本身。
异步逻辑里其实有两个不同的清理时机。
第一个是 watcher 即将重跑。比如商品编号从 A 变成 B,A 的请求已经过期,即使组件还在页面上也该取消。watch 回调的第三个参数可以注册本轮清理:
watch(
() => toValue(productId),
async (id, _oldId, onCleanup) => {
const controller = new AbortController()
onCleanup(() => {
controller.abort()
})
const response = await fetch(`/api/products/${id}`, {
signal: controller.signal
第二个是整个 composable 所在作用域结束。此时所有持续资源都该停止,包括不一定直接属于某一次 watcher 的定时器、事件订阅和当前请求。onScopeDispose 负责这个总收尾。
在简单的“一次 watcher 对应一次请求”里,把取消写进 watcher 清理非常自然。在我们的订单例子里,防抖定时器、手动 refresh() 和自动 watcher 都可能启动同一个 execute(),所以集中保存 activeController,再在新请求和 scope 结束时统一取消,更容易保证行为一致。
上面的 refresh() 返回 execute() 的 Promise。模板点击并不需要等待它,但其他 composable 或测试可以选择等待:
async function refreshAndAnnounce() {
await refresh()
announce('订单已经刷新')
}这里有一个接口细节:当前 execute() 即使请求失败,也把错误写入 error 而不继续抛出,所以 await refresh() 表示“本次请求流程已经结束”,不保证业务成功。如果调用者需要区分成功和失败,可以让 refresh() 返回一个明确结果:
return { ok: true }
// 或
return { ok: false, reason: error.value }另一种设计是保留异常抛出,让调用者 try/catch。两种都可以,最怕的是同一个 composable 有时吞掉错误、有时抛出错误。返回契约应当固定,并在函数名或类型中容易看出来。
用户连续输入时,我们不断重置计时器,但 orders、loading 和 error 这几个 ref 从创建到销毁始终是同一个对象。不要每次查询都返回一套新状态:
// 不合适:调用者每次都要换掉整套引用
async function refresh() {
const orders = ref(await load())
return { orders }
}稳定身份很重要。模板、computed 和 watcher 在 setup 阶段订阅的是那几个 ref;后续只需要修改 .value,依赖关系会继续有效。
订单数组为空,表示请求成功但没有匹配项;error 有值,表示这次请求失败;取消则表示这次请求已经失去意义,通常不应改变当前错误提示。
如果把三者都处理成 orders.value = [],页面只能显示“没有订单”,用户分不清是真的没有数据,还是网络坏了。一个可维护的异步 composable 至少要让模板区分:
<p v-if="loading">正在加载……</p>
<p v-else-if="error" role="alert">{{ error }}</p>
<p v-else-if="orders.length === 0">没有符合条件的订单</p>
<OrderList v-else :orders="orders"要不要在重新请求时清空旧列表,则是产品选择。保留旧数据可以减少页面跳动,但应同时显示“正在刷新”;清空旧数据更明确,却会让列表短暂消失。composable 应把策略固定下来,而不是让不同组件各自猜测。
loading.value = true 后,所有结束路径都必须能把最新请求的 loading 复原。网络异常、JSON 解析失败和代码主动 throw 都会进入 catch 与 finally。因此不要只在“成功分支”写 loading.value = false。
版本检查则确保旧请求没有资格替新请求收尾。可以把它想成取号办理业务:每次请求拿到号码,只有当前柜台屏幕上的最新号码能改页面;过号的请求即使回来,也只能安静结束。
键盘快捷键会碰到浏览器对象,所以要在组件挂载后注册。composable 保存同一个处理函数引用,并在作用域结束时移除监听。
// composables/useOrderShortcuts.js
import { nextTick, onMounted, onScopeDispose } from 'vue'
export function useOrderShortcuts(options) {
let listening = false
function isTypingTarget(target) {
return target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target?.isContentEditable
}
async function handleKeydown(event) {
这里没有返回任何状态也没关系。composable 的价值不是“必须 return 一堆东西”,而是封装一条有状态或有副作用的功能线。
清理监听器有一个非常常见的错误:
// 错误:添加和移除使用了两个不同的函数对象
window.addEventListener('keydown', event => handleKeydown(event))
window.removeEventListener('keydown', event => handleKeydown(event))哪怕两个箭头函数长得一样,它们也不是同一个引用,旧监听器不会被移除。把 handleKeydown 命名并在添加、移除时复用,问题就消失了。
对于服务端渲染,window 在服务端不存在。把 DOM 副作用放进 onMounted,可以保证这段代码只在浏览器挂载阶段运行。不要在模块顶层直接执行 window.addEventListener。
筛选条件已经在 useOrderFilters,异步状态在 useOrderQuery,快捷键的注册与清理在 useOrderShortcuts。组件现在只做编排和展示。
<!-- OrderWorkbench.vue:重构后 -->
<script setup>
import { ref, watch } from 'vue'
import { useOrderFilters } from './composables/useOrderFilters'
import { useOrderQuery } from './composables/useOrderQuery'
import { useOrderShortcuts } from './composables/useOrderShortcuts'
const props = defineProps({
shopId: {
type: String,
required: true
}
})
const searchInput = ref(
模板里为什么出现了 filters.keyword.value?模板只会自动解包顶层 ref。filters 是普通对象,filters.keyword 是它的属性,不属于顶层绑定,所以这里显式写 .value 最不容易产生误会。
如果你更喜欢模板简洁一些,可以在脚本中解构。因为 composable 返回的是普通对象加多个 ref,解构不会断开连接:
const {
keyword,
status,
page,
summary,
reset
} = useOrderFilters()此时模板可以写 v-model="keyword"、{{ summary }}。这两种风格都可以,关键是团队保持一致:保留命名空间能看出状态属于哪条功能线,解构则让模板更短。
重构后的组件变短只是表面结果。更实用的变化是:
useOrderQuery.js。useOrderFilters.js。Esc 清空搜索,只看 useOrderShortcuts.js 以及它明确接收的操作。useOrderQuery,而不复制整份组件。readonly、toRef、toRefs、unref、isRef 怎么选这些名字很像,但问题不同。可以按一句话判断:
toRef:我需要保持某一个属性的连接const page = toRef(filters, 'page')page.value 与 filters.page 指向同一份响应式属性。也可以把 getter 归一化成只读 ref:
const shopId = toRef(() => props.shopId)toRefs:我要解构一整个响应式对象const state = reactive({ keyword: '', page: 1 })
const { keyword, page } = toRefs(state)两个变量都是与原属性相连的 ref。它不会处理未来才新增的键。
unref:输入只可能是普通值或 ref,我要拿到值function formatPage(page) {
return `第 ${unref(page)} 页`
}isRef:我要知道输入到底是不是 refif (isRef(page)) {
console.log('这个输入可以通过 .value 读取')
}readonly:调用者可以观察,但不能从这条入口修改const internalPage = ref(1)
const publicPage = readonly(internalPage)internalPage.value 更新时,publicPage.value 仍会同步。readonly 是响应式的只读视图,不是一次静态复制。
如果一个函数只是接收值并返回格式化文本,它不必为了“统一风格”改成 composable。只有当它需要 Vue 的响应式能力、生命周期或可复用的有状态逻辑时,useXxx 这个名字才真正提供信息。
TypeScript 不是使用 Composition API 的前提,但 composable 恰好很适合写类型:它是普通函数,输入和返回都能直接表达,不必依赖组件实例上的 this 推断。
先定义订单与筛选接口:
// types/order.ts
import type { Ref } from 'vue'
export type OrderStatus = 'all' | 'pending' | 'shipped'
export interface Order {
id: string
code: string
customerName: string
statusLabel: string
}
export interface OrderFilters {
keyword: Ref
门店编号允许普通字符串、ref 或 getter,可以使用 Vue 提供的 MaybeRefOrGetter:
import type {
MaybeRefOrGetter,
DeepReadonly,
Ref
} from 'vue'
interface UseOrderQueryResult {
orders: DeepReadonly<Ref<Order[]>>
loading: Readonly<Ref<boolean>>
error: Readonly<Ref<string | null>>
refresh: ()
调用者把数字误传给 shopId,或者传入缺少 page 的筛选对象时,会在开发阶段得到提示。refresh 是否返回 Promise、错误是不是 null、订单能否直接修改,也从签名中清楚可见。
实际项目不一定需要手写整个 UseOrderQueryResult。TypeScript 通常能从返回对象推断出来。明确写接口更适合这是一个被很多业务模块调用的公共 composable,或者你希望锁住长期契约的情况;只在一个功能目录内部使用时,让推断工作会更轻便。
any支持普通值、ref 与 getter,不等于接收任何东西:
// 信息几乎全部丢失
function useOrderQuery(shopId: any, filters: any): any
// 输入灵活,但值的业务类型仍然明确
function useOrderQuery(
shopId: MaybeRefOrGetter<string>,
filters: OrderFilters
): UseOrderQueryResult前者会让 toValue(shopId) 的结果也是 any,拼请求参数时写错字段也不容易被发现。后者表达的是“来源形态灵活,最终必须得到字符串”,这才是归一化工具真正解决的问题。
快捷键 composable 接收搜索框 ref。组件挂载前 DOM 元素还不存在,所以类型必须包含 null:
import type { Ref } from 'vue'
interface ShortcutOptions {
searchInput: Ref<HTMLInputElement | null>
onRefresh: () => void | Promise<void>
}
export function useOrderShortcuts(options: ShortcutOptions) {
options.searchInput.value?.focus()
}强行写成 Ref<HTMLInputElement>,只是在类型层面假装它从一开始就存在,运行时仍可能读到 null。?.focus() 不是多余防御,而是在表达真实生命周期。
如果 composable 只需要读筛选条件,可以把接口声明为 Readonly<Ref<T>>:
interface ReadonlyOrderFilters {
keyword: Readonly<Ref<string>>
status: Readonly<Ref<OrderStatus>>
page: Readonly<Ref<number>>
}普通可写 ref 可以传给只读参数,因为读取能力满足要求;函数内部则不能随意写 filters.page.value = 1。谁负责重置页码会因此更加明确。
如果 useOrderQuery 不该修改筛选状态,就使用只读输入;“筛选变化后页码归一”可以留在 useOrderFilters 的动作或内部 watcher 中。类型在这里不是装饰,它在逼我们回答状态所有权。
组件里的 watch、watchEffect 和 computed 会随着组件作用域一起停止,所以多数页面不需要手动创建 effect scope。effectScope() 更适合两类场景:在组件外启动一组可停止的响应式任务,或者在测试和临时会话中希望一次性销毁所有 watcher。
下面创建一段订单观察会话。它不渲染界面,只记录筛选变化和订单数量;调用 stop() 后,两条 watcher 一起停止,onScopeDispose 注册的清理也会执行。
// createOrderObserver.js
import {
effectScope,
onScopeDispose,
watch,
watchEffect
} from 'vue'
export function createOrderObserver(filters, orders) {
const scope = effectScope(true)
scope.run(() => {
watch(
() => [filters.keyword.value, filters.status.value],
([keyword,
使用方式:
const observer = createOrderObserver(filters, orders)
filters.keyword.value = '林'
// 控制台:筛选变化:{ keyword: '林', status: 'all' }
observer.stop()
filters.keyword.value = '周'
// 不再输出筛选变化参数 true 创建的是脱离当前父作用域的 scope,所以必须由调用者保存并明确停止。如果去掉 true,它会成为当前活动 scope 的子作用域,父作用域停止时也会连带停止。

不要为了“更安全”给每个 composable 都包一层 detached scope。那会把本来能随组件自动销毁的副作用从组件生命周期中脱开,反而要求你额外管理 stop()。先依赖组件 scope;确实需要独立寿命时,再手动创建。
不使用生命周期钩子的 composable,本质上就是创建 ref 和函数的普通 JavaScript 函数。useOrderFilters 可以直接测试,不需要渲染组件:
// useOrderFilters.spec.js
import { describe, expect, it } from 'vitest'
import { useOrderFilters } from './useOrderFilters'
describe('useOrderFilters', () => {
it('重置后回到全部订单第一页', () => {
const filters = useOrderFilters()
filters.keyword.value = '林'
filters.status.value = 'pending'
filters.page.value = 4
expect(filters.summary.value).toBe
这个测试关心的是公开行为,没有检查函数内部究竟用了几个 ref。将来我们改变实现,只要契约不变,测试仍然有效。
useOrderQuery 依赖 watcher、计时器、fetch 和 scope 清理,测试要控制这些外部因素。可以用 effect scope 给它一个明确寿命,用假计时器推进防抖时间,并替换 fetch:
// useOrderQuery.spec.js
import {
effectScope,
nextTick,
ref
} from 'vue'
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest'
import { useOrderQuery } from './useOrderQuery'
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe(
测试结束调用 scope.stop(),会触发 onScopeDispose。这既避免 watcher 泄漏到下一个测试,也顺便验证 composable 能在非组件 scope 中正确收尾。
useOrderShortcuts 使用 onMounted,只创建 effect scope 还不会触发组件挂载钩子。测试它时需要挂载一个很小的宿主组件,让 composable 在真实 setup 上下文中运行:
import { mount } from '@vue/test-utils'
import { defineComponent, ref } from 'vue'
it('组件卸载后不再响应刷新快捷键', async () => {
const onRefresh = vi.fn()
const Host = defineComponent({
setup() {
const searchInput = ref(null)
useOrderShortcuts({ searchInput, onRefresh })
return { searchInput }
这个断言非常具体:卸载后再派发事件,计数不再增加。它比检查“调用过 removeEventListener”更接近用户真正会遇到的问题。
若要验证“旧请求不会覆盖新结果”,不要依赖随机网络延迟。创建两个由测试手动 resolve 的 Promise,让第二个请求先完成,再让第一个完成:
function deferred() {
let resolve
const promise = new Promise(done => {
resolve = done
})
return { promise, resolve }
}
const first = deferred()
const second = deferred()
fetchMock
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)这种测试能稳定重现最麻烦的时序,而不是“多跑几次看看会不会出错”。先完成第二个响应,断言页面采用新条件结果;再完成第一个响应,断言订单仍未被旧数据替换。
没有必要写测试证明 ref 修改后 computed 会更新,那是框架已经保证的行为。更值得测的是你的业务约定:重置筛选是否回第一页;请求失败显示哪类消息;取消是否保持当前数据;readonly 状态是否只能通过公开动作变化;停止 scope 后是否不再执行副作用。
当 composable 很难测试,通常也说明边界过宽。一个函数同时读浏览器存储、监听窗口、请求网络、改路由并显示通知,需要准备太多环境。把这些能力作为参数注入,或者按功能拆分,生产代码和测试都会更清楚。
组合式函数出问题时,别一上来怀疑“Vue 没监听到”。按下面四步走,通常很快能缩小范围。
const { shopId } = props
useOrderQuery(shopId, filters)在显式 setup(props) 中,这里的 shopId 是普通字符串。即使在 Vue 3.5 的 <script setup> 响应式 props 解构里,把它直接传给外部函数也只传入当前值。改成 getter:
useOrderQuery(() => props.shopId, filters)const page = ref(1)
watch(page.value, loadOrders) // 错误:传入数字 1,不是监听源
watch(page, loadOrders) // 正确:传入 ref
watch(() => page.value, loadOrders) // 也正确:传入 getter如果输入允许 getter,要确保 toValue(input) 在 watcher 的 getter 或 watchEffect 里面执行,而不是提前在外面算成常量。
readonly 警告通常说明调用者越过了接口边界:
const { orders } = useOrderQuery(...)
orders.value = [] // 开发环境警告正确做法不是移除 readonly,而是确认 composable 是否应该提供明确操作,例如 clear()。如果清空本来就不该由调用者做,那么警告正是在帮你找到越权写入。
一个组件反复打开关闭后,按一次 R 却请求多次,通常是事件监听没有移除;切换筛选后旧结果覆盖新结果,则多半是缺少取消或版本检查。
可以临时添加成对日志:
onMounted(() => {
console.count('注册订单快捷键')
window.addEventListener('keydown', handleKeydown)
})
onScopeDispose(() => {
console.count('移除订单快捷键')
window.removeEventListener('keydown', handleKeydown)
})如果组件开关三次,注册与移除次数应该成对。调试结束后删除这些临时日志。
评审 composable 不能只看函数有没有以 use 开头,也不能只数文件变短了多少。最有效的办法,是选择一个用户动作,从输入一路走到清理。
以“用户在搜索框输入林晓”为例:
v-model 修改 filters.keyword。orders、error 与 loading。如果这五步需要依靠一个未声明的模块变量、某个组件外的全局监听,或者调用者额外记得手工取消,那么边界还没有闭合。
函数收的是 shopId: string,实现里却对它建立 watcher,说明签名承诺不足;函数收的是 MaybeRefOrGetter<string>,却在 watcher 外先执行一次 toValue,说明实现把动态输入悄悄变成了快照。
还要检查默认值放在哪里。通用默认值适合放 composable,例如页码从 1 开始;页面特有的默认筛选应由调用者传入。否则另一个页面复用时只能调用后立刻覆盖,初始化期间还可能先触发一次错误请求。
export function useOrderFilters(initial = {}) {
const keyword = ref(initial.keyword ?? '')
const status = ref(initial.status ?? 'all')
const page = ref(initial.page ?? 1)
return { keyword, status, page }
}默认对象只用于读取,不要直接把它变成共享的 reactive 状态。每次调用仍然应该创建自己的 ref。
如果 orders 同时被组件、查询 composable 和快捷键 composable 直接赋值,任何一处都可能跳过错误处理与版本检查。更好的设计是只有查询 composable 写订单,其他功能通过 refresh()、cancel() 这类操作表达意图。
同理,筛选页码归一应只有一个负责人。可以让 useOrderFilters 内部监听关键词和状态并把页码归一,也可以由组件负责连接规则;不要两边各写一次。重复写入暂时看似无害,后面加埋点、路由同步或分页动画时就会执行两次。
公共 composable 不一定适合直接弹通知。它可以公开结构化错误,由页面决定放在列表内、弹窗中还是静默重试:
const error = ref(null)
error.value = {
type: 'network',
message: '网络连接失败',
retryable: true
}但也不要把底层响应对象整个暴露出去。页面通常不该依赖某个请求库的私有字段。composable 可以在边界内把异常归一成业务需要的最小信息。
评审时还要问:失败后保留旧数据还是清空?重试会不会重复提交?取消是否被当成失败?这些决定应该在实现和页面文案中一致。
看到下面任一操作,就顺手寻找对应清理:
addEventListener 对应 removeEventListener,而且函数引用相同。setInterval、setTimeout 对应 clear 操作。fetch 或请求库订阅对应取消机制。subscribe 对应 unsubscribe。watch 在 detached scope 或长期服务中要能随服务停止。组件 scope 会自动停止同步创建的 watcher,但不会替你移除 window 监听,也不会自动取消浏览器请求。不要把“Vue 会清理 watcher”误解成“所有副作用都会自动消失”。
const filters = useOrderFilters()
const query = useOrderQuery(() => props.shopId, filters)
useOrderShortcuts({ onRefresh: query.refresh, searchInput })这段调用能直接说出功能关系。如果改成 useData、useHandler、useCommon,每个名字都过于宽泛;如果拆成 useKeywordRef、useStatusRef、usePageRef,粒度又细到看不出完整功能。
一个实用标准是:名称应对应用户或业务能识别的能力,而不是对应内部使用的某个 API。useOrderQuery 比 useWatchFetch 更稳定,因为以后即使从 watcher 改成事件触发、从 fetch 改成别的客户端,它仍然在完成订单查询。
如果调用者必须知道“先调用 A,再调用 B,然后把 B 的内部变量塞回 A”,说明接口不自然。理想调用顺序应该从参数和返回值中显现;必须遵守的限制,例如“只能在 setup 同步阶段调用”“返回状态只读”,则应通过命名、类型和开发警告共同表达。
评审结束时,最好再模拟一次组件反复挂载、门店快速切换和请求失败。这三种情境会暴露大部分作用域、竞态与状态归属问题。
可以用三个问题判断:
三个问题大多回答“是”,就值得抽。下面这些情况则可以先不抽:
useSomething 只包了一行无状态格式化,普通函数更诚实。composable 也不等于全局状态。调用 useOrderFilters() 两次默认得到两份状态;如果所有组件必须共享同一份订单数据,应该明确设计模块级单例、依赖注入或状态仓库,而不是让“是否共享”取决于调用者猜测。
下面的组件在父组件切换 productId 后没有重新请求。请找出问题并修复:
<script setup>
const { productId } = defineProps({
productId: {
type: String,
required: true
}
})
const { product } = useProduct(productId)
</script>下面的 composable 在内容变化 800 毫秒后保存。请补上作用域结束时的清理,并避免重复计时器残留。
export function useAutoSave(content, save) {
let timer
watch(content, value => {
timer = window.setTimeout(() => save(value), 800)
})
}一个 useSelection() 需要支持选择、取消选择、清空,并让模板显示已选数量。请设计返回值,要求调用者不能直接替换内部集合。
经过这次重构,订单查询已经有了一条清楚的边界:状态、派生值、监听、动作和清理彼此靠在一起;输入交给 composable 之后,它会沿着响应式关系完成后续工作。
这也把一个新问题推到了面前:如果输入不只来自组件里的 ref,而是来自地址栏呢?门店、关键词和页码都可能随着刷新、分享链接以及浏览器前进后退而变化,composable 不能只读取打开页面那一刻的快照,它需要继续追踪地址的变化。
这些状态应该进入 URL。把响应式路由对象中的 () => route.params.shopId 直接交给 useOrderQuery,再把 route.query.keyword 与 route.query.page 归一成筛选状态;地址一变化,getter 与 watcher 就会沿着刚刚搭好的数据流重新触发查询,而不需要把 URL 在初始化时抄成一份失去联系的快照。
反过来,用户修改筛选条件时,页面也可以通过路由导航把新值写回查询参数。Router 负责让输入可分享、可刷新并进入浏览历史,composable 负责消费这些响应式输入、管理请求状态,并在页面离场时完成清理。问题停在这里:URL 与界面状态如何连接?
每次内容变化先取消上一个计时器,作用域结束时再停止 watcher 并清除最后一个计时器。组件卸载后就不会突然发送一次过期保存。
返回值把观察和修改分开:调用者读取 selectedIds 与 selectedCount,所有写入经过有业务含义的动作。内部以后把数组换成 Set 时,调用组件也更容易保持不变。