Composable API 为什么要默认用对象边界
写 composable 的时候,很容易先追求“短”。
一个函数只需要两个值,就写成 useFoo(a, b);当前只返回一个 computed,就直接 return result。这确实省几行,但它会把 API 早早绑死。等下次要追加一个配置项、一个 loading、一个错误态、一个 refresh 动作时,原来的短写法会变成迁移成本。
这篇文章想讲的不是“所有函数都必须对象化”。更准确的规则是:
会演进的业务 API,入参和返回值默认用对象边界;只有语义稳定、输入输出很单一的底层工具,才保留位置参数或单值返回。
位置参数会把顺序变成契约
业务 composable 的参数通常不是数学函数那种稳定输入。它一开始可能只需要 userProfile 和 fallbackName,后来会追加 relation、remarkMap、reviewStatus、refreshProfile。
如果一开始写成位置参数:
const displayName = useProfileIdentity(userProfile, fallbackName);第三个参数出现时,调用点会变成:
const displayName = useProfileIdentity(userProfile, fallbackName, remarkMap);再往后继续加,就只能靠顺序记忆。调用点看起来很短,但读者必须跳到定义处才知道第二个、第三个参数分别是什么。更麻烦的是,中间某个参数变成可选后,调用方要么传 undefined 占位,要么重载,要么新增一个相似 API。
对象参数把这件事摊开:
const { avatarSrc, displayName } = useProfileIdentity({
fallbackName,
userProfile,
});调用点自己就说明了参数名字。后续追加 remarkMap 时,只是在对象里多放一个字段:
const { avatarSrc, displayName, displayNameIsRemarked } = useProfileIdentity({
fallbackName,
remarkMap,
userProfile,
});这个对象字面量把“参数顺序”换成了“参数名字”。业务代码更适合后者,因为业务参数会长大。
返回单值也会锁死调用方
返回值也一样。
一开始只需要名字,直接返回一个 computed 很自然:
export default ({ fallbackName, userProfile }: Params) => {
return computed(
() => userProfile.value?.basicInfo?.nickname || fallbackName.value,
);
};
const displayName = useProfileIdentity({ fallbackName, userProfile });后面要追加头像,就会尴尬。要么把返回值改成对象,所有调用点都要从 const displayName = ... 改成解构;要么新增 useProfileAvatar(),让同一份 source 被多个 composable 分别消费。
一开始返回对象,调用方只拿自己需要的字段:
export default ({ fallbackName, userProfile }: Params) => {
const displayName = computed(
() => userProfile.value?.basicInfo?.nickname || fallbackName.value,
);
const avatarSrc = computed(() => userProfile.value?.basicInfo?.avatar ?? "");
return {
avatarSrc,
displayName,
};
};
const { displayName } = useProfileIdentity({ fallbackName, userProfile });调用方现在只用 displayName,并不妨碍未来新增 avatarSrc、displayNameIsReviewing 或 refreshIdentity()。对象返回值给 API 留了扩展缝隙,而不是逼后续开发者改形态。
对象不是 state 包
用对象不代表把一坨东西命名成 xxxState 再到处传。
这两种写法差别很大:
const profileIdentityState = useProfileIdentity({
fallbackName,
userProfile,
});
const name = profileIdentityState.displayName.value;const { avatarSrc, displayName } = useProfileIdentity({
fallbackName,
userProfile,
});第一种只是把跳转路径从 displayName 变成 profileIdentityState.displayName。如果这个对象不会作为整体缓存、传递或批量调度,组合变量名反而会增加阅读成本。
更好的默认写法是:定义处解构参数,调用处用 shorthand 传入,返回处返回对象,调用处解构字段。
interface Params {
fallbackName: Ref<string>;
userProfile: Ref<UserProfile | null>;
}
export default ({ fallbackName, userProfile }: Params) => {
const displayName = computed(
() => userProfile.value?.basicInfo?.nickname || fallbackName.value,
);
return {
displayName,
};
};
const { displayName } = useProfileIdentity({
fallbackName,
userProfile,
});对象边界负责扩展性,解构负责可读性。不要把对象本身变成新的阅读障碍。
什么时候适合无参 composable
还有一种常见形态:composable 自己拿全局状态,返回一个有参方法。
比如用户展示名要综合后端昵称、备注缓存、审核态和本地更新。调用方真正关心的是“给我这个 user,我应该显示什么名字”。备注缓存和更新机制属于全局能力,不应该让每个调用点都重复传。
这时可以写成:
export function useUserDisplayName() {
const { getRemarkByUid } = useUserRemark();
return (user: UserLike | null) => {
if (!user) return "";
return getRemarkByUid(user.uid) || user.nickname || "";
};
}
const resolveDisplayName = useUserDisplayName();
const displayName = computed(() => resolveDisplayName(user.value));这和“对象参数”并不冲突。这里的 composable API 是无参的,因为它的依赖来自统一的全局 source;返回的 resolver 才是业务函数。如果 resolver 后面需要更多可选输入,再让 resolver 用对象参数也可以:
const resolveDisplayName = useUserDisplayName();
const displayName = computed(() =>
resolveDisplayName({
fallbackName,
user: user.value,
}),
);判断口径还是同一条:谁是稳定依赖,谁是每次调用的业务输入。不要为了“所有 composable 都必须有 Params”而把全局能力反复传进来。
例外留给底层工具
位置参数不是不能用。它适合那些输入和输出都很稳定的底层工具:
const next = clamp(value, min, max);
const label = formatDate(date);
const { width, height } = useElementSize(target);这些 API 的名字已经说明了输入关系,参数数量也基本不会演进成业务配置。clamp(value, min, max) 改成 clamp({ value, min, max }) 未必更清楚。
但业务 composable 不一样。它通常会随着产品状态、接口字段、权限、错误态和交互流程一起长大。今天只有一个值,不代表明天不会有 loading、error、refresh、disabledReason。越靠近业务,越应该默认给 API 留对象边界。
Skill 里应该怎么写
这条经验可以沉淀成两层规则:
- 通用 API 规则:会演进的内部 API 默认用对象承接入参和返回值,例外只给稳定底层工具。
- Vue composable 规则:有入参时定义局部
Params,函数签名直接解构;返回对象让调用方解构字段;不要把返回对象统一命名成xxxState,除非它确实要作为整体传递。
它背后的思路其实很朴素:API 的边界越早定死,未来越容易用兼容、重载、平行 helper 去补洞。对象边界多写的几行,是在用明确字段名换未来的低迁移成本。
总结下来就是三句话:
- 会演进的业务 API,默认对象入参、对象返回。
- 调用点用 shorthand 传入,用解构消费,避免
xxxState.xxx多跳一层。 - 稳定底层工具可以保留位置参数,不要为了统一而牺牲直接性。
这不是风格洁癖,是给后续改代码的人留一点余地。