Skip to content

前端状态管理与请求层设计 ​

看在前面 ​

组件框架解决"怎么画",剩下两件事决定应用能不能长大。状态放哪(状态管理)。怎么跟后端说话(请求层设计)。这两件事我在 Java 里都有现成答案。状态管理 ≈ Bean 的作用域设计(singleton/prototype/request)。请求层 ≈ integration 层的封装纪律(统一鉴权头、超时、错误码翻译、调用方无感)。

本篇素材全部来自 star-site-frontend 生产代码:src/stores/user.ts(27 行的标准 setup store)、src/api/request.ts(43 行的拦截器链)、src/api/ 按域拆分的 10 个文件。风格对齐 typescript/从Java视角看TypeScript-开篇,响应式机制见 Vue3响应式原理与组件设计。

状态放哪:一棵判断树 ​

面试官:一个数据到底该放组件里还是 store 里?

这个状态谁用?
│
├─ 只有本组件 ──────────────▶ 组件内 ref/reactive(局部变量)
│
├─ 父子链共享 ──────────────▶ props/emit 上提到最近公共父组件
│                             (状态上提 = Bean 作用域就近原则)
│
├─ 跨页面/全局(登录态/主题)▶ Pinia store(singleton Bean)
│
├─ 需要跨会话持久 ──────────▶ localStorage + store 初始化时恢复
│                             (≈ 数据库恢复运行时缓存)
│
└─ 可分享可回退的视图状态 ───▶ URL(query/params)
                              (≈ 无状态 REST:状态在地址里,刷新不丢)

最容易被忽略的是最后一条:URL 也是状态。star-site-frontend 登录守卫把 redirect=fullPath 塞进 query,登录后回跳——会话状态进了 URL,刷新/分享都不丢。把"搜索关键词/当前 tab"写进组件 ref 导致"一刷新就没"的,都是没把 URL 当状态容器看。对照后端一句话:URL 是前端的分布式会话,免费、可分享、可收藏。

Pinia:setup store 就是带响应式的 Service Bean ​

面试官:Pinia 的 store 和你熟悉的 Spring Bean 作用域能对上吗?

SpringPinia生命周期
@Service singletondefineStore 的 store(app 级唯一)应用存活期间常驻
@Component prototype组件 setup 里的 ref(每实例一份)随组件创建销毁
request scope路由/页面级 composable 状态随页面进出
@PostConstructstore 首次被 use 时执行 setup惰性初始化
Bean 之间 @Autowiredstore 里 use 另一个 store显式组合

src/stores/user.ts 全文 27 行,是"单例有状态 Service"的标准像:

ts
// star-site-frontend/src/stores/user.ts(全文骨架)
export const useUserStore = defineStore('user', () => {
  const token = ref<string | null>(getToken())     // 字段:从 localStorage 恢复(≈ 重启时从 DB 恢复缓存)
  const member = ref<MemberVO | null>(null)

  function setLogin(t: string, m: MemberVO) {      // 写操作同时落持久化
    token.value = t; member.value = m; setToken(t)
  }
  function logout() {                              // 失效即清(内存 + 持久层一起)
    token.value = null; member.value = null; removeToken()
  }
  return { token, member, setLogin, setMember, logout }   // public 暴露面
})

三个设计纪律。持久化是 store 的私事:调用方只管 setLogin,localStorage 细节藏在里面——封装与 Java 别无二致。store 管登录态,路由守卫管访问控制:守卫读 getToken(),两者解耦,职责像 Service 与 Filter。别把所有东西塞进全局——全局状态每多一个,组件就少一分可复用性。"能局部不全局"是负熵纪律。

请求层:前端也有 integration 层 ​

面试官:你们的请求层怎么封装?为什么不让组件直接调 axios?

后端调远程服务不会在业务代码里手写 HTTP,前端同理。src/api/request.ts 全文 43 行,四段各司其职:

ts
const request = axios.create({ baseURL: '/api', timeout: 10000 })   // ① 实例:统一基址与超时

request.interceptors.request.use((config) => {                      // ② 出站:塞鉴权头
  const token = getToken()
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

request.interceptors.response.use(
  (response) => {                                                   // ③ 入站:业务码归一
    const res = response.data as R
    if (res.code !== 0) {
      ElMessage.error(res.message || '请求失败')
      return Promise.reject(new Error(res.message))                 //    业务错→统一弹窗+reject
    }
    return res.data as never                                        //    解包 R<T>,交付业务类型
  },
  (error) => {                                                      // ④ 传输错:401 统一处理
    if (error.response?.status === 401) {
      removeToken(); ElMessage.warning('登录已过期'); router.push('/login')
    } else {
      ElMessage.error(error.response?.data?.message || '网络异常')
    }
    return Promise.reject(error)
  },
)

对照 Feign/RestTemplate 封装:② 是出站拦截器加鉴权头。③④ 是入站错误翻译(把 code!==0 和 HTTP 401 翻译成用户语言和统一 reject)。业务组件只见 Promise<T>,不见 HTTP。组件里再手写 if (res.code !== 0) 的,就是没做错误归一的信号。使用侧纪律在 api 目录:10 个域文件(auth/family/health/insurance/finance/...)每个函数签名即接口文档,详见 TS 开篇"请求层封装纪律"一节。

追问:return res.data as never 为什么这么写?

axios 拦截器签名声明"必须返回 AxiosResponse",但我们想交付解包后的 data——类型系统里这两个说不通。于是借 never 的"底部类型可赋给一切"绕过签名冲突。断言是逃生舱不是校验:运行时正确性由 api 层函数签名兜底,这是全文最值得讲的一处刻意的"脏"。

取消、重放与竞态:请求层的下半场 ​

面试官:列表页快速切筛选,旧请求后返回把新数据覆盖了怎么办?

这是竞态问题,前端版的"旧事务迟到提交"。三件套解法:

ts
// ① 取消:AbortController —— 页面卸载/切筛选时作废在途请求
const controller = new AbortController()
listReports({ page: 1, signal: controller.signal })
controller.abort()                                  // ≈ 线程 interrupt,挂起的 Promise 直接 reject

// ② 竞态丢弃:请求序号守卫(最简单可靠)
let seq = 0
async function search(q: string) {
  const cur = ++seq
  const data = await api(q)
  if (cur !== seq) return                           // 我已经过时,丢弃结果
  render(data)
}

// ③ 幂等重放:失败指数退避重试,仅限幂等接口(GET)——
//    和后端重试队列同一个纪律:非幂等操作(支付/下单)绝不自动重放

demo2 首页还示范了第四种:缓存先行 + 后台刷新(cachedMember() 先渲染、refreshMemberCache() 静默更新)。等价于 cache-aside 读模式,体验上"秒开再纠偏"。加上 loading 态的 try-finally 复位(LoginView 里可考),这一节就是前端的"可靠性三件套:取消、重试、降级"。

两端请求层的分叉 ​

同一套纪律在 uni-app 里换 API 不换思想。star-site-app-demo2/src/utils/request.ts 用 uni.request 回调包 Promise,401 → toLogin(),业务码翻译在 success 回调里。差异在基础设施。H5 走 axios 拦截器链(浏览器 Request API 支持 AbortController)。小程序没有 DOM/router,登录跳转换 uni.reLaunch。

BASE_URL 靠条件编译分叉(小程序无 dev 代理,必须完整域名)。纪律是跨端的,实现是端定的——这句话本身就是面试答案。

面试官视角小结:30 秒版本 ​

  1. 状态分层:就近原则——组件内 ref。→ 父子 props 上提。→ 全局 Pinia。→ 持久化 localStorage。→ 视图状态进 URL。URL 是免费的分布式会话。
  2. Pinia:setup store = singleton Bean + 响应式字段 + public 面;持久化是 store 私事;能局部不全局。
  3. 请求层:integration 层同构——出站塞鉴权头、入站解包 R、业务码/401 错误归一。组件只见 Promise<T>。api 目录函数签名即文档。
  4. 可靠性:AbortController 取消(≈ interrupt)、序号守卫丢弃迟到响应、幂等才重放、缓存先行后台刷新。
  5. 跨端:纪律不变实现变——axios 拦截器链 vs uni.request 回调包装,401 处理换跳转 API。

开场模板:

"我的前端架构直觉全部来自后端。状态管理按 Bean 作用域分层——组件是 prototype、Pinia 是 singleton、URL 是免费的分布式会话。请求层照 integration 层纪律封装——拦截器塞鉴权头、错误归一、取消与竞态守卫,业务组件只见类型化的 Promise。"

2026-10 新增(F 轨前端 · 首批)。