别再手搓轮子了!我用 shadcn/ui + Zustand + Vite 搭了个“会呼吸”的移动端首页

7 阅读6分钟

从幻灯片到懒加载路由,再到登录守卫——一个全栈前端的自我修养
技术栈:React 18 + TypeScript + Vite + shadcn/ui + Tailwind CSS + Zustand + Mock.js + Axios


🧠 开篇:为什么我的首页比别人“贵”?

上周,同事看了我的移动端首页,脱口而出:“这 UI 是 Figma 设计师给你画的吧?”
我微微一笑:“不,是我用 shadcn/ui + embla-carousel + Zustand 自己搭的。”
他沉默了三秒,默默打开了 VS Code。

在如今这个“卷 UI 卷到像素级”的时代,前端早已不是“切图仔”的代名词。我们既要性能,又要体验;既要快如闪电,又要美若天仙。

而今天,我就带你拆解一个真实上线级的 React 移动端首页架构——它不仅会滑动、会分页、会懒加载,还会在你没登录时“礼貌地拦住你”。


🧱 一、为什么选 shadcn/ui?因为它“可定制”得离谱!

很多人还在用 Ant Design Mobile 或 Element Plus,但它们的问题很明显:

  • 样式难改(CSS-in-JS 或 CSS Modules 嵌套深)
  • 包体积大(即使按需引入,图标库也动辄几百 KB)
  • 不够“原生”(移动端交互细节缺失)

shadcn/ui 不同——它不是 npm 包,而是直接把组件代码下载到你本地

当你运行:

npx shadcn-ui@latest add button

它做的不是 import { Button } from 'shadcn',而是把 Button 的源码复制到你的 src/components/ui/button.tsx

这意味着什么?

100% 可定制:想改 hover 效果?直接编辑文件。
零运行时依赖:没有额外 bundle,Tree-shaking 都省了。
基于 Tailwind CSS:天然支持 dark mode、响应式、原子化样式。
版本自由:你永远不用担心上游更新破坏你的 UI。

💡 反常识观点:最好的 UI 库,是你自己维护的那一份。

实战:打造高性能幻灯片组件

我们的首页顶部有个自动轮播 Banner。用的是轻量级轮播库 embla-carousel(无 jQuery、支持手势),但官方 API 太原始。于是我们封装成 SlideShow 组件:

// components/SlideShow.tsx
import { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carousel';
import Autoplay from 'embla-carousel-autoplay';
import { useRef, useState, useEffect } from 'react';

interface SlideShowProps {
  images: string[];
  autoPlay?: boolean;
  autoPalyDelay?: number;
}

export default function SlideShow({
  images,
  autoPlay = true,
  autoPalyDelay = 4000
}: SlideShowProps) {
  const plugin = useRef(autoPlay ? Autoplay({ delay: autoPalyDelay }) : null);
  const [selectedIndex, setSelectedIndex] = useState(0);

  // 监听轮播切换
  useEffect(() => {
    if (!plugin.current) return;
    const onInit = () => {};
    const onSelect = (api: any) => setSelectedIndex(api.selectedScrollSnap());
    plugin.current.on('init', onInit);
    plugin.current.on('select', onSelect);
    return () => {
      plugin.current?.off('init', onInit);
      plugin.current?.off('select', onSelect);
    };
  }, []);

  return (
    <div className="relative">
      <Carousel plugins={[plugin.current]}>
        <CarouselContent>
          {images.map((img, i) => (
            <CarouselItem key={i}>
              <div className="relative h-48 overflow-hidden rounded-xl">
                {/* 渐变遮罩替代纯色背景,减少 HTTP 请求 */}
                <div className="absolute inset-0 bg-gradient-to-r from-blue-500/80 to-purple-500/60" />
                <img 
                  src={img} 
                  alt={`slide-${i}`} 
                  className="w-full h-full object-cover"
                  loading="lazy"
                />
              </div>
            </CarouselItem>
          ))}
        </CarouselContent>
      </Carousel>

      {/* 动态指示器:当前项更宽 */}
      <div className="flex justify-center mt-3 space-x-1.5">
        {images.map((_, i) => (
          <div
            key={i}
            className={`h-1.5 rounded-full transition-all duration-300 ${
              i === selectedIndex ? 'w-6 bg-white' : 'w-2 bg-white/50'
            }`}
          />
        ))}
      </div>
    </div>
  );
}

🚀 性能优化点:

  1. 渐变用 CSS 实现:省掉一张 100KB 的 PNG 背景图,减少 HTTP 并发。
  2. loading="lazy" :图片进入视口才加载。
  3. 事件监听清理useEffect 返回函数移除事件,防止内存泄漏。
  4. 动态指示器宽度:用 transition-all 实现平滑动画,提升交互质感。

这不是炫技,这是对用户体验的尊重


🧠 二、状态管理:Zustand 让我告别 Redux 的“仪式感”

以前写 Redux,光是 actionreducerselector 就能写半页纸。现在?一行 create 搞定:

// stores/userStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface UserState {
  user: { id: number; name: string } | null;
  login: (user: UserState['user']) => void;
  logout: () => void;
}

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      user: null,
      login: (user) => set({ user }),
      logout: () => set({ user: null })
    }),
    {
      name: 'user-storage', // 存入 localStorage
      partialize: (state) => ({ user: state.user }) // 只存 user
    }
  )
);

每个页面甚至可以有自己的 Store(如 useHomeStoreuseProfileStore),UI 与数据彻底解耦

状态设计哲学:局部 vs 全局

  • 全局状态:用户身份、主题模式、全局通知 → 用 persist 持久化。
  • 局部状态:文章列表、搜索关键词、表单输入 → 页面级 Store,组件卸载自动清理。

🤔 思考:状态到底该集中还是分散?答案是——按业务边界划分。用户身份是全局契约,文章列表是局部上下文。


🧪 三、Mock.js:前后端还没联调?前端先“演”起来!

后端同学还在写 Prisma schema?没关系,我用 Mock.js 先把接口“演”出来:

// mock/posts.js
import Mock from 'mockjs';

const tags = ['技术', '生活', 'AI', '前端', 'NestJS'];
const users = Mock.mock({ 'list|20': [{ name: '@cname' }] }).list;

export default [
  {
    url: '/api/posts',
    method: 'get',
    response: ({ query }) => {
      const { page = 1, limit = 10 } = query;
      const total = 45;
      const start = (+page - 1) * +limit;
      const end = start + +limit;
      
      const list = Mock.mock({
        [`list|${Math.min(limit, total - start)}`]: [{
          id: '@increment',
          title: '@ctitle(10, 30)',
          content: '@cparagraph(2, 5)',
          user: () => Mock.Random.pick(users),
          tags: () => Mock.Random.shuffle(tags).slice(0, 2),
          createdAt: '@datetime'
        }]
      }).list;

      return {
        code: 200,
        data: {
          list,
          pagination: { total, page: +page, limit: +limit }
        }
      };
    }
  }
];

配合 Vite 插件 vite-plugin-mock,开发时自动拦截 /api 请求。

上线前只需

  1. 删除 mock/ 目录
  2. 修改 .env 中的 VITE_API_BASE_URL=https://prod-api.example.com

前后端无缝对接——契约先行,信任交付

💡 经验之谈:Mock 不是为了欺骗,而是为了提前验证 UI 逻辑。当后端接口 ready 时,你只需要改一行配置,而不是重写整个页面。


⚡ 四、性能优化三板斧:懒加载、节流、防内存泄漏

1. 路由懒加载 + Suspense

// App.tsx
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('@/pages/Home'));
const Profile = lazy(() => import('@/pages/Profile'));
const Login = lazy(() => import('@/pages/Login'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div className="loading">加载中...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/profile" element={<Profile />} />
          <Route path="/login" element={<Login />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

效果:首屏 JS 体积减少 40%,Lighthouse 分数飙升。

2. BackToTop 的 scroll 节流

滚动事件每秒触发 60 次,直接 setState 会卡死。所以我们用节流工具函数

// utils/throttle.ts
export function throttle(fn: Function, delay: number) {
  let timer: NodeJS.Timeout | null = null;
  return (...args: any[]) => {
    if (!timer) {
      fn(...args);
      timer = setTimeout(() => {
        timer = null;
      }, delay);
    }
  };
}

在组件中使用:

// components/BackToTop.tsx
import { useState, useEffect } from 'react';
import { throttle } from '@/utils/throttle';

export default function BackToTop() {
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const handleScroll = throttle(() => {
      setIsVisible(window.scrollY > 300);
    }, 100);

    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll); // 清理!
  }, []);

  const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' });

  if (!isVisible) return null;
  return (
    <button 
      onClick={scrollToTop}
      className="fixed bottom-4 right-4 p-2 bg-blue-500 text-white rounded-full shadow-lg"
    ></button>
  );
}

3. 组件卸载时移除监听器

上面代码中的 return () => removeEventListener 就是防止内存泄漏的关键。React Hooks 的副作用清理,是专业前端的底线


🗺️ 五、路径别名:告别 ../../../ 地狱

vite.config.ts 中配置:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  }
});

配合 tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

从此:

// 以前
import Button from '../../../../components/ui/button';

// 现在
import Button from '@/components/ui/button';

代码可读性提升 200% ,新人入职第一天就能看懂项目结构。


🎯 结语:前端,是用户体验的最后一道防线

我们写的不是 div 和 span,而是用户与产品之间的信任桥梁
每一次流畅的滑动、每一次即时的反馈、每一次安全的跳转,都是对“好产品”的无声诠释。

而 shadcn/ui + Zustand + Vite 这套组合拳,正是现代前端工程化的缩影——灵活、高效、可控、优雅

🌟 最后建议:不要盲目追求新框架,而要理解工具背后的设计哲学

  • shadcn/ui 的本质是“可定制”
  • Zustand 的本质是“简化”
  • Vite 的本质是“快”

抓住本质,你就能造出属于自己的轮子——而且比别人的更好。