前端代码组织与维护方法详解(AI 大模型评测平台的工程化实践)

功能上得快、维护时崩得也快——两个月后 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          # 模块说明

新模块从零落地的流程,我们固定为四步:

  1. 在 modules 下建目录,按 components/composables/pages 分好子目录;
  2. 写 index.ts 定义对外出口;
  3. 补 README.md 说明模块职责;
  4. 在 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:组件要不要写单测?

关键组件(被多页面复用)写。一次性组件不必。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部