🚀 AI 全栈项目第六天:Notes 实战项目 —— 幻灯片与 Mock 数据的“欺骗”艺术

149 阅读12分钟

哈喽,掘金的各位全栈练习生们!👋 欢迎回到 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 返回了一个新函数,这个新函数“记住”了 lastdeferTimer 变量。这就是闭包的魔力,它让状态得以保留。
  • 时间戳判断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 实现主体逻辑

接下来是组件的具体实现。我们要解决三个核心问题:

  1. 自动播放:怎么让它自己动?
  2. 状态同步:怎么知道当前滚到第几张了?
  3. 指示器:下面那几个小圆点怎么做?
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! 我们成功实现了“无后端开发”。


📝 总结与预告

今天我们的含金量是不是超高?

  1. 性能优化:用节流 (Throttle) 搞定了高频滚动的性能隐患。
  2. UI 实战:手写了一个功能完备、带自动播放和指示器的幻灯片组件
  3. Mock 实战:学会了用 Mock.js 伪造数据,拦截请求,实现了分页逻辑
  4. 架构思维:API 模块化封装,Store 数据分发,让代码结构清晰可维护。

现在的 Notes 应用已经有了漂亮的首页和真实的数据流。 明天,我们将把这些数据渲染到页面上,实现真正的文章列表,并且添加无限滚动加载(Infinite Scroll)功能。还要处理登录状态的真实校验。

全栈之路,道阻且长,但风景独好!我们明天见!👋


如果你觉得这篇文章对你有帮助,记得点赞、收藏、关注三连哦!你的支持是我更新的最大动力! 💖*