前端工程化实战:请求封装、环境变量、MSW 与 TanStack Query

从 http.ts 到数据缓存的完整工程实践——封装、Mock、缓存、错误处理一条链路

1. HTTP 请求封装(http.ts)

核心设计:洋葱模型(拦截器管道)

请求发出 → 拦截器1(加 token)→ 拦截器2(加 request-id)→ fetch → 拦截器3(检查状态码)→ 返回结果

设计思路(通用模板):

1. 类型先行:先定义 Input / Output / Config 的类型
2. 工具函数:拆成纯函数(buildUrl / parseResponse / createHttpError)
3. 核心逻辑:一个 request 函数串起来(唯一调用 fetch 的地方)
4. 语法糖:get / post / put / patch / delete 快捷方法
5. 拦截器:use 注册 / eject 注销(发布-订阅模式)
6. 导出门面:只暴露 http 对象

拦截器用法:

// 请求拦截:自动注入 token
http.interceptors.request.use((config) => {
  const token = localStorage.getItem('token')
  if (token) config.headers = { ...config.headers, Authorization: `Bearer ${token}` }
  return config
})

// 响应拦截:401 自动跳登录
http.interceptors.response.use(
  (res) => res,
  (err) => {
    if (err.status === 401) {
      localStorage.removeItem('token')
      window.location.href = '/login'
    }
    throw err
  },
)

职责分工:

lib/http.ts          → 请求管道(全局,一个就够)
features/*/api.ts    → 模块 API(每个模块自己的接口)
features/*/hooks.ts  → TanStack Query hooks
组件                  → 调 hooks,不直接调 api

2. 环境变量(.env)

文件优先级:

.env.development.local  >  .env.development  >  .env
.env.production.local   >  .env.production   >  .env

三种模式:

VITE_API_BASE_URL=                       → 同源,MSW 拦截(默认)
VITE_API_BASE_URL=http://localhost:3000  → 跨域,Vite proxy 转发
VITE_API_BASE_URL=https://api.xxx.com   → 生产环境

Vite proxy vs MSW vs 真实后端:

开发 + MSW:  请求走同源 → MSW Service Worker 拦截 → 返回假数据
开发 + proxy:请求走同源 → Vite dev server 转发 → 真实后端
生产:        请求走 .env.production 的 URL → 真实后端

.env 文件规则:

.env / .env.development / .env.production → 提交到 git(非敏感配置)
.env.local / .env.development.local       → 不提交(个人覆盖)

http.ts 的 buildUrl 逻辑:

// BASE_URL 为空 → 同源(MSW / proxy 能拦截)
// BASE_URL 有值 → 跨域(直接请求)
function buildUrl(path, params) {
  if (!BASE_URL) return path  // /api/users
  return new URL(path, BASE_URL).toString()  // http://localhost:3000/api/users
}

3. MSW(Mock Service Worker)

在浏览器里拦截 fetch 请求,返回假数据。

// handlers.ts
export const handlers = [
  http.get('/api/users', () => HttpResponse.json({ data: [...] })),
  http.post('/api/auth/login', async ({ request }) => {
    const body = await request.json()
    if (body.email === 'admin@example.com') return HttpResponse.json({ token: 'xxx' })
    return HttpResponse.json({ error: 'failed' }, { status: 401 })
  }),
]

启动方式(main.tsx):

async function enableMocking() {
  if (import.meta.env.PROD) return  // 生产环境不启动
  const { worker } = await import('./mocks/browser')
  return worker.start({ onUnhandledRequest: 'bypass' })
}

enableMocking().then(() => {
  createRoot(document.getElementById('root')!).render(...)
})

关键点:

  • MSW 只拦截同源请求(不同端口 = 不同源,拦截不到)

  • BASE_URL 必须为空,请求走相对路径 /api/users

  • 生产环境自动关闭,切换到真实后端零改动


4. TanStack Query

声明式数据管理,替代手动 useEffect \+ useState。

// hooks.ts
export function useUsers(params?: { page?: number }) {
  return useQuery({
    queryKey: ['users', params],  // 缓存 key(变化时重新请求)
    queryFn: () => userApi.list(params),
    staleTime: 5 * 60 * 1000,     // 5 分钟内不重新请求
  })
}

// 组件里
const { data, isLoading, error } = useUsers()

对比手动写法:

// ❌ 手动:5 个 useState + 1 个 useEffect + 手动管理 loading/error
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => { ... }, [])

// ✅ TanStack Query:1 行搞定
const { data, isLoading, error } = useUsers()

核心配置:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,      // 5 分钟新鲜
      gcTime: 10 * 60 * 1000,         // 10 分钟缓存保留
      refetchOnWindowFocus: false,     // 切回页面不自动刷新
      retry: 1,                        // 失败重试 1 次
    },
  },
})

自动管理的能力:

  • loading / error / data 状态

  • 缓存(同一个 queryKey 不重复请求)

  • 重新聚焦时自动刷新

  • 失败重试

  • 窗口切换时的乐观更新


核心一句话

http.ts 管请求管道,.env 管环境切换,MSW 管 mock 数据,TanStack Query 管缓存和状态。四层各司其职,互不耦合。