23.12-错误提示规范
要点
- 统一的错误格式让前端可以一致地处理错误
- 错误响应应该包含:错误代码、错误信息、详细信息
- 前端需要根据错误类型展示不同的提示(Toast、弹窗、表单错误)
- 国际化支持:错误信息应该支持多语言
内容
1. 后端统一错误格式
1.1 定义错误响应结构
// src/lib/errors.ts
export interface ApiError {
code: string // 错误代码,如 'VALIDATION_ERROR'
message: string // 人类可读的错误信息
details?: any // 详细错误信息(如字段校验错误)
requestId?: string // 请求 ID,用于追踪
}
export class AppError extends Error {
constructor(
public code: string,
public message: string,
public statusCode: number = 500,
public details?: any
) {
super(message)
}
toJSON(): ApiError {
return {
code: this.code,
message: this.message,
details: this.details,
}
}
}1.2 预定义错误类型
// src/lib/errors.ts
export class ValidationError extends AppError {
constructor(details: any) {
super('VALIDATION_ERROR', '请求参数验证失败', 400, details)
}
}
export class AuthenticationError extends AppError {
constructor(message = '未登录或登录已过期') {
super('AUTHENTICATION_ERROR', message, 401)
}
}
export class AuthorizationError extends AppError {
constructor(message = '没有权限执行此操作') {
super('AUTHORIZATION_ERROR', message, 403)
}
}
export class NotFoundError extends AppError {
constructor(resource = '资源') {
super('NOT_FOUND', `${resource}不存在`, 404)
}
}
export class ConflictError extends AppError {
constructor(message = '资源冲突') {
super('CONFLICT_ERROR', message, 409)
}
}
export class RateLimitError extends AppError {
constructor(message = '请求过于频繁,请稍后再试') {
super('RATE_LIMIT_ERROR', message, 429)
}
}
export class InternalError extends AppError {
constructor(message = '服务器内部错误') {
super('INTERNAL_ERROR', message, 500)
}
}1.3 全局错误处理
// src/index.ts
import { Hono } from 'hono'
import { AppError, InternalError } from './lib/errors'
const app = new Hono()
// 全局错误处理
app.onError((err, c) => {
const requestId = c.get('requestId')
// 如果是自定义错误
if (err instanceof AppError) {
console.error(`[${err.code}] ${err.message}`, {
requestId,
details: err.details,
})
return c.json(
{
...err.toJSON(),
requestId,
},
err.statusCode
)
}
// 未知错误
console.error('Unhandled error:', err, { requestId })
const error = new InternalError()
return c.json(
{
...error.toJSON(),
requestId,
},
500
)
})
// 404 处理
app.notFound((c) => {
return c.json(
{
code: 'NOT_FOUND',
message: '接口不存在',
},
404
)
})1.4 在路由中使用
// src/routes/users.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { ValidationError, NotFoundError } from '../lib/errors'
const app = new Hono()
const CreateUserSchema = z.object({
name: z.string().min(1, '名称不能为空'),
email: z.string().email('邮箱格式不正确'),
})
app.post('/api/users', zValidator('json', CreateUserSchema), async (c) => {
const result = c.req.valid('json')
try {
const user = await createUser(result)
return c.json(user, 201)
} catch (error) {
// 转换 Zod 错误为 ValidationError
if (error instanceof z.ZodError) {
throw new ValidationError(error.errors)
}
throw error
}
})
app.get('/api/users/:id', async (c) => {
const id = c.req.param('id')
const user = await getUser(id)
if (!user) {
throw new NotFoundError('用户')
}
return c.json(user)
})2. 前端错误处理
2.1 定义错误类型
// src/lib/api-error.ts
export interface ApiErrorResponse {
code: string
message: string
details?: any
requestId?: string
}
export class ApiError extends Error {
constructor(
public code: string,
public message: string,
public statusCode: number,
public details?: any,
public requestId?: string
) {
super(message)
}
static fromResponse(response: ApiErrorResponse, statusCode: number): ApiError {
return new ApiError(
response.code,
response.message,
statusCode,
response.details,
response.requestId
)
}
}2.2 统一处理 API 错误
// src/lib/api.ts
import { hc } from 'hono/client'
import type { AppType } from '@api/src/index'
import { ApiError } from './api-error'
async function handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
const error = await res.json().catch(() => null)
throw ApiError.fromResponse(
error || { code: 'UNKNOWN_ERROR', message: '未知错误' },
res.status
)
}
return res.json()
}
export const api = hc<AppType>(process.env.NEXT_PUBLIC_API_URL!, {
fetch: async (input, init) => {
const res = await fetch(input, init)
return res
},
})
// 封装带错误处理的方法
export const apiClient = {
async getUser(userId: string) {
const res = await api.api.users[':id'].$get({
param: { id: userId },
})
return handleResponse(res)
},
async createUser(data: any) {
const res = await api.api.users.$post({
json: data,
})
return handleResponse(res)
},
}3. 错误提示 UI
3.1 Toast 提示
// src/components/Toast.tsx
import { useState, useEffect } from 'react'
interface Toast {
id: string
type: 'success' | 'error' | 'warning' | 'info'
message: string
}
let toasts: Toast[] = []
let listeners: (() => void)[] = []
function notify() {
listeners.forEach(listener => listener())
}
export function showToast(type: Toast['type'], message: string) {
const id = crypto.randomUUID()
toasts = [...toasts, { id, type, message }]
notify()
// 3 秒后自动关闭
setTimeout(() => {
toasts = toasts.filter(t => t.id !== id)
notify()
}, 3000)
}
export function ToastContainer() {
const [, forceUpdate] = useState({})
useEffect(() => {
const listener = () => forceUpdate({})
listeners.push(listener)
return () => {
listeners = listeners.filter(l => l !== listener)
}
}, [])
return (
<div className="toast-container">
{toasts.map(toast => (
<div key={toast.id} className={`toast toast-${toast.type}`}>
{toast.message}
</div>
))}
</div>
)
}// 使用
import { showToast } from '@/components/Toast'
async function handleCreateUser() {
try {
await apiClient.createUser({ name: 'Alice', email: '[email protected]' })
showToast('success', '用户创建成功')
} catch (error) {
if (error instanceof ApiError) {
showToast('error', error.message)
} else {
showToast('error', '操作失败')
}
}
}3.2 表单错误提示
// src/components/UserForm.tsx
import { useState } from 'react'
import { ApiError } from '@/lib/api-error'
import { apiClient } from '@/lib/api'
export function UserForm() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setErrors({})
setSubmitting(true)
try {
await apiClient.createUser({ name, email })
// 成功处理
} catch (error) {
if (error instanceof ApiError) {
if (error.code === 'VALIDATION_ERROR' && error.details) {
// 转换字段错误
const fieldErrors: Record<string, string> = {}
for (const detail of error.details) {
const field = detail.path[0]
fieldErrors[field] = detail.message
}
setErrors(fieldErrors)
} else {
// 其他错误
setErrors({ _form: error.message })
}
}
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
{errors._form && (
<div className="form-error">{errors._form}</div>
)}
<div>
<label>名称</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
{errors.name && <div className="field-error">{errors.name}</div>}
</div>
<div>
<label>邮箱</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{errors.email && <div className="field-error">{errors.email}</div>}
</div>
<button type="submit" disabled={submitting}>
{submitting ? '提交中...' : '提交'}
</button>
</form>
)
}4. 错误代码映射
// src/lib/error-messages.ts
const ERROR_MESSAGES: Record<string, string> = {
VALIDATION_ERROR: '请求参数有误',
AUTHENTICATION_ERROR: '请先登录',
AUTHORIZATION_ERROR: '没有权限执行此操作',
NOT_FOUND: '资源不存在',
CONFLICT_ERROR: '资源冲突',
RATE_LIMIT_ERROR: '请求过于频繁,请稍后再试',
INTERNAL_ERROR: '服务器内部错误',
NETWORK_ERROR: '网络连接失败',
}
export function getErrorMessage(code: string, fallback?: string): string {
return ERROR_MESSAGES[code] || fallback || '未知错误'
}// 使用
import { getErrorMessage } from '@/lib/error-messages'
catch (error) {
if (error instanceof ApiError) {
showToast('error', getErrorMessage(error.code, error.message))
}
}5. 国际化支持
// src/i18n/errors.ts
const ERROR_MESSAGES = {
zh: {
VALIDATION_ERROR: '请求参数有误',
AUTHENTICATION_ERROR: '请先登录',
// ...
},
en: {
VALIDATION_ERROR: 'Validation failed',
AUTHENTICATION_ERROR: 'Please login first',
// ...
},
}
export function getErrorMessage(code: string, locale = 'zh'): string {
return ERROR_MESSAGES[locale]?.[code] || ERROR_MESSAGES.zh[code] || 'Unknown error'
}6. 小结
错误提示规范的关键点:
- 后端统一格式:错误响应包含 code、message、details
- 预定义错误类型:ValidationError、AuthenticationError 等
- 前端错误分类:根据错误代码展示不同的提示
- 表单错误:字段级别的错误提示
- Toast 提示:全局错误提示
- 错误代码映射:统一的错误信息映射表
- 国际化:支持多语言错误信息
一句话带走:
统一的错误格式让前后端协作更顺畅。后端返回结构化的错误信息,前端根据错误代码展示合适的提示,提升用户体验。