01 一个功能模块,怎么分层才不乱

重写这件事,最先动手的不是写功能,而是建目录。

这听起来有点形式主义,但我当时是真的被旧代码吓到了:通信、播放、录音、页面状态全缠在一个一千多行的文件里,想改一处,得先读懂整个文件。所以开新目录的第一天,我想先回答一个问题:一段代码,应该放在哪里?

如果这个问题有一个大家都认的答案,后面的代码就不太容易长成一团。


四层,外加两个小目录

最后定下来的结构大概是这样:

vbnet 复制代码
V2/
├── core/        基础设施:LiveKit、接口请求、音频
├── domain/      跨功能共享的领域逻辑:对话气泡、消息类型、通用 hook
├── features/    一个个具体的功能:列表页、对话页、结果页、偏好设置......
├── shared/      和业务无关的工具:通用组件、请求 hook、重试工具
├── navigation/  路由
└── types/       全局类型

每一层的意思,我是用「它知道多少事情」来区分的:

  • core 知道怎么跟 LiveKit 打交道、怎么发请求、怎么录音,但不知道页面长什么样。
  • domain 知道「一段对话」是什么:有用户消息、有 AI 消息、AI 消息会有打字效果和播放按钮。好几个页面都要展示对话,这部分就放这里。
  • features 是一个个具体的页面和流程,它们组合下面几层的东西,拼出用户真正看到的界面。
  • shared 是最「笨」的一层:一个带重试的请求 hook、一个通用弹窗,跟这个产品是干什么的毫无关系,拿到别的项目里也能用。

依赖的方向是从上往下的:features 可以用 domain 和 core,domain 可以用 core,反过来不行。


几条小规矩

光有目录还不够,还有几条配套的规矩,后来证明很管用。

一是 feature 只通过 index.ts 对外。 每个 feature 目录下有一个 index.ts,别人要用这个 feature 的东西,只能从这里拿。里面没导出的,就当它是私有的。

比如结果页这个 feature,对外只暴露一个页面组件,其他所有的组件、hook、store 都是它自己的事:

typescript 复制代码
// features/result/index.ts
export { ResultScreen } from "./ResultScreen";

而 core 层的 LiveKit 模块,对外暴露的东西就多一些,但同样是一份清单,清单之外的都不该被直接引用:

typescript 复制代码
// core/livekit/index.ts(节选)
export { useLiveKitConversation } from "./hooks/useLiveKitConversation";
export { useRoomConnection } from "./hooks/useRoomConnection";
export { useAgentAudioControl } from "./hooks/useAgentAudioControl";
export { useLiveKitStore } from "./stores/liveKitStore";
export { usePlaybackStore } from "./stores/usePlaybackStore";
export type * from "./types/protocol";

这份清单本身就是一份文档:想知道 core 层能提供什么,看这一个文件就够了。

二是跨层用别名,同层用相对路径。 我们给 V2 目录配了一个路径别名(下面用 @voice 代替),TypeScript 和 Babel 各配一次:

typescript 复制代码
// tsconfig.json
"paths": {
  "@voice/*": ["src/voice/*"]
}
typescript 复制代码
// babel.config.js
plugins: [
  ["module-resolver", { alias: { "@voice": "./src/voice" } }],
],

跨层引用一律写成 @voice/core/livekit 这种形式,同一层里面才用 ./xxx:

typescript 复制代码
// features/conversation/ConversationContent.tsx
import { useLiveKitConversation } from "@voice/core/livekit";   // 往下拿:core
import { MessageList, BottomMenu } from "@voice/domain/conversation"; // 往下拿:domain
import { useConversationStore } from "./stores/useConversationStore"; // 同层:自己的

这样看一眼 import 就知道这是在「往下拿东西」还是「拿自己的东西」,../../../ 这种让人数层数的路径在整个 V2 里一个都没有。

三是不在 V2 里直接碰 Redux。 整个 App 的老代码是 Redux 的,V2 需要的那几个全局数据,比如当前用户、当前的一些配置,都包了一层 hook 放在 shared 里:

typescript 复制代码
// shared/hooks/useAppSelectors.ts
import { useSelector } from "react-redux";
import { createSelector } from "reselect";
import { store } from "@app/configureStore";

const selectDisplayName = createSelector([(state: any) => state], (state) =>
  get(state, ["entities", "profile", 0, "displayName"], ""),
);

// 组件里用这个
export const useDisplayName = (): string => useSelector(selectDisplayName);

// service、store 这类 React 之外的地方用这个
export const getDisplayNameFromStore = (): string => selectDisplayName(store.getState());

新代码只认这几个函数,不知道 Redux 的存在,也不知道数据藏在 state 的哪个角落里。哪天老的状态管理换掉了,改的也只是这一个文件。


然后,现实来了

规矩定得很漂亮,写这篇文章之前,我专门把整个 V2 的依赖方向扫了一遍。结果有点打脸。

core 里有一个文件 import 了 features 层的 store。shared 里有一个「重置所有状态」的工具,把三个 feature 的 store 都 import 进来了。domain 里也有两处往上引用了 feature。feature 之间就更热闹了:六个 feature,互相之间有十几条依赖关系,其中还有两两互相引用的。

这些都不是一天形成的。每一条拿出来看,当时都有一个很合理的理由:「这里只是需要一个类型」「这里只是需要调一下另一个页面的 store」「先这样,回头再挪」。然后就没有回头了。

我觉得这件事本身挺值得说的:分层规则最大的敌人不是有人故意违反,而是一次次「就这一处」的例外。 而且这些例外不会报错,TypeScript 不管,测试也不管,只有你哪天专门去看,才会发现它们已经织成了一张网。


一次「顺手」修出来的循环依赖

说一个具体的例子。

列表页和结果页之间有一个双向依赖:结果页要展示「再来一个」的推荐卡片,直接用了列表页里的卡片组件和类型;列表页在用户点卡片的时候,又要往结果页的 store 里记一下用户选了哪个。两边都是直接写深路径 import 对方的内部文件,违反了「只通过 index.ts」那条规矩。

有一次做别的需求的时候,我看到了这个,想着顺手修一下:既然规矩是走 index.ts,那就把结果页的那几个 import 都改成从列表页的 index.ts 拿。改完 tsc 通过,三百多个测试全绿,看起来完美。

然后我多看了一眼列表页的 index.ts 导出了什么,发现它不光导出了卡片和类型,还导出了整个列表页的页面组件。而这个页面组件往下,正好有一个子组件 import 了结果页的 store。

也就是说,原来两个叶子文件之间互相引用,虽然不规范,但不会出事;我这一改,变成了这样:

typescript 复制代码
// features/result/components/TryNext.tsx(改之后)
import { TopicCard } from "@voice/features/list";         // 只想要一个卡片

// features/list/index.ts
export { ListScreen } from "./ListScreen";               // 但 index 顺带导出了整个页面
export { TopicCard } from "./components/TopicCard";

// features/list/components/CategoryList.tsx(ListScreen 的子组件)
import { useResultStore } from "@voice/features/result/stores/useResultStore"; // 又绕回了结果页

「结果页 import 列表页的 index,列表页的 index 加载整个页面,页面又 import 结果页」,成了一个真正的模块级循环依赖。在 JS 里,循环依赖不一定当场报错,但模块初始化的顺序会变得不可预测,某天某个值在被用到的时候还是 undefined,那种 bug 查起来是真的痛苦。

我当天就把这次改动回滚了。

这件事让我想明白两点。第一,index.ts 这种桶文件(barrel),本身也会制造依赖。 你以为你只拿了一个类型,实际上你加载了它导出的所有东西。第二,这个双向依赖真正的问题不是 import 路径写得不规范,而是那个卡片和那个类型放错了地方。它们被两个 feature 共用,本来就该往下沉到 domain 或 shared 里,这样两边都依赖下面那一层,彼此就不用认识了。

这个正确的修法,到现在都还在待办列表里。它需要动四个 feature 的引用,不适合「顺手」做。


那这套分层还有意义吗

有,而且我觉得意义很大。

虽然有这么多例外,但大部分代码还是待在它该待的地方。我现在要找一个东西,基本能猜到它在哪一层;新来的人看目录,也能大致明白这个模块是怎么组织的。对话页和引导页是两个流程完全不同的页面,用的却是同一套 core 里的 LiveKit 代码。和旧代码那种「一切都在一个文件里」比,是完全不同的状态。

如果要我总结一点经验,大概是:

分层的价值不在于它有多严格,而在于它让「放错地方」这件事变得可以被看见。 没有分层的时候,代码乱是一种常态,你感觉不到;有了分层,每一个往上的 import、每一条 feature 之间的依赖,都是一个明确的信号,告诉你这里有东西放得不对。

至于要不要把这些规则用工具强制起来,比如 eslint 的依赖边界检查,我现在的想法是:值得做,而且最好在项目一开始就做。不需要什么复杂的插件,no-restricted-imports 就能守住最关键的那条「不许往上拿」:

typescript 复制代码
// .eslintrc.js(如果再来一次,我会在第一天就加上)
overrides: [
  {
    files: ["src/voice/core/**"],
    rules: {
      "no-restricted-imports": ["error", {
        patterns: ["@voice/features/*", "@voice/domain/*"],
      }],
    },
  },
  {
    files: ["src/voice/domain/**", "src/voice/shared/**"],
    rules: {
      "no-restricted-imports": ["error", { patterns: ["@voice/features/*"] }],
    },
  },
],

等例外已经有十几处了再加,你面对的就不是「防止新问题」,而是「先把十几个旧账还掉」。这也是我这次最想重来的地方之一。

相关推荐
文谦南宁AI普工31 分钟前
CVSS 10.0 沙箱逃逸 + 假扩展后门:给 Agent 开发者的供应链与沙箱安全复盘(附可跑的配置审计器)
安全·agent·ai编程
T_Wizard34 分钟前
后端转 AI 应用开发,我先把这 12 个名词理清了:一张图看懂架构、RAG、Tool Calling 与 MCP
java·ai编程
Amos_Web34 分钟前
通过 Rspack 源码学 Rust(五):读懂 Result 与问号运算符的错误返回
前端
杨航 AI1 小时前
JEV的用法
java·前端·数据库
Amos_Web1 小时前
Rspack 源码解析(三十五):rspack_error 如何用 miette 渲染带源码定位的错误链
前端
去伪存真1 小时前
高敏资产场景下的大模型协同设计架构
前端·agent
修炼的dance1 小时前
OCR预处理测了108组,纠斜去噪有用,坑在顺序和力度
前端
Potato_土豆1 小时前
AI 工具链的三层架构:Function Calling、MCP 与 Agent 的边界
ai编程
疯华整猫1 小时前
Android 16 后台常驻适配:用 Trae 搭一套“前台服务 + 加急 Job”的可存活架构
前端