哈喽,掘金的各位全栈练习生们!👋 欢迎回到 AI 全栈项目实战 的第十天!
昨天,我们用 观察者模式 给图片加上了懒加载,让文章列表的图片“按需分配”,大大提升了首屏性能。
今天,我们要继续深挖这个列表,让它不仅能懒加载图片,还能自动加载更多内容(Infinite Scroll),彻底告别传统的“下一页”按钮。
更重要的是,我们要解决一个困扰无数前端开发者的痛点:当你点进文章详情再退回列表时,页面竟然重置回顶部了? 😱
别慌,今天我们就用 KeepAlive 来搞定这个“路由状态保持”的优化!
📜 一、 无限滚动:告别“下一页”
在移动端,用户习惯了“刷”的感觉。抖音、小红书、今日头条,没有哪个 App 是让你点“下一页”的。 我们要实现的效果是:当用户滑到底部时,自动请求下一页数据并拼接到列表中,让用户感觉内容是无穷无尽的。
1.1 状态管理:Zustand 仓库设计
首先,我们需要一个强大的“管家”来管理列表的状态。打开 src/store/home.ts:
// frontend/notes/src/store/home.ts
import { create } from 'zustand';
interface HomeState {
posts: Post[]; // 存文章列表数据
page: number; // 当前加载到第几页
loading: boolean; // 是否正在请求中(防止重复触发)
hasMore: boolean; // 后端还有没有数据了?
loadMore: () => Promise<void>; // 加载动作
}
export const useHomeStore = create<HomeState>((set, get) => ({
posts: [],
page: 1,
loading: false,
hasMore: true,
loadMore: async () => {
// 🛑 防抖:如果正在加载,或者没数据了,直接返回
if(get().loading || !get().hasMore) return;
set({ loading: true }); // 🔒 上锁
try {
// 请求当前页数据
const { items } = await fetchPosts(get().page);
if(items.length === 0) {
// 🚫 没数据了,标记 hasMore 为 false
set({ hasMore: false });
} else {
// ✅ 有数据:
// 1. 把新数据拼接到旧数据后面
// 2. 页码 + 1
set({
posts: [...get().posts, ...items],
page: get().page + 1,
})
}
} catch(err) {
console.error('加载失败', err);
} finally {
set({ loading: false }); // 🔓 解锁
}
}
}))
🧠 核心逻辑:
posts: [...get().posts, ...items]:这是实现无限滚动的关键。我们不是替换数据,而是追加数据。loading锁:网络请求是异步的,用户滑动过快可能导致短时间内触发多次请求,导致数据重复。必须加锁!
1.2 组件实现:InfiniteScroll
有了仓库,我们还需要一个通用的 UI 组件来触发 loadMore。
打开 src/components/infiniteScroll.tsx。
这里我们再次请出了老朋友 —— IntersectionObserver。 思路很简单:在列表的最底部放一个看不见的“哨兵”元素(Sentinel),一旦哨兵进入视窗,就说明用户滑到底了。
// frontend/notes/src/components/infiniteScroll.tsx
import { useRef, useEffect } from 'react';
interface InfiniteScrollProps {
hasMore: boolean;
isLoading?: boolean;
onLoadMore: () => void;
children: React.ReactNode; // 列表内容作为 children 传入
}
const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
hasMore,
isLoading = false,
onLoadMore,
children,
}) => {
// 🕵️♀️ 哨兵元素的引用
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// 如果没数据了或者正在加载,就不需要观察了
if(!hasMore || isLoading) return;
const observer = new IntersectionObserver((entries) => {
// entries[0] 就是我们的哨兵
if(entries[0].isIntersecting) {
// 🚨 哨兵出现!触发加载!
onLoadMore();
}
},{
threshold: 0, // 只要露个头就触发
// rootMargin: '100px' // 其实可以设置提前 100px 触发,体验更好
});
if(sentinelRef.current) {
observer.observe(sentinelRef.current);
}
// 🧹 清理函数:组件卸载时取消观察
return () => {
if(sentinelRef.current) {
observer.unobserve(sentinelRef.current);
}
}
}, [onLoadMore, hasMore, isLoading]);
return (
<>
{children}
{/* 🕵️♀️ 这是一个看不见的哨兵 div */}
<div ref={sentinelRef} className="h-4"/>
{/* 加载状态提示 */}
{
isLoading && (
<div className="text-center text-sm py-4 text-muted-foreground">
加载中...
</div>
)
}
{/* 底线提示 */}
{
!hasMore && (
<div className="text-center text-sm py-4 text-muted-foreground">
没有更多内容了 ~
</div>
)
}
</>
)
}
1.3 组装:Home 页面
最后,在 src/pages/Home.tsx 里把它们组合起来:
// frontend/notes/src/pages/Home.tsx
<InfiniteScroll
hasMore={hasMore}
isLoading={loading}
onLoadMore={loadMore}
>
<ul>
{
posts.map(post => (
<PostItem key={post.id} post={post} />
))
}
</ul>
</InfiniteScroll>
现在,当你滑动页面,到底部时就会自动加载下一页,丝滑流畅!
🚀 二、 路由跳转与嵌套
文章列表不仅要能看,还要能点。
我们在 PostItem 里已经写了跳转逻辑:onClick={() => navigate(/post/${post.id})}。
现在我们需要配置一下路由。
打开 src/router/index.tsx:
// frontend/notes/src/router/index.tsx
// 懒加载详情页组件
const PostLayout = lazy(() => import('@/layouts/PostLayout'))
const PostDetail = lazy(() => import('@/pages/post'))
<Routes>
{/* ...其他路由 */}
{/* 📝 文章详情路由嵌套 */}
<Route path="/post" element={<PostLayout/>}>
<Route path=":id" element={<PostDetail />}/>
</Route>
</Routes>
这里用到了 嵌套路由。PostLayout 是一个布局容器(虽然目前很简单,只返回了 <Outlet />),PostDetail 是具体的详情页。这种结构方便以后给所有文章详情页统一添加头部、底部或者侧边栏。
💾 三、 极致体验:KeepAlive 页面缓存
好的,功能都做完了,但体验上还有一个大坑。 场景复现:
- 用户在首页刷了 5 页文章,滑到了第 50 条。
- 用户点击第 50 条文章,进入详情页看了一会儿。
- 用户点击“返回”,回到首页。
- 灾难发生:首页重新渲染了!数据重新从第 1 页加载,滚动条回到了顶部!用户想看第 51 条,得重新滑半天。🤬
这在 SPA(单页应用)中是默认行为,因为组件被卸载了。
为了解决这个问题,我们需要 KeepAlive(页面缓存)。React 官方暂时没有完美的方案,我们使用社区优秀的库 react-activation。
3.1 引入 AliveScope
首先,在路由的根部包裹 AliveScope。它就像一个“休眠仓”,负责管理那些被缓存的组件节点。
// frontend/notes/src/router/index.tsx
import { AliveScope } from 'react-activation';
export default function RouterConfig({children}) {
return (
<Router>
{/* ⚡️ 赋予应用 KeepAlive 能力 */}
<AliveScope>
<Suspense fallback={<Loading/>}>
<Routes>
{/* ... */}
</Routes>
</Suspense>
</AliveScope>
</Router>
)
}
3.2 封装 KeepAliveHome
我们需要把 Home 组件“包”起来,告诉它:“你不要卸载,你只是睡着了”。
新建 src/components/KeepAliveHome.tsx:
// frontend/notes/src/components/KeepAliveHome.tsx
import { KeepAlive } from 'react-activation';
import Home from '@/pages/Home';
const KeepAliveHome = () => {
return (
// 🔒 name: 缓存的唯一标识
// 💾 saveScrollPosition="screen": 自动保存滚动位置!神器!
<KeepAlive name="home" saveScrollPosition="screen">
<Home />
</KeepAlive>
)
}
export default KeepAliveHome;
3.3 替换路由组件
最后,修改路由配置,让 / 路径指向这个被包裹的 KeepAliveHome,而不是原始的 Home。
// frontend/notes/src/router/index.tsx
// 🔄 替换引入
const Home = lazy(() => import('@/components/KeepAliveHome'));
// ...
<Route path="/" element={<MainLayout/>}>
<Route path="" element={<Home />} /> {/* 这里渲染的是 KeepAliveHome */}
{/* ... */}
</Route>
3.4 见证奇迹
现在,你再去首页滑到第 50 条,点进去,再退回来。 你会发现:页面没有刷新,滚动条纹丝不动,数据还在那里! 🎉
原理揭秘:
KeepAlive 并不会真的卸载组件,而是把组件的 DOM 节点从文档流中移走(比如放到一个看不见的 div 里),或者设为 display: none。当路由切回来时,再把这些 DOM 节点移回来。这样,组件的状态(State)和 DOM 状态(滚动位置、输入框内容)都能完美保留。
🎬 总结
今天我们完成了一次用户体验的巨大飞跃:
- 无限滚动 (InfiniteScroll):利用
IntersectionObserver和Zustand实现了自动加载更多,让内容流更加顺滑。 - 路由嵌套:规范了文章详情页的路由结构。
- 页面缓存 (KeepAlive):解决了 SPA 应用“返回重置”的顽疾,让我们的 Web App 拥有了原生 App 般的丝滑体验。
到这里,文章列表的核心功能已经非常完善了。明天,我们将进入 文章详情页 的开发,不仅要展示 Markdown 内容,还要实现点赞、评论等互动功能!
保持好奇,保持热爱,我们明天见!👋