1. 项目背景与核心思路
很多人看到"rea"这个名字,第一反应是某个英文单词的缩写,比如Reactive、Real、Research之类。我最初拿到这个项目代号的时候也琢磨了一下,后来才明白这就是一个内部项目的名字,没有太多深意,但做的内容倒是相当扎实——一套基于React的前端应用系统,我把整个搭建过程从头到尾走了一遍,这里把经验和坑都整理出来。
rea这个名字,简单直接,我决定让它作为项目的核心代号,它承载的是一个完整的前端应用,覆盖了从脚手架搭建、组件设计、状态管理、性能优化到最终打包上线的全流程。整套方案面向的是需要快速搭建中型Web应用的开发者,特别是团队里已经有React基础但缺乏统一规范的人。项目定位不是大型复杂系统,而是一个聚焦核心业务、能跑得快也跑得稳的实战型项目。
先说说为什么要做这样一个项目。市面上讲React入门的资料很多,但大部分停留在"认识组件"或者"跑通demo"的层面。真正把一套应用从零搭起来,并且在开发中持续维护,中间涉及的大量工程化决策——用不用TypeScript、状态管理选什么方案、样式方案怎么定、接口层怎么设计——这些才是在一线干活最头疼的部分。rea项目的价值就在这里:它不是一个玩具,而是一条完整的生产线,拉通了从页面到接口再到构建的每个环节。
适合参考这个项目的人,我觉得有两类。一类是刚接触前端工程化不久,看过不少React文档但还没独立搭建过完整应用的开发者,可以把它当作一套"手把手"的完整案例;另一类是已经在写业务代码,但团队里缺少统一规范,想看看一套相对规范的React项目长什么样的人。两种读者都能从中找到自己需要的部分。
我也要提前说明,本文涉及的具体方案,基于的是我在rea项目中的实际取舍。前端这个领域方案实在太多,每个选择背后都有理由,我会尽量把"为什么这样选"讲清楚,而不是只丢给你一套配置让人照着抄。
2. 技术选型与整体架构拆解
2.1 基础框架:选择React而非Vue或Svelte
项目代号是rea,天然与React绑定,但选React不仅仅因为名字契合。做技术选型时我们重点考量了三方面:生态成熟度、团队熟悉度、以及长期维护的稳定性。
React的生态优势依然明显,特别是周边库的丰富程度。状态管理有Redux Toolkit和Zustand两条主流路线,路由有React Router,UI库有Ant Design和MUI,数据请求有React Query和SWR。这种"每个问题都有成熟解法"的生态,在项目排期紧张时非常宝贵。Vue的生态同样不错,Svelte的写法更简洁,但React基于组件和Hook的心智模型,在处理复杂交互时表现得更加灵活。
还有一个实际原因:团队成员对React的熟悉程度更高。技术选型不能只看技术本身的优劣,还要看人。如果团队对Vue更熟,那用Vue反而更好;但我们的情况是React经验更集中,这个决策就几乎没有悬念。
2.2 构建工具:Vite替代Webpack
构建工具的选择上,我用了Vite而不是传统的Webpack。这不是跟风,而是实际对比后的结果。
第一次启动rea项目时,Webpack完成冷启动大概需要七八秒,改动一次代码热更新也要两三秒。Vite基于ESModule的实现方式,开发环境启动只需要一秒钟左右,HMR几乎是即时生效。这种体验上的差异,在一天里频繁切换页面、反复调试样式的时候,积累下来的时间成本非常可观。
Vite也并非没有缺点。它依赖原生ESModule,在老版本浏览器上需要额外的兼容处理;某些Webpack插件生态在Vite上没有完全对等的替代品。不过在rea项目的场景下,目标用户使用的主流浏览器都是现代版本,这些问题影响不大。生产构建我用的是Vite自带的Rollup打包,出来的产物体积控制得很好,配合下面会讲到的代码分割策略,首屏加载性能达标。
2.3 状态管理:Zustand的轻量与Redux Toolkit的规范
状态管理是React项目里最容易起争执的地方。我的最终选择是:全局状态用Zustand,服务端状态用React Query(现在叫TanStack Query),两者互补,各管一摊。
Zustand相比Redux最大的优势是样板代码少。Redux要写action、reducer、dispatch,一个简单的计数器就要好几行代码;Zustand直接定义一个hook,函数体内修改状态,组件里调用即可。这对节奏快的业务开发极其友好。有人担心Zustand不够规范,团队水平参差不齐时容易写乱,这个担心有道理,但rea项目通过约定来约束——所有全局状态必须定义在store目录下,组件内不允许直接修改store结构之外的状态——这样既保留了灵活度,又有了基本规范。
React Query则专门处理请求带来的服务端状态。传统的useEffect加手动loading管理写法,在rea项目里被彻底废弃了,替换成useQuery和useMutation后,缓存、自动重试、过期失效这些都交给库来处理。代码量降下来,同时请求逻辑的正确性反而提高了。
2.4 目录结构与模块划分
rea项目的目录结构,直接决定了后续开发的顺畅程度。我采用的是按功能模块划分的扁平结构:
src/ api/ -- 接口定义与请求封装 assets/ -- 静态资源 components/ -- 全局通用组件 hooks/ -- 自定义Hook layouts/ -- 页面布局组件 pages/ -- 页面级组件 stores/ -- Zustand状态定义 utils/ -- 工具函数 types/ -- TypeScript类型定义按功能模块划分的好处是,每个人都能快速定位代码位置。components放通用组件,pages放页面组件,如果某个页面有专属的子组件,就在pages对应目录下建一个components子目录,而不是强行塞进全局components里。这种约定的价值在项目后期尤其明显,新人接手时,光看目录结构就能理解整个项目的组织方式。
TypeScript的使用是强制的。rea项目里没有js文件的例外,类型安全是代码质量的第一条防线。接口返回的数据类型、组件props的类型、事件处理的参数类型,全部明确声明。前期多花一点时间写类型,后期省下的是大量排查undefined和字段写错的时间。
3. 核心功能实现与关键练手落地
3.1 开发环境初始化完整流程
说到落地,先讲最基础但最关键的一步:从零初始化一个rea项目。网上各种脚手架教程很多,但真正操作起来容易踩坑的地方,我来完整演示一遍。
第一步,确保Node版本在18以上。Vite对Node版本有要求,版本太低启动时会直接报错。检查Node版本用命令:
node -v如果版本不够,建议用版本管理工具安装新版,而不是直接覆盖系统Node,避免影响其他项目。
第二步,创建项目。我用的是Vite官方脚手架:
npm create vite@latest rea -- --template react-ts这个命令会生成一个react-ts模板项目,自带TypeScript支持,省去手动配置的麻烦。生成完毕后进入项目目录安装依赖:
cd rea npm install第三步,安装核心依赖。我一次性列出了rea项目需要的基础包:
npm install react-router-dom zustand @tanstack/react-query npm install -D tailwindcss @types/nodereact-router-dom负责路由,zustand做状态管理,@tanstack/react-query处理服务端状态。Tailwind CSS用于样式方案,这个选择后面会细说。
第四步,配置路径别名。在tsconfig.json和vite.config.ts里都加上路径别名,避免写相对路径地狱:
// vite.config.ts import path from 'path' import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src') } } })同时tsconfig.json里的baseUrl和paths也要对应配置。不配置的话,TypeScript会报找不到模块的错误。
第五步,搭建路由骨架。react-router-dom在6.x版本后用createBrowserRouter的方式创建路由:
// src/router/index.tsx import { createBrowserRouter, Navigate } from 'react-router-dom' import HomePage from '@/pages/HomePage' import AboutPage from '@/pages/AboutPage' export const router = createBrowserRouter([ { path: '/', element: <HomePage /> }, { path: '/about', element: <AboutPage /> }, { path: '*', element: <Navigate to="/" replace /> } ])到这里,一个最基础的rea项目就初始化完成了,执行npm run dev就能在浏览器里看到项目跑起来。
3.2 接口层封装与类型定义
接口层是rea项目里我最重视的部分。前端项目最容易出的问题之一,就是接口返回的数据结构在代码里被随意处理,导致某个字段在多个地方被引用,后端一改字段名,前端排查半天。
rea项目的接口封装分成两层。底层是一个实例化的请求客户端,基于axios封装:
// src/api/client.ts import axios from 'axios' const client = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10_000 }) client.interceptors.request.use((config) => { // 从Zustand store中读取token const token = useAuthStore.getState().token if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) client.interceptors.response.use( (response) => response.data, (error) => { if (error.response?.status === 401) { // 跳转登录页 window.location.href = '/login' } return Promise.reject(error) } ) export default client底层之上的每个接口,都对应一个带类型的函数:
// src/api/user.ts import client from './client' import type { UserInfo, UserListParams, UserListResponse } from '@/types/user' export const getUserList = (params: UserListParams) => client.get<UserListResponse>('/api/users', { params }) export const getUserDetail = (id: number) => client.get<UserInfo>(`/api/users/${id}`)这里的关键在于,每个接口的入参和返回值都有明确的TypeScript类型,类型定义在types目录里单独维护,与后端约定的接口文档保持同步。一旦后端改了字段,前端这边在编译期就能发现所有引用点,而不是到运行时才崩溃。
3.3 通用组件设计规范
组件设计上,rea项目确立了几个原则:容器组件与展示组件分离、props单向数据流、以及组件的颗粒度适中。
举个例子,用户列表中有一个"用户卡片"组件,它的设计遵循了展示组件规范——自身不请求数据,只接收props渲染:
// src/components/UserCard/index.tsx import type { UserInfo } from '@/types/user' interface UserCardProps { user: UserInfo onClick?: (user: UserInfo) => void showRoles?: boolean } export default function UserCard({ user, onClick, showRoles = true }: UserCardProps) { return ( <div className="p-4 border rounded-lg shadow-sm hover:shadow-md transition" onClick={() => onClick?.(user)}> <div className="flex items-center justify-between"> <span className="font-medium text-gray-800">{user.name}</span> <span className="text-sm text-gray-500">{user.department}</span> </div> {showRoles && ( <div className="mt-2 flex gap-1"> {user.roles.map((role) => ( <span key={role.id} className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full text-xs"> {role.name} </span> ))} </div> )} </div> ) }设计上有几个细节值得注意。第一,props的默认值在解构时定义,不要每次渲染时单独判断;第二,onClick事件回调接收参数,让父组件能够获取到具体数据而不是只知道"被点击了";第三,showRoles这样的开关型props需要有默认值,调用方可以少传一个参数。
react memo的使用也有讲究。在UserCard这个例子上,showRoles默认值会导致一个问题:父组件每次渲染时,如果没有传showRoles,它仍然是undefined,但解构赋值已经在函数内部把它设为true,memo做浅比较时用的是undefined,这样memo会失效。解决方法是把默认值提升到Props定义层,或者直接用defaultProps的写法。
3.4 状态管理落地:Zustand的最佳实践
用Zustand管理rea项目的全局状态,我总结了一套相对稳妥的模式。
以用户信息store为例:
// src/stores/auth.ts import { create } from 'zustand' import { persist } from 'zustand/middleware' import { getUserInfo, logoutApi } from '@/api/user' import type { UserInfo } from '@/types/user' interface AuthState { token: string | null userInfo: UserInfo | null setToken: (token: string | null) => void fetchUserInfo: () => Promise<void> logout: () => Promise<void> } export const useAuthStore = create<AuthState>()( persist( (set, get) => ({ token: null, userInfo: null, setToken: (token) => set({ token }), fetchUserInfo: async () => { const userInfo = await getUserInfo() set({ userInfo }) }, logout: async () => { await logoutApi() set({ token: null, userInfo: null }) } }), { name: 'rea-auth-storage' } ) )几个关键点:一,用set直接更新状态,不要通过reducer间接操作;二,持久化中间件persist把token存到localStorage,刷新页面后登录状态不丢;三,异步操作放到store的方法里,组件调用时只管触发,不需要自己处理loading。
使用persist中间件时要小心一个问题:localStorage里存储的state和代码里的结构不同步时,可能拿到旧数据导致类型错误。解决方法是给persist加一个version字段和migrate函数,数据格式变更时自动迁移。
组件里调用store的状态时,要尽量用selector精准选中,而不是整个store拿过来:
// ✅ 推荐:只选需要的字段 const token = useAuthStore((state) => state.token) // ❌ 避免:整个store都拿回来 const { token, userInfo, setToken } = useAuthStore()区别在于,用selector时Zustand会在token没变化时跳过组件渲染;而后者一旦store里任何字段变化,组件都会被重新渲染一遍。
3.5 React Query管理服务端状态的实践
服务端状态的痛点在于:缓存、重试、失效更新,这些逻辑如果自己写,很容易出错且代码冗余。rea项目直接交给@tanstack/react-query处理。
一个完整的列表查询:
// src/pages/UserList/index.tsx import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { getUserList, deleteUser } from '@/api/user' import UserCard from '@/components/UserCard' export default function UserList() { const queryClient = useQueryClient() const { data, isLoading, isError, error } = useQuery({ queryKey: ['users', { page: 1, size: 20 }], queryFn: () => getUserList({ page: 1, size: 20 }) }) const deleteMutation = useMutation({ mutationFn: (id: number) => deleteUser(id), onSuccess: () => { // 成功后失效缓存,触发重新拉取 queryClient.invalidateQueries({ queryKey: ['users'] }) } }) if (isLoading) return <div>加载中...</div> if (isError) return <div>加载失败:{(error as Error).message}</div> return ( <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> {data?.list.map((user) => ( <UserCard key={user.id} user={user} onClick={(clickedUser) => deleteMutation.mutate(clickedUser.id)} /> ))} </div> ) }这段代码的核心在于queryKey的设计。queryKey是React Query的缓存标识,它有两个作用:一是在多个组件间共享同一份缓存,二是失效时精准定位需要重新拉取的query。把查询参数也放进queryKey里,可以让每次查询都是唯一的,不会互相覆盖。
实际操作中我见过太多人在自定义Hook里手动管理"数据请求中""请求成功""请求失败"三个状态,代码又长又容易漏掉边界情况。React Query把这些全封装好了,写业务的时候只需要关心queryKey和queryFn,开发效率和代码质量都能提升。
3.6 样式方案:Tailwind CSS的实用主义
rea项目的样式方案用的是Tailwind CSS,不是CSS Modules也不是styled-components。这个选择在实际开发中被验证了是高效的。
Tailwind的工作方式是,在HTML类名里直接用工具类来描述样式。传统CSS里,一个组件可能要写几十行样式代码,用Tailwind后,类名里就能直观地看到padding、颜色、布局方式,不需要在CSS文件里来回切换。
实际操作中,我建议把主题色和间距这些通过tailwind.config.js的类型定义来约束:
// tailwind.config.js export default { content: [ './index.html', './src/**/*.{ts,tsx}' ], theme: { extend: { colors: { primary: { DEFAULT: '#3B82F6', dark: '#2563EB', light: '#60A5FA' } } } } }这样项目里所有地方都用primary这个语义化颜色而不是直接写十六进制色值,哪天公司换主题色,只需要改这一处。
Tailwind的缺点是,标签上的类名会很长,初次接触的人会觉得眼花缭乱。但适应以后,效率确实比特地去写CSS文件高得多,而且类名的顺序也可以通过prettier插件自动排序,维持可读性。
4. 性能优化的几个关键决策
4.1 代码分割:改掉"一个chunk装所有"的坏毛病
rea项目初期有过一个让人头疼的问题:首屏加载时间过长。打开控制台的Network面板,发现打包出来的主chunk接近1MB,60%的代码都属于用户根本不常访问的页面。
解决办法是路由级别的代码分割:
// src/router/index.tsx import { lazy, Suspense } from 'react' import { createBrowserRouter, Navigate } from 'react-router-dom' import layout from '@/layouts/MainLayout' const HomePage = lazy(() => import('@/pages/HomePage')) const AboutPage = lazy(() => import('@/pages/AboutPage')) const UserList = lazy(() => import('@/pages/UserList')) export const router = createBrowserRouter([ { element: <MainLayout />, children: [ { path: '/', element: <Suspense fallback={<PageLoading />}><HomePage /></Suspense> }, { path: '/about', element: <Suspense fallback={<PageLoading />}><AboutPage /></Suspense> }, { path: '/users', element: <Suspense fallback={<PageLoading />}><UserList /></Suspense> }, { path: '*', element: <Navigate to="/" replace /> } ] } ])React的lazy配合Suspense实现按需加载,访问哪个路由才加载对应的chunk。改造后,主chunk缩到400KB以内,首屏白屏时间从两秒多降到一秒以内,体验提升非常明显。
实际打包时还可以用rollupOptions手动把那些第三方的库拆出去,或者使用chunkSplitStrategy让vendor包独立缓存,改动更少见效更快:
// vite.config.ts build: { rollupOptions: { output: { manualChunks: { react: ['react', 'react-dom'], router: ['react-router-dom'], antd: ['antd'] } } } }这样做的另一个好处是,react和react-dom版本几乎不更新,浏览器可以长期使用缓存的chunk,只有业务代码更新时,用户下载的增量体积也会变小。
4.2 React.memo和useMemo要克制
关于性能优化,我看见很多人一上来就到处套useMemo。这个习惯其实弊大于利。React的memo化和useMemo并非免费的,它们本身也有成本,而且如果依赖项写错了,还会导致bug。
在rea项目里我们实际的经验是:优先解决渲染频率问题,而不是某个组件的渲染成本。渲染频率问题的根源往往是状态放的位置不对——状态放在父组件里,结果子组件也被带着一起刷新。
一个典型的优化场景是这样的:
// ❌ 状态提升太高 function Parent() { const [count, setCount] = useState(0) return ( <> <ExpensiveComponent /> // 每次count变化它都跟着渲染 <Counter count={count} onIncrement={() => setCount(c => c + 1)} /> </> ) } // ✅ 把状态放到只关心它的子树里 function CounterSection() { const [count, setCount] = useState(0) return <Counter count={count} onIncrement={() => setCount(c => c + 1)} /> }在这个例子里,提前把状态下放到CounterSection,ExpensiveComponent就不会再做无用渲染了。这比给ExpensiveComponent套memo更根本——状态不下放,memo也只是治标不治本。
真正要用memo的场景是组件props结构稳定且接收了复杂对象,比如传递onClick回调时,配合useCallback保证每次渲染传入同一个函数引用,这样memo浅比较才能通过。只做memo不配useCallback,回调每次都是新引用,等于memo白写了。
4.3 图片懒加载:不只是加个loading="lazy"
图片在Web应用里占的带宽越来越多。rea项目里有一个用户头像列表页面,一屏显示了上百个头像,如果没有懒加载,首屏就会请求上百个图片资源。
浏览器原生支持的loading="lazy"是一个极其简单有效的方案:
<img src="/users/1.jpg" loading="lazy" alt="用户头像" />如果你的项目需要更低版本的浏览器兼容性,可以用IntersectionObserver做自定义懒加载:
// src/components/LazyImage/index.tsx import { useRef, useState, useEffect } from 'react' export default function LazyImage({ src, alt, ...rest }) { const imgRef = useRef<HTMLImageElement>(null) const [loaded, setLoaded] = useState(false) useEffect(() => { const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) { setLoaded(true) observer.disconnect() } }, { rootMargin: '200px' }) if (imgRef.current) { observer.observe(imgRef.current) } return () => observer.disconnect() }, []) return ( <img ref={imgRef} src={loaded ? src : placeholderSrc} alt={alt} {...rest} /> ) }rootMargin设为200px,意思是在图片进入视口前200px就开始加载,体验上基本上感知不到"懒加载"的存在。另外给图片加了骨架屏占位背景色,加载过程中不会白屏闪烁。
5. 构建部署与上线发布的坑
5.1 环境变量管理与多环境部署
rea项目有开发、测试、生产三套环境,每套环境的接口地址不同。直接用一行代码硬编码显然是业余的做法,我用Vite的mode机制配合环境变量文件来管理。
在项目根目录创建三个文件:
.env.development -- 开发环境 .env.test -- 测试环境 .env.production -- 生产环境每个文件里的内容类似这样:
VITE_API_BASE_URL=https://test-api.example.com VITE_APP_ENV=test在代码中通过import.meta.env.VITE_API_BASE_URL来读取。注意只有以VITE_开头的变量会被Vite暴露到前端代码,其他的不会。
这套方案在打包时也自然支持:npm run build默认读的是.env.production,如果要打测试环境的包,可以使用:
npm run build -- --mode test环境变量文件本身有一个坑:如果.cenv文件内容有改动,需要重启开发服务器或重新打包,否则改动不会生效。因为这个机制,我们经历了好几次"改了配置但没生效"的困惑,后来规范是每次改配置后明确重启服务。
5.2 部署路径的坑与Base配置
第一次部署rea项目到服务器时,页面直接白屏。打开控制台,脚本资源全部404。排查了很久才发现问题出在打包资源的路径上。
Vite默认的base是/,也就是资源路径从根目录开始。但如果项目的静态资源部署在某个子路径下,比如https://example.com/site/,那么打包时路径也必须带上前缀。
解决办法是在vite.config.ts里设置base:
export default defineConfig({ base: process.env.NODE_ENV === 'production' ? '/site/' : '/' })也可以用环境变量的方式更灵活地控制。这个配置在本地开发时保持/,打包时自动切换成/site/,一次性解决生产环境的资源路径问题。
另外一个和它关联的坑是路由。使用react-router-dom的createBrowserRouter时,路由路径是真实的URL路径,部署在子路径下就要求服务器把该路径下的所有请求都转发到index.html。如果服务器不配合,可以改用createHashRouter,用hash方式管理路由,这样部署在静态服务器上不会遇到页面刷新404的问题。两种方式各有优缺点:BrowserRouter的URL更优雅,但要求服务器配置好History fallback;HashRouter更省事,但URL里会有#号。rea项目一开始用BrowserRouter,打包上去刷新子页面就404,要么改服务器配置,要么换个思路用HashRouter。如果你控制不了服务器配置,用HashRouter是最稳健的方案。
5.3 Nginx部署配置实录
我用了一个简单的Nginx配置来部署rea项目,实测下来稳定运行,再加一层缓存优化:
server { listen 80; server_name your-domain.example; root /var/www/rea/dist; index index.html; gzip on; gzip_types text/css application/javascript application/json image/svg+xml; location / { try_files $uri $uri/ /index.html; } location /assets/ { expires 30d; add_header Cache-Control "public, immutable"; } }这里的try_files $uri $uri/ /index.html是BrowserRouter能正常工作的关键——当天路径不存在时,Nginx会把请求重写回index.html,由前端路由接管页面的渲染。
assets目录下的文件在生产构建时文件名带有hash指纹,内容变化文件名也会变,因此可以放心设置30天的Cache-Control。而index.html本身是入口文件,不能缓存太久,否则用户更新了版本看到的还是旧页面。
5.4 版本发布与缓存刷新的一体化策略
提到缓存,这里有一个非常值得注意的实战问题。浏览器对index.html有默认的启发式缓存,用户访问过一次之后,就算你的服务器返货了新版本,浏览器也可能先用本地缓存直接显示旧页面。
解决这个问题的方案是在打包时给index.html生成带时间戳的版本文件,并在Nginx的配置中单独设置index.html的缓存策略:
location = /index.html { add_header Cache-Control "no-cache, no-store, must-revalidate"; }设置no-cache后,浏览器每次加载首页都会向服务器确认是否有新版本,有新版本就下新的,没有则用304快速响应。这样既不牺牲首页的更新及时性,又不会让资源文件被重复下载。rea项目上线后,用户基本能在刷新页面后的几个小时内看到最新版本,体验上接近实时更新。
6. 开发过程中的十大高频问题排查
6.1 TypeScript类型报错与接口对齐
现象:后端返回的数据接口字段名与前端类型定义不一致,编译时TS报错,但页面一刷新就白屏。
排查思路:类型定义是TS编译器在编译时检查的,跟后端接口文档保持同步才能不报错。rea项目真正跑通后我做的第一件事是把types目录作为一个独立的业务模块来做,每次后端改字段名,都要同步更新types目录下的对应定义。
解决方案:在接口层函数处,利用TypeScript的泛型来保证类型一致性——这个后端返回的字段是UserInfo类型,那前端就拿它当UserInfo用。如果后端真的改了字段,编译器会精准告诉你这次改动影响了哪些调用方。
6.2 路由切换后滚动条未回到顶部
现象:从一个长页面滚动到底部后,点击页面底部导航切换到新的路由,新页面仍然停留在底部位置,体验很怪。
原因:BrowserRouter在做路由切换时只是替换了组件树,不会自动修改window.scrollY。
解决方案:在Root组件里监听location变化,手动重置滚动位置:
// src/components/RouterScrollRestoration.tsx import { useEffect } from 'react' import { useLocation } from 'react-router-dom' export default function RouterScrollRestoration() { const { pathname } = useLocation() useEffect(() => { window.scrollTo(0, 0) }, [pathname]) return null }把这个组件放到路由的根节点下,每次路径变化自动回到顶部。如果你的页面里还有一些特殊场景需要保留滚动位置的,可以加一个判断逻辑,只在特定路由下重置。
6.3 生产环境首屏白屏与JavaScript错误
现象:本地开发完全正常,部署到测试环境后,部分用户打开应用白屏。
排查:打开控制台,看到的报错信息是一个TypeError,说某个变量在undefined上读取属性。进一步定位发现,这是使用了浏览器不支持的语法特性导致的老版本浏览器崩溃。Edge 16及以下版本对可选链运算符和空值合并运算符的支持不完整。
解决方案:Vite默认的构建目标是基于绝大多数现代浏览器,但老版本浏览器确实覆盖不到。在生产构建配置里调整browserslist目标,让esbuild做语法降级:
// vite.config.ts export default defineConfig({ build: { target: 'es2015' } })如果还有更老的环境需求,可以引入@vitejs/plugin-legacy,在构建产物里额外生成一套兼容版本,给现代浏览器加载新代码、老浏览器加载旧代码。
6.4 不受控表单导致的Wrong-nesting警告
现象:浏览器控制台一直报警告,说某个div嵌套在p标签里,DOM结构不符合标准。
原因:React组件里写了段落文字,里面用了封装成div的通用组件,浏览器解析时自动做了标签修复,导致React的虚拟DOM和真实DOM不一致。
解决方案:排查该组件的标签嵌套关系,把段落里的通用组件改成semantic元素。这个报错虽然不会直接导致应用崩溃,但会影响SEO和可访问性,还是及早改掉为好。
6.5 Effect重复执行:React 18 StrictMode惹的祸
现象:在useEffect里写的接口请求,开发模式下执行了两次。
原因:React 18的StrictMode在开发环境会刻意double-invoke部分生命周期和Effect,目的是提前暴露副作用的不纯净性。这是特性而不是bug。
解决方案:在开发环境下不需要处理,担心重复请求可以在Effect里加cancel标记。有意思的是生产环境下这个问题不会出现,所以如果你在开发模式看到请求发两次,可以先检查一下是不是StrictMode导致的,省得白费力气排查。
6.6 Zustand状态更新的异步陷阱
现象:一个事件里连续调用了两次useAuthStore.setState,但页面状态只更新了一次。
排查:查看代码发现,两个setState中间有过长的异步等待,导致第二次setState操作作用在旧状态快照上,真实情况是第二次setState被merge处理时覆盖了第一次的变更。
解决方案:Zustand的set方法默认是浅合并,如果状态更新依赖上一次的状态,务必用函数式写法:
set((state) => ({ count: state.count + 1 }))这个写法能确保更新操作基于最新的state,而不是闭包捕获的旧值。
6.7 React Query缓存没更新的坑
现象:用户修改个人信息后,列表页里的用户信息没有同步更新。
原因:修改和查询用的是不同的queryKey,修改成功后没有让旧的列表query失效。
解决方案:在useMutation里配置onSuccess,主动失效相关queryKey,这是和rea项目里第一个相似的坑。更复杂一点可以用setQueryData直接写入新的缓存值,避免额外的网络请求,但失效重拉是个更简单稳妥的思路。
6.8 打包体积过大与首屏资源加载
现象:路由懒加载之后,发现某个路由单独拆出来的chunk仍然特别大。
排查:在浏览器devtools的Source面板里找到具体chunk,展开看打包了哪些模块,发现问题出在身上:某个页面引入了一个巨大的第三方库,但这个库只有在特定交互时才需要。
解决方案:把这个库改成动态加载,用到时再import。如果某个库实在太大且拆不开,可以用自定义构建插件做模块级别的拆分。实际项目里webpack的splitChunks和Vite的manualChunks配置都可以承担这个任务,不过更根本的解法还是从依赖源头控制体积。
6.9 环境变量失效与热更新的坑
现象:修改.env.development里配置的接口地址后,dev服务器重新编译但请求的仍是旧地址。
原因:Vite的环境变量是从master环境快照里读取的,热更新时不会自动重新读取。
解决方案:修改完.env文件后必须重启npm run dev,这个坑很容易被忽略,建议做好团队约定,改了环境变量就重启服务。
6.10 HMR失效,页面卡死的排查
现象:代码保存后,页面提示热更新失败,控制台报error。
排查:最常见的原因是某个文件里的语法错误没有闭合标签,导致编译中断。
解决方案:先检查编辑器的问题,修改后保存再试。如果还是不行,关掉dev服务重开,多半能解决。还有个别时候,启动多个dev进程,导致HMR的WebSocket连接串了,也会出现类似问题。
7. 从rea项目沉淀的实操经验与体会
7.1 状态管理规范的执行比选型更重要
回顾整个rea项目,我最想强调的一点是:状态管理方案的选型排名并不重要,重要的是团队能不能严格执行一套约定。
Zustand也好,Redux Toolkit也好,甚至是简单的Context,都能写出好代码,关键是把这些方案背后的边界和用法定清楚:什么状态放进全局store、什么状态放在组件内部、服务端状态和客户端状态的区分。rea项目在进入稳定开发期后,我把这套约定写成了一个简短的开发规范文档,例会时过了一遍。后面新同学入场后,看一遍文档加翻一遍已有代码,就能沿用这套模式,不会凭个人风格乱写。
7.2 React Query的缓存策略值得重视
React Query是这套技术栈里替换成本最高的一个选择。它的价值不在是少写几行请求代码,而在于它把服务端状态的缓存模型做得很完整。staaleTime、cacheTime、refetchOnWindowFocus这些参数的语义如果不理解,容易被默认行为坑到。
rea项目中使用的几个参数配置:
// src/main.tsx const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, // 30秒内不重新拉取 gcTime: 5 * 60_000, // 5分钟无引用后清缓存(旧字段cacheTime) refetchOnWindowFocus: false, // 窗口聚焦不重拉 retry: 1 // 失败重试一次 } } })这组配置经受住了验证:数据30秒有效,窗口切回来不会频繁触发请求,用户体验和服务器压力之间比较均衡。
7.3 从乱到规范的迭代,比从零到一更重要
rea项目最初也不是一蹴而就的规范项目,它经历过组件越写越大、状态散落各处的阶段。大约到第五个页面时,我们决定停下来做一次代码整理。
这次整理做了三件事:把重复的列表组件提取出来、把状态统一收敛到Zustand store、把请求全部迁移到React Query。过程不复杂,但做完整理之后,项目维护成本明显下降。这印证了一个道理:技术项目的成长不是只做加法,而是在关键节点上敢于做减法,让代码结构始终保持可控。
对于刚入手React项目的读者,我的建议是一致的:不必追求一次就把架构搭到完美,重要的是在开发过程中持续重构,保持项目结构时刻比业务复杂度领先半步。过早设计过度架构反而会增加上手成本,专项重构集中做一次,收益会非常明显。