♾️ AI 全栈项目实战第十天:用无限滚动和KeepAlive优化你的文章列表

98 阅读6分钟

哈喽,掘金的各位全栈练习生们!👋 欢迎回到 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 页面缓存

好的,功能都做完了,但体验上还有一个大坑。 场景复现

  1. 用户在首页刷了 5 页文章,滑到了第 50 条。
  2. 用户点击第 50 条文章,进入详情页看了一会儿。
  3. 用户点击“返回”,回到首页。
  4. 灾难发生:首页重新渲染了!数据重新从第 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 状态(滚动位置、输入框内容)都能完美保留。


🎬 总结

今天我们完成了一次用户体验的巨大飞跃:

  1. 无限滚动 (InfiniteScroll):利用 IntersectionObserverZustand 实现了自动加载更多,让内容流更加顺滑。
  2. 路由嵌套:规范了文章详情页的路由结构。
  3. 页面缓存 (KeepAlive):解决了 SPA 应用“返回重置”的顽疾,让我们的 Web App 拥有了原生 App 般的丝滑体验。

到这里,文章列表的核心功能已经非常完善了。明天,我们将进入 文章详情页 的开发,不仅要展示 Markdown 内容,还要实现点赞、评论等互动功能!

保持好奇,保持热爱,我们明天见!👋