阅读视图

发现新文章,点击刷新页面。

从零打造 AI 全栈应用(五):Post List 背后的前后端解耦与 Mock 工程化实践

在上一篇《从零打造 AI 全栈应用(四):别小看幻灯片,一个 Carousel 组件背后的工程化与性能思维一、为什么一个幻灯片组件》 中,我们通过一个轮播组件,拆解了组件状态、插件模式与性能取舍

这一篇,我们把视角从「组件内部」拉回到「页面级数据流」,聊一个在真实项目中几乎绕不开的问题

前端页面的数据,到底该怎么来?


一、Post List 是 UI 问题,还是数据问题?

在很多博客 / 社区 / AI 内容产品中,都会有一个核心页面:

文章列表(Post List)

表面上它是一个 UI:

  • 卡片列表
  • 分页
  • 点赞数 / 评论数 / 标签

但工程上,它首先是一个数据协作问题

  • 数据来自哪里?
  • 前端要不要等后端?
  • 后端接口没好,页面怎么办?

如果这几个问题想不清楚,项目一定会出现:

  • 前端被后端接口卡进度
  • 大量 if (data) 防御式代码
  • 开发阶段和线上环境差异巨大

二、前后端分离的核心前提:前端不能等后端

很多同学嘴上说前后端分离,但心智模型还是:

「等后端接口写好,我再写页面」

这是不成立的

真正的前后端分离,意味着:

  • 前端可以独立完成页面与交互
  • 后端只要遵守接口契约即可无缝接入

而这个“契约”,就是 —— API 接口文档


三、先定义接口,而不是先写代码

以 Post List 为例,我们先约定接口形态:

GET /api/posts?page=1&limit=10

返回结构:

{
  "code": 200,
  "items": Post[],
  "pagination": {
    "current": 1,
    "limit": 10,
    "total": 45,
    "totalPage": 5
  }
}

注意这里的几个工程化点:

  • 分页信息由后端返回,不是前端猜
  • 字段命名稳定,不夹杂 UI 语义
  • 返回结构对 mock / 真接口完全一致

只要这个结构不变,
前端切换真实后端地址是无感的


四、Mock 的工程意义,而不是“假数据”

很多人一听 mock,就觉得是:

随便造点数据糊页面

这是对 mock 的严重低估。

在工程中,mock 的作用是:

  • 解耦前后端开发节奏
  • 固化接口结构
  • 提前暴露分页 / 边界问题
  • 让前端在真实请求语义下开发

五、Vite + vite-plugin-mock:最小成本接入

pnpm i vite-plugin-mock -D

它的优势在于:

  • 与 Vite 深度集成
  • 开发环境自动启用
  • 不侵入业务代码

上线前只需要切换 baseURL,
mock 即可完全下线。


六、Post Mock 的核心不是数据,而是「逻辑完整度」

1️⃣ 使用 Mock.js 构造真实数据结构

const posts = Mock.mock({
  'list|45': [{
    title: '@ctitle(8, 20)',
    brief: '@ctitle(20,100)',
    totalComment: '@integer(0, 30)',
    totalLikes: '@integer(0, 500)',
    publishedAt: '@datetime("yyyy-MM-dd HH:mm")',
    user: {
      id: '@integer(1, 10)',
      name: '@cname(2, 4)',
      avatar: '@image(300x200)'
    },
    tags: () => Mock.Random.pick(tags, 2),
    thumbnail: '@image(300x200)',
    id: '@increment(1)'
  }]
}).list;

重点不在“随机”,而在:

  • 字段类型是否稳定
  • 嵌套结构是否真实
  • 是否足够支撑 UI 复杂度

2️⃣ 分页逻辑必须和真实后端一致

const currentPage = parseInt(page, 10);
const size = parseInt(limit, 10);

const start = (currentPage - 1) * size;
const end = start + size;
const paginatedData = posts.slice(start, end);

以及返回的 pagination:

pagination: {
  current: currentPage,
  limit: size,
  total,
  totalPage: Math.ceil(total / size)
}

如果 mock 不做分页,
那前端分页逻辑一定会在真接口时翻车。


七、Axios:前端的“数据适配层”

前端通过 axios 请求接口,本质上并不关心:

  • 数据是真是假
  • 接口来自 mock 还是真后端

它只关心:

  • 返回结构是否符合约定
  • 错误码是否可控

这也是为什么:

mock 必须 100% 模拟真实接口语义


八、顺带一提:Auth Mock 是怎么服务页面的?

登录 mock 使用 JWT:

  • login:签发 token
  • check:验证 token

这带来的好处是:

  • 前端可以完整开发登录态逻辑
  • 请求拦截器、权限判断全部可写
  • 不等真实用户系统

九、回到工程视角:这一篇你真正学到什么?

通过一个 Post List,你应该能讲清楚:

  • 什么是“前端不能等后端”
  • mock 在工程中的真实价值
  • 分页为什么必须由接口驱动
  • 如何设计一个可无缝切换的 API 层

面试官真正关心的不是你会不会用 mock,
而是你是否具备前后端协作的工程意识


从零打造 AI 全栈应用(四):别小看幻灯片,一个 Carousel 组件背后的工程化与性能思维

在上一篇文章 《从零打造 AI 全栈应用(三):一个 BackToTop 组件背后的工程化与性能思维》中,我们拆解了一个看似简单却极易被忽视的组件,并通过它讨论了事件监听、性能优化与工程边界的问题。

但真实项目中,工程能力往往体现在细节

这篇文章,我们从一个再常见不过的组件 —— 幻灯片 / Carousel 入手,聊聊它背后隐藏的组件设计、状态管理、性能优化和工程化取舍。


一、为什么一个幻灯片组件值得单独写一篇?

很多同学会觉得:

幻灯片不就是个 UI 组件吗?能滑就行。

但在真实项目(尤其是首页、活动页、AI 产品的内容入口)中,它往往意味着:

  • 页面首屏核心组件
  • 高频渲染、长时间驻留
  • 涉及自动播放、交互、状态同步
  • 很容易成为性能与可维护性的隐患

所以我们这次的目标不是「写一个能跑的轮播图」,而是:

写一个工程上站得住脚的 Carousel 组件


二、技术选型:为什么是 shadcn/ui + Embla?

1️⃣ shadcn/ui 的 Carousel 设计哲学

shadcn/ui 提供的并不是一个“黑盒组件”,而是一组组合式组件

  • Carousel
  • CarouselContent
  • CarouselItem

特点很明显:

  • 结构清晰,层次分明
  • 不强绑定样式
  • 底层基于 Embla Carousel,性能成熟

本质上,它更像是一个“轮播能力的外壳”,而不是一个定死的 UI。

这点非常重要 —— 可定制性 = 长期可维护性


2️⃣ 自动播放为什么用插件,而不是自己写 setInterval

自动播放我们选择的是:

import AutoPlay from 'embla-carousel-autoplay'

原因很简单:

  • 和 Embla API 深度适配
  • 内部处理了生命周期、交互中断
  • 不需要自己处理定时器清理

工程中一个重要原则:
能用成熟插件解决的,不要重复造轮子


三、组件设计:从 Props 到职责边界

1️⃣ 数据结构设计

export interface SlideData {
  id: number | string;
  image: string;
  title?: string;
}

几个刻意的设计点:

  • id 不强制 number,兼容服务端数据
  • title 可选,UI 自动适配
  • 组件只关心展示所需的最小数据

不要把组件变成业务垃圾桶。


2️⃣ 组件 Props:只暴露真正需要的能力

interface SlideShowProps {
  slides: SlideData[];
  autoPlay?: boolean;
  autoPlayDelay?: number;
}

这里没有:

  • 当前 index 的受控状态
  • 复杂回调

原因是:

这是一个偏展示型组件,而不是业务中枢


四、状态管理:selectedIndex 为什么是私有状态?

const [selectedIndex, setSelectedIndex] = useState<number>(0);
const [api, setApi] = useState<CarouselApi | null>(null);

关键点:

  • selectedIndex 不从外部传入
  • 通过 CarouselApi 与底层同步
useEffect(() => {
  if (!api) return;
  setSelectedIndex(api.selectedScrollSnap());

  const onSelect = () => {
    setSelectedIndex(api.selectedScrollSnap());
  };

  api.on('select', onSelect);
  return () => api.off('select', onSelect);
}, [api]);

这段代码的工程意义:

  • UI 状态 来源单一(底层 carousel)
  • React 状态只是一个映射
  • 避免“双向状态不同步”

面试常考:如何避免状态源混乱?
这个例子非常典型。


五、自动播放:为什么一定要用 useRef

const plugin = useRef(
  autoPlay
    ? AutoPlay({ delay: autoPlayDelay, stopOnInteraction: true })
    : null
);

如果不用 useRef 会发生什么?

  • 每次 render 都创建新插件实例
  • 自动播放被反复重置
  • 性能抖动、行为不可控

useRef 的本质价值:

在 React 渲染体系外,持久化一个可变对象

这是一个非常典型、非常“面试级别”的用法。


六、交互细节:为什么鼠标移入要暂停?

onMouseEnter={() => plugin.current?.stop()}
onMouseLeave={() => plugin.current?.reset()}

这是一个看似很小,但体验影响极大的细节:

  • 用户正在看内容
  • 自动切走 = 强干扰

好的组件,不是功能多,而是尊重用户行为


七、指示点设计:状态驱动,而不是 DOM 操作

slides.map((_, i) => (
  <button
    key={i}
    className={`h-2 w-2 rounded-full transition-all
      ${selectedIndex === i ? 'bg-white w-6' : 'bg-white/50'}`}
  />
));

几个工程要点:

  • 循环渲染,不操作 DOM
  • 动态类名完全由状态驱动
  • transition-all 提供平滑过渡

React 组件的本质:
UI = f(state)


八、CSS 与性能:渐变背景为什么优于图片?

bg-gradient-to-t from-black/60 to-transparent

相比图片背景,渐变的优势:

  • 不需要额外 HTTP 请求
  • 减少并发资源下载
  • GPU 友好
  • 更容易适配深浅色

在高性能场景下,
能不用图片,就不用图片


九、总结:一个小组件,体现哪些工程能力?

回看这个 Carousel,你至少可以聊清楚:

  • 组件拆分与职责边界
  • 第三方库的正确使用方式
  • React 状态与外部状态同步
  • useRef 的真实应用场景
  • UI 细节与性能取舍

面试官真正想看的,不是你会不会写轮播图,
而是你为什么这么写


❌