CC 咖啡猫的工作空间 Coding Space

前端工程化实践

1. RESTful API 设计(前端视角)

1.1 统一响应格式约定

前后端分离架构下,统一响应格式是降低联调成本的第一道防线。前端应当与后端对齐响应结构,并在 TypeScript 层面建立强类型约束。

标准响应格式

// 通用 API 响应类型
interface ApiResponse<T = unknown> {
  code: number        // 业务状态码(非 HTTP 状态码)
  message: string     // 提示信息
  data: T             // 实际数据
  timestamp: number   // 服务器时间戳
  requestId: string   // 请求链路 ID,用于排查
}

// 错误响应(字段级)
interface ApiError {
  code: number
  message: string
  errors?: FieldError[]
  timestamp: number
  requestId: string
}

interface FieldError {
  field: string
  message: string
}

方案对比

方案 优点 缺点 推荐
只有 data 简单 无法区分成功/失败 不推荐
code + message + data 标准成熟 需约定 code 含义 推荐
RESTful HTTP 状态码 语义化 前端难以处理业务错误 辅助使用
GraphQL 强类型,按需取 学习成本,后端改造成本 新项目可考虑

请求封装示例

// ---------- 防御式封装 ----------
// 永远不要信任后端返回的数据

// Vue 3 + Axios
// src/utils/http.ts
import axios, { AxiosError, type AxiosInstance, type AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'

const http: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 15000,
})

// 响应拦截器:统一处理业务错误
http.interceptors.response.use(
  (response: AxiosResponse<ApiResponse>) => {
    const { code, message, data } = response.data

    // 防御:兜底处理空响应
    if (!response.data) {
      return Promise.reject(new Error('后端响应为空'))
    }

    if (code !== 0) {
      ElMessage.error(message || '请求失败')
      return Promise.reject(new ApiBusinessError(code, message))
    }

    return data  // 直接返回 data,业务层无需再解构
  },
  (error: AxiosError) => {
    // 网络错误 / 超时统一处理
    if (!error.response) {
      ElMessage.error('网络连接异常,请检查网络')
      return Promise.reject(error)
    }

    // HTTP 状态码映射
    const status = error.response.status
    const statusMap: Record<number, string> = {
      401: '登录已过期,请重新登录',
      403: '无权限访问',
      404: '请求的资源不存在',
      500: '服务器内部错误',
    }
    ElMessage.error(statusMap[status] || `请求失败(${status})`)

    return Promise.reject(error)
  },
)

export default http
// ---------- React + Zustand + Ant Design ----------
// src/utils/http.ts
import axios, { type AxiosInstance, type AxiosResponse } from 'axios'
import { message } from 'antd'

const http: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 15000,
})

http.interceptors.response.use(
  (response: AxiosResponse<ApiResponse>) => {
    const { code, message: msg, data } = response.data

    if (!response.data) {
      return Promise.reject(new Error('后端响应为空'))
    }

    if (code !== 0) {
      message.error(msg || '请求失败')
      return Promise.reject(new ApiBusinessError(code, msg))
    }

    return data
  },
  (error) => {
    if (!error.response) {
      message.error('网络连接异常,请检查网络')
      return Promise.reject(error)
    }

    const status = error.response.status
    const statusMap: Record<number, string> = {
      401: '登录已过期,请重新登录',
      403: '无权限访问',
      404: '请求的资源不存在',
      500: '服务器内部错误',
    }
    message.error(statusMap[status] || `请求失败(${status})`)
    return Promise.reject(error)
  },
)

踩坑点

坑点 说明 解决方案
后端返回格式不统一 部分接口 data 是 null,部分直接返回数组 拦截器做归一化
后端漏传字段 约定有 timestamp,实际没有 接口响应用 Partial<> 或默认值
嵌套过深 data.user.profile.avatar 多层判断繁琐 使用可选链 ?. 和空值合并 ??
后端返回 message 不友好 直接抛出 SQL 异常信息 前端做二次映射兜底

1.2 分页参数规范

统一分页参数命名,避免混用 page/pageNum/offset/pageNo

// ---------- 分页请求与响应类型 ----------
// 请求参数
interface PageParams {
  page: number     // 从 1 开始
  size: number     // 每页条数,默认 20
  sort?: string    // 排序字段,e.g. "createTime,desc"
}

// 响应结构
interface PageResult<T> {
  list: T[]
  total: number
  page: number
  size: number
  totalPages: number
  hasMore: boolean  // 游标分页时使用
}

分页方案对比

方案 请求参数 特点 适用场景
偏移分页 page + size 简单直接,深翻页性能差 后台管理列表(数据量 < 10w)
游标分页 cursor + size 性能稳定,不支持随机跳页 无限滚动、IM 消息列表
键值分页 afterId + size 性能最优,逻辑复杂 大数据量(> 10w)
// ---------- Vue 3:分页请求封装 ----------
<script setup lang="ts">
import { reactive, onMounted, watch } from 'vue'
import type { PageResult, User } from '@/types'

const pageParams = reactive<PageParams>({
  page: 1,
  size: 20,
})

const pageResult = reactive<PageResult<User>>({
  list: [],
  total: 0,
  page: 1,
  size: 20,
  totalPages: 0,
  hasMore: false,
})

const loading = ref(false)

async function fetchUsers() {
  loading.value = true
  try {
    // 防御:确保参数合法
    const params: PageParams = {
      page: Math.max(1, pageParams.page),
      size: Math.min(100, Math.max(1, pageParams.size)),
    }
    const res = await http.get<PageResult<User>>('/users', { params })
    // 防御:兜底空列表
    pageResult.list = res.list ?? []
    pageResult.total = res.total ?? 0
    pageResult.page = res.page ?? params.page
    pageResult.size = res.size ?? params.size
  } catch (err) {
    pageResult.list = []  // 异常时重置列表
  } finally {
    loading.value = false
  }
}

function handlePageChange(page: number) {
  pageParams.page = page
  fetchUsers()
}

onMounted(fetchUsers)
watch(() => pageParams.size, () => {
  pageParams.page = 1
  fetchUsers()
})
</script>

<template>
  <el-table :data="pageResult.list" v-loading="loading">
    <el-table-column prop="name" label="姓名" />
  </el-table>
  <el-pagination
    v-model:current-page="pageParams.page"
    v-model:page-size="pageParams.size"
    :total="pageResult.total"
    :page-sizes="[10, 20, 50, 100]"
    layout="total, sizes, prev, pager, next"
    @current-change="handlePageChange"
  />
</template>
// ---------- React:分页 Hook 封装 ----------
// src/hooks/usePagination.ts
import { useState, useEffect, useCallback } from 'react'
import type { PageParams, PageResult } from '@/types'

interface UsePaginationOptions<T> {
  fetcher: (params: PageParams) => Promise<PageResult<T>>
  defaultSize?: number
}

export function usePagination<T>({ fetcher, defaultSize = 20 }: UsePaginationOptions<T>) {
  const [params, setParams] = useState<PageParams>({ page: 1, size: defaultSize })
  const [result, setResult] = useState<PageResult<T>>({
    list: [], total: 0, page: 1, size: defaultSize, totalPages: 0, hasMore: false,
  })
  const [loading, setLoading] = useState(false)

  const fetch = useCallback(async () => {
    setLoading(true)
    try {
      const safeParams: PageParams = {
        page: Math.max(1, params.page),
        size: Math.min(100, Math.max(1, params.size)),
      }
      const res = await fetcher(safeParams)
      setResult({
        list: res.list ?? [],
        total: res.total ?? 0,
        page: res.page ?? safeParams.page,
        size: res.size ?? safeParams.size,
        totalPages: res.totalPages ?? 0,
        hasMore: res.hasMore ?? false,
      })
    } catch {
      setResult(prev => ({ ...prev, list: [] }))
    } finally {
      setLoading(false)
    }
  }, [params, fetcher])

  useEffect(() => { fetch() }, [fetch])

  const onPageChange = useCallback((page: number) => {
    setParams(prev => ({ ...prev, page }))
  }, [])

  const onSizeChange = useCallback((size: number) => {
    setParams({ page: 1, size })
  }, [])

  return { ...result, loading, params, onPageChange, onSizeChange, refresh: fetch }
}

// 使用
function UserList() {
  const { list, total, loading, onPageChange } = usePagination({
    fetcher: (params) => http.get('/users', { params }),
  })

  return (
    <>
      <Table dataSource={list} loading={loading} rowKey="id" />
      <Pagination total={total} onChange={onPageChange} />
    </>
  )
}

1.3 错误码映射

前端应当建立业务错误码映射表,避免在代码中散落魔法数字。

// src/constants/error-codes.ts

/**
 * 全局错误码枚举
 * 与后端约定一致:0 表示成功,非 0 表示失败
 */
export enum ErrorCode {
  // 通用错误
  SUCCESS = 0,
  UNKNOWN_ERROR = -1,
  BAD_REQUEST = 400,
  UNAUTHORIZED = 401,
  FORBIDDEN = 403,
  NOT_FOUND = 404,
  METHOD_NOT_ALLOWED = 405,
  CONFLICT = 409,
  INTERNAL_ERROR = 500,

  // 业务错误码(业务范围 1000-9999)
  USER_NOT_EXIST = 1001,
  USER_ALREADY_EXISTS = 1002,
  PASSWORD_ERROR = 1003,
  ACCOUNT_LOCKED = 1004,

  ORDER_NOT_EXIST = 2001,
  ORDER_STATUS_INVALID = 2002,
  INVENTORY_INSUFFICIENT = 2003,

  // 限流/安全
  RATE_LIMITED = 3001,
  INVALID_TOKEN = 3002,
  TOKEN_EXPIRED = 3003,
}

/** 错误码 → 用户可读文案 */
export const ERROR_MESSAGES: Record<number, string> = {
  [ErrorCode.SUCCESS]: '操作成功',
  [ErrorCode.UNKNOWN_ERROR]: '系统繁忙,请稍后重试',
  [ErrorCode.USER_NOT_EXIST]: '用户不存在',
  [ErrorCode.USER_ALREADY_EXISTS]: '用户已存在',
  [ErrorCode.PASSWORD_ERROR]: '密码错误',
  [ErrorCode.ACCOUNT_LOCKED]: '账户已被锁定,请联系管理员',
  [ErrorCode.ORDER_NOT_EXIST]: '订单不存在',
  [ErrorCode.ORDER_STATUS_INVALID]: '订单状态不允许此操作',
  [ErrorCode.INVENTORY_INSUFFICIENT]: '库存不足',
  [ErrorCode.RATE_LIMITED]: '请求过于频繁,请稍后重试',
  [ErrorCode.INVALID_TOKEN]: '登录信息无效,请重新登录',
  [ErrorCode.TOKEN_EXPIRED]: '登录已过期,请重新登录',
}

/** 错误码 → 前端处理策略 */
export enum ErrorStrategy {
  /** 仅提示用户 */
  TOAST_ONLY,
  /** 提示并跳转登录 */
  REDIRECT_LOGIN,
  /** 静默处理 */
  SILENT,
  /** 重试 */
  RETRY,
}

export const ERROR_STRATEGIES: Record<number, ErrorStrategy> = {
  [ErrorCode.INVALID_TOKEN]: ErrorStrategy.REDIRECT_LOGIN,
  [ErrorCode.TOKEN_EXPIRED]: ErrorStrategy.REDIRECT_LOGIN,
  [ErrorCode.ACCOUNT_LOCKED]: ErrorStrategy.REDIRECT_LOGIN,
  [ErrorCode.RATE_LIMITED]: ErrorStrategy.RETRY,
}

/** 获取错误处理策略 */
export function getErrorStrategy(code: number): ErrorStrategy {
  return ERROR_STRATEGIES[code] ?? ErrorStrategy.TOAST_ONLY
}

踩坑点

坑点 说明 解决方案
HTTP 状态码 vs 业务 code 混用 同时判断 status 和 code 导致逻辑混乱 统一用 code,HTTP 状态码仅用于传输层
后端新增错误码 前端未及时同步,漏处理 兜底:未匹配的 code 显示通用错误提示
错误码不满足单一职责 一个 code 代表多种含义 要求后端拆分,前端拒绝联调不清晰的 code

2. 代码命名规范

2.1 核心原则

原则 说明 对比
自描述性 名称本身说明用途 userList > list
一致性 同类型用同风格 不要混用 userNameuser_name
可搜索性 方便 grep 定位 避免 tmpdata 等泛名
避免缩写 除非是通用缩写 config ✓, cfg

2.2 各类命名规范速查

类型 规范 示例
组件名 PascalCase UserProfile.vue, UserProfile.tsx
目录名 kebab-case user-profile/, common-components/
文件名(Vue) PascalCase UserTable.vue, LoginForm.vue
文件名(JS/TS) kebab-case http-client.ts, format-date.ts
变量名 camelCase userList, isVisible
函数名 camelCase fetchUsers(), handleClick()
常量 UPPER_SNAKE_CASE MAX_RETRY_COUNT
CSS 类名 BEM / kebab-case user-card__title--active
Props(Vue) camelCase -> kebab-case :user-name="xxx"
Pinia Store use + PascalCase useUserStore
Zustand Store use + PascalCase useAuthStore
枚举 PascalCase + 枚举值 UPPER_SNAKE UserRole.ADMIN
接口/类型 PascalCase + 前缀 I(团队约定) IUserUser(按团队约定)
泛型参数 一个大写字母或描述性名称 <T>, <TData>, <TItem>

2.3 推荐与不推荐示例

// ✅ 好的命名
const userList = ref<User[]>([])
const isLoading = ref(false)
const MAX_PAGE_SIZE = 100
function formatDate(date: Date): string { }

// ❌ 不好的命名
const data = ref<User[]>([])       // data 太泛
const list = ref<User[]>([])       // list 无法表明是什么列表
const isLoad = ref(false)          // isLoad 语义不清
const MAX = 100                     // 常量名无意义
function fun(date: Date): string { }  // fun 无意义

2.4 CSS 命名方案对比

方案 原理 优点 缺点 推荐
BEM .block__element--modifier 命名即结构,无嵌套 类名长 大型项目
Utility-First(Tailwind) 原子化 class 开发快,无命名困扰 HTML 冗长,学习成本 推荐
CSS Modules 编译期自动哈希 样式隔离,无冲突 调试不便 可接受
CSS-in-JS JS 中写样式 动态样式,组件化 运行时开销 中等项目
Scoped(Vue SFC) 属性选择器隔离 使用简单 权重问题 Vue 项目默认
<!-- BEM 示例 -->
<div class="user-card">
  <div class="user-card__header">
    <h2 class="user-card__title">用户名</h2>
    <span class="user-card__tag user-card__tag--vip">VIP</span>
  </div>
</div>

<!-- Tailwind CSS 示例 -->
<div class="rounded-lg bg-white p-4 shadow-sm">
  <div class="flex items-center justify-between">
    <h2 class="text-lg font-semibold text-gray-800">用户名</h2>
    <span class="rounded bg-yellow-100 px-2 py-0.5 text-xs text-yellow-800">VIP</span>
  </div>
</div>

2.5 常见缩写规范

// ✅ 通用缩写(允许使用)
config → config          // 配置
param  → param           // 参数
props  → props           // 属性
utils  → utils           // 工具
info   → info            // 信息
btn    → button          // 按钮

// ❌ 避免使用的缩写
// tmp, temp → temp       // 临时变量通常意味着代码设计有问题
// str, num  → string, number
// ctrl      → control
// mgr       → manager

2.6 踩坑点

  • Vue 组件名与 HTML 原生标签冲突:不使用已存在标签,如 headerbuttonmenu
  • React 组件名首字母必须大写:JSX 中区分 HTML 标签和自定义组件
  • Vue 事件命名:父组件使用 kebab-case 监听事件 @user-select,子组件使用 camelCase emit 'userSelect'
  • 文件系统大小写问题:macOS 默认大小写不敏感但 CI(Linux)敏感,跨平台保持严格一致

3. 代码重构

3.1 重构原则

原则 说明
单一职责 每个组件/函数只做一件事
DRY 不要重复自己,提取公共逻辑
小步提交 每次重构改一个点,保持测试通过
童子军规则 每次修改都比之前好一点

3.2 提取组件(大组件拆分为小组件)

重构时机:组件超过 200 行、模板超过 80 行、一个组件有多个数据源。

// ---------- Vue 3 ----------
// 重构前:UserManagement.vue - 一个组件包含搜索、表格、编辑弹窗
<script setup lang="ts">
const searchForm = reactive({ name: '', status: '' })
const tableData = ref<User[]>([])
const dialogVisible = ref(false)
const editingUser = ref<User | null>(null)

// 搜索逻辑...
// 表格逻辑...
// 弹窗逻辑...
// 每个功能块耦合在一起
</script>

// 重构后:
// UserManagement.vue - 页面容器,只负责组合
<script setup lang="ts">
import UserSearchForm from './UserSearchForm.vue'
import UserTable from './UserTable.vue'
import UserEditDialog from './UserEditDialog.vue'

const searchParams = ref<PageParams>({ page: 1, size: 20 })

function handleSearch(params: PageParams) {
  searchParams.value = { ...searchParams.value, ...params, page: 1 }
  userTableRef.value?.fetch()
}
</script>

<template>
  <UserSearchForm @search="handleSearch" />
  <UserTable ref="userTableRef" :params="searchParams" @edit="openDialog" />
  <UserEditDialog v-model:visible="dialogVisible" :user="editingUser" @saved="userTableRef?.fetch()" />
</template>

// UserSearchForm.vue - 搜索表单组件
<script setup lang="ts">
const emit = defineEmits<{
  search: [params: Record<string, string>]
}>()
</script>

// UserTable.vue - 表格组件(内含分页)
<script setup lang="ts">
defineExpose({ fetch })
</script>

// UserEditDialog.vue - 编辑弹窗组件
<script setup lang="ts">
const props = withDefaults(defineProps<{
  visible: boolean
  user?: User | null
}>(), { user: null })

const emit = defineEmits<{
  (e: 'update:visible', val: boolean): void
  (e: 'saved'): void
}>()
</script>
// ---------- React ----------
// 重构前:一个文件包含搜索、表格、弹窗
function UserManagement() {
  const [searchForm, setSearchForm] = useState({ name: '', status: '' })
  const [list, setList] = useState<User[]>([])
  const [dialogOpen, setDialogOpen] = useState(false)
  const [editingUser, setEditingUser] = useState<User | null>(null)
  // ... 全部耦合在一起
}

// 重构后:
// UserManagement.tsx
function UserManagement() {
  const tableRef = useRef<{ fetch: () => void }>(null)
  const [searchParams, setSearchParams] = useState<PageParams>({ page: 1, size: 20 })

  const handleSearch = useCallback((params: Partial<PageParams>) => {
    setSearchParams(prev => ({ ...prev, ...params, page: 1 }))
    tableRef.current?.fetch()
  }, [])

  return (
    <div>
      <UserSearchForm onSearch={handleSearch} />
      <UserTable ref={tableRef} params={searchParams} />
      <UserEditDialog />
    </div>
  )
}

// UserSearchForm.tsx
interface UserSearchFormProps {
  onSearch: (params: Record<string, string>) => void
}

// UserTable.tsx - 表格组件
const UserTable = forwardRef<{ fetch: () => void }, UserTableProps>((props, ref) => {
  useImperativeHandle(ref, () => ({ fetch }))
  // ...
})

// UserEditDialog.tsx
interface UserEditDialogProps {
  open: boolean
  user?: User | null
  onClose: () => void
  onSaved: () => void
}

3.3 提取 Composables / Hooks

将可复用的逻辑(请求、表单处理、表格管理)提取为 Composition API 函数。

useTable — 通用表格逻辑封装

// ---------- Vue 3:useTable ----------
// src/composables/useTable.ts
import { ref, reactive, type Ref } from 'vue'
import type { PageParams, PageResult } from '@/types'

interface UseTableOptions<T> {
  fetcher: (params: PageParams) => Promise<PageResult<T>>
  defaultSize?: number
  immediate?: boolean
}

export function useTable<T>(options: UseTableOptions<T>) {
  const { fetcher, defaultSize = 20, immediate = true } = options

  const loading = ref(false)
  const params = reactive<PageParams>({ page: 1, size: defaultSize })
  const list = ref<T[]>([]) as Ref<T[]>
  const total = ref(0)
  const totalPages = ref(0)

  async function fetch() {
    loading.value = true
    try {
      const safeParams: PageParams = {
        page: Math.max(1, params.page),
        size: Math.min(100, Math.max(1, params.size)),
      }
      const res = await fetcher(safeParams)
      // 防御:兜底空列表
      list.value = res.list ?? []
      total.value = res.total ?? 0
      totalPages.value = res.totalPages ?? 0
    } catch {
      list.value = []
      total.value = 0
    } finally {
      loading.value = false
    }
  }

  function onPageChange(page: number) {
    params.page = page
    fetch()
  }

  function onSizeChange(size: number) {
    params.page = 1
    params.size = size
    fetch()
  }

  function refresh() {
    params.page = 1
    fetch()
  }

  if (immediate) {
    fetch()
  }

  return {
    loading,
    params,
    list,
    total,
    totalPages,
    fetch,
    onPageChange,
    onSizeChange,
    refresh,
  }
}

// ---------- 使用 ----------
<script setup lang="ts">
import { useTable } from '@/composables/useTable'
import { http } from '@/utils/http'

const { loading, list, total, params, onPageChange, onSizeChange, refresh } = useTable({
  fetcher: (p) => http.get('/users', { params: p }),
})
</script>
// ---------- React:useTable ----------
// src/hooks/useTable.ts
import { useState, useEffect, useCallback, useRef } from 'react'
import type { PageParams, PageResult } from '@/types'

interface UseTableOptions<T> {
  fetcher: (params: PageParams) => Promise<PageResult<T>>
  defaultSize?: number
  immediate?: boolean
}

export function useTable<T>(options: UseTableOptions<T>) {
  const { fetcher, defaultSize = 20, immediate = true } = options
  const [loading, setLoading] = useState(false)
  const [list, setList] = useState<T[]>([])
  const [total, setTotal] = useState(0)
  const [totalPages, setTotalPages] = useState(0)
  const [params, setParams] = useState<PageParams>({ page: 1, size: defaultSize })
  const mountedRef = useRef(false)

  const fetch = useCallback(async () => {
    setLoading(true)
    try {
      const safeParams: PageParams = {
        page: Math.max(1, params.page),
        size: Math.min(100, Math.max(1, params.size)),
      }
      const res = await fetcher(safeParams)
      setList(res.list ?? [])
      setTotal(res.total ?? 0)
      setTotalPages(res.totalPages ?? 0)
    } catch {
      setList([])
      setTotal(0)
    } finally {
      setLoading(false)
    }
  }, [params, fetcher])

  useEffect(() => {
    if (immediate || mountedRef.current) {
      fetch()
    }
    mountedRef.current = true
  }, [fetch, immediate])

  const onPageChange = useCallback((page: number) => {
    setParams(prev => ({ ...prev, page }))
  }, [])

  const onSizeChange = useCallback((size: number) => {
    setParams({ page: 1, size })
  }, [])

  const refresh = useCallback(() => {
    setParams(prev => ({ ...prev, page: 1 }))
  }, [])

  return { loading, list, total, totalPages, params, onPageChange, onSizeChange, refresh }
}

// ---------- 使用 ----------
function UserList() {
  const { loading, list, total, onPageChange, onSizeChange, refresh } = useTable({
    fetcher: (p) => http.get('/users', { params: p }),
  })
  // ...
}

useForm — 表单提交逻辑封装

// ---------- Vue 3:useForm ----------
// src/composables/useForm.ts
import { ref, reactive, type UnwrapRef } from 'vue'
import { ElMessage } from 'element-plus'

interface UseFormOptions<T extends Record<string, any>> {
  initialValues: T
  submitFn: (values: T) => Promise<void>
  /** 提交成功后是否重置表单 */
  resetAfterSubmit?: boolean
  /** 成功回调 */
  onSuccess?: () => void
}

export function useForm<T extends Record<string, any>>(options: UseFormOptions<T>) {
  const { initialValues, submitFn, resetAfterSubmit = false, onSuccess } = options
  const form = reactive<T>({ ...initialValues }) as UnwrapRef<T>
  const submitting = ref(false)

  function resetForm() {
    Object.assign(form, initialValues)
  }

  async function handleSubmit() {
    // 防御:禁止重复提交
    if (submitting.value) return

    submitting.value = true
    try {
      // 防御:浅拷贝,防止提交过程中表单变化影响请求
      await submitFn({ ...form })
      ElMessage.success('操作成功')
      onSuccess?.()
      if (resetAfterSubmit) {
        resetForm()
      }
    } catch (err: any) {
      ElMessage.error(err?.message || '提交失败')
      throw err  // 让调用方也能处理
    } finally {
      submitting.value = false
    }
  }

  return {
    form,
    submitting,
    resetForm,
    handleSubmit,
  }
}

// ---------- 使用 ----------
<script setup lang="ts">
import { useForm } from '@/composables/useForm'
import type { CreateUserDTO } from '@/types'

const { form, submitting, handleSubmit } = useForm<CreateUserDTO>({
  initialValues: { name: '', email: '', role: 'USER' },
  submitFn: (values) => http.post('/users', values),
  onSuccess: () => router.push('/users'),
})
</script>

<template>
  <el-form :model="form" @submit.prevent="handleSubmit">
    <el-form-item label="用户名">
      <el-input v-model="form.name" />
    </el-form-item>
    <el-button type="primary" :loading="submitting" @click="handleSubmit">
      提交
    </el-button>
  </el-form>
</template>
// ---------- React:useForm ----------
// src/hooks/useForm.ts
import { useState, useCallback } from 'react'
import { message } from 'antd'

interface UseFormOptions<T extends Record<string, any>> {
  initialValues: T
  submitFn: (values: T) => Promise<void>
  resetAfterSubmit?: boolean
  onSuccess?: () => void
}

export function useForm<T extends Record<string, any>>(options: UseFormOptions<T>) {
  const { initialValues, submitFn, resetAfterSubmit = false, onSuccess } = options
  const [form, setForm] = useState<T>({ ...initialValues })
  const [submitting, setSubmitting] = useState(false)

  const setField = useCallback(<K extends keyof T>(key: K, value: T[K]) => {
    setForm(prev => ({ ...prev, [key]: value }))
  }, [])

  const resetForm = useCallback(() => {
    setForm({ ...initialValues })
  }, [initialValues])

  const handleSubmit = useCallback(async () => {
    if (submitting) return
    setSubmitting(true)
    try {
      await submitFn({ ...form })
      message.success('操作成功')
      onSuccess?.()
      if (resetAfterSubmit) resetForm()
    } catch (err: any) {
      message.error(err?.message || '提交失败')
    } finally {
      setSubmitting(false)
    }
  }, [form, submitting, submitFn, resetAfterSubmit, onSuccess, resetForm])

  return { form, setField, submitting, resetForm, handleSubmit }
}

// ---------- 使用 ----------
function CreateUserPage() {
  const { form, setField, submitting, handleSubmit } = useForm({
    initialValues: { name: '', email: '', role: 'USER' },
    submitFn: (values) => http.post('/users', values),
    onSuccess: () => navigate('/users'),
  })

  return (
    <Form layout="vertical" onFinish={handleSubmit}>
      <Form.Item label="用户名">
        <Input value={form.name} onChange={e => setField('name', e.target.value)} />
      </Form.Item>
      <Button type="primary" htmlType="submit" loading={submitting}>提交</Button>
    </Form>
  )
}

3.4 提取工具函数

将纯逻辑从组件中剥离到 utils/ 目录。

// src/utils/format.ts — 格式工具函数
export function formatDate(date: Date | string | number, template = 'YYYY-MM-DD HH:mm:ss'): string {
  // 防御:处理 null/undefined
  if (date == null) return ''
  const d = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date
  // 防御:Invalid Date
  if (isNaN(d.getTime())) return ''
  // ... 格式化实现
  return template
    .replace('YYYY', d.getFullYear().toString())
    .replace('MM', String(d.getMonth() + 1).padStart(2, '0'))
    .replace('DD', String(d.getDate()).padStart(2, '0'))
    // ...
}

// src/utils/validate.ts — 校验工具函数
export function isValidPhone(phone: string): boolean {
  if (!phone) return false
  return /^1[3-9]\d{9}$/.test(phone)
}

export function isValidEmail(email: string): boolean {
  if (!email) return false
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}

// src/utils/tree.ts — 树结构工具
export function buildTree<T extends { id: string | number; parentId: string | number | null }>(
  items: T[],
): (T & { children: T[] })[] {
  // 防御:空数组
  if (!items?.length) return []

  const map = new Map<string | number, T & { children: T[] }>()
  const roots: (T & { children: T[] })[] = []

  items.forEach(item => {
    map.set(item.id, { ...item, children: [] })
  })

  items.forEach(item => {
    const node = map.get(item.id)!
    if (item.parentId == null || !map.has(item.parentId)) {
      roots.push(node)
    } else {
      map.get(item.parentId)!.children.push(node)
    }
  })

  return roots
}

3.5 重构检查清单

□ 组件/函数是否超过合理长度(组件 < 200 行,函数 < 30 行)?
□ 是否有重复逻辑可以提取?
□ 接口类型是否与组件解耦?
□ 纯逻辑是否已提取到 utils/?
□ 状态逻辑是否已提取到 composables/hooks?
□ 是否有不必要的 state/props/reactive?
□ 变更后是否运行了 ESLint + TypeScript 类型检查?

4. 目录结构规范

4.1 Vue 3 项目推荐目录结构

src/
├── api/                     # API 请求定义
│   ├── user.ts
│   ├── order.ts
│   └── types.ts            # API 请求/响应类型
├── assets/                  # 静态资源(图片、字体)
│   ├── images/
│   └── styles/
├── components/              # 公共组件
│   ├── common/              # 基础组件(按钮、表单等)
│   │   ├── AppTable.vue
│   │   └── AppForm.vue
│   └── business/            # 业务组件
│       └── UserCard.vue
├── composables/             # 组合式函数
│   ├── useTable.ts
│   ├── useForm.ts
│   ├── useAuth.ts
│   └── usePermission.ts
├── constants/               # 常量、枚举
│   ├── error-codes.ts
│   └── index.ts
├── layouts/                 # 布局组件
│   ├── DefaultLayout.vue
│   └── BlankLayout.vue
├── hooks/                   # 非响应式工具(日期、格式化)
├── plugins/                 # 插件注册
│   ├── pinia.ts
│   └── router.ts
├── router/                  # 路由配置
│   ├── index.ts
│   └── modules/             # 按业务拆分路由
│       ├── user.routes.ts
│       └── order.routes.ts
├── stores/                  # Pinia 状态管理
│   ├── user.ts
│   ├── app.ts
│   └── permission.ts
├── types/                   # TypeScript 类型
│   ├── api.d.ts             # 接口响应类型
│   ├── global.d.ts          # 全局类型
│   └── index.ts             # 统一导出
├── utils/                   # 工具函数
│   ├── http.ts              # Axios 封装
│   ├── format.ts            # 格式化
│   ├── validate.ts          # 校验
│   └── tree.ts              # 树操作
├── views/                   # 页面组件
│   ├── user/
│   │   ├── index.vue
│   │   ├── UserForm.vue
│   │   └── components/      # 页面专用子组件
│   │       └── UserSearchForm.vue
│   └── order/
│       └── index.vue
├── App.vue
└── main.ts

4.2 React 项目推荐目录结构

src/
├── api/                     # API 请求定义
│   ├── user.ts
│   ├── order.ts
│   └── types.ts
├── assets/
│   ├── images/
│   └── styles/
├── components/              # 公共组件
│   ├── common/
│   │   ├── AppTable.tsx
│   │   └── AppForm.tsx
│   └── business/
│       └── UserCard.tsx
├── constants/
│   ├── error-codes.ts
│   └── index.ts
├── hooks/                   # 自定义 Hooks
│   ├── useTable.ts
│   ├── useForm.ts
│   ├── useAuth.ts
│   └── usePermission.ts
├── layouts/
│   ├── DefaultLayout.tsx
│   └── BlankLayout.tsx
├── router/
│   ├── index.tsx
│   └── modules/
│       ├── user.routes.ts
│       └── order.routes.ts
├── stores/                  # Zustand 状态管理
│   ├── user.ts
│   ├── app.ts
│   └── permission.ts
├── types/
│   ├── api.d.ts
│   ├── global.d.ts
│   └── index.ts
├── utils/
│   ├── http.ts
│   ├── format.ts
│   ├── validate.ts
│   └── tree.ts
├── pages/                   # 页面组件
│   ├── user/
│   │   ├── index.tsx        # 页面入口
│   │   ├── UserForm.tsx
│   │   └── components/
│   │       └── UserSearchForm.tsx
│   └── order/
│       └── index.tsx
├── App.tsx
└── main.tsx

4.3 目录规范说明

目录 用途 注意事项
api/ 接口定义文件 不包含业务逻辑,只负责请求和类型
composables/ / hooks/ 逻辑复用 保持 pure,不要直接依赖 UI 组件
components/ 公共 UI 组件 不与具体页面耦合
views/ / pages/ 页面组件 可引用 components 下的公共组件
stores/ 全局状态 区分全局状态(用户、权限)vs 局部状态

踩坑点

  • 避免循环引用views/ 引用 components/ 正常,但 components/ 不要引用 views/
  • 组件目录下放类型定义:类型定义应集中到 types/,不要散落在组件目录中
  • Hooks 不要引用组件useTable 应该返回数据和方法,而不是返回 JSX

5. TypeScript 使用规范

5.1 接口定义原则

原则 说明
精确 不滥用 any,尽可能精确描述结构
可读 接口名自描述,使用泛型提高复用
防御 有可选字段用 ?,不假设后端一定会返回
// ✅ 好的接口定义
interface User {
  id: number
  name: string
  email: string
  phone?: string          // 可能为空
  avatar?: string         // 可能为空
  role: UserRole
  status: UserStatus
  createdAt: string       // 后端返回字符串,前端自行解析
}

// ❌ 不好的接口定义
interface User {
  id: any                // 类型不精确
  name: any
  data: any              // 逃避型 any
}

5.2 API 响应类型

// ---------- API 通用类型 ----------
interface ApiResponse<T = unknown> {
  code: number
  message: string
  data: T
  timestamp: number
  requestId: string
}

interface PageResult<T> {
  list: T[]
  total: number
  page: number
  size: number
  totalPages: number
}

// ---------- 业务接口定义 ----------
// src/api/types.ts
import type { PageParams } from '@/types'

/** 创建用户请求 */
export interface CreateUserReq {
  name: string
  email: string
  phone?: string
  roleId: number
}

/** 用户列表项 */
export interface UserItem {
  id: number
  name: string
  email: string
  phone: string
  roleName: string
  status: number
  createdAt: string
}

/** 用户详情 */
export interface UserDetail extends UserItem {
  permissions: string[]
  lastLoginAt: string | null
}

// ---------- API 函数定义 ----------
export const userApi = {
  list(params: PageParams): Promise<PageResult<UserItem>> {
    return http.get('/users', { params })
  },

  detail(id: number): Promise<UserDetail> {
    return http.get(`/users/${id}`)
  },

  create(data: CreateUserReq): Promise<void> {
    return http.post('/users', data)
  },
}

5.3 组件 Props 类型

// ---------- Vue 3 ----------
// ✅ 推荐:精准定义 Props 类型
interface UserTableProps {
  /** 当前选中的用户 ID */
  selectedId?: number
  /** 表格数据 */
  data: UserItem[]
  /** 是否正在加载 */
  loading: boolean
  /** 操作按钮权限标识 */
  permission?: {
    edit?: string
    delete?: string
  }
}

const props = withDefaults(defineProps<UserTableProps>(), {
  selectedId: undefined,
  permission: () => ({}),
})

// ❌ 不推荐:使用 any / 不做默认值
const props = defineProps<{
  data: any        // 类型丢失
  loading: boolean // 不可见默认值
}>()

// ---------- React ----------
interface UserTableProps {
  selectedId?: number
  data: UserItem[]
  loading: boolean
  permission?: {
    edit?: string
    delete?: string
  }
  onEdit?: (user: UserItem) => void
  onDelete?: (id: number) => void
}

function UserTable({ data, loading, onEdit, onDelete }: UserTableProps) {
  // ...
}

5.4 泛型使用

// ---------- 工具类型 ----------
/** 将对象的所有字段变为可选(递归) */
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}

/** 选取对象的指定字段 */
type Pick<T, K extends keyof T> = { [P in K]: T[P] }

// ---------- 泛型工具函数 ----------
/** 安全的类型转换 */
function ensureArray<T>(value: T | T[] | undefined | null): T[] {
  if (value == null) return []
  return Array.isArray(value) ? value : [value]
}

/** 提取 key 对应的 value 类型 */
type ValueOf<T> = T[keyof T]

// ---------- 泛型组件(Vue 3) ----------
// 表格列定义
interface Column<T> {
  key: keyof T & string
  label: string
  width?: number
  formatter?: (value: ValueOf<T>, row: T) => string
}

// 带有泛型 Props 的组件
<script setup lang="ts" generic="T extends Record<string, any>">
const props = defineProps<{
  data: T[]
  columns: Column<T>[]
  loading?: boolean
}>()
</script>

// ---------- 泛型组件(React) ----------
interface AppTableProps<T extends Record<string, any>> {
  data: T[]
  columns: Column<T>[]
  loading?: boolean
  rowKey: keyof T & string
}

function AppTable<T extends Record<string, any>>({ data, columns, rowKey }: AppTableProps<T>) {
  return (
    <Table dataSource={data} columns={columns} rowKey={rowKey as string} />
  )
}

5.5 避免 any 的实用策略

场景 错误做法 正确做法
后端响应 const res: any = await http.get(...) 定义精确类型
事件对象 (e: any) => handle(e) (e: Event) => handle(e as KeyboardEvent)
路由参数 const id: any = route.params.id const id = route.params.id as string
异步数据 const data: any = ref(null) const data = ref<T | null>(null)
第三方库无类型 const lib: any = useLib() // @ts-expect-error + 编写 .d.ts 声明
// ✅ 渐进式类型化:先用 unknown,然后细化
async function getUser(id: number): Promise<User | null> {
  try {
    const res: unknown = await http.get(`/users/${id}`)
    // 类型收窄
    if (isUser(res)) {
      return res
    }
    console.error('响应格式异常', res)
    return null
  } catch {
    return null
  }
}

function isUser(value: unknown): value is User {
  if (!value || typeof value !== 'object') return false
  return 'id' in value && 'name' in value
}

5.6 踩坑点

坑点 说明 解决方案
as 滥用 any 转为具体类型绕过检查 使用类型守卫 + 运行时校验
Record<string, any> 泛滥 整个对象逃脱类型检查 改为 Record<string, unknown> + 类型收窄
后端字段名驼峰不一致 后端返回 user_name,前端用 userName 统一在 API 层做转换,组件层统一 camelCase
枚举值被视为 number 后端返回 0/1,前端枚举 UserStatus.ACTIVE = 1 Number(value) 确保类型

6. Git 提交规范(Conventional Commits + Husky + lint-staged)

6.1 Conventional Commits 规范

# 格式
<type>(<scope>): <description>

# 示例
feat(user): 新增用户批量导入功能
fix(order): 修复订单金额计算精度问题
refactor(auth): 重构登录流程,提取 useAuth hook
docs: 更新 API 文档
chore(deps): 升级 axios 到 1.7.0
style: 格式化代码,修复 ESLint 警告
test(user): 补充用户列表单元测试
perf(table): 优化虚拟滚动性能

提交类型说明

Type 含义 是否出现在 Changelog
feat 新功能
fix Bug 修复
refactor 重构(不改功能不改修 bug)
perf 性能优化
style 代码风格(格式化、空白)
test 添加测试
docs 文档变更
chore 构建/工具/依赖变更
ci CI 配置变更

6.2 工具链配置

# 安装依赖
npm install -D husky lint-staged @commitlint/cli @commitlint/config-conventional
// ---------- commitlint.config.js ----------
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      ['feat', 'fix', 'refactor', 'perf', 'style', 'test', 'docs', 'chore', 'ci'],
    ],
    'scope-case': [2, 'always', 'kebab-case'],
    'subject-case': [2, 'always', 'lower-case'],
    'subject-empty': [2, 'never'],
    'type-empty': [2, 'never'],
  },
}
// ---------- package.json ----------
{
  "lint-staged": {
    "*.{ts,tsx,vue}": ["eslint --fix", "prettier --write"],
    "*.{css,scss,less}": ["prettier --write"],
    "*.{json,md,yaml}": ["prettier --write"]
  }
}
# 初始化 Husky
npx husky init

# .husky/pre-commit
npx lint-staged

# .husky/commit-msg
npx --no -- commitlint --edit $1

6.3 分支命名规范

# 格式
<type>/<scope>-<description>

# 示例
feat/user-add-batch-import
fix/order-precision-loss
refactor/auth-login-flow
chore/upgrade-axios-1.7.0

# 主要分支
main          # 生产分支
develop       # 开发分支
release/*     # 发布分支,如 release/v1.2.0

6.4 提交信息示例(实际代码场景)

# ❌ 不推荐
fix: 修bug
update: 更新代码
修改了一些东西

# ✅ 推荐
fix(order): 修复金额计算时浮点数精度丢失 (close #123)
feat(user): 用户列表新增批量导入 Excel 功能
refactor(auth): 将登录鉴权逻辑从组件中提取为 useAuth hook
chore(deps): 升级 Element Plus 到 2.8.0
perf(table): 大数据量列表启用虚拟滚动,渲染性能提升 60%

6.5 踩坑点

坑点 说明 解决方案
husky 钩子未生效 .git/hooks 未正确生成 运行 npx husky install,确保 prepare script 配置
lint-staged 跳过文件 只有 Stage 的文件才会检查 养成 git add -p 习惯,避免漏掉
commitlint 影响紧急修复 强制规范阻碍热修复 紧急情况使用 --no-verify,后续补提交
scope 命名不统一 有人用 user,有人用 user-module 在 commitlint 配置中枚举 scope-enum

7. Vite Dev Server:localhost vs 0.0.0.0

7.1 两行命令的区别

# 只有本机能访问(安全,适合日常开发)
npm run dev -- --host localhost

# 同局域网其他设备也能访问(适合移动端调试)
npm run dev -- --host 0.0.0.0

很多同学以为"同一局域网、同一个 WiFi 就能直接访问",不是的——关键一步就是 --host

7.2 原理:服务绑定的是哪个 IP

--host 决定 Vite dev server 绑定到哪个网络地址:

--host localhost(或默认不写 host)
  服务只绑定 127.0.0.1 这个"回环地址"
  → 只有本机能访问

--host 0.0.0.0
  服务绑定本机所有网卡的所有 IP
  → 本机能访问 + 同局域网其他设备也能访问

核心原因127.0.0.1 是一个特殊 IP,每台电脑的 127.0.0.1 都只指向自己。即使两台电脑在同一个 WiFi 下:

电脑 A                          电脑 B
127.0.0.1:5173  ← A 自己能访问   127.0.0.1  ← B 指向自己
192.168.1.10   ← A 的局域网 IP   192.168.1.11

B 访问 A 的 127.0.0.1:5173 → ❌ 访问的是 B 自己,B 没开这个端口
B 访问 A 的 192.168.1.10:5173 → 取决于 --host 设置

--host 0.0.0.0 后,服务同时绑定 127.0.0.1192.168.1.10,于是 B 通过 192.168.1.10:5173 就能访问了。

7.3 一张图总结

--host localhost                  --host 0.0.0.0
┌─────────────────┐              ┌─────────────────┐
│ 127.0.0.1       │ ← 只绑这条   │ 127.0.0.1       │
│                 │              │ 192.168.1.x     │ ← 局域网可用
│  [Vite Dev]     │              │                 │
└─────────────────┘              │  [Vite Dev]     │
   只本机能访问                   └─────────────────┘
                                   本机 + 局域网都能访问

7.4 使用场景

场景 命令 原因
日常开发 --host localhost 别人不需要看你的开发页面
手机/平板调试 --host 0.0.0.0 手机通过局域网 IP 访问
给同事看效果 --host 0.0.0.0 同事浏览器 http://你的局域网IP:5173
公共网络 不要用 0.0.0.0 咖啡厅/机场 WiFi 下任何人可能扫到你的端口

7.5 两个额外注意点

防火墙:即使用了 --host 0.0.0.0,如果电脑防火墙没放行端口,局域网其他设备也连不上:

# macOS 首次启动时会弹窗询问,也可以手动检查
sudo lsof -i :5173  # 看端口是否在监听

# Windows 需要允许 Node.js 通过防火墙

手机调试:手机浏览器访问 http://电脑的局域网IP:5173,注意:

  • 电脑和手机必须连同一个 WiFi
  • 部分公司网络有 AP 隔离(同一 WiFi 设备互相看不到),这种情况下用手机热点共享给电脑

7.6 Vite 配置持久化

每次手动加 -- --host 比较麻烦,可以直接写到 vite.config.ts

// vite.config.ts
export default defineConfig({
  server: {
    host: '0.0.0.0',  // 或 'localhost';等同于 npm run dev -- --host xxx
    port: 5173,
    open: true,        // 自动打开浏览器
  },
})

参考资料