Composable 的公开面别靠默契:用 ESLint 守住私有模块边界
Composable 很适合把复杂组件拆开,但拆多了以后,目录会出现另一类问题:每个文件都叫 useXxx,每个文件都像公共能力,最后谁该依赖谁、谁拥有状态、哪些函数可以被外部 import,全都靠 review 时的记忆维持。
这类问题一开始不一定会变成 bug。它通常先变成几个很轻的异味:
- 全局 composable 为了复用一个 helper,反向 import 了
app/components里的 UI 私有代码。 - 父组件先调用
useRoute()、useBreakpoints(),再把返回值转手传给局部 composable。 - 一个
useFeature.ts同时导出主 composable、类型、枚举、常量和 helper,调用方开始 deep import。 useParent.ts只调用同目录的useChild.ts,但它们平铺在一起,看起来像两个并列能力。useRouter()、useNuxtApp()、inject()被藏进内部回调或await之后,运行时上下文依赖变得不稳定。
Skill 和 README 可以写这些约定,但 Agent 写代码时还是会顺手复制当前文件附近的写法。最后比较稳的落点是 ESLint:把目录 ownership 和调用时机写成编辑器里的提示,让错误在改代码时直接出现。
先分清四层边界
这组规则先把项目里的 composable 分成四层,再按各层 owner 约束依赖和公开面:
- 全局 source/action 层:通常放在
app/composables,负责当前账号、路由资源、请求副作用、跨入口同步、WebSocket bridge 这类稳定业务能力。它可以被 UI 层使用,但不应该反向依赖 UI 层。 - 页面或组件局部 composable:通常放在页面、组件自己的
composables/目录里,负责局部 view model、滚动、弹窗、布局和交互状态。它可以自己读取必要的全局上下文,也可以接收业务输入。 - 私有子模块:只服务一个父 composable 或父组件的 helper、子流程、子组件。它应该通过目录结构表达归属,而不是和父模块平铺成同级公共能力。
- Vue/Nuxt 上下文 API:
useRoute()、useRouter()、useNuxtApp()、inject()、useState()这类 API 依赖当前组件实例、当前 Nuxt app 或请求上下文。它们应该在setup或主 composable 顶层同步读取,再通过闭包给内部函数使用。
这个分层定下来以后,规则就不再是泛泛的「代码风格」,而是具体的 ownership 检查。
全局 composable 不反向依赖 UI 层
app/composables 里的全局 composable 应该比页面和组件更稳定。它可以提供 source、action、缓存刷新和跨入口同步,但不能为了省事去 import 某个组件目录里的 helper。
如果全局层反向依赖 UI 层,后续重构组件时会拖到基础状态;组件私有 helper 也会被误认为公共 API。更合理的做法是把共享逻辑上移到 app/composables、app/utils、shared 或稳定类型层;如果逻辑只服务某个组件,就留在组件就近目录。
局部 composable 自己读取上下文能力
父组件调用全局 composable,再把结果原样传给局部 composable,看起来是在显式传参,实际会把依赖关系放错 owner。
比如局部列表布局需要断点状态,它可以在自己的 composable 里调用 useBreakpoints();父组件只传真实业务输入。这样父组件参数更少,局部 composable 的测试也更清楚:它依赖哪些全局能力,就由它自己 mock 哪些能力。
这条规则只提醒「全局 composable 返回值原样透传给相对路径导入的局部 composable」。接口数据、props ref、业务 source owner 仍然可以按项目语义传递。
use*.ts 只暴露主入口
一个 useFeature.ts 文件最好只表达一件事:这里的默认导出就是这个 composable 的主入口。
类型、枚举、常量和辅助函数如果也从这个文件导出,调用方很快会开始 deep import:
import useFeature, { FeatureMode, createFeatureState } from './useFeature'这会让主 composable 变成小型工具桶。后续想移动内部 helper、收紧公开面或拆目录时,外部调用方已经被这些导出绑住了。
更稳的结构是:
useFeature.ts或useFeature/index.ts只默认导出主 composable。- 类型放
types.ts。 - 常量放
constants.ts。 - helper 放
utils.ts或主目录下的私有文件。 - 目录对外公开面交给
index.tsbarrel 控制。
私有子模块要表达归属
当 useParent.ts 只 import 同目录的 useChild.ts,并且 useChild.ts 没有其他调用方时,这两个文件表达的是父子关系。平铺结构会把它们伪装成并列公共能力。
目录里一排 useXxx.ts 看起来都可以被外部拿走。更清楚的写法是把父能力改成文件夹 facade:
composables/
useParent/
index.ts
useChild.ts
types.tsindex.ts 表示当前目录就是父模块边界,useChild.ts 留在目录内部,归属关系一眼能看出来。
真实规则会读取同目录文件,确认子模块是否只有当前父文件一个引用方;浏览器里的演示只展示可疑形态,完整判断看源码包里的 RuleTester。
上下文 API 顶层同步调用
Vue 和 Nuxt 的上下文 API 不能当成普通函数随处调用。useRoute()、useRouter()、useNuxtApp()、inject()、useState() 这类函数依赖当前 setup 实例、当前 Nuxt app 或当前请求上下文。把它们放进内部函数、事件回调、watch 回调、条件分支或 await 之后,会让调用时机变得不稳定。
推荐写法是先在主边界顶层同步拿到上下文能力,再通过闭包给内部逻辑使用:
export default function useFeatureNavigation() {
const router = useRouter()
function openDetail(id: string) {
router.push(`/detail/${id}`)
}
return {
openDetail,
}
}这条规则默认只检查明确依赖上下文的名单,不限制普通的 useFormatter()、useNumberUtils() 这类纯工具 composable。项目里有自己的上下文 API 时,可以通过 names 配置补进去。
源码包可以直接迁移
请把 Composable Boundary Guardrail 接入当前项目。
工具包根路径:https://shengsheng.fun/files/composable-module-boundary-eslint-guardrails/kits/composable-boundary-guardrail/
先读 README.md、MANIFEST.json、FILES.json、CHANGELOG.md、AGENT_PROMPT.md,再按 FILES.json 读取源码;迁移 copy/eslint/rules、copy/tests 和 examples/eslint.config.mjs,把 5 条规则先用 warn 接入。迁移时先不要把规则全部设成 error。这类架构规则通常会扫出一些历史债,第一轮用 warn 更合适;等目录 owner 和公开面清理完,再把「全局 composable 反向依赖 UI 层」「上下文 API 调用时机」这类高风险规则升为 error。
总结
- Composable 的问题不只在函数怎么写,更在公开面、owner 和调用时机。
- 全局 source/action 层不要反向 import UI 层;局部 composable 可以自己读取全局上下文能力。
use*.ts文件只导出主 composable,类型、常量、helper 交给旁边文件和 barrel 管。- 只有一个父调用方的私有子模块应该放进父模块目录,目录结构要表达归属。
- 依赖 Vue/Nuxt 上下文的 API 保持顶层同步调用,再用闭包传给内部逻辑。