哈喽,掘金的各位全栈练习生们!👋 欢迎回到 AI 全栈项目实战 的第六天。
昨天,我们像装修新房一样,引入了 shadcn/ui 这位“顶级设计师”,搭建了 Notes 应用的骨架,写了路由,还搞定了一个带动画的“回到顶部”按钮。感觉如何?是不是觉得自己离全栈大佬又近了一步?
今天,我们要继续给这个“毛坯房”添砖加瓦,把它变成精装房!🏠 今天的任务量有点“硬核”,但放心,我会像剥小龙虾一样,把知识点一个个剥好喂到你嘴里。我们不仅要优化性能,还要实现 App 首页最核心的**幻灯片(轮播图)**功能,甚至在没有后端的情况下,用 Mock.js “伪造”出真实的数据流。
准备好了吗?系好安全带,我们要发车了!🏎️
⚡ 一、 性能优化的“魔法棒”:防抖与节流
还记得昨天那个可爱的 BackToTop 组件吗?它会在我们滚动页面时通过监听 scroll 事件来决定是否显示。
但是!有一个严重的问题。🙅♂️
scroll 事件触发得太频繁了!你鼠标滚轮轻轻一滑,浏览器可能就触发了几十次事件。如果在事件处理函数里做一些复杂的计算(比如 DOM 操作),页面就会变得像 PPT 一样卡顿。
这时候,我们需要请出前端性能优化的两大护法:防抖 (Debounce) 和 节流 (Throttle)。 (关于这两者的详细原理,可以去翻翻我之前的文章 前端性能优化魔法:防抖与节流,这里咱们直接实战!)
1.1 编写节流工具函数
对于“滚动”这种高频触发且需要持续响应的场景,节流 (Throttle) 是最佳选择。它的作用就像水龙头每隔一段时间滴一滴水,而不是一直哗啦啦地流。
打开 src/utils/index.ts,让我们来看看这个神奇的函数:
// 定义一个通用的函数类型,接收任意参数,没有返回值
type ThrottleFunction = (...args: any[]) => void;
// 节流函数:接收一个要执行的函数 fun,和一个延迟时间 delay
export function throttle(fun: ThrottleFunction, delay: number): ThrottleFunction {
let last: number | undefined; // 上次执行的时间戳
let deferTimer: NodeJS.Timeout | undefined; // 定时器引用
// 返回一个新的函数(闭包),这个函数就是实际绑定到事件上的函数
return function (...args: any[]) {
const now = +new Date(); // 获取当前时间戳
// 如果上次执行过,且当前时间距离上次执行还不到 delay 毫秒
if (last && now < last + delay) {
// 清除之前的定时器(如果有的话)
clearTimeout(deferTimer);
// 设置一个新的定时器,保证最后一次操作也能被执行
deferTimer = setTimeout(function () {
last = now;
fun(args);
}, delay);
} else {
// 如果是第一次执行,或者已经超过了 delay 时间
last = now;
fun(args); // 立即执行
}
};
}
🧐 代码深度解析:
- 闭包 (Closure):
throttle返回了一个新函数,这个新函数“记住”了last和deferTimer变量。这就是闭包的魔力,它让状态得以保留。 - 时间戳判断:
now < last + delay是核心逻辑。如果不满足这个条件(说明时间间隔够了),就立即执行函数。 - 兜底定时器:为什么要加
setTimeout?是为了防止用户最后一次操作(比如停止滚动)刚好卡在delay之间,导致最后一次状态没更新。
1.2 在组件中使用节流
回到 src/components/BackToTop.tsx,我们来改造它。
import React, { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button';
import { ArrowUp } from 'lucide-react';
// 1️⃣ 引入刚才写的节流函数
import { throttle } from '@/utils';
interface BackToTopProps {
threshould?: number;
}
const BackToTop:React.FC<BackToTopProps> = ({ threshould = 400 }) =>{
const [isVisible, setIsVisible] = useState<boolean>(false);
// ... scrollTop 函数省略 ...
useEffect (() => {
// 原始的判断逻辑
const toggleVisibility = () => {
setIsVisible(window.scrollY > threshould);
}
// 2️⃣ 给它穿上“节流铠甲”
// 每 200ms 最多执行一次 toggleVisibility
const thtottled_func = throttle(toggleVisibility, 200);
// 3️⃣ 监听在这个节流后的函数上
window.addEventListener('scroll', thtottled_func);
// 4️⃣ ⚠️ 非常重要:组件卸载时移除监听!
// 如果不移除,当组件消失了,监听还在,就会导致内存泄漏(Memory Leak)。
return () => {
window.removeEventListener('scroll', thtottled_func);
}
}, [threshould]) // 依赖项
if(!isVisible) return null;
return (
// ... 按钮 JSX ...
<Button
variant="outline"
size="icon"
onClick={scrollTop}
className="fixed bottom-6 right-6 rounded-full shadow-lg hover:shadow-xl z-50"
>
<ArrowUp className="w-4 h-4"/>
</Button>
)
}
export default BackToTop;
现在,你的 BackToTop 组件不仅功能完善,而且性能极佳,哪怕用户疯狂滚动页面,它也稳如泰山。🏔️
🎠 二、 首页的门面担当:幻灯片 (SlideShow)
打开任何一个主流 App(淘宝、B站、京东),首页最显眼的位置永远是——幻灯片(轮播图)。它是展示核心内容、吸引用户点击的绝佳位置。
今天,我们就用 shadcn/ui 提供的 Carousel 组件,来实现一个功能完备的幻灯片。
2.1 组件结构与基础配置
首先,我们需要创建一个新组件 src/components/SlideShow.tsx。
shadcn 的 Carousel 其实是基于 embla-carousel-react 封装的,性能非常好,而且 API 设计得很优雅。
import {
useRef,
useEffect,
useState
} from 'react';
// 1️⃣ 引入自动播放插件 (npm install embla-carousel-autoplay)
import Autoplay from 'embla-carousel-autoplay';
import {
Carousel,
CarouselItem,
CarouselContent,
type CarouselApi, // 这是一个类型,用于 TS 提示
} from '@/components/ui/carousel';
// 2️⃣ 定义数据接口:TS 的好习惯
export interface SlideData {
id: number | string;
image: string;
title?: string;
}
interface SlideShowProps {
slides: SlideData[]; // 幻灯片数据数组
autoPlay?: boolean; // 开关:是否自动播放
autoPlayDelay?: number; // 延迟时间
}
2.2 实现主体逻辑
接下来是组件的具体实现。我们要解决三个核心问题:
- 自动播放:怎么让它自己动?
- 状态同步:怎么知道当前滚到第几张了?
- 指示器:下面那几个小圆点怎么做?
const SlideShow:React.FC<SlideShowProps> = ({
slides,
autoPlay = true,
autoPlayDelay = 3000
}) => {
// 记录当前显示的是第几张(用于渲染小圆点)
const [selectedIndex, setSelectedIndex] = useState<number>(0);
// 获取 Carousel 的 API 实例,通过它我们可以控制轮播图
const [api, setApi] = useState<CarouselApi | null>(null);
// 🌟 自动播放插件配置
// useRef 可以持久化存储对象,不会因为组件重渲染而重置
const plugin = useRef(
autoPlay ? Autoplay({
delay: autoPlayDelay,
stopOnInteraction: true // 用户触摸时停止自动播放,体验更好
}) : null
);
// 🌟 监听轮播图的变化
useEffect(() => {
if(!api) return; // 如果 API 还没准备好,直接返回
// 初始化时,获取当前索引
setSelectedIndex(api.selectedScrollSnap());
// 定义监听函数
const onSelect = () => {
// 当轮播图切换时,更新 selectedIndex
setSelectedIndex(api.selectedScrollSnap());
}
// 绑定事件监听:当 'select' 事件发生时,执行 onSelect
api.on("select", onSelect);
// ⚠️ 清理函数:组件卸载时取消监听
return () => {
api.off("select", onSelect);
}
}, [api]) // 依赖项是 api,只有 api 变化时才执行
return (
<div className="relative w-full">
<Carousel
className="w-full"
setApi={setApi} // 把 api 实例传出来
plugins={plugin.current ? [plugin.current] : []} // 挂载插件
opts={{loop: true}} // 开启无限循环模式
onMouseEnter={() => plugin.current?.stop()} // 鼠标移入暂停
onMouseLeave={() => plugin.current?.reset()} // 鼠标移出恢复
>
<CarouselContent>
{
slides.map(({id, image, title}, index) => (
<CarouselItem key={id}>
{/* aspect-[16/9] 是 Tailwind 的高宽比类,保持图片比例 */}
<div className="relative aspect-[16/9] w-full rounded-xl overflow-hidden">
<img
src={image}
alt={title || `slide${index+1}`}
className="w-full h-full object-cover" // object-cover 保证图片填满且不变形
/>
{/* 渐变遮罩层:让文字更清晰 */}
{
title && (
<div className="absolute bottom-0 left-0 right-0
bg-gradient-to-t from-black/60 to-transparent
p-4 text-white">
<h3 className="text-lg font-bold">{title}</h3>
</div>
)
}
</div>
</CarouselItem>
))
}
</CarouselContent>
</Carousel>
{/* 👇 指示器小圆点 */}
<div className="absolute bottom-3 left-0 right-0 flex justify-center gap-2">
{
slides.map((_, i) => (
<button
key={i}
// 动态类名:如果是当前页,变成宽一点的长条(w-6),否则是小圆点(w-2)
// transition-all 让变化过程有动画
className={`h-2 w-2 rounded-full transition-all
${selectedIndex === i ? "bg-white w-6" : "bg-white/50"}
`}
/>
))
}
</div>
</div>
)
}
export default SlideShow;
🎨 视觉魔法解析:
aspect-[16/9]: 这是一个非常现代的 CSS 属性。它锁定了盒子的长宽比。无论屏幕多宽,高度都会自动计算,保证图片不会被拉伸成“大长脸”。bg-gradient-to-t: 从下到上的渐变。from-black/60(60%透明度的黑色) 到to-transparent(透明)。这是 UI 设计中处理“图片上加文字”的经典手法,保证文字在任何背景图上都清晰可见。- 指示器逻辑:我们用
map遍历 slides 生成按钮,但按钮并不需要点击功能(虽然可以加),这里主要作为视觉反馈。通过selectedIndex === i来判断哪个点应该“亮”起来。
2.3 数据注入 (Zustand)
组件写好了,数据从哪来?
当然是我们的老朋友 Zustand。我们在 src/store/home.ts 里预置一些 Banner 数据。
import { create } from 'zustand';
// ...
interface HomeStore {
banners: SlideData[];
// ...
}
export const useHomeStore = create<HomeStore>((set) => ({
banners: [{
id: 1,
title: "React 生态系统",
image: "https://images.unsplash.com/photo-1633356122544-f134324a6cee?q=80&w=2070&auto=format&fit=crop",
},
// ... 更多图片
],
// ...
}));
现在,在 Home.tsx 里引入使用,你的首页瞬间就“动”起来了!💃
🎭 三、 后端还没好?Mock.js 来救场!
做前端开发最痛苦的是什么? “接口还没好。” 😭 后端兄弟还在埋头苦干,你的页面却因为没有数据一片空白。为了不让进度停滞,我们需要学会**“造假”**。
Mock.js 就是前端界的“伪钞印制机”(合法的哈)。它可以拦截 Ajax 请求,并返回你定义好的随机数据。
3.1 下载导入mock
pnpm i vite-plugin-mock -D
3.2 配置vite-config
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import {
viteMockServe
} from 'vite-plugin-mock'// 导入
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
viteMockServe({// 配置
mockPath: 'mock'// 路径
}),
]
})
3.3 编写 Mock 数据规则
我们在 mock/posts.js 里定义一个生成文章列表的规则。Mock.js 的语法非常有趣,像写正则表达式一样。
import Mock from 'mockjs'
const tags = ["前端", "后端", "职场", "AI", "副业", "面经", "算法"];
// Mock.mock 生成数据
const posts = Mock.mock({
"list|45": [ // 生成 45 条数据
{
// @ctitle 是 Mock 的占位符,生成中文标题,长度 8-20 字
title: '@ctitle(8,20)',
brief: '@ctitle(20,100)', // 简介
totalComments: '@integer(1,30)', // 随机整数
totalLikes: '@integer(0,500)',
publishedAt: '@datetime("yyyy-MM-dd HH:mm")', // 随机时间
user: {
id: '@integer(1,10)',
name: '@ctitle(2,4)',
avatar: '@image(300x200)' // 生成随机图片 URL
},
// 自定义函数:从 tags 数组里随机挑 2 个
tags: () => Mock.Random.pick(tags, 2),
thumbnail: '@image(300x200)',
id: '@increment(1)' // 自增 ID
}
]
}).list // 取出 list 属性
看,不到 20 行代码,我们就拥有了 45 条包含标题、作者、头像、点赞数的逼真数据!
3.2 模拟分页接口 (GET)
有了数据,还得模拟接口逻辑。真实的后端接口通常支持分页(Pagination)。我们也得像模像样地实现它。
export default [
{
url: '/api/posts', // 拦截这个 URL
method: 'get', // 拦截 GET 请求
response: ({ query }, res) => {
console.log('Mock 拦截到了请求:', query);
// 1️⃣ 解析查询参数 (Query Params)
// 默认第1页,每页10条
const { page = '1', limit = '10' } = query;
const currentPage = parseInt(page);
const size = parseInt(limit, 10);
// 2️⃣ 参数校验 (模拟后端的严谨)
if(isNaN(currentPage) || isNaN(size) || currentPage < 1 || size < 1){
return {
code: 400,
msg: 'Invalid page or pageSize',
data: null
}
}
// 3️⃣ 核心算法:分页切片
const total = posts.length;
const start = (currentPage - 1) * size; // 起始索引
const end = start + size; // 结束索引
// slice(start, end) 提取当前页的数据
// 注意:如果 end 超过数组长度,slice 会自动处理,不会报错
const paginatedData = posts.slice(start, end);
// 4️⃣ 返回标准格式的响应体
return {
code: 200,
msg: 'success',
items: paginatedData, // 当前页数据
pagination: { // 分页元数据
current: currentPage,
limit: size,
total,
totalpages: Math.ceil(total / size) // 总页数
}
}
}
}
]
💡 面试题预警:
面试官经常问:“前端怎么做分页?” 或者 “如果后端一次性给了 1000 条数据,前端怎么处理?”
这里的 slice 逻辑就是前端分页的核心。虽然在真实项目中我们推荐后端分页(数据库查询时 LIMIT),但在 Mock 阶段或者处理小规模数据时,前端分页非常实用。
🔗 四、 接口对接:Axios 封装与调用
现在假数据准备好了,我们要用 Axios 去请求它。 为了方便管理,我们将 API 请求模块化。
4.1 基础配置 (config.ts)
import axios from 'axios';
// 设置基准 URL
// 这里的技巧是:Mock 服务通常也会拦截 xhr 请求
// 等后端做好了,把这个地址改成真实服务器地址,其他代码一行都不用动!
axios.defaults.baseURL = 'http://localhost:5173/api';
export default axios;
4.2 业务 API (posts.ts)
import axios from './config';
// 引入类型定义,保持类型安全
import type { Post } from '@/types';
// 获取文章列表
export const fetchPosts = async (page: number = 1, limit: number = 10) => {
try {
const response = await axios.get('/posts', {
params: { // axios 会自动把 params 拼接到 URL 后面 ?page=1&limit=10
page,
limit
}
})
console.log('API 响应:', response);
return response.data; // 返回 response.data,因为 axios 包了一层 data
} catch(err) {
console.error("请求失败", err);
return { items: [], pagination: {} }; // 失败返回空数据,防止页面崩坏
}
}
4.3 在 Store 中调用
回到 src/store/home.ts,添加 loadMore 方法。
// ...
posts: [], // 初始为空
loadMore: async () => {
// 调用 API
const data = await fetchPosts(1, 10); // 暂时写死第一页
// 更新状态
if (data.code === 200) {
console.log("获取到的文章数据:", data.items);
// set({ posts: data.items }); // 后面会实现这个 set
}
}
// ...
4.4 在 Home 页面触发
最后,在 Home.tsx 中使用 useEffect 触发加载。
const {
banners,
loadMore
} = useHomeStore();
useEffect(() => {
// 组件挂载时,请求第一页数据
loadMore();
}, [])
打开浏览器控制台,刷新页面。你应该能看到一行绿色的 Mock 拦截到了请求,紧接着打印出了 10 条伪造得像模像样的文章数据。
🎉 Bingo! 我们成功实现了“无后端开发”。
📝 总结与预告
今天我们的含金量是不是超高?
- 性能优化:用节流 (Throttle) 搞定了高频滚动的性能隐患。
- UI 实战:手写了一个功能完备、带自动播放和指示器的幻灯片组件。
- Mock 实战:学会了用 Mock.js 伪造数据,拦截请求,实现了分页逻辑。
- 架构思维:API 模块化封装,Store 数据分发,让代码结构清晰可维护。
现在的 Notes 应用已经有了漂亮的首页和真实的数据流。 明天,我们将把这些数据渲染到页面上,实现真正的文章列表,并且添加无限滚动加载(Infinite Scroll)功能。还要处理登录状态的真实校验。
全栈之路,道阻且长,但风景独好!我们明天见!👋
如果你觉得这篇文章对你有帮助,记得点赞、收藏、关注三连哦!你的支持是我更新的最大动力! 💖*