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))
}
两步拆解它的真正作用:
-
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 的部分) -
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 随机标签,真实感拉满
分页机制详解
- page & limit 来自 query string(字符串类型)→ 必须用 parseInt(..., 10) 转数字,基数 10 防前导 0(如 '010' → 8 的坑)。
- start = (currentPage - 1) * size:标准页码计算(第 1 页从索引 0 开始)。
- slice(start, end) :Array.slice 高效截取子数组。
- 返回完整 pagination:前端拿到 current/limit/total/totalPage,就能轻松渲染分页器、加载更多按钮、显示“已加载 20/120 条”。
- 优点:简单、直观、兼容性强。
最后
前后端接口文档定好后,一键切换 baseURL,无缝对接真实后端
结尾:收获满满,下一步继续深挖
至此,这个 React Mobile 小项目的基本框架已经成型。从 shadcn/ui 的按需引入与本地定制,到 Vite 的路径别名优化,再到路由懒加载与守卫、BackToTop 的滚动性能处理、Carousel 的渐变遮罩技巧、Zustand 的状态分层设计,以及 vite-plugin-mock 的前后端分离开发,每一个环节都让我从“能用”逐步走向“理解为什么这么用”。
还有就是:前端开发远不止堆砌代码,更需要从用户体验、浏览器渲染成本、可维护性、团队协作等多个维度去权衡取舍。