哈喽,掘金的各位全栈练习生们!👋 欢迎回到 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(*)。 数据示例(结构混乱):
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
}
数据示例(结构清晰):
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;
💡 实战技巧:
- 动态路由:
onClick={() => navigate(/post/${post.id})}。点击文章跳转到详情页,这是 SPA(单页应用)最常见的交互。 react-lazy-load:虽然我们懂IntersectionObserver原理,但在 React 项目中,直接使用封装好的库(如react-lazy-load)能让代码更简洁。它会自动处理组件的挂载、卸载和视窗检测。- 用户体验:给图片容器设置固定的宽高(
w-24 h-24)非常重要!这能防止图片加载出来瞬间导致的页面抖动 (Layout Shift),是核心 Web 指标 (CLS) 优化的关键。
🎬 总结
今天我们完成了一次精彩的前后端联动:
- 原理层面:通过原生 Demo 彻底理解了
IntersectionObserver实现图片懒加载的机制。 - 后端层面:利用 Prisma 的
include和select实现了复杂的关系型数据查询,并清洗成规范的 JSON 格式;配置了静态资源服务器。 - 前端层面:封装了
PostItem组件,集成了react-lazy-load,实现了高性能的文章列表展示。
现在的 Notes 应用,不仅有了真实的后端数据,还拥有了丝滑的滚动体验。
明天,我们将深入讲解 InfiniteScroll(无限滚动) 的实现逻辑,让我们的列表彻底告别“下一页”按钮,实现像刷抖音一样停不下来的体验!
保持好奇,保持热爱,我们明天见!👋