重写这件事,最先动手的不是写功能,而是建目录。
这听起来有点形式主义,但我当时是真的被旧代码吓到了:通信、播放、录音、页面状态全缠在一个一千多行的文件里,想改一处,得先读懂整个文件。所以开新目录的第一天,我想先回答一个问题:一段代码,应该放在哪里?
如果这个问题有一个大家都认的答案,后面的代码就不太容易长成一团。
四层,外加两个小目录
最后定下来的结构大概是这样:
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/*"] }],
},
},
],
等例外已经有十几处了再加,你面对的就不是「防止新问题」,而是「先把十几个旧账还掉」。这也是我这次最想重来的地方之一。