功能上得快、维护时崩得也快——两个月后 200+ 页面、上千组件堆在一起,改一个公共组件要提心吊胆半天,新同学接手一周还在迷路。那时候我才意识到,代码组织不是洁癖,是生产力。我们后来把目录结构、命名、模块边界、文档化这些规则一一固化下来,写进规范和 CI,前端从此进入”靠流程而不是靠记忆”的维护模式。下面这套工程化实践,就是平台从 0 到 200+ 页面踩出来的全部沉淀。
一、目录结构
先说结论:目录结构决定一个新人进来多久能上手。我们定的这套结构按”入口、API、通用组件、业务模块、状态、工具”分层,每个目录职责单一:
src/
├── main.ts # 入口
├── App.vue # 根组件
├── api/ # HTTP 客户端 + 类型
│ ├── client.ts
│ ├── endpoints/
│ │ ├── eval.ts
│ │ └── user.ts
│ └── types/
├── assets/ # 静态资源
│ ├── icons/ # SVG 图标
│ ├── images/ # 图片
│ └── styles/ # 全局样式
│ ├── variables.scss
│ └── reset.scss
├── components/ # 全局通用组件
│ ├── AppButton.vue
│ ├── AppCard.vue
│ └── AppModal.vue
├── composables/ # 组合式函数
│ ├── usePagination.ts
│ ├── useRequest.ts
│ └── useWebSocket.ts
├── modules/ # 业务模块
│ ├── eval/
│ ├── batch/
│ ├── scene/
│ ├── model/
│ ├── report/
│ ├── account/
│ └── admin/
├── router/ # 路由
│ ├── index.ts
│ └── routes.ts
├── stores/ # Pinia
│ ├── user.ts
│ ├── eval.ts
│ └── batch.ts
├── types/ # 全局类型
│ ├── api.ts
│ ├── model.ts
│ └── utils.d.ts
├── utils/ # 工具函数
│ ├── format.ts
│ ├── validate.ts
│ └── http.ts
├── views/ # 顶层页面(路由组件)
│ ├── Login.vue
│ ├── Layout.vue
│ └── NotFound.vue
└── auto-imports.d.ts # 自动生成
关键设计是 modules 目录:评测、批量任务、报告这些业务各自成模块,页面和组件就近放,而不是全堆在 views 和 components 里。实践下来,新增一个业务时只需在 modules 下加一个目录,不会动到其他模块,协作冲突少了一大半。
二、命名规范
命名是最容易产生分歧的地方,先定规则再执行,争议自然消失:
| 类型 | 规范 | 示例 |
|---|---|---|
| 目录 | kebab-case | eval-form/ |
| 组件 | PascalCase | EvalForm.vue |
| 工具/组合式 | camelCase | usePagination.ts |
| 常量 | UPPER_SNAKE | MAX_RETRY_COUNT |
| 接口/类型 | PascalCase | EvalSession |
| CSS class | kebab-case | eval-card__title |
| 布尔 prop | is/has/can 前缀 |
isLoading hasPermission |
这套规范不是随便拍的。组件用 PascalCase 是 Vue 社区主流约定,目录用小写 kebab-case 是为跨平台文件系统安全,布尔 prop 加 is/has/can 前缀让模板读起来像英语句子。命名规范必须靠 ESLint 和命名检查落地,纯靠人自觉撑不过三个月,我们把常见项写进规则文件后,review 时关于命名的争论基本消失了。
三、模块边界
模块内的组织同样要定规矩,每个业务模块内部自洽:
modules/eval/
├── components/ # 私有组件
├── composables/ # 私有 composable
├── pages/ # 页面
├── types.ts # 模块内类型
├── index.ts # 模块入口
└── README.md # 模块说明
新模块从零落地的流程,我们固定为四步:
- 在 modules 下建目录,按 components/composables/pages 分好子目录;
- 写 index.ts 定义对外出口;
- 补 README.md 说明模块职责;
- 在 router 和 store 里挂接入口。
这样每个模块从出生就自带边界,不会等到代码堆积成山再回头治理。index.ts 是模块对外的唯一窗口,外部只允许从这里引用:
// modules/eval/index.ts
export { default as EvalHome } from './pages/EvalHome.vue'
export { useEvalStore } from '@/stores/eval'
export type { EvalSession, EvalItem } from './types'
把内部细节用 index.ts 挡起来,外部代码就不会直接钻到模块深处 import 私有文件,这是很便宜的解耦方式。我们甚至给 ESLint 配了模块间引用限制规则,跨模块直接引私有路径会在 CI 里报错,逼着大家走出口。开始觉得啰嗦,半年后改模块内部结构时才发现它的价值——可以放心重构,不用担心打破外部依赖。
四、依赖分层
代码之间的依赖方向一旦乱掉,改一处就牵一发动全身。我们强制单向依赖:
views → modules → components → utils
(页面) (业务) (通用组件) (工具)
依赖只能向下指,不能反向
views调modules和components;modules调components和utils;components只调utils;utils不依赖任何业务。
这条规则的底层逻辑是:越往下越通用、越稳定。utils 不碰业务,组件不碰 API,改 API 字段时影响面就能控制在 modules 层以内。我们曾经放任过一次,一个组件直接调了 HTTP 客户端,后来接口迁移时被迫同步改了一堆组件,从那以后这条规则就再没人敢破了。
五、状态归属
状态放错位置是隐性性能杀手和调试噩梦,我们按”作用域”分配存储位置:
| 状态类型 | 存放位置 |
|---|---|
| 全局(用户、token) | Pinia |
| 业务(评测 session) | Pinia |
| 跨组件临时状态 | provide/inject |
| 单组件状态 | ref/reactive |
| 表单状态 | 组件本地 + 自研 Form hook |
| URL 状态 | query/params |
| 持久化(主题) | Pinia + persistedstate |
原则很简单:状态的作用域决定它的家。只有跨页面共享的才进 Pinia,单组件能搞定的别污染全局;可分享的临时状态用 provide/inject 而非层层传 prop。评测 session 这种业务状态放进 Pinia 是因为多个页面都要读,而表单字段这种一次性状态留在组件本地,页面销毁就回收,不会造成内存泄漏。
六、API 类型定义
前后端联调最痛的就是”字段对不上”,我们通过类型先行的方式从源头解决:
// api/types/eval.ts
export interface EvalCreateRequest {
question: string
modelCodes: string[]
parameters?: Record<string, unknown>
}
export interface EvalSessionVO {
id: number
question: string
status: 'pending' | 'running' | 'done' | 'error'
items: EvalItemVO[]
createdAt: string
}
export interface EvalItemVO {
id: number
modelCode: string
output: string
score?: number
durationMs: number
}
// api/endpoints/eval.ts
import type { EvalCreateRequest, EvalSessionVO } from '../types/eval'
export const evalApi = {
create: (req: EvalCreateRequest) =>
http.post<EvalCreateRequest, EvalSessionVO>('/api/eval/session', req),
get: (id: number) =>
http.get<never, EvalSessionVO>(`/api/eval/session/${id}`),
list: (params: PageQuery) =>
http.get<PageQuery, PageResult<EvalSessionVO>>('/api/eval/sessions', params)
}
请求体和响应体各定义一套类型,http 方法的泛型参数明确”入参类型、出参类型”,字段改名或删除时编译期直接报错。后端接口变更时,我们让后端先出 OpenAPI 文档,前端用工具生成类型,两边对不上在联调前就暴露,而不是上线后才发现。
七、组件 API 设计原则
组件写得好不好,看它对外暴露的接口就知道。
Props 设计
明确 props 接口,事件用 on 前缀命名:
// ✅ 明确的 props 接口
interface Props {
list: Item[]
loading?: boolean
size?: 'small' | 'default' | 'large'
onItemClick?: (item: Item) => void // 事件用 on 前缀
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
size: 'default'
})
避免反模式
一个组件塞 20 个 props 是代码坏味道的开始:
// ❌ 一个组件传 20 个 props
<EvalCard userId={1} userName="x" userAvatar="..."
onClick onEdit onDelete ... />
// ✅ 传一个对象 + 具名 slot
<EvalCard :user="user">
<template #actions>
<el-button @click="onEdit">编辑</el-button>
</template>
</EvalCard>
props 超过 8 个就该考虑合并成对象或拆组件。我们的经验是:关联字段合成一个对象 prop,操作按钮交给具名 slot,组件只关心”展示什么”,不关心”怎么操作”。这样 EvalCard 能复用在列表和详情两个场景,只靠 slot 内容区分,一版需求下来少写两个几乎重复的组件。
八、错误处理分层
错误处理最怕各写各的,导致同样的错误在不同页面表现不一致。我们按三层分工:
// 1) HTTP 层:拦截器统一处理 401/403/500
http.interceptors.response.use(...)
// 2) Store 层:业务错误抛出
async function fetchData() {
try {
return await api.get(...)
} catch (e) {
if (e.code === 'NOT_FOUND') {
return null
}
throw e // 向上抛
}
}
// 3) 组件层:UI 错误展示
try {
await evalStore.createSession(form)
ElMessage.success('创建成功')
} catch (e) {
ElMessage.error(e.message)
}
HTTP 层负责鉴权失败统一跳登录、500 统一提示;Store 层处理业务语义,能兜住的就消化成空值,兜不住就原样抛出;组件层只做一件事——把错误展示给用户。这个分层的价值在于,新增错误类型时不用满项目找谁在处理,按层级对号入座就行。
九、代码 review 清单
Review 不是走过场,我们用清单把检查项固定下来:
- [ ] 类型完整(无 any 滥用)
- [ ] 组件 props 接口明确
- [ ] 命名符合规范
- [ ] 错误处理到位
- [ ] 无 console.log 残留
- [ ] 无未使用 import
- [ ] 关键逻辑有注释
- [ ] 涉及 UI 改动附截图
- [ ] 涉及后端接口改动同步文档
清单里每一条都对应一次真实事故。无 console.log 是因为生产日志被误刷过;附截图是因为 UI 改动评审时肉眼对比不了样式。清单挂在 PR 模板里,提交代码就带上,比事后提醒高效得多。
十、文档化
代码会撒谎,文档也会过期,所以文档要”贴着代码活”。
1. README.md(项目级)
项目入口文档,新人第一个读的文件:
# 评测平台前端
## 启动
npm i
npm run dev
## 部署
npm run build:prod
## 技术栈
Vue 3.4 / TypeScript 5 / Vite 5 / Pinia / Element Plus
2. 模块 README
每个模块一份,说明职责、组件、状态、接口:
# modules/eval
评测核心模块,包含 Side-by-Side 和 Prompt Lab。
## 组件
- EvalForm: 创建评测表单
- SxSRunner: 多模型对比
- LabEditor: Prompt Lab 编辑器
## Store
- useEvalStore: 评测 session 状态
## API
- evalApi.create
- evalApi.get
- evalApi.list
3. JSDoc 关键函数
复杂函数用 JSDoc 写清入参出参,让 IDE 悬停提示:
/**
* 渲染 prompt 模板
* @param template 模板字符串,含 {{var}}
* @param vars 变量值
* @returns 渲染后字符串
*/
export function renderTemplate(template: string, vars: Record<string, unknown>): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => String(vars[key] ?? ''))
}
文档化的分寸感很重要:README 只写”怎么跑、怎么部署、模块有哪些”,不写详细设计;模块 README 跟着模块走,目录动它也得动;JSDoc 只给逻辑不直观的函数写,给每个函数都写反而没人看。
十一、测试策略
测试不是越多越好,而是”关键路径必须测”:
| 层级 | 工具 | 覆盖目标 |
|---|---|---|
| 工具函数 | Vitest 单测 | > 80% |
| Composables | Vitest 单测 | > 70% |
| 组件 | Vue Test Utils | 关键组件 |
| E2E | Playwright | 核心流程 |
// usePagination.test.ts
import { usePagination } from './usePagination'
test('分页基础功能', async () => {
const fetcher = vi.fn().mockResolvedValue([{ id: 1 }, { id: 2 }])
const { list, page, next } = usePagination(fetcher)
await next()
expect(list.value).toHaveLength(2)
expect(page.value).toBe(2)
})
我们的取舍标准是”改动越频繁、影响面越大的代码越要测”。工具函数和 composable 改动频率高、逻辑纯,优先补单测;页面级 E2E 只保核心流程,全页面都写 E2E 维护成本撑不住。测试用例本身也要 review,测试写错方向比不写更危险——它会给你虚假的安全感。
十二、版本管理
发版靠语义化版本(SemVer)规范:
MAJOR不兼容 API 改动MINOR向后兼容新增PATCH修复
CHANGELOG.md 记录每次发版,格式固定:
## 2.3.0 (2026-08-15)
### Features
- 新增批量测试进度实时推送
### Fixes
- 修复 SxS 模式并发竞态
版本号的作用是让团队和下游对”变更有多大”有共识。我们在 CI 里校验 PR 标签和版本号一致,防止随手 bump 导致发布记录失真。CHANGELOG 由 PR 描述自动汇总,不用手工维护,也就不会出现”版本升了但没人记得改了什么”的尴尬。
十三、踩过的坑
工程化实施中的坑,条条都是学费:
- 循环依赖:A import B,B import A。检查 vite 报错”circular dependency”。抽公共类型到独立文件。
- 巨型文件:单个 .vue 写 2000 行。拆成小组件 + composable。
- store 互相引用:A store 调 B store 在 setup 阶段触发。延后引用(
useXxxStore()放 action 里)。 - 类型 any 扩散:一处
any会污染整链路。CI 跑tsc --noEmit阻断。 - CSS 全局污染:scoped style 不生效。检查
<style scoped>是否漏写。 - package.json 重复依赖:两个包依赖同一库不同版本,Vite 报”duplicate”。统一升/降。
循环依赖最隐蔽,Vite 的报错信息又晦涩,第一次遇到排查了整整一天,最后是逐个注释 import 定位出来的。any 扩散属于慢性病,一个接口字段偷懒标 any,下游所有消费它的地方都失去类型保护,所以 CI 里 tsc 检查配了 noImplicitAny,堵住源头。
十四、CI 流水线
所有规范最终要靠 CI 强制执行,而不是靠人自觉:
# .github/workflows/ci.yml
- name: Lint
run: pnpm lint
- name: Type Check
run: pnpm typecheck
- name: Unit Test
run: pnpm test
- name: Build
run: pnpm build:prod
- name: Bundle Size Check
run: pnpm size-limit
任何步骤失败阻断 merge。Lint 拦风格问题,typecheck 拦类型问题,单测拦逻辑回归,构建和体积检查拦产物风险。把规范写进流水线之后,评审人才从”抓小偷”变成”看重点”,整个团队的交付质量明显上了一个台阶。工程化做到这一步,才算真正闭环。
常见问题(FAQ)
Q1:模块间能互相 import 吗?
能,但只能向下依赖(modules → components)。同层不互通。
Q2:怎么禁止”一个文件 2000 行”?
ESLint 规则 max-lines: [error, 500]。
Q3:组件要不要写单测?
关键组件(被多页面复用)写。一次性组件不必。