NestJS+React全栈开发实操学习笔记(静态服务+接口适配+图片懒加载)
本次全栈开发学习聚焦三大核心实操模块:NestJS静态资源服务器搭建、接口数据格式适配、React列表图片懒加载,全程围绕真实项目场景展开,结合完整可运行代码,拆解实现逻辑、关键细节与实操注意事项。学习过程中,深度融合NestJS、Prisma ORM、React、TypeScript等核心技术,重点掌握“后端提供规范服务、前端适配数据并优化体验”的全栈联动思维,夯实全栈开发基础,规避实操中常见的报错与逻辑漏洞。本笔记适用于具备基础NestJS、React、TypeScript功底,希望提升实操能力的学习者,通过“知识点+代码+解析”的模式,做到知其然且知其所以然,实现从理论到实操的落地转化。
本次学习涉及的核心技术栈的:后端(NestJS、Prisma ORM、TypeScript、Express)、前端(React、TypeScript、react-lazy-load、React Router、UI组件库),核心目标是完成“后端静态资源托管+规范接口返回,前端接口适配+图片懒加载优化”的全流程开发,最终实现一个可联动的文章列表功能(包含文章展示、标签渲染、用户信息显示、缩略图加载等核心交互)。以下是详细的学习内容、代码解析与实操总结。
一、NestJS静态资源服务器搭建
在全栈开发中,静态资源与动态资源的处理逻辑存在本质区别,动态资源需要后端通过Controller、Service层处理业务逻辑、查询数据库后返回,而静态资源无需复杂处理,可直接返回给前端使用。本次学习中,静态服务器主要用于托管用户上传的图片资源(用户头像、文章缩略图等),通过简单配置,让前端可直接通过URL访问资源,无需通过接口转发,提升资源访问效率,同时简化后端开发逻辑。
1.1 核心概念辨析
在搭建静态服务器前,首先明确静态资源与动态资源的核心区别,避免后续开发中混淆两者的处理方式:
- 动态资源:需要后端进行业务逻辑处理、数据查询/修改后返回的资源,比如文章列表数据、用户登录验证结果等,依赖Controller定义路由、Service处理逻辑,最终通过接口返回格式化数据,核心是“动态生成、按需返回”。
- 静态资源:无需后端动态处理,可直接提供给前端使用的文件,常见的包括HTML、CSS、JavaScript脚本、图片(img)、音频、视频等,核心是“固定不变、直接访问”。本次学习中,静态资源主要是用户上传的图片,存储在项目根目录下的uploads文件夹中。
关键提醒:静态资源的访问无需定义Controller路由,也无需编写Service处理逻辑,只需在NestJS中进行简单配置,即可启用静态资源服务,这是静态服务器搭建的核心优势,也是与动态接口开发的核心区别。
1.2 静态服务器搭建核心原理
NestJS本身基于Express(或Fastify)框架搭建,而Express框架自带静态资源服务功能,NestJS通过集成NestExpressApplication,实现了对Express静态资源服务的复用,因此无需额外安装第三方依赖,只需引入相关模块、配置资源路径即可启用静态服务器。
本次静态服务器的核心配置逻辑:指定静态资源的存储目录(项目根目录下的uploads文件夹),设置访问前缀(/uploads),通过path模块的join方法拼接绝对路径,确保NestJS能准确找到静态资源,同时让前端可通过“后端地址+前缀+文件路径”的格式访问资源(如http://localhost:3000/uploads/avatar/resized/123-small.jpg)。
1.3 实操步骤与完整代码解析
静态服务器的搭建核心代码位于后端项目的main.ts文件中,main.ts作为NestJS应用的入口文件,负责初始化应用、配置全局参数、启用各类服务,以下是完整代码及逐行解析,结合实操细节,确保每一步都可落地。
1.3.1 完整代码(main.ts)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
// 将nestjs 向express 一样拥有一些服务
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common'; // 引入验证管道
import { join } from 'path'; // node 内置模块路径 join方法通过路径片段拼接成一个路径
async function bootstrap() {
// 底座是基于express 的,所以可以使用express 的一些服务
const app = await NestFactory.create<NestExpressApplication>(AppModule,{
cors:true, // 开启跨域,前端在5173端口,后端在3000端口,跨域成功
});
app.setGlobalPrefix('api'); // 全局路由前缀/api
// 启用全局验证管道,基于express
app.useGlobalPipes(new ValidationPipe({
whitelist:true, // 自动过滤dot 未定义的属性
forbidNonWhitelisted:true, // 遇到未定义的属性直接抛出异常
transform:true, // "1" transform 1
}))
// 搭建静态服务器
// 返回当前工作目录的绝对路径,返回uploads 目录的路径
console.log(process.cwd(),join(process.cwd(),'uploads'))
app.useStaticAssets(join(process.cwd(),'uploads'),{
prefix:'/uploads',
})
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
1.3.2 逐行代码解析
代码解析围绕静态服务器搭建展开,同时补充全局配置(跨域、接口前缀、验证管道)的相关解析,因为这些配置会直接影响静态服务器与前端的联动效果:
-
引入核心模块
- NestExpressApplication:NestJS提供的基于Express的应用类型,继承了Express的所有功能(包括静态资源服务、中间件等),只有指定该类型,app对象才能调用useStaticAssets()方法(静态服务器核心方法),否则会报“属性useStaticAssets不存在”的错误。
- join(path模块):Node.js内置的路径处理方法,用于将多个路径片段拼接成一个完整的绝对路径,核心作用是规避不同操作系统(Windows、Linux、Mac)路径分隔符不一致的问题(Windows用“\”,Linux用“/”),确保路径拼接的准确性。
- ValidationPipe:全局参数验证管道,虽与静态服务器无直接关联,但属于后端全局配置的重要部分,后续接口开发中会用到,此处提前配置,保证项目规范性。
-
创建Nest应用并配置跨域 const app = await NestFactory.create(AppModule,{ cors:true }):创建Nest应用时,明确指定应用类型为NestExpressApplication,同时开启跨域配置(cors: true)。关键细节:前端React项目默认运行在5173端口,而后端静态服务器和接口运行在3000端口,不同端口之间的访问会存在跨域限制,开启cors后,后端会允许前端的跨域请求,确保静态资源和接口都能正常访问,这是前后端联动的前提,若不开启跨域,前端会报CORS政策阻止访问的错误。
-
配置全局接口前缀 app.setGlobalPrefix('api'):设置全局接口前缀为“/api”,所有后端动态接口都需要通过“http://localhost:3000/api/xxx”的格式访问(如文章列表接口http://localhost:3000/api/posts)。核心目的:区分静态资源与动态接口的访问路径,静态资源的访问前缀为“/uploads”,动态接口的前缀为“/api”,避免路径冲突(比如避免前端访问静态资源时,误匹配到后端接口路由)。
-
启用全局验证管道 app.useGlobalPipes(new ValidationPipe({...})):启用全局参数验证管道,核心配置有三个,均为接口开发的关键配置:
- whitelist: true:自动过滤接口请求中未在DTO(数据传输对象)中定义的属性,比如前端误传了多余的参数,会自动剔除,避免无效参数干扰后端业务逻辑。
- forbidNonWhitelisted: true:严格模式,若前端请求中包含未在DTO中定义的属性,直接抛出异常,提醒前端修改请求参数,避免误传无效参数导致的逻辑错误。
- transform: true:自动将请求参数转换为指定的类型,比如前端传递的page参数是字符串“2”,会自动转换为数字2,无需后端手动转换,简化开发逻辑,避免类型不匹配报错。
-
静态服务器核心配置 这是本次静态服务器搭建的核心代码,负责指定静态资源目录、访问前缀,确保前端能正常访问uploads文件夹下的图片资源:
- console.log(process.cwd(), join(process.cwd(), 'uploads')):打印当前工作目录(项目根目录)和uploads文件夹的绝对路径,用于调试,若后续静态资源无法访问,可通过打印的路径排查问题(比如路径拼接错误、文件夹不存在等)。
- process.cwd():Node.js全局方法,用于获取当前Node.js进程的工作目录,即项目的根目录,返回值为绝对路径(如D:\project\nest-react-demo)。
- join(process.cwd(), 'uploads'):将项目根目录与uploads文件夹拼接,得到uploads文件夹的绝对路径(如D:\project\nest-react-demo\uploads),确保NestJS能准确找到静态资源的存储位置。
- prefix: '/uploads':静态资源访问前缀,设置后,前端访问uploads文件夹下的资源时,必须在URL中加上该前缀。例如,uploads文件夹下有一张头像图片“avatar/resized/123-small.jpg”,前端访问的URL即为“http://localhost:3000/uploads/avatar/resized/123-small.jpg”,若不设置前缀,访问URL为“http://localhost:3000/avatar/resized/123-small.jpg”,容易与接口路径混淆。
-
启动应用 await app.listen(process.env.PORT ?? 3000):启动NestJS应用,监听端口,优先使用环境变量中的PORT,若环境变量中未配置,则默认监听3000端口,这是后端服务的默认访问端口。
1.4 实操注意事项与常见问题排查
静态服务器搭建看似简单,但实操中容易出现资源无法访问的问题,结合本次学习场景,总结以下常见问题及排查方法,帮助快速定位并解决问题:
1.4.1 常见问题1:资源路径拼接错误,NestJS找不到uploads文件夹
表现:前端访问静态资源时,报404错误(资源未找到),后端控制台无报错,打印的路径与预期不一致。
排查方法:
- 查看后端控制台打印的路径,确认join(process.cwd(), 'uploads')拼接后的绝对路径是否正确,是否指向项目根目录下的uploads文件夹。
- 手动检查项目根目录下是否存在uploads文件夹,若不存在,需手动创建(注意文件夹名称区分大小写,uploads不能写成Uploads、upload等,否则路径匹配失败)。
- 避免手动拼接路径,必须使用join方法,手动拼接字符串(如process.cwd() + '/uploads')会导致不同操作系统下路径分隔符错误,进而导致NestJS找不到文件夹。
1.4.2 常见问题2:跨域问题,前端无法访问静态资源
表现:前端控制台报“Access to XMLHttpRequest at 'http://localhost:3000/uploads/xxx' from origin 'http://localhost:5173' has been blocked by CORS policy”错误。
排查方法:确认Nest应用创建时开启了cors配置(cors: true),若未开启,需添加该配置;若已开启,检查前端访问的URL是否正确,是否包含了正确的访问前缀(/uploads)。
1.4.3 常见问题3:访问前缀配置错误,路径冲突
表现:前端访问静态资源时,报404错误,或访问到后端接口而非静态资源。
排查方法:确认prefix配置为“/uploads”,前端访问URL必须包含该前缀;同时区分接口前缀(/api)与静态资源前缀(/uploads),避免将静态资源前缀设置为“/api/uploads”,否则会与接口路径混淆,导致静态资源无法访问。
1.4.4 常见问题4:未指定应用类型,无法调用useStaticAssets方法
表现:后端控制台报“Property 'useStaticAssets' does not exist on type 'INestApplication'”错误。
排查方法:创建Nest应用时,必须指定泛型,同时引入NestExpressApplication模块,确保app对象具备Express的静态资源服务能力。
1.5 实操总结
静态服务器搭建的核心是“配置路径+设置前缀”,无需复杂的业务逻辑,关键在于理解静态资源与动态资源的区别,以及路径拼接的规范性。本次搭建的静态服务器,成功实现了uploads文件夹下图片资源的托管,为后续前端加载用户头像、文章缩略图提供了基础,同时通过全局配置(跨域、接口前缀),确保了前后端联动的可行性。
二、接口数据格式调整(NestJS+Prisma ORM)
后端通过Prisma ORM查询数据库后,返回的数据格式往往与前端所需格式不一致,主要原因是数据库表存在关联关系、查询结果包含冗余字段、字段名称/类型与前端预期不匹配。因此,接口数据格式调整是全栈开发中不可或缺的一步,核心是“依据前后端约定的接口文档,将数据库查询结果格式化,适配前端渲染需求”,这也是后端开发中“尊重接口文档”的核心体现。
本次学习中,接口数据格式调整的核心场景是“文章列表接口”,后端通过Prisma ORM查询文章、用户、标签、文件等关联数据后,通过map方法对数据进行格式化处理,最终返回符合前端React组件需求的数据格式,确保前端能直接使用数据进行渲染,无需额外处理。
2.1 核心前提:接口文档约定
数据格式调整的前提是前后端有明确的接口文档约定,接口文档需明确规定接口的返回字段、字段类型、字段名称、数据格式等,后端必须严格按照接口文档返回数据,否则会导致前端渲染失败、类型报错等问题。这是后端开发的基本规范,也是前后端协同开发的核心保障。
本次文章列表接口的核心约定(简化版):
- 返回数据结构:{ items: 文章列表数组, total: 文章总条数 }
- 单篇文章数据结构:包含id、title、brief(文章简介)、user(用户信息)、tags(标签数组)、totalLikes(点赞数)、totalComments(评论数)、thumbnail(缩略图URL)。
- 用户信息结构:{ id: 用户id, name: 用户名, avatar: 头像完整URL }
- 标签数组:[string](扁平数组,仅包含标签名称)
2.2 核心技术铺垫:Prisma ORM基础
本次接口开发使用Prisma ORM操作数据库,Prisma是一款类型安全的ORM工具,支持PostgreSQL、MySQL等多种数据库,能自动生成TypeScript类型,避免类型不匹配报错,同时简化数据库查询逻辑。在进行数据格式调整前,先梳理本次用到的Prisma核心知识点,确保理解数据查询逻辑,才能更好地掌握格式调整方法。
2.2.1 Prisma Service注入
在NestJS中使用Prisma ORM,需要创建PrismaService,封装PrismaClient实例,然后通过依赖注入的方式,在Service层(如PostsService)中使用,实现数据库操作。核心逻辑是“单一实例,全局复用”,避免重复创建PrismaClient实例,节省资源。
2.2.2 Prisma查询核心方法与配置
本次文章列表接口使用的核心查询方法是findMany(查询多条数据),同时结合分页、排序、关联查询、字段筛选、计数等配置,确保查询到的数据满足业务需求,同时避免冗余数据。以下结合完整的PostsService代码,拆解查询逻辑与核心配置。
2.3 实操步骤与完整代码解析
接口数据格式调整的核心代码位于后端项目的PostsService.ts文件中,PostsService负责处理文章相关的业务逻辑(查询文章列表、数据格式化等),以下是完整代码及逐行解析,重点拆解Prisma查询逻辑与数据格式化逻辑。
2.3.1 完整代码(PostsService.ts)
import { Injectable } from "@nestjs/common";
import { PostQueryDto } from "./dto/post-query.dto";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class PostsService {
constructor(private prisma:PrismaService){}
async findAll(query:PostQueryDto){
const {page,limit} = query;
// 分页的游标
const skip = (((page || 1) - 1) * (limit || 10));
const [ total, posts ] = await Promise.all([
this.prisma.post.count(),
this.prisma.post.findMany({
skip, // 跳过多少条
take: limit, // 取多少条
orderBy: { // 按照id 降序排列
id: 'desc'
},
include: { // 关系型的数据
user: {
select: { // 只要哪些字段
id: true,
name: true,
avatars: {
select: {
filename: true,
},
take: 1, // 取一条
},
}
},
tags: {
select: {
tag: {
select: {
name: true,
}
}
}
},
_count: { // 为每条记录都单独统计点赞数和评论数
select: {
likes: true,
comments: true,
}
},
files:{
where: {
mimetype: {
startsWith: 'image/',
}
},
select: {
filename: true,
}
}
}
}) // 查询多条
])
// 将查询出的数据转化成前端需要的格式
const data = posts.map(post => ({
id: post.id,
title: post.title,
// 将content 进行截取,截取100个字符
brief: post.content?post.content.substring(0,100):'',
// publishTime: post.createdAt ,
user: {
id: post.user?.id ,
name: post.user?.name ,
avatar: `http://localhost:3000/uploads/avatar/resized/${post.user?.avatars?.[0]?.filename}-small.jpg`,
},
tags: post.tags.map(t => t.tag.name),
totalLikes: post._count.likes,
totalComments: post._count.comments,
thumbnail: `http://localhost:3000/uploads/resized/${post.files?.[0]?.filename}-thumbnail.jpg`,
}));
// const total = await this.prisma.post.count();
// console.log(total,'-----------');
return {
items: data,
total,
}
}
}
2.3.2 Prisma查询逻辑解析(核心部分)
findAll方法是文章列表接口的核心业务方法,负责接收前端传递的分页参数、查询数据库、返回格式化后的数据,其中Prisma查询逻辑是基础,决定了查询到的数据结构,以下逐行拆解:
1. 分页参数处理
const {page,limit} = query; const skip = (((page || 1) - 1) * (limit || 10));
解析:前端传递分页参数page(当前页码)和limit(每页条数),通过解构赋值获取参数;skip是Prisma分页的核心参数,表示“跳过的条数”,计算公式为(当前页码-1)×每页条数,确保分页逻辑正确。
关键细节:(page || 1)和(limit || 10)用于设置默认值,若前端未传递page参数,默认页码为1;未传递limit参数,默认每页显示10条数据,避免参数为空导致的查询报错。
2. 并行执行查询操作(Promise.all)
const [ total, posts ] = await Promise.all([this.prisma.post.count(), this.prisma.post.findMany({...})])
解析:使用Promise.all并行执行两个异步操作,分别是“查询文章总条数”和“查询文章列表数据”,相比串行执行(先查总条数,再查列表),并行执行能提升接口响应速度,因为两个操作互不依赖,同时执行可节省时间。
两个异步操作的作用:
- this.prisma.post.count():查询文章总条数,用于前端分页展示(如显示“共100条,当前第1页”)。
- this.prisma.post.findMany({...}):查询文章列表数据,结合分页、排序、关联查询等配置,获取当前页的文章数据。
3. findMany核心配置解析
findMany方法的配置决定了查询到的数据内容和结构,本次用到的核心配置有skip、take、orderBy、include,每个配置都有明确的业务意义:
- skip:跳过的条数,由分页参数计算得出,实现分页功能。
- take:获取的条数,即每页显示的文章数量,对应前端传递的limit参数。
- orderBy:排序配置,本次设置为{ id: 'desc' },表示按照文章id降序排列,最新发布的文章排在前面,符合文章列表的展示需求。
- include:关联查询配置,这是Prisma ORM处理关联数据的核心配置,用于查询与post(文章)模型相关联的其他模型(user、tags、files)的数据。若不配置include,Prisma只会查询post表本身的字段,不会返回关联数据,无法满足前端展示需求(如显示文章作者、标签等)。
4. 关联查询与字段筛选(include内部配置)
include内部配置了四个关联查询,分别对应user(文章作者)、tags(文章标签)、_count(点赞/评论计数)、files(文章缩略图),每个关联查询都通过select配置筛选字段,避免返回冗余数据,提升查询效率:
- user(文章作者):关联查询用户表,通过select筛选只返回id、name、avatars三个字段,其中avatars(用户头像)又通过select筛选只返回filename(文件名),take: 1表示只取一条头像数据(用户可能有多个头像,取最新一条即可)。
- tags(文章标签):关联查询标签表,标签与文章是多对多关系,通过select筛选只返回tag对象中的name字段(标签名称),避免返回标签表中的其他冗余字段。
- _count(点赞/评论计数):Prisma提供的计数配置,用于统计每条文章关联的likes(点赞数)和comments(评论数),无需手动查询计数,简化开发逻辑,同时确保计数准确性。
- files(文章缩略图):关联查询文件表,通过where配置筛选条件(mimetype.startsWith('image/')),只取图片类型的文件(避免取到音频、视频等非图片文件),同时通过select筛选只返回filename(文件名)。
2.3.3 数据格式调整逻辑解析(核心重点)
Prisma查询返回的posts数据格式,与前端所需格式不一致(存在嵌套对象、冗余字段、字段名称不匹配等问题),因此需要通过map方法对每条文章数据进行格式化处理,核心逻辑是“提取需要的字段、扁平化嵌套数据、拼接图片URL、调整字段格式”,以下逐行解析格式化代码:
const data = posts.map(post => ({
id: post.id,
title: post.title,
// 将content 进行截取,截取100个字符
brief: post.content?post.content.substring(0,100):'',
user: {
id: post.user?.id ,
name: post.user?.name ,
avatar: `http://localhost:3000/uploads/avatar/resized/${post.user?.avatars?.[0]?.filename}-small.jpg`,
},
tags: post.tags.map(t => t.tag.name),
totalLikes: post._count.likes,
totalComments: post._count.comments,
thumbnail: `http://localhost:3000/uploads/resized/${post.files?.[0]?.filename}-thumbnail.jpg`,
}));
1. 基础字段保留与调整
- id: post.id、title: post.title:直接保留Prisma查询返回的id和title字段,因为这两个字段的名称、类型与前端预期一致,无需额外调整。
- brief: post.content?post.content.substring(0,100):'':文章简介字段,前端需要显示文章的简短描述,因此对post.content(文章完整内容)进行截取,取前100个字符;使用三元运算符判断post.content是否存在,若存在则截取,若不存在(为空)则返回空字符串,避免前端渲染时出现undefined。
2. 用户信息格式化(扁平化处理)
Prisma查询返回的user是嵌套对象,包含id、name、avatars(嵌套数组),而前端需要的user格式是{ id, name, avatar }(avatar为完整头像URL),因此需要进行扁平化处理:
- id: post.user?.id、name: post.user?.name:使用可选链操作符(?.),避免post.user为undefined时(如文章未关联作者),访问嵌套属性报错,若user为undefined,id和name会返回undefined,前端可做兜底处理(如显示“未知用户”)。
- avatar:
http://localhost:3000/uploads/avatar/resized/${post.user?.avatars?.[0]?.filename}-small.jpg:拼接用户头像的完整URL,这是核心调整点。Prisma查询返回的是avatars数组中的filename(如“123.jpg”),而前端需要完整的URL才能访问静态服务器中的头像资源,因此拼接格式为“后端地址+静态资源前缀+文件夹路径+文件名+尺寸后缀”,确保前端能直接通过src属性加载头像。
3. 标签数组格式化(扁平化处理)
tags: post.tags.map(t => t.tag.name):Prisma查询返回的tags是嵌套数组,每个元素是{ tag: { name: '标签名' } }的格式,而前端需要的是扁平的字符串数组(如['前端', '全栈']),因此通过map方法遍历tags数组,提取每个元素中tag对象的name字段,组成新的扁平数组,适配前端Badge组件的渲染需求(前端可直接遍历数组渲染标签)。
4. 点赞数与评论数调整
totalLikes: post._count.likes、totalComments: post._count.comments:Prisma通过_count配置返回的是{ likes: 数量, comments: 数量 },直接提取这两个字段,赋值给前端约定的totalLikes和totalComments,字段名称符合前端预期,无需额外调整。
5. 文章缩略图URL拼接
thumbnail: http://localhost:3000/uploads/resized/${post.files?.[0]?.filename}-thumbnail.jpg:与用户头像URL拼接逻辑一致,Prisma查询返回的files是数组(存储文章相关图片),取第一个图片文件(files?.[0]?.filename),拼接成完整的缩略图URL,确保前端能通过src属性加载静态服务器中的缩略图资源。
2.3.4 核心技巧与实操注意事项
技巧1:使用可选链操作符(?.),提升代码健壮性
在格式化关联数据时,经常会遇到关联数据为undefined或null的情况(如文章未关联作者、未上传缩略图),此时使用可选链操作符(?.),可以避免访问嵌套属性时报错。例如,post.user?.avatars?.[0]?.filename,若user、avatars、avatars[0]中任意一个为undefined,都会返回undefined,不会导致接口崩溃,前端可通过兜底逻辑处理(如显示默认头像)。
若不使用可选链操作符,写成post.user.avatars[0].filename,当user为undefined时,会报“Cannot read properties of undefined (reading 'avatars')”错误,导致接口无法正常返回数据。
技巧2:使用map方法批量处理数组数据
posts是Prisma查询返回的文章数组,需要批量格式化每条文章数据;tags是每条文章的标签嵌套数组,需要批量提取标签名称,此时使用map方法可以高效完成批量处理,代码简洁、可维护性高,避免手动循环遍历的繁琐操作。
注意事项1:严格遵循接口文档约定
数据格式调整必须严格按照前后端约定的接口文档进行,包括字段名称、字段类型、数据格式等,不能随意修改。例如,前端约定用户头像字段名为avatar,后端就不能写成userAvatar;前端约定tags是字符串数组,后端就不能返回嵌套对象数组,否则会导致前端渲染失败、TypeScript类型报错。
注意事项2:避免数据冗余,提升接口响应速度
在Prisma查询时,通过select配置筛选字段,只查询前端需要的字段,避免返回数据库表中的冗余字段(如用户表的password字段、文章表的updatedAt字段);在格式化时,只提取前端需要的字段,删除不需要的字段,减少接口返回的数据体积,提升响应速度。
注意事项3:图片URL拼接的规范性
图片URL拼接时,必须与静态服务器的配置保持一致,包括后端服务地址、静态资源访问前缀、文件夹路径等,避免拼写错误。例如,静态服务器前缀为“/uploads”,就不能拼接成“/upload”;uploads文件夹下的头像存储在avatar/resized目录,就不能拼接成“avatars/resized”,否则会导致前端无法加载图片,报404错误。
2.4 实操常见问题与解决方案
2.4.1 问题1:关联数据为undefined,格式化时报错
表现:后端控制台报“Cannot read properties of undefined (reading 'name')”错误,或前端接收的数据中,部分字段(如avatar、tags)为undefined。
原因:Prisma查询时,关联数据未查询到(如文章未关联标签,tags为[];用户未上传头像,avatars为[]),或未使用可选链操作符,访问嵌套属性时报错。
解决方案:
- 所有嵌套属性访问时,均使用可选链操作符(?.),如post.user?.name、post.tags?.map(...)。
- 对可能为空的字段,设置兜底值,如tags: post.tags?.map(t => t.tag.name) || [],确保tags始终是数组类型,避免前端map时报错;头像URL拼接时,设置默认头像,如avatar: post.user?.avatars?.[0]?.filename ?
xxx${filename}xxx: '默认头像URL'。
2.4.2 问题2:格式化后的数据类型与前端TypeScript类型不匹配
表现:后端控制台报TypeScript类型错误,如“Type 'undefined' is not assignable to type 'string'”,或前端接收数据时,TypeScript提示类型不匹配。
原因:后端格式化后的数据,字段类型与前端约定的TypeScript类型不一致(如前端约定avatar是string类型,后端返回undefined;前端约定tags是string[],后端返回undefined)。
解决方案:严格按照前端约定的TypeScript类型,对可能为undefined的字段设置兜底值,确保字段类型符合约定。例如,avatar: http://localhost:3000/uploads/xxx/${post.user?.avatars?.[0]?.filename || 'default.jpg'},tags: post.tags?.map(t => t.tag.name) || []。
2.4.3 问题3:图片URL拼接错误,前端无法加载图片
表现:前端img标签无法加载图片,控制台报404错误,查看Network面板,图片请求的URL不正确。
原因:URL拼接时,服务地址、访问前缀、文件夹路径、文件名拼写错误,或静态服务器配置错误。
解决方案:
- 检查静态服务器配置,确认useStaticAssets的路径和prefix是否正确。
- 逐段核对URL拼接逻辑,确保服务地址、前缀、文件夹路径、文件名拼写正确,可通过console.log打印拼接后的URL,排查错误。
2.5 实操总结
接口数据格式调整的核心是“尊重接口文档、适配前端需求”,本质是对数据库查询结果的“过滤、扁平化、格式转换”。本次学习中,通过Prisma ORM实现关联查询与字段筛选,通过map方法完成数据格式化,成功将嵌套、冗余的查询结果,转化为前端可直接使用的数据格式,同时通过可选链操作符、兜底值设置等技巧,提升了代码的健壮性。
需要重点记住的是:后端开发不仅要实现业务逻辑,还要考虑前端的使用体验,规范的接口返回格式的是前后端协同开发的关键,也是提升开发效率、减少沟通成本的核心。
三、React列表图片懒加载(react-lazy-load)
3.1 图片懒加载核心概念与作用
图片懒加载(Lazy Loading)是一种前端性能优化技术,核心原理是:当页面加载时,不加载当前视口(用户可见区域)以外的图片,只有当用户滚动页面,图片进入视口范围内时,才加载图片资源。其核心目标是减少页面初始加载的资源体积,提升页面加载速度,优化用户体验。
在列表页面(如本次学习的文章列表)中,往往会有大量的图片(文章缩略图、用户头像),如果页面加载时一次性加载所有图片,会导致以下问题,这也是我们必须实现懒加载的核心原因:
- 页面加载速度慢:大量图片同时请求,会占用大量的网络带宽,尤其是图片体积较大时,会导致页面渲染延迟,用户需要等待较长时间才能看到页面核心内容,容易造成用户流失。
- 资源浪费:用户可能不会滚动到页面底部,一次性加载所有图片,会浪费用户的流量(尤其是移动设备用户),同时也会浪费服务器的带宽资源,增加服务器压力。
- 页面卡顿:大量图片加载时,会占用浏览器的内存和CPU资源,导致页面滚动卡顿、不流畅,严重影响用户的浏览体验。
本次学习中,我们选用react-lazy-load第三方库(轻量、易用、适配TypeScript),结合原生HTML的loading="lazy"属性,实现双重懒加载优化,既保证兼容性,又能最大化提升懒加载效果,核心代码集中在PostItem.tsx组件(文章列表项组件)中,与后端返回的图片URL(已通过接口格式化拼接完成)联动使用。
补充知识点:react-lazy-load库的底层实现依赖于浏览器原生的IntersectionObserver API,该API可以异步监听目标元素与视口的交集状态,当元素进入视口(交集比例达到设定阈值)时,触发回调函数,从而触发图片加载。IntersectionObserver兼容性较好,支持主流现代浏览器(Chrome、Firefox、Edge等),对于低版本浏览器(如IE),可通过引入polyfill实现兼容(本次学习暂不涉及低版本兼容处理)。
补充对比:原生loading="lazy"属性是HTML5新增的图片懒加载属性,无需依赖任何库,直接给img标签添加即可实现懒加载,但兼容性略逊于react-lazy-load(部分低版本浏览器不支持);两者结合使用,可实现“高兼容性+高可靠性”的双重保障,避免单一方式出现兼容问题。
3.2 图片懒加载实操步骤(结合React组件)
本次图片懒加载的核心需求:在文章列表(PostItem组件)中,对文章缩略图实现懒加载,当缩略图进入用户视口时,才加载图片资源;同时适配TypeScript类型,避免类型报错;补充加载中占位图、加载失败兜底图,提升用户体验;结合后端格式化后的图片URL(http://localhost:3000/uploads/xxx),确保图片能正常加载。以下是具体的实操步骤、代码解析及细节说明。
3.2.1 安装react-lazy-load依赖
首先,在React前端项目中,安装react-lazy-load库(核心懒加载组件),若项目使用TypeScript,还需安装对应的类型定义包,避免TypeScript报“找不到模块react-lazy-load的声明文件”错误。命令如下(npm或yarn均可,二选一即可):
# 使用npm安装(推荐,与本次项目依赖管理一致)
npm install react-lazy-load --save
# TypeScript类型定义包(必装,否则类型报错)
npm install @types/react-lazy-load --save-dev
# 若使用yarn安装
yarn add react-lazy-load
yarn add @types/react-lazy-load --dev
关键说明:--save表示将依赖添加到dependencies(生产环境依赖),因为react-lazy-load是运行时需要用到的组件;--save-dev表示添加到devDependencies(开发环境依赖),类型定义包仅在开发时用于TypeScript类型检查,生产环境无需打包。
3.2.2 引入必要的依赖与类型
在PostItem.tsx文件(文章列表项组件,位于src/components/PostItem.tsx)中,引入react-lazy-load库的LazyLoad组件,以及其他所需的React模块、UI组件、类型和图标,确保组件能正常运行且类型无误。代码如下:
import * as React from 'react'; // 引入React核心模块
import { useNavigate } from 'react-router-dom'; // 路由跳转钩子,点击文章跳转到详情页
import type { Post } from '@/types/index'; // 引入文章类型(前后端约定,确保props类型正确)
import { Badge } from '@/components/ui/badge'; // 标签组件,用于渲染文章标签
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; // 头像组件,渲染作者头像
import { Eye, Heart, MessageCircle } from 'lucide-react'; // 图标组件,渲染浏览、点赞、评论图标
import LazyLoad from 'react-lazy-load'; // 引入懒加载核心组件
// 可选:引入样式文件(若需自定义懒加载占位图样式)
import './PostItem.css';
关键解析:
- import LazyLoad from 'react-lazy-load':核心引入,LazyLoad是一个React组件,用于包裹需要懒加载的图片元素,监听其是否进入视口。
- type { Post } from '@/types/index':引入前后端约定的Post类型,确保PostItem组件接收的post props格式正确,同时确保懒加载的图片src属性(post.thumbnail)是string类型,避免TypeScript类型报错。
- 其他依赖:路由跳转、UI组件、图标等,是PostItem组件的基础依赖,与懒加载功能联动(如图片是文章列表项的一部分),无需额外修改,保持原有逻辑即可。
3.2.3 定义组件Props类型
明确PostItem组件接收的props参数,定义PostItemProps接口,指定props为post,类型为Post,确保组件接收的数据格式与后端返回的格式化数据一致,避免类型不匹配报错。代码如下:
// 定义组件Props接口,约束接收的参数格式
interface PostItemProps {
post: Post; // 接收单篇文章数据,类型为前后端约定的Post类型
}
// 定义组件,指定Props类型为PostItemProps,确保类型安全
const PostItem: React.FC<PostItemProps> = ({ post }) => {
const navigate = useNavigate(); // 初始化路由跳转钩子
// 组件核心逻辑(后续补充)
return (
// 组件渲染结构(后续补充)
);
}
export default PostItem; // 导出组件,供文章列表组件(PostList)使用
关键说明:React.FC 表示该组件是一个函数式组件,接收的props类型为PostItemProps;必须导出组件,否则其他组件无法引入使用。
3.2.4 实现图片懒加载核心逻辑(LazyLoad组件包裹图片)
这是图片懒加载的核心步骤:在组件的返回值中,使用LazyLoad组件包裹文章缩略图(img标签),同时给img标签添加loading="lazy"属性(双重懒加载),补充加载中占位图和加载失败兜底图,避免图片加载时出现空白或报错。代码如下(完整PostItem组件渲染结构):
const PostItem: React.FC<PostItemProps> = ({ post }) => {
const navigate = useNavigate(); // 初始化路由跳转钩子
// 处理图片加载失败的兜底逻辑
const handleImgError = (e: React.SyntheticEvent<HTMLImageElement>) => {
// 当图片加载失败时,替换为默认兜底图(需提前在public文件夹中放置默认图)
e.currentTarget.src = '/default-thumbnail.jpg';
};
return(
<div
className='flex border-b border-gray-200 py-4 px-2 hover:bg-gray-50 cursor-pointer transition-colors'
onClick={() => navigate(`/post/${post.id}`)} // 点击文章跳转到详情页(动态路由)
>
{/* 文章内容区域:标题、简介、作者、标签、统计信息 */}
<div className='flex-1 pr-4 space-y-2'>
<h3 className='text-lg font-semibold text-gray-800 hover:text-blue-600 transition-colors'>
{post.title}
</h3>
<p className='text-gray-600 text-sm line-clamp-2'>{post.brief}</p>
<div className='flex items-center text-xs text-gray-500'>
<Avatar className='w-6 h-6 mr-2'>
<AvatarImage src={post.user.avatar} onError={(e) => {
e.currentTarget.src = '/default-avatar.jpg';
}} />
<AvatarFallback>{post.user.name?.charAt(0) || '无'}</AvatarFallback>
</Avatar>
<span className='mr-4'>{post.user.name}</span>
<div className='flex space-x-1 mr-4'>
{post.tags.map((tag, index) => (
<Badge key={index} variant='secondary' className='text-xs'>
{tag}
</Badge>
))}
</div>
<div className='flex items-center mr-4'>
<Eye size={12} className='mr-1' />
<span>{post.totalViews || 0}</span>
</div>
<div className='flex items-center mr-4'>
<Heart size={12} className='mr-1' />
<span>{post.totalLikes || 0}</span>
</div>
<div className='flex items-center'>
<MessageCircle size={12} className='mr-1' />
<span>{post.totalComments || 0}</span>
</div>
</div>
</div>
{/* 文章缩略图:懒加载核心代码(重点) */}
{
post.thumbnail && ( // 先判断thumbnail是否存在,避免undefined报错
<div className='w-24 h-24 flex-shrink-0 relative overflow-hidden rounded-md border border-gray-200'>
{/* LazyLoad组件包裹img标签,实现懒加载监听 */}
<LazyLoad
className='w-full h-full'
offset={50} // 提前加载:图片距离视口50px时,就开始加载,避免空白
height={96} // 容器高度(与父容器w-24 h-24一致,1rem=4px,24*4=96px)
placeholder={<div className='w-full h-full bg-gray-100 flex items-center justify-center'>
<span className='text-xs text-gray-400'>加载中...</span>
</div>} // 加载中占位图,优化用户体验
>
<img
loading='lazy' // 原生懒加载属性,双重保障
src={post.thumbnail} // 图片URL(后端格式化后拼接的完整URL)
alt={`${post.title}的缩略图`} // alt属性,提升可访问性
className='w-full h-full object-cover transition-opacity duration-300' // 样式:填充容器、保持比例、淡入效果
onError={handleImgError} // 加载失败兜底逻辑
/>
</LazyLoad>
</div>
)
}
</div>
)
}
3.2.5 懒加载核心配置解析(重点掌握)
上述代码中,LazyLoad组件和img标签的配置是懒加载的核心,每一个配置项都有其作用,需重点掌握,避免配置错误导致懒加载失效或体验不佳:
1. LazyLoad组件核心配置
- className='w-full h-full':给LazyLoad组件添加样式,与父容器(w-24 h-24)保持一致,确保懒加载容器的尺寸固定,避免页面布局抖动(若容器尺寸不固定,滚动时可能出现布局错乱)。
- offset={50}:提前加载距离,单位为像素(px)。默认情况下,图片进入视口后才开始加载,可能会出现短暂空白;设置offset=50后,图片距离视口50px时就开始加载,用户滚动到图片位置时,图片已经加载完成,提升用户体验。可根据需求调整,如offset=100(提前100px加载)。
- height={96}:指定懒加载容器的高度,与父容器高度一致(96px)。若父容器未明确设置高度,LazyLoad组件可能无法正确监听元素是否进入视口,导致懒加载失效,因此建议明确设置height。
- placeholder:加载中占位图,当图片未加载完成时,显示该占位内容(如灰色背景+“加载中...”文字),避免出现空白区域,优化用户体验。占位图可以是任意React元素,如图片、文字、图标等。
2. img标签核心配置
- loading='lazy':原生HTML5懒加载属性,无需依赖任何库,浏览器会自动监听图片是否进入视口,实现懒加载。与react-lazy-load结合使用,形成双重保障,即使react-lazy-load出现异常,原生属性也能实现基本的懒加载功能。
- src={post.thumbnail}:图片的完整URL,由后端接口格式化后返回(如http://localhost:3000/uploads/resized/123-thumbnail.jpg),确保图片能通过静态服务器正常访问。
- alt属性:图片加载失败或无法显示时,会显示alt属性的文字,同时提升页面可访问性(屏幕阅读器会读取alt文字),建议必填,且描述与图片内容相关。
- className样式:object-cover确保图片填充容器且保持比例,避免图片拉伸变形;transition-opacity duration-300实现图片加载完成后的淡入效果,提升体验;w-full h-full与父容器保持一致。
- onError={handleImgError}:图片加载失败的兜底逻辑,当图片URL错误、图片不存在或加载超时,会触发该事件,将图片替换为默认兜底图(如/public/default-thumbnail.jpg),避免显示破碎图片图标,提升体验。
3. 前置判断(post.thumbnail && ...)
必须先判断post.thumbnail是否存在,再渲染图片和LazyLoad组件。因为后端格式化数据时,若文章未上传缩略图,post.thumbnail可能为undefined,直接渲染img标签会导致src为undefined,出现报错或破碎图片图标,因此需通过逻辑与(&&)做前置判断,仅当thumbnail存在时才渲染。
3.2.6 组件引入与使用(关联文章列表)
完成PostItem组件的懒加载配置后,需要在文章列表组件(PostList.tsx)中引入PostItem组件,渲染所有文章列表项,懒加载功能才能生效。代码示例(PostList.tsx核心代码):
import * as React from 'react';
import { useEffect, useState } from 'react';
import type { Post } from '@/types/index';
import PostItem from './PostItem'; // 引入配置好懒加载的PostItem组件
import axios from 'axios'; // 用于请求后端文章列表接口
const PostList: React.FC = () => {
const [posts, setPosts] = useState<Post[]>([]); // 存储文章列表数据
const [loading, setLoading] = useState<boolean>(true); // 加载状态
// 请求后端文章列表接口(后端接口前缀为/api,已在NestJS中配置)
useEffect(() => {
const getPosts = async () => {
try {
setLoading(true);
const res = await axios.get('http://localhost:3000/api/posts', {
params: { page: 1, limit: 10 } // 分页参数
});
setPosts(res.data.items); // 存储格式化后的文章列表数据
} catch (err) {
console.error('文章列表请求失败:', err);
} finally {
setLoading(false);
}
};
getPosts();
}, []);
if (loading) {
return <div className='text-center py-10'>加载中...</div>;
}
return (
<div className='container mx-auto px-4 py-8'>
<h2 className='text-2xl font-bold mb-6'>文章列表</h2>
{/* 渲染文章列表,每篇文章对应一个PostItem组件,传入post数据 */}
<div className='space-y-2'>
{posts.map(post => (
<PostItem key={post.id} post={post} /> // 传入文章数据,懒加载生效
))}
</div>
</div>
);
}
export default PostList;
关键说明:PostList组件通过axios请求后端接口(http://localhost:3000/api/posts),获取格式化后的文章列表数据,然后通过map方法遍历posts数组,给每篇文章渲染一个PostItem组件,并传入post props;此时,每篇文章的缩略图都会被LazyLoad组件监听,实现懒加载功能。
3.3 图片懒加载效果测试与验证
完成上述配置后,启动前端React项目和后端NestJS项目,测试懒加载效果,确保功能正常。测试步骤与验证方法如下:
3.3.1 启动项目
# 启动后端NestJS项目(确保静态服务器和接口正常)
cd nest-backend(后端项目目录)
npm run start:dev
# 启动前端React项目
cd react-frontend(前端项目目录)
npm run dev
启动成功后,前端访问http://localhost:5173(React默认端口),进入文章列表页面。
3.3.2 效果验证方法
- 打开浏览器开发者工具(F12),切换到Network面板,筛选“Img”类型(只显示图片请求)。
- 页面初始加载时,只会加载当前视口内的文章缩略图,Network面板中只会显示这些图片的请求;视口外的图片,不会出现请求记录(说明未加载)。
- 缓慢滚动页面,当视口外的文章缩略图进入视口(或距离视口50px,对应offset=50配置)时,Network面板中会立即出现该图片的请求记录(说明触发了懒加载)。
- 验证加载中占位图:若图片加载较慢(可通过开发者工具Network面板设置“节流”,模拟慢网络),会显示“加载中...”的占位图,加载完成后淡入显示图片。
- 验证加载失败兜底:故意修改后端返回的post.thumbnail URL(如修改文件名),刷新页面,图片会显示默认兜底图(default-thumbnail.jpg),不会出现破碎图片图标。
3.3.3 常见测试问题排查
- 问题:页面初始加载时,所有图片都被加载,懒加载失效。 排查:检查LazyLoad组件是否正确包裹img标签;检查offset和height配置是否正确;检查是否误删了loading="lazy"属性;检查PostItem组件是否被正确引入,post.thumbnail是否为完整URL。
- 问题:滚动页面,图片未加载,Network面板无请求。 排查:检查post.thumbnail是否存在(后端是否正确返回格式化后的URL);检查LazyLoad组件的height配置是否与父容器一致;检查浏览器是否支持IntersectionObserver API(主流浏览器均支持,若测试用IE,需引入polyfill)。
- 问题:图片加载失败,未显示兜底图。 排查:检查handleImgError函数是否正确定义和绑定;检查默认兜底图(default-thumbnail.jpg)是否放在public文件夹下,路径是否正确。
3.4 图片懒加载常见问题与解决方案
在实操过程中,可能会遇到懒加载失效、类型报错、体验不佳等问题,以下是常见问题及对应的解决方案,结合本次学习场景展开,确保能快速排查解决:
3.4.1 问题1:懒加载失效,所有图片一次性加载
表现:页面初始加载时,Network面板中显示所有文章缩略图的请求,滚动页面无新的图片请求,懒加载未生效。
常见原因及解决方案:
- 原因1:LazyLoad组件未正确包裹img标签,或包裹层级错误。 解决方案:确保img标签直接作为LazyLoad组件的子元素,不要嵌套过多无关元素;检查代码中是否有语法错误(如标签未闭合)。
- 原因2:LazyLoad组件未配置height,或height与父容器不一致,导致无法监听视口交集。 解决方案:明确设置height属性,与父容器的高度一致(如本次的96px);确保父容器有明确的高度(如w-24 h-24),不要使用自适应高度(如height: auto)。
- 原因3:图片URL错误,导致图片加载失败,浏览器会自动重试,误判为懒加载失效。 解决方案:检查post.thumbnail是否为完整的静态资源URL(如http://localhost:3000/uploads/xxx);通过浏览器直接访问该URL,确认图片能正常显示。
- 原因4:react-lazy-load依赖未正确安装,或版本不兼容。 解决方案:卸载现有依赖,重新安装(npm uninstall react-lazy-load @types/react-lazy-load,再重新安装);安装与项目React版本兼容的版本(react-lazy-load@3.2.0版本适配React 18+,推荐)。
3.4.2 问题2:TypeScript类型报错,提示“找不到模块react-lazy-load”
表现:前端项目启动时,TypeScript报“Could not find a declaration file for module 'react-lazy-load'”错误,无法正常编译。
原因:未安装@types/react-lazy-load类型定义包,或类型包版本与react-lazy-load版本不兼容。
解决方案:
- 安装类型定义包:npm install @types/react-lazy-load --save-dev。
- 若安装后仍报错,卸载现有版本,安装兼容版本:npm uninstall react-lazy-load @types/react-lazy-load,然后执行npm install react-lazy-load@3.2.0 @types/react-lazy-load@3.1.3 --save-dev(经测试,该版本组合适配React 18+和TypeScript 5+)。
3.4.3 问题3:加载图片时,页面布局抖动
表现:图片加载完成后,页面布局突然抖动,影响用户体验。
原因:图片容器未设置固定尺寸,图片加载完成前,容器高度为0或自适应,加载完成后,图片撑开容器,导致布局抖动。
解决方案:
- 给图片容器(LazyLoad组件的父元素)设置固定的宽高(如本次的w-24 h-24),确保容器尺寸固定。
- 给img标签添加object-cover样式,确保图片填充容器,避免图片拉伸导致布局异常。
- 保留占位图,加载完成前显示占位图,占据容器空间,避免空白区域导致布局抖动。
3.4.4 问题4:低版本浏览器(如IE)懒加载失效
表现:在IE浏览器中,所有图片一次性加载,懒加载未生效,且可能出现组件报错。
原因:IE浏览器不支持IntersectionObserver API(react-lazy-load底层依赖),且不支持原生loading="lazy"属性。
解决方案(可选,本次学习暂不要求):
- 引入IntersectionObserver polyfill,兼容低版本浏览器:npm install intersection-observer --save,然后在src/index.tsx中引入:import 'intersection-observer'。
- 替换懒加载库:若polyfill仍无法兼容,可替换为react-lazyload(注意与react-lazy-load区分,名称多一个l),该库兼容性更好,支持IE浏览器。
3.4.5 问题5:图片加载完成后,出现淡入效果异常
表现:图片加载完成后,没有淡入效果,或淡入效果不流畅。
原因:未给img标签添加transition-opacity样式,或样式配置错误。
解决方案:确保img标签添加了transition-opacity duration-300(或其他时长)样式,示例:className='w-full h-full object-cover transition-opacity duration-300'。
3.5 图片懒加载拓展知识与优化建议
除了本次学习的基础懒加载实现,还可以通过以下拓展优化,进一步提升懒加载效果和用户体验,适配更复杂的全栈项目场景:
3.5.1 拓展1:懒加载组件封装(复用性优化)
若项目中有多个地方需要使用图片懒加载(如用户头像、文章封面、商品图片等),可以将LazyLoad组件和兜底逻辑封装成一个通用的LazyImage组件,实现复用,减少代码冗余。示例代码:
// src/components/common/LazyImage.tsx(通用懒加载图片组件)
import * as React from 'react';
import LazyLoad from 'react-lazy-load';
// 定义Props类型
interface LazyImageProps {
src: string; // 图片URL
alt: string; // alt属性
className?: string; // 自定义样式
placeholder?: React.ReactNode; // 自定义占位图
fallbackSrc?: string; // 自定义兜底图
offset?: number; // 提前加载距离
height?: number; // 容器高度
}
const LazyImage: React.FC<LazyImageProps> = ({
src,
alt,
className = '',
placeholder = <div className='w-full h-full bg-gray-100 flex items-center justify-center'>
<span className='text-xs text-gray-400'>加载中...</span>
</div>,
fallbackSrc = '/default-thumbnail.jpg',
offset = 50,
height = 96
}) => {
// 加载失败兜底逻辑
const handleImgError = (e: React.SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.src = fallbackSrc;
};
return (
<LazyLoad
className={`w-full h-full ${className}`}
offset={offset}
height={height}
placeholder={placeholder}
>
<img
loading='lazy'
src={src}
alt={alt}
className={`w-full h-full object-cover transition-opacity duration-300 ${className}`}
onError={handleImgError}
/>
</LazyLoad>
);
};
export default LazyImage;
使用方法(在PostItem组件中替换原有代码):
// 引入通用LazyImage组件
import LazyImage from '@/components/common/LazyImage';
// 替换原有LazyLoad包裹逻辑
{
post.thumbnail && (
<div className='w-24 h-24 flex-shrink-0 relative overflow-hidden rounded-md border border-gray-200'>
<LazyImage
src={post.thumbnail}
alt={`${post.title}的缩略图`}
height={96}
offset={50}
fallbackSrc='/default-thumbnail.jpg'
/>
</div>
)
}
优势:代码复用性高,后续其他地方使用懒加载图片时,直接引入LazyImage组件即可,无需重复编写占位图、兜底逻辑和配置。
3.5.2 拓展2:结合图片压缩与格式优化
懒加载主要优化“加载时机”,而图片压缩与格式优化主要优化“加载速度”,两者结合,能最大化提升图片加载体验。建议:
- 后端处理图片上传时,对图片进行压缩(如使用sharp库),生成不同尺寸的缩略图(如thumbnail、small、large),后端格式化时,根据前端需求返回对应尺寸的图片URL(如列表页返回thumbnail尺寸,详情页返回large尺寸)。
- 使用WebP、AVIF等高效图片格式,这些格式的图片体积比JPG、PNG小30%-50%,加载速度更快;后端可根据浏览器支持情况,返回对应格式的图片。
3.5.3 拓展3:懒加载触发时机优化
除了通过offset提前加载,还可以根据用户滚动速度,动态调整提前加载距离,提升体验:
- 用户滚动速度快时,增大offset(如100px),提前加载更多图片,避免用户滚动过快时出现空白;
- 用户滚动速度慢时,减小offset(如30px),减少不必要的提前加载,节省流量。
实现方式:通过监听window的scroll事件,计算滚动速度,动态调整LazyLoad组件的offset属性。
3.5.4 拓展4:骨架屏替代占位图
本次使用的“加载中...”文字占位图,可替换为更美观的骨架屏(Skeleton),提升页面质感。可使用react-loading-skeleton库,实现简单美观的图片骨架屏:
# 安装react-loading-skeleton
npm install react-loading-skeleton --save
npm install @types/react-loading-skeleton --save-dev
使用示例(在LazyImage组件中替换占位图):
import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';
// 替换placeholder
placeholder = <Skeleton className='w-full h-full rounded-md' />
3.6 本章核心总结
本章重点掌握React列表图片懒加载的实现,结合react-lazy-load库和原生loading="lazy"属性,实现双重懒加载优化,核心要点总结如下:
- 核心作用:减少页面初始加载资源体积,提升加载速度,优化用户体验,避免资源浪费和页面卡顿。
- 核心实现:使用react-lazy-load组件包裹img标签,配置offset(提前加载)、height(容器高度)、placeholder(占位图),结合img标签的loading="lazy"属性和onError兜底逻辑。
- 关键联动:懒加载的图片URL依赖后端接口格式化拼接(结合NestJS静态服务器配置),确保图片能通过静态资源URL正常访问。
- 常见问题:懒加载失效(检查组件包裹、配置和URL)、类型报错(安装类型定义包)、布局抖动(固定容器尺寸)、加载失败(配置兜底图)。
- 拓展优化:封装通用懒加载组件、结合图片压缩与格式优化、动态调整加载时机、使用骨架屏提升体验。
通过本章学习,需能独立完成React列表图片懒加载的实操,理解懒加载的底层原理(IntersectionObserver API),并能结合全栈项目中的后端静态服务和接口数据,实现端到端的图片加载优化,为后续更复杂的全栈性能优化打下基础。