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 上下文 APIuseRoute()useRouter()useNuxtApp()inject()useState() 这类 API 依赖当前组件实例、当前 Nuxt app 或请求上下文。它们应该在 setup 或主 composable 顶层同步读取,再通过闭包给内部函数使用。

这个分层定下来以后,规则就不再是泛泛的「代码风格」,而是具体的 ownership 检查。

全局 composable 不反向依赖 UI 层

app/composables 里的全局 composable 应该比页面和组件更稳定。它可以提供 source、action、缓存刷新和跨入口同步,但不能为了省事去 import 某个组件目录里的 helper。

如果全局层反向依赖 UI 层,后续重构组件时会拖到基础状态;组件私有 helper 也会被误认为公共 API。更合理的做法是把共享逻辑上移到 app/composablesapp/utilsshared 或稳定类型层;如果逻辑只服务某个组件,就留在组件就近目录。

全局 composable 反向依赖 UI 层正在加载代码工作区...

局部 composable 自己读取上下文能力

父组件调用全局 composable,再把结果原样传给局部 composable,看起来是在显式传参,实际会把依赖关系放错 owner。

比如局部列表布局需要断点状态,它可以在自己的 composable 里调用 useBreakpoints();父组件只传真实业务输入。这样父组件参数更少,局部 composable 的测试也更清楚:它依赖哪些全局能力,就由它自己 mock 哪些能力。

这条规则只提醒「全局 composable 返回值原样透传给相对路径导入的局部 composable」。接口数据、props ref、业务 source owner 仍然可以按项目语义传递。

全局能力透传给局部 composable正在加载代码工作区...

use*.ts 只暴露主入口

一个 useFeature.ts 文件最好只表达一件事:这里的默认导出就是这个 composable 的主入口。

类型、枚举、常量和辅助函数如果也从这个文件导出,调用方很快会开始 deep import:

import useFeature, { FeatureMode, createFeatureState } from './useFeature'

这会让主 composable 变成小型工具桶。后续想移动内部 helper、收紧公开面或拆目录时,外部调用方已经被这些导出绑住了。

更稳的结构是:

  • useFeature.tsuseFeature/index.ts 只默认导出主 composable。
  • 类型放 types.ts
  • 常量放 constants.ts
  • helper 放 utils.ts 或主目录下的私有文件。
  • 目录对外公开面交给 index.ts barrel 控制。
use*.ts 额外导出公开面正在加载代码工作区...

私有子模块要表达归属

useParent.ts 只 import 同目录的 useChild.ts,并且 useChild.ts 没有其他调用方时,这两个文件表达的是父子关系。平铺结构会把它们伪装成并列公共能力。

目录里一排 useXxx.ts 看起来都可以被外部拿走。更清楚的写法是把父能力改成文件夹 facade:

composables/
  useParent/
    index.ts
    useChild.ts
    types.ts

index.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 配置补进去。

上下文 API 嵌套调用正在加载代码工作区...

源码包可以直接迁移

请把 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 接入。
Composable 边界 ESLint 源码正在加载代码工作区...

迁移时先不要把规则全部设成 error。这类架构规则通常会扫出一些历史债,第一轮用 warn 更合适;等目录 owner 和公开面清理完,再把「全局 composable 反向依赖 UI 层」「上下文 API 调用时机」这类高风险规则升为 error

总结

  • Composable 的问题不只在函数怎么写,更在公开面、owner 和调用时机。
  • 全局 source/action 层不要反向 import UI 层;局部 composable 可以自己读取全局上下文能力。
  • use*.ts 文件只导出主 composable,类型、常量、helper 交给旁边文件和 barrel 管。
  • 只有一个父调用方的私有子模块应该放进父模块目录,目录结构要表达归属。
  • 依赖 Vue/Nuxt 上下文的 API 保持顶层同步调用,再用闭包传给内部逻辑。