📸 AI 全栈学习第九天:观察者模式实现图片懒加载,完善项目文章列表功能

67 阅读7分钟

哈喽,掘金的各位全栈练习生们!👋 欢迎回到 AI 全栈项目实战 的第九天!

昨天,我们成功打通了后端的“任督二脉”,用 NestJS + Prisma 连接了数据库,让我们的应用有了真实的数据支撑。今天,我们要把目光转回前端,解决一个经典的性能问题——图片懒加载,并把我们之前写的“假文章列表”替换成真正从后端获取的数据。

试想一下,如果你在逛淘宝或京东,页面一打开,成千上万张商品图片同时加载,你的浏览器不仅会卡死,流量也会瞬间爆炸。为了避免这种情况,聪明的开发者发明了“懒加载”(Lazy Load)。

今天,我们就从原生的观察者模式讲起,一路实战到 React 组件封装,最后结合后端 API,打造一个高性能的文章列表!🚀


🔭 一、 见微知著:从 Demo 看懂图片懒加载

在进入 React 项目之前,我们先用一个原生的 HTML Demo 来理解懒加载的核心原理。

1.1 什么是图片懒加载?

简单来说,就是”只加载你看得到的图片“。 当图片还在屏幕可视区域(Viewport)之外时,我们只给它一个很小的占位图或者干脆不加载。只有当你滚动页面,图片即将进入视线时,才去请求真正的图片资源。

这就像去吃自助餐,你不会一次性把所有菜都拿回桌上(那样桌子放不下,菜也凉了),而是吃多少拿多少

1.2 核心武器:IntersectionObserver

以前实现懒加载,我们需要监听 scroll 事件,然后疯狂计算 scrollTop 和元素位置,性能非常差。现在,浏览器为我们提供了一个超强的 API —— IntersectionObserver(交叉观察者)。

让我们看看 demo/lazy_load.html 是怎么写的:

<!-- 数据属性:data-src 存真实地址,src 存占位图 -->
<img class="lazy" src="placeholder.png" data-src="https://real-image-url.jpg" alt="">
<div class="box"></div> <!-- 撑开高度,模拟长列表 -->
<img class="lazy" src="placeholder.png" data-src="https://real-image-url.jpg" alt="">

<script>
  // 1. 获取所有需要懒加载的图片
  const images = document.querySelectorAll('.lazy');

  // 2. 创建观察者实例
  // 浏览器提供的观察者模式,自动观察,没有性能负担
  const observer = new IntersectionObserver((entries) => {
    // entries 是一个数组,包含所有被观察元素的状态变化
    console.log(entries, '-------');
    
    entries.forEach(entry => {
      // isIntersecting: 布尔值,true 代表元素出现在了视窗中(相交)
      if (entry.isIntersecting) {
        const img = entry.target; // 被观察的 DOM 元素
        
        // 3. 偷梁换柱:把 data-src 赋值给 src
        const original_img = img.dataset.src;
        console.log('加载图片:', original_img);
        img.src = original_img; // 此时浏览器才会真正去请求图片
        
        // 4. 任务完成,停止观察
        // 这一点很重要,图片加载过一次后就不需要再观察了,节省资源
        observer.unobserve(img);
      }
    })
  })

  // 5. 开启观察
  images.forEach(img => observer.observe(img));
</script>

🧐 知识点解析:

  • IntersectionObserver:它是浏览器原生的 API,性能极佳,专门用来处理元素与视窗(或祖先元素)的交叉状态。
  • entries:回调函数的参数,是一个数组。为什么是数组?因为可能会有多个图片同时进入视窗。
  • entry.isIntersecting:最关键的属性,判断元素是否“可见”。
  • entry.target:当前发生变化的 DOM 元素。
  • data-src:HTML5 的自定义属性,用来暂存真实的图片地址,防止页面加载时直接发起请求。

虽然在后面的 React 项目中我们会直接用现成的组件,但理解底层的 IntersectionObserver 是全栈工程师的基本修养


📡 二、 后端供粮:Prisma 复杂查询与数据清洗

前端要展示列表,后端得先有货。来看看我们是如何用 Prisma 玩转复杂查询的。

2.1 依赖注入与 DTO

首先,别忘了在 Service 中注入 Prisma 实例,并使用 DTO 规范输入。

@Injectable()
export class PostsService {
    // 💉 注入 PrismaService,就像注入了通往数据库的钥匙
    constructor(private prisma: PrismaService) {}

    async findAll(query: PostQueryDto) {
        const { page, limit } = query;
        // 📐 计算分页游标 (Skip)
        // 比如第 2 页,每页 10 条,就要跳过前 10 条:(2-1)*10 = 10
        const skip = ((page || 1) -1) * (limit || 10);
        
        // ...
    }
}

2.2 Prisma 的连表查询 (Join)

这是今天的重头戏!我们要查文章,但文章里还包含作者信息、标签、点赞数、评论数、首图文件... 这要是写 SQL,恐怕得写几十行 LEFT JOIN。但在 Prisma 里,一切都如此优雅:

const [total, posts] = await Promise.all([
    this.prisma.post.count(), // 并行查询总数
    this.prisma.post.findMany({ // 查询列表
        skip,
        take: limit, // 取多少条
        orderBy: { id: 'desc' }, // 按 ID 倒序,新文章在前
        
        // 🔗 关系查询 (Include) —— Prisma 的杀手锏
        include: { 
            // 1. 关联查询作者信息
            user: {
                select: { // 只取需要的字段,防止密码泄露!
                    id: true,
                    name: true,
                    avatars: { // 查用户的头像
                        select: { filename: true },
                        take: 1 // 只取一张
                    }
                }
            },
            // 2. 关联查询标签
            tags: {
                select: {
                    tag: { select: { name: true } }
                }
            },
            // 3. 统计关联数量
            _count: {
                select: {
                    likes: true,    // 多少人点了赞
                    comments: true  // 多少条评论
                }
            },
            // 4. 查询文章配图(作为封面)
            files: {
                where: {
                    mimetype: { startsWith: "image/" }, // 只要图片
                },
                select: { filename: true }
            }
        }
    }),
])

🔥 硬核解析:

  • findMany:查询多条记录。
  • include:这就是 Prisma 强大的“关系查询”。它会自动帮你去关联表中抓取数据,并嵌套在返回的对象里。
  • select极其重要! 后端必须要有数据敏感度。千万不要把 user.password 这种字段查出来返给前端。用 select 精确控制需要的字段。
  • _count:Prisma 特有的聚合查询,能直接帮你算出关联记录的数量,不用你自己写 SQL 的 COUNT(*)。 数据示例(结构混乱): 屏幕截图 2026-01-26 231317.png

2.3 数据清洗:对接口文档负责

Prisma 查出来的原始数据嵌套层级很深(比如 post.user.avatars[0].filename),如果直接丢给前端,前端同学可能会拿着刀来找你。🔪

作为后端,我们要把数据清洗成扁平、易用的格式,严格遵守接口文档。

// 🧹 数据清洗 (Data Transformation)
const data = posts.map(post => ({
    id: post.id,
    title: post.title,
    // 截取前 100 字作为简介
    brief: post.content ? post.content.slice(0, 100) : '',
    user: {
        id: post.user?.id,
        name: post.user?.name,
        // 拼接头像完整 URL
        avatar: `http://localhost:3000/uploads/avatar/resized/${post.user?.avatars[0].filename}-small.jpg`
    },
    // 扁平化标签数组
    tags: post.tags.map(tag => tag.tag.name),
    totalLikes: post._count.likes,
    totalComments: post._count.comments,
    // 拼接封面图 URL
    thumbnail: `http://localhost:3000/uploads/resized/${post.files[0]?.filename}-thumbnail.jpg` || ""
}))

return {
    items: data,
    total
}

数据示例(结构清晰): 屏幕截图 2026-01-26 231240.png

2.4 静态资源服务器:图片怎么访问?

细心的同学发现了,我们返回的 URL 里有 /uploads/...。这些图片存在哪里?前端怎么访问?

这就需要 NestJS 开启静态资源服务。 打开 backend/posts/src/main.ts

import { join } from 'path';

// ...
app.useStaticAssets(join(process.cwd(), 'uploads'), { 
    prefix: '/uploads', // 虚拟路径前缀
});
  • process.cwd():当前工作目录。
  • prefix: '/uploads':意思是,当你访问 http://localhost:3000/uploads/xxx.jpg 时,NestJS 会自动去项目根目录下的 uploads 文件夹里找 xxx.jpg 并返回。

这样,我们的图片就有了“身份证”URL,前端就能通过 <img src="..."> 访问了。


🎨 三、 前端展示:React 组件实战

后端数据接口好了,静态资源通了,终于轮到前端大展身手了。

3.1 首页文章列表 (Home.tsx)

src/pages/Home.tsx 中,我们把之前的 Mock 数据换成真实的 API 调用,并引入 InfiniteScroll(这部分我们明天详细讲,今天先看结构)。

<div className="container mx-auto py-8">
  <h1 className='text-2xl font-bold mb-6'>文章列表</h1>
  
  {/* 无限滚动容器 */}
  <InfiniteScroll
    hasMore={hasMore}
    isLoading={loading}
    onLoadMore={loadMore}
  >
    <ul>
    {
      posts.map(post => (
        // 👇 今天的核心组件:PostItem
        <PostItem 
          key={post.id}
          post={post}
        />
      ))
    }
    </ul>
  </InfiniteScroll>
</div>

3.2 封装 PostItem 组件:集成懒加载

打开 src/components/PostItem.tsx。这是一个通用的文章卡片组件,包含了点击跳转、信息展示以及我们心心念念的图片懒加载

import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import type { Post } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Eye, Heart } from 'lucide-react';
// 📦 引入懒加载库,它的底层就是 IntersectionObserver!
import LazyLoad from 'react-lazy-load';

interface PostItemProps {
    post: Post;
}

const PostItem: React.FC<PostItemProps> = ({ post }) => {
    const navigate = useNavigate();
    
    return (
        <div 
          className="flex border-b border-border py-4"
          // 🚀 动态路由跳转RESTful 风格 URL
          onClick={() => navigate(`/post/${post.id}`)}
        >
            {/* 左侧文字信息区域(省略部分代码...) */}
            <div className="flex-1 pr-4 space-y-2">
                <h2 className="text-base font-semibold">{post.title}</h2>
                {/* ... 作者、点赞数等 ... */}
            </div>

            {/* 👉 右侧封面图区域 */}
            {
                post.thumbnail && (
                    <div className='w-24 h-24 flex-shrink-0 relative overflow-hidden'>
                        {/* 🌟 懒加载包裹层 */}
                        <LazyLoad className='w-full h-full'>
                            <img 
                              loading="lazy" // 浏览器的原生属性双重保险
                              src={post.thumbnail}
                              className='w-full h-full object-cover'
                              alt={post.title}
                            />
                        </LazyLoad>
                    </div>
                )
            }
        </div>
    )
}

export default PostItem;

💡 实战技巧:

  1. 动态路由onClick={() => navigate(/post/${post.id})}。点击文章跳转到详情页,这是 SPA(单页应用)最常见的交互。
  2. react-lazy-load:虽然我们懂 IntersectionObserver 原理,但在 React 项目中,直接使用封装好的库(如 react-lazy-load)能让代码更简洁。它会自动处理组件的挂载、卸载和视窗检测。
  3. 用户体验:给图片容器设置固定的宽高(w-24 h-24)非常重要!这能防止图片加载出来瞬间导致的页面抖动 (Layout Shift),是核心 Web 指标 (CLS) 优化的关键。

🎬 总结

今天我们完成了一次精彩的前后端联动:

  1. 原理层面:通过原生 Demo 彻底理解了 IntersectionObserver 实现图片懒加载的机制。
  2. 后端层面:利用 Prisma 的 includeselect 实现了复杂的关系型数据查询,并清洗成规范的 JSON 格式;配置了静态资源服务器。
  3. 前端层面:封装了 PostItem 组件,集成了 react-lazy-load,实现了高性能的文章列表展示。

现在的 Notes 应用,不仅有了真实的后端数据,还拥有了丝滑的滚动体验。

明天,我们将深入讲解 InfiniteScroll(无限滚动) 的实现逻辑,让我们的列表彻底告别“下一页”按钮,实现像刷抖音一样停不下来的体验!

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