React Mobile 项目从 0 到 1(前端篇):Shadcn/ui + Vite + 路由 + 状态管理全流程

232 阅读7分钟

React Mobile 项目从 0 到 1(前端篇):Shadcn/ui + Vite + 路由 + 状态管理全流程

在移动端首页的开发实践中,轮播图、内容流列表与底部导航几乎已成为标准布局形态。然而,传统组件库(如 Ant Design Mobile 或 Element Plus)在实际项目中常常暴露出包体积较大、样式定制成本高、以及与设计需求难以精准对齐等问题。所以,Shadcn/ui + Vite + React + Zustand + vite-plugin-mock来了,用他们来从零搭建一个完整的 React Mobile 端应用,整个项目分为前端和后端两部分。本文是系列的第一篇,重点记录前端项目从 0 到 1 的完整搭建过程,包括 Vite 配置、shadcn/ui 组件实践、路由架构、状态管理(Zustand)、Mock 数据、性能优化等关键环节。

起点:为什么选shadcn?页面组件化和按需加载的甜头

shadcn的核心是“页面由组件构成,选用第三方组件库”,但它严格按需加载——组件直接下载到本地,随意修改。基于Tailwind CSS,配置alias设置路径别名更短好用。

shadcn/ui 的本质:不是一个npm包,而是一套可拷贝、可魔改的组件模板。组件直接落盘到你项目里,想怎么改就怎么改,零运行时开销

Mobile端最爽的三点:

  • 按需引入 → bundle体积小,首屏秒开
  • Tailwind原生 → 响应式写法超级顺手(sm: md: lg: 直接用)
  • 本地魔改 → 设计师说“这个按钮圆角再大2px、阴影再淡点”,直接改本地tsx,1分钟搞定

初始化命令速查

初始化(会问你一堆配置,建议选默认 + TypeScript + alias @/* → src/*) npx shadcn@latest init

加组件(超级丝滑) npx shadcn@latest add button card carousel sheet tabs

比如我的Button组件代码:

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        // ...其他variant
      },
      size: {
        default: "h-9 px-4 py-2 has-[>svg]:px-3",
        // ...其他size
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

// ...Button函数定义
cn() 到底在干嘛?为什么 shadcn 里到处都用它?

shadcn 提供的 cn 函数(在 src/lib/utils.ts 里)其实就一行实现:

import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

两步拆解它的真正作用:

  1. clsx 先把所有乱七八糟的输入(字符串、对象、数组、三元、false/null)变成一个干净的 class 字符串 比如:

    TypeScript

    clsx("p-4", { "bg-primary": isActive, "text-white": isActive }, false, ["text-sm", condition && "font-bold"])
    // → "p-4 bg-primary text-white text-sm font-bold" (自动过滤掉 false 的部分)
    
  2. twMerge 才是关键:它理解 Tailwind 的类规则,智能合并冲突类

    • 同类组(比如 bg-、text-、p-、border-、rounded- 等)只保留最后一个出现的
    • 不同组正常拼接
    • 保证后面传入的类优先级最高,样式永远可预测

不使用 cn 会翻车的经典例子(Mobile 很常见):

tsx

// 坏写法:顺序不稳定,容易被 base 类冲掉
<button className={`px-4 py-2 bg-muted ${isActive ? 'bg-primary text-white' : ''}`}>

→ 如果组件本身有 bg-muted,再加 bg-primary,浏览器看到两个 bg-,结果取决于 class 字符串的拼接顺序(不可控)。

用 cn 后稳如老狗

tsx

<Button
  className={cn(
    "px-4 py-2 transition-colors",                    // base
    isActive 
      ? "bg-primary text-primary-foreground hover:bg-primary/90"
      : "bg-muted text-muted-foreground",
    disabled && "opacity-50 cursor-not-allowed",
    className  // 外部传入的永远最后,优先级最高
  )}
/>

Mobile 真实场景收益

  • 外部传 rounded-full 覆盖默认 rounded-md → 完美生效
  • 加 active:scale-95 做触屏按下反馈 → 不会被 base 的 transition 冲掉
  • variant/destructive/outline 等多种状态叠加 → 样式冲突自动解决

一句话总结: cn = clsx(处理条件) + twMerge(解决 Tailwind 冲突) 它是 shadcn 组件系统里最硬核的“防抖”工具,让你写动态类名时再也不用担心“谁覆盖谁”,维护性直接起飞。

Vite配置的那些事儿:alias和类型声明的坑

搭好shadcn,接下来配置Vite。vite.config.ts是配置对象,plugins放react()和tailwindcss()。

resolve里alias:'@':path.resolve(__dirname,'src')。

注意 Vite基于Rollup,alias必须嵌套在resolve下。

代码:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
import {viteMockServe} from 'vite-plugin-mock'

export default defineConfig({
  plugins: [
    react(),
    tailwindcss(),
    viteMockServe({ mockPath: 'mock' })
  ],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    }
  }
})

为什么选择 path.resolve(__dirname, 'src'),而不能直接写 '/src' 或 './src'?

  • __dirname 是 Node.js 提供的当前文件所在目录(项目根目录)。
  • path.resolve() 把相对路径转为绝对路径,确保在任何环境(本地开发、Docker、Vercel、Netlify 等部署场景)下路径都正确解析。
  • 直接写 './src' 或 '/src' 在某些打包工具或服务器环境下会解析失败,导致 import 报错(尤其是 monorepo 或子目录部署时最容易炸)。

TS 项目必备一步:安装类型声明

Bash

pnpm add -D @types/node
  • TS 是静态类型检查,编译期就需要知道 path、__dirname 等 Node API 的类型。
  • 没装 @types/node → path.resolve 直接报“找不到模块 'path'” 或类型错误。
  • 纯 JS 项目不需要,因为运行时才解析,动态类型无所谓。

tsconfig.json 必须对齐路径映射

JSON

"compilerOptions": {
  "paths": {
    "@/*": ["src/*"]
  }
}
  • Vite 的 alias 和 tsconfig 的 paths 保持一致 → 编辑器跳转、自动补全、类型检查才正常。
  • 实际写法:import { cn } from "@/lib/utils" —— 短小、干净、跨项目一致。

路由懒加载和守卫:性能优化的关键和我的大坑

路由部分,我用react-router-dom v6。路由懒加载(性能优化的关键),Suspense + lazy实现。自定义Loading组件。

为什么用 lazy + Suspense?(首屏性能杀手锏)

页面一多,初始 bundle 体积直接爆炸。

React.lazy + Suspense 是标配解决方案:

  • lazy:动态导入组件,只在访问对应路由时才加载代码 chunk。
  • 页面多时,初始 bundle 小 → 首屏加载快 30%+(尤其 Mobile 端明显)。
  • Suspense fallback:加载中显示自定义 Loading(我用 CSS 两个 div 做缩放动画,丝滑不卡)。

正确写法(推荐把 Suspense 包在 Routes 外):

import { Suspense, lazy } from "react";
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Loading from "@/components/Loading";
import MainLayout from "@/layouts/MainLayout";
const Home = lazy(() => import('@/pages/Home'))
// ...其他lazy

export default function RouterConfig({ children }: { children?: React.ReactNode }) {
  return (
    <Router>
      <Suspense fallback={<Loading />}>
        <Routes>
          <Route path="/login" element={<Login />}></Route>
          <Route path="/" element={<MainLayout />}>
            <Route path="" element={<Home />}></Route>
            // ...嵌套路由
          </Route>
        </Routes>
      </Suspense>
      {children}
    </Router>
  )
}
路由守卫:保护敏感页面,用户体验拉满

用 Zustand 的 useUserStore 存 isLogin,在 App 或 Layout 里用 useEffect 做简单守卫。

代码:

import { useEffect } from 'react'
import { useUserStore } from './store/useUserStore'
import { useNavigate, useLocation } from 'react-router-dom';

function App() {
  const { isLogin } = useUserStore();
  const navigate = useNavigate();
  const { pathname } = useLocation();

  useEffect(() => {
    if (!isLogin && needsLoginPath.includes(pathname)) {
      navigate('/login')
    }
  }, [isLogin, navigate, pathname])

  return <>{/* ... */}</>
}

易错点

  • 一开始把 useNavigate 写在 App 外层或 main.tsx → 报错: useNavigate() may be used only in the context of a <Router> component.
  • 原因:所有路由 hook(useNavigate / useLocation 等)都依赖 (或其它 Router)提供的上下文,必须是 Router 的后代组件。
  • 解决:把 移到 main.tsx 最外层包裹 ,或确保守卫组件在 Router 树内。

实际加固

  • BottomNav 点击跳转时也加同样判断:if (!isLogin && protectedPaths.includes(to)) navigate('/login')
  • 这样未登录用户点“我的/订单”直接拦截,不让进敏感页,用户体验好太多。

一句话:lazy + Suspense 优化首屏,守卫 + 上下文检查保护路由 —— Mobile 项目必备组合

BackToTop组件:通用组件的自有状态和性能优化

BackToTop 是独立组件,自带 isVisible 状态,根据滚动距离(threshold 默认 400px)显示/隐藏回到顶部按钮

代码:

import React, { useEffect, useState } from "react"
import { Button } from "./button"
import { ArrowUp } from "lucide-react"
import { throttle } from "@/utils"

const BackToTop: React.FC<BackToTopProps> = ({ threshold = 400 }) => {
  const [isVisible, setIsVisible] = useState<boolean>(false);
  const scrollTop = () => {
    window.scrollTo({ top: 0, behavior: 'smooth' })
  }
  const toggleVisibility = () => {
    setIsVisible(window.scrollY > threshold);
  }
  const throttle_func = throttle(toggleVisibility, 200)
  useEffect(() => {
    window.addEventListener('scroll', throttle_func)
    return () => window.removeEventListener('scroll', throttle_func)
  }, [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">
      <ArrowUp className="h-4 w-4" />
    </Button>
  )
}

为什么必须节流?

  • scroll 事件在浏览器中几乎每帧(约 16ms)触发一次。
  • 不节流 → 每滚动一下就 setState 几十上百次 → CPU 飙升、页面卡顿(Mobile 端尤其明显)。
  • 节流 200ms:每 200ms 最多执行一次,体验几乎无损,性能提升巨大。

节流函数简单实现(utils/throttle.ts):

TypeScript

export function throttle(fn: Function, delay: number) {
  let last = 0
  return function (...args: any[]) {
    const now = Date.now()
    if (now - last >= delay) {
      fn(...args)
      last = now
    }
  }
}

易错点

  • 忘记 return () => removeEventListener → 组件卸载后监听器还在,多次热重载/切换页面后事件堆积 → 页面严重卡顿甚至崩溃。
  • React useEffect 的 cleanup 函数 就是为此设计的:组件卸载/依赖变化时自动清理副作用。

长列表、文章详情页滚动时,BackToTop + 节流 + cleanup 组合拳,体验丝滑,用户爱不释手。

幻灯片组件:shadcn的Carousel和gradient优化的惊喜

幻灯片slides用shadcn的Carousel、CarouselContent、CarouselItem,一组组件,层次结构。自动播放功能作为插件引入,shadcn简单性能好,定制性更好。

代码:

import { useRef, useState, useEffect } from "react"
import Autoplay from 'embla-carousel-autoplay'
import { Carousel, CarouselItem, CarouselContent, type CarouselApi } from '@/components/ui/carousel'

const SlideShow: React.FC<SlideShowProps> = ({ slides, autoPlay = true, autoPlayDelay = 3000 }) => {
  const [selectedIndex, setSelectedIndex] = useState<number>(0);
  const [api, setApi] = useState<CarouselApi | null>(null)
  useEffect(() => {
    if (!api) return;
    setSelectedIndex(api.selectedScrollSnap());
    const onSelect = () => setSelectedIndex(api.selectedScrollSnap())
    api.on('select', onSelect)
    return () => { api.off('select', onSelect) }
  }, [api])
  const plugin = useRef(autoPlay ? Autoplay({ delay: autoPlayDelay, stopOnInteraction: true }) : null)
  return (
    <div className="relative w-full">
      <Carousel 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}>
              <div className="relative h-48">
                <img src={image} alt={title || `slide${index + 1}`} className="h-full w-full 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} className={`h-2 w-2 rounded-full transition-all ${selectedIndex === i ? "bg-white w-6" : "bg-white/50"}`} />
        ))}
      </div>
    </div>
  )
}

api 的核心作用(CarouselApi 类型):

  • 通过 setApi 暴露 Embla 的内部 API 接口。
  • 允许监听事件(如 api.on('select', ...) 更新 selectedIndex)。
  • 控制轮播行为:如跳到指定 slide(api.scrollTo(index))、暂停/恢复等。
  • selectedIndex 用私有状态跟踪当前 slide,结合指示点动态类名 + transition-all 实现平滑切换。

gradient 优化的惊喜

  • 用 bg-gradient-to-t CSS 线性渐变取代 PNG 图片遮罩 → 零 HTTP 请求,减少并发数。
  • 为什么性能更好? 浏览器原生渲染 gradient,无需下载图片,加载更快(Mobile 端首屏提速明显)。
  • 底层:减少网络开销,HTTP 连接池更空闲。

易错提醒

  • 忘记 return () => api.off('select', onSelect) → 组件卸载时事件监听器泄漏,多次渲染后内存爆。

Store:全局共享和页面级分离的思考

Zustand 的最大亮点就是完全基于 Hook:不需要 Provider、不需要 Context、没有复杂的 reducer boilerplate,直接用 create 创建一个 Hook 函数,就能全局或局部共享状态。

user全局共享,每个页面级别组件都有自己独立的store,组件UI和数据分离

useUserStore:

import { create } from 'zustand';
import type { User } from '@/types';

interface UserState {
  isLogin: boolean;
  user: User | null;
}

export const useUserStore = create<UserState>((set) => ({
  isLogin: false,
  user: null
}))
小 Tip:用 persist middleware 实现登录态持久化(超实用)

Zustand 自带 persist 中间件,一行代码就能把 store 数据存到 localStorage(或 sessionStorage),页面刷新/关闭后自动恢复。

最常见用法(用户登录态持久化):

// stores/useUserStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

interface UserState {
  isLogin: boolean
  user: User | null
  login: (user: User) => void
  logout: () => void
}

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      isLogin: false,
      user: null,
      login: (user) => set({ isLogin: true, user }),
      logout: () => set({ isLogin: false, user: null }),
    }),
    {
      name: 'user-storage',                    // localStorage 中的 key 名
      storage: createJSONStorage(() => localStorage), // 或 sessionStorage
      partialize: (state) => ({                // 只持久化关键字段,避免存多余数据
        isLogin: state.isLogin,
        user: state.user,
      }),
      // 可选:onRehydrateStorage 回调,恢复时做额外逻辑(如检查 token 有效期)
    }
  )
)

小技巧 & 注意点

  • partialize:别把所有状态都存(尤其是临时 loading、posts 列表),只存 isLogin + user 就够,减少存储体积和潜在泄漏。
  • name:用唯一的前缀(如 'cq-app-user-v1'),避免不同项目冲突。

Post List:axios + mockjs的前后端分离神器

真实项目中,后端接口往往滞后。前端不能干等,于是用 vite-plugin-mock + axios 实现完美前后端分离开发。

config.ts:axios.defaults.baseURL='http://localhost:5173/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: { page, limit } })
    return response.data
  } catch (error) { }
}

mockjs前端接口伪造,开发时候用,上线前切换成后端真实接口。vite启动mock,前后端确立接口开发文档。

posts.js:

import Mock from 'mockjs'
const tags = ['前端', '后端', '职场', 'AI', '副业', '面经', '算法']
const posts = Mock.mock({ 'list|45': [{ /* ... */ }] }).list
export default [{
  url: '/api/posts',
  method: 'get',
  response: ({ query }, res) => {
    const { page = '1', limit = '10' } = query;
    const currentPage = parseInt(page, 10);
    const size = parseInt(limit, 10);
    // 参数校验
    if (isNaN(currentPage) || isNaN(size) || currentPage < 1 || size < 1) {
      return { code: 400, msg: 'Invalid page or pagesize', data: null }
    }
    const total = posts.length;
    const start = (currentPage - 1) * size
    const end = start + size;
    const paginatedData = posts.slice(start, end);
    return {
      code: 200,
      msg: 'success',
      items: paginatedData,
      pagination: { current: currentPage, limit: size, total, totalPage: Math.ceil(total / size) }
    }
  }
}]

Mockjs 小技巧

  • @ctitle、@cparagraph、@image、@cname 等随机生成中文数据
  • Random.shuffle 随机标签,真实感拉满

分页机制详解

  1. page & limit 来自 query string(字符串类型)→ 必须用 parseInt(..., 10) 转数字,基数 10 防前导 0(如 '010' → 8 的坑)。
  2. start = (currentPage - 1) * size:标准页码计算(第 1 页从索引 0 开始)。
  3. slice(start, end) :Array.slice 高效截取子数组。
  4. 返回完整 pagination:前端拿到 current/limit/total/totalPage,就能轻松渲染分页器、加载更多按钮、显示“已加载 20/120 条”。
  5. 优点:简单、直观、兼容性强。

最后

前后端接口文档定好后,一键切换 baseURL,无缝对接真实后端

结尾:收获满满,下一步继续深挖

至此,这个 React Mobile 小项目的基本框架已经成型。从 shadcn/ui 的按需引入与本地定制,到 Vite 的路径别名优化,再到路由懒加载与守卫BackToTop 的滚动性能处理、Carousel 的渐变遮罩技巧、Zustand 的状态分层设计,以及 vite-plugin-mock 的前后端分离开发,每一个环节都让我从“能用”逐步走向“理解为什么这么用”。

还有就是:前端开发远不止堆砌代码,更需要从用户体验、浏览器渲染成本、可维护性、团队协作等多个维度去权衡取舍。