Appearance
前端状态管理与请求层设计
看在前面
组件框架解决"怎么画",剩下两件事决定应用能不能长大。状态放哪(状态管理)。怎么跟后端说话(请求层设计)。这两件事我在 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 作用域能对上吗?
| Spring | Pinia | 生命周期 |
|---|---|---|
| @Service singleton | defineStore 的 store(app 级唯一) | 应用存活期间常驻 |
| @Component prototype | 组件 setup 里的 ref(每实例一份) | 随组件创建销毁 |
| request scope | 路由/页面级 composable 状态 | 随页面进出 |
| @PostConstruct | store 首次被 use 时执行 setup | 惰性初始化 |
| Bean 之间 @Autowired | store 里 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 秒版本
- 状态分层:就近原则——组件内 ref。→ 父子 props 上提。→ 全局 Pinia。→ 持久化 localStorage。→ 视图状态进 URL。URL 是免费的分布式会话。
- Pinia:setup store = singleton Bean + 响应式字段 + public 面;持久化是 store 私事;能局部不全局。
- 请求层:integration 层同构——出站塞鉴权头、入站解包 R、业务码/401 错误归一。组件只见 Promise<T>。api 目录函数签名即文档。
- 可靠性:AbortController 取消(≈ interrupt)、序号守卫丢弃迟到响应、幂等才重放、缓存先行后台刷新。
- 跨端:纪律不变实现变——axios 拦截器链 vs uni.request 回调包装,401 处理换跳转 API。
开场模板:
"我的前端架构直觉全部来自后端。状态管理按 Bean 作用域分层——组件是 prototype、Pinia 是 singleton、URL 是免费的分布式会话。请求层照 integration 层纪律封装——拦截器塞鉴权头、错误归一、取消与竞态守卫,业务组件只见类型化的 Promise。"
2026-10 新增(F 轨前端 · 首批)。