AI 全栈实战第六天:从性能优化到 Mock 数据,打造丝滑首页

0 阅读9分钟

🚀 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); // 立即执行
    }
  };
}

🧐 代码深度解析:

  1. 闭包 (Closure)throttle 返回的新函数“记住”了 lastdeferTimer 变量。这就是闭包的魔力,让状态在多次调用间得以保留。
  2. 时间戳判断now < last + delay 是核心逻辑。只有时间间隔够了,才允许立即执行。
  3. 兜底定时器:为什么要加 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); // 挂载时加载第一页
}, []);

🎉 闭环完成!

打开浏览器控制台,刷新页面:

  1. 你会看到 🕵️‍♂️ Mock 拦截请求 的日志。
  2. 网络面板中显示 /api/posts 请求成功。
  3. 页面上渲染出了带有随机头像、标题、点赞数的文章列表。
  4. 顶部的轮播图自动播放,底部的指示器随动。
  5. 滚动页面,回到顶部按钮在节流保护下流畅显示。

性能优化UI 交互,再到数据模拟架构设计,我们今天完成了一个高质量前端页面的核心闭环。即使后端缺席,我们依然能全速前进!

明天,我们将挑战更复杂的文章详情页Markdown 渲染。敬请期待!👋