让代码结构随真实复杂度演化
最新判断
更成熟的工程思维不是一开始就设计出完整架构,而是先让局部保持清晰,再根据真实出现的复杂度逐步演化。
不要在业务还没有稳定时,就把代码预先拆成:
types.ts
constants.ts
utils.ts
hooks.ts
services.ts
adapters.ts
models.ts
schemas.ts这种结构看起来很工程化,却可能提前引入文件跳转、抽象层和维护成本。此时还不知道哪些边界会稳定,拆出来的往往只是想象中的结构。
更合适的演化顺序是:
- 先写在一起,让当前逻辑跑通并且容易读。
- 发现重复,再抽函数。
- 发现类型被复用,再抽共享类型。
- 发现底层资源具有独立生命周期,再抽资源 Hook。
- 发现业务流程需要协调多个资源,再抽组合 Hook。
- 发现模块边界已经稳定,再拆目录。
核心不是拒绝抽象,而是不要为想象中的复杂度提前付成本。等真实复杂度出现,再用抽象消化它。
类型先放在使用它的地方
如果 TtsFormat 只服务于 useTtsPlayback.ts,直接放在文件顶部最清晰:
type TtsFormat = {
sampleRate: number
channels: number
}
export function useTtsPlayback() {
// ...
}这样读当前 Hook 时就能直接看到它依赖的数据结构,不需要跳转到另一个文件。
内部状态、内部参数、临时辅助结构和组件专用 Props,也适合先留在当前文件:
type PlaybackState = 'idle' | 'playing' | 'paused'
type AudioQueueItem = {
id: string
buffer: ArrayBuffer
}当 useVoiceSession.ts、useVoiceWebSocket.ts 或解码模块也开始依赖 TtsFormat 时,再把它移动到 features/voice/types.ts。这时抽离不是为了形式统一,而是因为它已经成为模块内共享概念。
函数和 Hook 也按同样方式演化
消息处理逻辑可以先留在 useVoiceSession 内:
function handleMessage(message: BackendMessage) {
// ...
}当逻辑变复杂时,先抽成语义明确的 handleBackendMessage();当它进一步被多个模块复用,或者能够脱离当前 Hook 独立测试时,再移动到单独文件。
目录结构也不需要一步到位。早期可以只有:
features/voice/
VoiceInterviewPage.tsx
useVoiceSession.ts当资源管理、业务编排和共享类型真正出现后,再演化为:
features/voice/
components/
hooks/
types.ts
utils/
services/抽象应当解释已经出现的复杂度,而不是预测所有未来可能出现的复杂度。
可以提前拆出的内容
不是所有东西都必须等到重复后再拆。系统边界上的稳定契约,一开始就值得被显式表达,例如:
- API 协议类型
- 前后端消息类型
- 数据库模型
- 跨模块共享常量
- 已经稳定的领域模型
export type BackendMessage =
| { type: 'asr.partial'; text: string }
| { type: 'tts.started'; format: TtsFormat }
| { type: 'tts.audio'; audio: ArrayBuffer }
| { type: 'tts.ended' }它们不是某个函数或 Hook 的内部细节,而是模块之间或系统之间的契约。提前集中定义,可以让边界更明确,也能减少协议漂移。
当前可执行标准
- 只在当前文件使用:留在当前文件。
- 重复两次:先观察,不急着统一。
- 重复三次:开始判断是否存在稳定共性。
- 跨模块共享:抽到共享位置。
- 系统边界契约:可以提前抽离。
这些数字不是机械规则。真正要判断的是:抽离后是否减少了理解和修改成本,还是只增加了跳转。
好的工程结构不是“拆得足够多”,而是当前代码容易读、容易改,边界没有被过度设计,同时未来仍能自然拆分。每一步只让代码保持刚好清晰。