🚀 AI 全栈实战 Day 6:从性能优化到 Mock 数据,打造丝滑首页
哈喽,掘金的各位全栈练习生们!👋 欢迎回到 AI 全栈项目实战 的第六天。
昨天,我们像装修新房一样,引入了 shadcn/ui 这位“顶级设计师”,搭建了 Notes 应用的骨架,写了路由,还搞定了一个带动画的“回到顶部”按钮。感觉如何?是不是觉得自己离全栈大佬又近了一步?
今天,我们要继续给这个“毛坯房”添砖加瓦,把它变成精装房!🏠 今天的任务量有点“硬核”,但放心,我会像剥小龙虾一样,把知识点一个个剥好喂到你嘴里。我们不仅要优化滚动性能,还要实现 App 首页最核心的**幻灯片(轮播图)**功能,甚至在没有后端的情况下,用 Mock.js “伪造”出真实的数据流,完成从前端到“假后端”的全链路闭环。
准备好了吗?系好安全带,我们要发车了!🏎️
⚡ 一、性能优化的“魔法棒”:防抖与节流
还记得昨天那个可爱的 BackToTop 组件吗?它会在我们滚动页面时通过监听 scroll 事件来决定是否显示。
但是!有一个严重的问题。🙅♂️ scroll 事件触发得太频繁了!你鼠标滚轮轻轻一滑,浏览器可能就触发了几十次事件。如果在事件处理函数里做一些复杂的计算(比如 DOM 操作或状态更新),页面就会变得像 PPT 一样卡顿,用户体验极差。
这时候,我们需要请出前端性能优化的两大护法:防抖 (Debounce) 和 节流 (Throttle) 。
- 防抖:适合搜索框输入,等用户停下来了再执行。
- 节流:适合滚动、窗口缩放,每隔固定时间执行一次。
对于“滚动”这种高频触发且需要持续响应的场景,节流 (Throttle) 是最佳选择。它的作用就像水龙头每隔一段时间滴一滴水,而不是一直哗啦啦地流。
1.1 编写通用节流工具函数
打开 src/utils/index.ts,让我们来实现这个神奇的函数:
// 定义一个通用的函数类型,接收任意参数,没有返回值
type ThrottleFunction = (...args: any[]) => void;
/**
* 节流函数
* @param fun 要执行的函数
* @param delay 延迟时间 (ms)
*/
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 在 BackToTop 组件中实战
回到 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 {
threshold?: number;
}
const BackToTop: React.FC<BackToTopProps> = ({ threshold = 400 }) => {
const [isVisible, setIsVisible] = useState<boolean>(false);
const scrollTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
useEffect(() => {
// 原始的判断逻辑
const toggleVisibility = () => {
setIsVisible(window.scrollY > threshold);
};
// 2️⃣ 给它穿上“节流铠甲”:每 200ms 最多执行一次
const throttledFunc = throttle(toggleVisibility, 200);
// 3️⃣ 监听在这个节流后的函数上
window.addEventListener('scroll', throttledFunc);
// 4️⃣ ⚠️ 非常重要:组件卸载时移除监听!
// 如果不移除,当组件消失了,监听还在,就会导致内存泄漏(Memory Leak)。
return () => {
window.removeEventListener('scroll', throttledFunc);
};
}, [threshold]);
if (!isVisible) return null;
return (
<Button
variant="outline"
size="icon"
onClick={scrollTop}
className="fixed bottom-6 right-6 rounded-full shadow-lg hover:shadow-xl z-50 transition-all duration-300"
>
<ArrowUp className="w-4 h-4"/>
</Button>
);
};
export default BackToTop;
现在,你的 BackToTop 组件不仅功能完善,而且性能极佳,哪怕用户疯狂滚动页面,它也稳如泰山。🏔️
🎠 二、首页的门面担当:幻灯片 (SlideShow)
打开任何一个主流 App(淘宝、B 站、京东),首页最显眼的位置永远是——幻灯片(轮播图) 。它是展示核心内容、吸引用户点击的绝佳位置。
今天,我们就用 shadcn/ui 提供的 Carousel 组件(底层基于 embla-carousel-react),来实现一个功能完备、交互细腻的幻灯片。
2.1 组件结构与基础配置
首先,创建 src/components/SlideShow.tsx。我们需要安装自动播放插件:
pnpm install embla-carousel-autoplay
import { useRef, useEffect, useState } from 'react';
import Autoplay from 'embla-carousel-autoplay';
import {
Carousel,
CarouselItem,
CarouselContent,
type CarouselApi,
} from '@/components/ui/carousel';
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);
const [api, setApi] = useState<CarouselApi | null>(null);
// 🌟 自动播放插件配置
// useRef 持久化存储插件实例,避免重渲染重置
const plugin = useRef(
autoPlay ? Autoplay({
delay: autoPlayDelay,
stopOnInteraction: true // 用户触摸时停止,体验更好
}) : null
);
// 🌟 监听轮播图的变化,同步索引以更新指示器
useEffect(() => {
if (!api) return;
// 初始化获取当前索引
setSelectedIndex(api.selectedScrollSnap());
const onSelect = () => {
setSelectedIndex(api.selectedScrollSnap());
};
api.on("select", onSelect);
// ⚠️ 清理函数:取消监听
return () => {
api.off("select", onSelect);
};
}, [api]);
return (
<div className="relative w-full group">
<Carousel
className="w-full"
setApi={setApi}
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] 锁定宽高比,object-cover 保证图片填满 */}
<div className="relative aspect-[16/9] w-full rounded-xl overflow-hidden shadow-md">
<img
src={image}
alt={title || `slide-${index}`}
className="w-full h-full object-cover transition-transform duration-500 hover:scale-105"
/>
{/* 渐变遮罩层:经典 UI 手法,保证文字清晰 */}
{title && (
<div className="absolute bottom-0 left-0 right-0
bg-gradient-to-t from-black/70 via-black/30 to-transparent
p-6 text-white">
<h3 className="text-xl font-bold drop-shadow-md">{title}</h3>
</div>
)}
</div>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
{/* 👇 自定义指示器:动态变长条 */}
<div className="absolute bottom-4 left-0 right-0 flex justify-center gap-2 z-10">
{slides.map((_, i) => (
<button
key={i}
aria-label={`Go to slide ${i + 1}`}
// 动态类名:激活态变宽 (w-6),非激活态为圆点 (w-2)
className={`h-2 rounded-full transition-all duration-300
${selectedIndex === i ? "bg-white w-6" : "bg-white/50 w-2 hover:bg-white/80"}
`}
onClick={() => api?.scrollTo(i)} // 点击切换
/>
))}
</div>
</div>
);
};
export default SlideShow;
🎨 视觉魔法解析:
aspect-[16/9]:Tailwind 的神器,无论屏幕多宽,高度自动计算,杜绝图片拉伸变形。bg-gradient-to-t:从底部黑色半透明渐变到顶部透明。这是处理“图片上加白字”的标准答案,无需担心背景图太亮导致文字看不清。- 指示器动画:利用
transition-all,让圆点变长条的过程丝滑流畅,提升精致感。
2.3 数据注入 (Zustand)
组件写好了,数据从哪来?当然是我们的老朋友 Zustand。在 src/store/home.ts 中预置 Banner 数据:
// src/store/home.ts
import { create } from 'zustand';
import { SlideData } from '@/components/SlideShow';
interface HomeStore {
banners: SlideData[];
// ... 其他状态
}
export const useHomeStore = create<HomeStore>((set) => ({
banners: [
{
id: 1,
title: "探索 React 19 新特性",
image: "https://images.unsplash.com/photo-1633356122544-f134324a6cee?q=80&w=2070&auto=format&fit=crop",
},
{
id: 2,
title: "TypeScript 进阶指南",
image: "https://images.unsplash.com/photo-1516116216624-53e697fedbea?q=80&w=2000&auto=format&fit=crop",
},
// ... 更多数据
],
// ...
}));
在 Home.tsx 中直接调用,首页瞬间“动”起来了!💃
🎭 三、后端还没好?Mock.js 来救场!
做前端开发最痛苦的是什么? “接口还没好。” 😭 后端兄弟还在埋头苦干,你的页面却因为没有数据一片空白。为了不让进度停滞,我们需要学会**“造假”**。
Mock.js 就是前端界的“合法伪钞印制机”。它可以拦截 Ajax 请求,并返回你定义好的随机数据。
3.1 环境搭建
安装依赖:
pnpm add -D vite-plugin-mock mockjs
配置 vite.config.ts:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteMockServe } from 'vite-plugin-mock'
export default defineConfig({
plugins: [
react(),
viteMockServe({
mockPath: 'mock', // 指定 mock 文件目录
localEnabled: true, // 开发环境启用
}),
]
})
3.2 编写 Mock 数据规则
在 mock/posts.ts 中,利用 Mock.js 语法快速生成逼真的文章数据:
import Mock from 'mockjs';
const tags = ["前端", "后端", "职场", "AI", "副业", "面经", "算法"];
// 生成 45 条模拟数据
const postsList = Mock.mock({
"list|45": [
{
"id|+1": 1, // 自增 ID
"title": "@ctitle(10, 20)", // 10-20 字中文标题
"brief": "@ctitle(30, 60)", // 简介
"totalComments": "@integer(1, 50)",
"totalLikes": "@integer(10, 1000)",
"publishedAt": "@datetime('yyyy-MM-dd HH:mm')",
"user": {
"name": "@cname",
"avatar": "@image('100x100', '#e0e0e0', '@cname')"
},
"tags": () => Mock.Random.pick(tags, 2), // 随机选 2 个标签
"thumbnail": "@image('400x250', '#f0f0f0', 'Article Cover')"
}
]
}).list;
export default [
{
url: '/api/posts', // 拦截 /api/posts 请求
method: 'get',
response: ({ query }) => {
console.log('🕵️♂️ Mock 拦截请求:', query);
// 1️⃣ 解析分页参数
const { page = '1', limit = '10' } = query;
const currentPage = parseInt(page);
const pageSize = parseInt(limit);
// 2️⃣ 简单校验
if (isNaN(currentPage) || currentPage < 1) {
return { code: 400, msg: 'Invalid page', data: null };
}
// 3️⃣ 核心算法:前端模拟分页切片
const total = postsList.length;
const start = (currentPage - 1) * pageSize;
const end = start + pageSize;
// slice 不会修改原数组,返回新数组
const paginatedData = postsList.slice(start, end);
// 4️⃣ 返回标准后端格式
return {
code: 200,
msg: 'success',
data: {
items: paginatedData,
pagination: {
current: currentPage,
limit: pageSize,
total,
totalPages: Math.ceil(total / pageSize)
}
}
};
}
}
];
💡 亮点: 这段代码完美模拟了后端分页逻辑。即使没有数据库,前端也能测试分页组件、空状态加载等边界情况。
🔗 四、接口对接:Axios 封装与全链路闭环
现在“假后端”准备好了,我们需要用 Axios 去请求它。良好的架构设计能让未来切换真实后端时,业务代码一行都不用改。
4.1 Axios 统一封装
创建 src/lib/axios.ts:
import axios from 'axios';
const instance = axios.create({
baseURL: '/api', // 相对路径,Vite 会代理或 Mock 插件会拦截
timeout: 5000,
headers: { 'Content-Type': 'application/json' },
});
// 可选:添加响应拦截器统一处理错误
instance.interceptors.response.use(
(response) => response.data, // 直接返回 data 部分
(error) => {
console.error('API Error:', error);
return Promise.reject(error);
}
);
export default instance;
4.2 模块化 API 调用
创建 src/api/posts.ts:
import axios from '@/lib/axios';
export interface PostItem {
id: number;
title: string;
// ... 其他字段
}
export interface PostsResponse {
items: PostItem[];
pagination: {
total: number;
current: number;
totalPages: number;
};
}
export const fetchPosts = async (page: number = 1, limit: number = 10): Promise<PostsResponse> => {
// axios 会自动将 params 对象序列化为 ?page=1&limit=10
return axios.get('/posts', { params: { page, limit } });
};
4.3 状态联动 (Store -> Component)
最后,在 src/store/home.ts 中调用 API,并在 Home.tsx 中触发。
Store 更新:
// src/store/home.ts
import { fetchPosts } from '@/api/posts';
// ... inside create
set: {
// ...
loadPosts: async (page: number) => {
try {
const res = await fetchPosts(page, 10);
// 这里可以合并数据实现无限滚动,或者替换数据
set({ posts: res.items, pagination: res.pagination });
} catch (e) {
console.error("Load posts failed", e);
}
}
}
组件触发:
// src/pages/Home.tsx
useEffect(() => {
loadPosts(1); // 挂载时加载第一页
}, []);
🎉 闭环完成!
打开浏览器控制台,刷新页面:
- 你会看到
🕵️♂️ Mock 拦截请求的日志。 - 网络面板中显示
/api/posts请求成功。 - 页面上渲染出了带有随机头像、标题、点赞数的文章列表。
- 顶部的轮播图自动播放,底部的指示器随动。
- 滚动页面,回到顶部按钮在节流保护下流畅显示。
从性能优化到UI 交互,再到数据模拟与架构设计,我们今天完成了一个高质量前端页面的核心闭环。即使后端缺席,我们依然能全速前进!
明天,我们将挑战更复杂的文章详情页与Markdown 渲染。敬请期待!👋