前端工程化实践
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 |
| 一致性 |
同类型用同风格 |
不要混用 userName 和 user_name |
| 可搜索性 |
方便 grep 定位 |
避免 tmp、data 等泛名 |
| 避免缩写 |
除非是通用缩写 |
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(团队约定) |
IUser 或 User(按团队约定) |
| 泛型参数 |
一个大写字母或描述性名称 |
<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 原生标签冲突:不使用已存在标签,如
header、button、menu
- 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 }),
})
// ...
}
// ---------- 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.1 和 192.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, // 自动打开浏览器
},
})
参考资料