feat(ui): hydrate homepage user state with zustand (#346)

This commit is contained in:
shenlan 2025-10-01 12:48:12 +08:00 committed by GitHub
parent 1bd6d536bb
commit 7d72025ecf
4 changed files with 96 additions and 2 deletions

View File

@ -0,0 +1,51 @@
# 登录态保持方案评估SWR + Zustand 组合
## 背景
当前首页应用在完成账号登录后,需要在多个页面和组件之间共享用户会话信息。如果仅依赖页面局部的 `useState` 或基于路由的服务器渲染数据,容易出现以下问题:
- 页面刷新或路由切换时用户信息丢失,需要重新请求或依赖 URL 参数。
- 跨组件访问用户数据时需要层层透传 props维护成本高。
- 登录或注销动作完成后,无法及时通知其它组件更新 UI。
因此,需要一个能够缓存用户会话信息并支持跨组件订阅更新的客户端状态方案。
## 推荐方案SWR 负责数据获取 + Zustand 负责缓存
结合数据获取库 [SWR](https://swr.vercel.app/) 和轻量级状态管理库 [Zustand](https://zustand-demo.pmnd.rs/) 可以解决上述痛点:
1. **SWR 持久化获取登录态**
- 通过 `useSWR``/api/auth/session` 为数据源拉取当前登录用户信息。
- 内置请求缓存、请求合并、焦点重新验证等能力,避免重复请求。
- 统一处理 `fetch` 错误,确保异常情况下回退为匿名用户。
2. **Zustand 提供全局缓存与订阅**
- 使用 `create` 创建 `sessionStore`,保存当前 `user` 对象及 `setUser` 更新方法。
- 任意组件可通过自定义 hook 订阅所需切片,避免 Context 重渲染开销。
- 当 `SWR` 数据变更时,通过 `setUser` 写入 store从而驱动 UI 更新。
3. **登录 / 注销流程统一**
- 登录成功后调用 `mutate()` 重新验证 session 并更新 `user`
- 注销时清理服务器 session再执行刷新逻辑使所有订阅者同步为匿名态。
## 实现概览
项目已经在 `ui/homepage/lib/userStore.tsx` 中实现了上述组合:
- 定义 `SESSION_CACHE_KEY``fetchSessionUser()`,统一 session 拉取逻辑。
- 通过 `useSWR` 获取 `data`、`isLoading`、`mutate`,并关闭聚焦重新验证以减少闪烁。
- 使用 `useEffect``SWR` 数据变化时写入 Zustand store。
- 对外暴露 `UserProvider``useUser()`供页面和组件消费登录态。【F:ui/homepage/lib/userStore.tsx†L1-L143】
在登录表单中,成功提交后执行 `await login()`(内部调用 `mutate()`随后刷新路由即可看到用户个性化信息。【F:ui/homepage/app/login/LoginForm.tsx†L1-L104】
## 预期收益
- **可靠的登录态保持**:刷新或跨页面访问时直接从 SWR 缓存中读取,无需二次登录。
- **更好的用户体验**:导航栏、表单等组件即时获得更新,无需手动通知。
- **扩展性**:后续若要增加多种角色、权限标识,只需在 `User` 类型中扩展字段,并在 `fetchSessionUser` 中统一处理。
- **易于测试**Zustand store 支持在测试环境注入初始状态,方便模拟已登录/未登录场景。
## 可能的注意事项
- 需确保 `/api/auth/session` 在登录后返回正确的用户对象,否则客户端只会得到 `null`
- 如果存在多标签页同步需求,可以结合 `SWR``broadcastCache` 或浏览器 `storage` 事件实现额外同步。
- 在严格的安全场景下,应避免在客户端存储敏感信息,保持用户对象最小化。
## 结论
相较于单纯依赖 Context 或手工管理请求SWR + Zustand 的组合能够以较小的改动提供稳定的登录态缓存能力,适合继续沿用并在更多页面推广。如需进一步优化,可考虑封装更多用户行为(如刷新 token、权限校验到同一 store实现统一的认证状态管理。

View File

@ -1,3 +1,5 @@
export const dynamic = 'error'
import './globals.css'
import { AppProviders } from './AppProviders'

View File

@ -2,11 +2,34 @@
import { Menu } from 'lucide-react'
import { useUser } from '@lib/userStore'
interface HeaderProps {
onMenu: () => void
}
function resolveAccountInitial(input?: string | null) {
if (!input) {
return '?'
}
const normalized = input.trim()
if (!normalized) {
return '?'
}
return normalized.charAt(0).toUpperCase()
}
export default function Header({ onMenu }: HeaderProps) {
const { user, isLoading } = useUser()
const accountLabel = user?.name ?? user?.username ?? user?.email ?? 'Guest user'
const accountInitial = resolveAccountInitial(accountLabel)
const statusBadge = isLoading ? 'Syncing' : user ? 'Admin' : 'Guest'
const badgeClasses = user
? 'bg-purple-100 text-purple-700'
: 'bg-gray-200 text-gray-600'
return (
<header className="sticky top-0 z-30 flex items-center justify-between border-b border-gray-200 bg-white/80 px-4 py-3 shadow-sm backdrop-blur md:px-8">
<button
@ -25,9 +48,13 @@ export default function Header({ onMenu }: HeaderProps) {
<span>Unified governance for admins and end users</span>
</div>
<div className="flex items-center gap-3">
<span className="rounded-full bg-purple-100 px-3 py-1 text-xs font-semibold text-purple-700">Admin</span>
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${badgeClasses}`}>{statusBadge}</span>
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-purple-500 to-indigo-500 text-sm font-semibold text-white">
A
{isLoading ? <span className="animate-pulse"></span> : accountInitial}
</div>
<div className="hidden flex-col text-right text-xs text-gray-500 sm:flex">
<span className="text-sm font-semibold text-gray-900">{accountLabel}</span>
<span>{user?.email ?? (isLoading ? 'Checking session…' : 'Not signed in')}</span>
</div>
</div>
</div>

View File

@ -0,0 +1,14 @@
'use client'
import type { AppProps } from 'next/app'
import '../app/globals.css'
import { AppProviders } from '../app/AppProviders'
export default function HomepageApp({ Component, pageProps }: AppProps) {
return (
<AppProviders>
<Component {...pageProps} />
</AppProviders>
)
}