1. 从“rea”这个标题说起:一个被低估的缩写背后藏着什么
第一次看到“rea”这个标题的时候,我承认自己愣了几秒。三个字母,没有上下文,没有说明,放在任何一个技术社区里都像是某种暗号。但恰恰是这种极简的命名方式,在圈子里其实非常常见——很多开发者习惯用项目核心模块的首字母缩写来命名仓库,时间久了,连他们自己都忘了当初为什么这么叫。
我后来花了不少时间去梳理“rea”在不同技术语境下可能指向的含义。结合当前网络上的讨论热度和实际项目经验,这个标题最可能指向的是React 生态下的某种工程化实践,或者更具体一点,是围绕React 应用架构中“响应式、可扩展、可维护”三个核心诉求展开的一套方法论。也有人把它理解为“Runtime Environment Adapter”的缩写,指向运行时环境适配层的设计与实现。不管是哪种解读,核心都绕不开一个关键词:前端工程化中的架构抽象。
为什么我要从这个角度切入?因为单纯讨论“rea”这三个字母本身没有意义,真正有价值的是它背后代表的那类问题——当你的前端项目从几十个组件膨胀到几百个组件,从单页面扩展到多页面、多端复用的时候,你会发现代码的组织方式比具体用什么框架重要得多。这也是我在过去几年里反复踩坑、反复重构之后最深的体会。
这篇文章适合谁看?如果你正在经历以下任何一种情况,那接下来的内容应该能帮到你:项目里的组件越写越多,但复用率极低;状态管理逻辑散落在各个角落,改一个地方要翻五个文件;团队里每个人写代码的风格都不一样,代码审查变成吵架现场;或者你只是单纯对“如何让 React 项目在规模变大之后依然好维护”这个问题感兴趣。我会从架构设计的底层逻辑讲起,一直讲到可以直接抄作业的目录结构和代码示例,中间穿插我自己踩过的坑和总结出来的经验。
2. 核心思路拆解:为什么“rea”式架构值得认真对待
2.1 从“能跑就行”到“能改才行”的思维转变
大部分前端项目在起步阶段都是“能跑就行”。产品经理说要做个列表页,你三下五除二写个组件,数据一拉,渲染出来,收工。这个阶段没有任何问题,快速交付本来就是第一优先级。但问题在于,很多项目在进入第二个阶段——也就是需求开始频繁变更、页面开始互相嵌套、不同角色开始提不同要求的时候——代码结构并没有跟着升级。
我见过太多项目,到了中期就变成了一锅粥。一个components文件夹下面塞了上百个文件,命名从Header到HeaderNew再到HeaderFinal,谁也不敢删任何一个。状态管理更是重灾区,有的用 Redux,有的用 Context,有的直接组件内部useState硬扛,数据流向完全不可预测。
“rea”式架构的核心思路,就是在项目还没有烂到无法维护之前,先建立一套约束机制。这套机制不依赖某个具体的库或框架,而是一组关于“代码应该放在哪里、数据应该怎么流动、组件之间应该怎么通信”的约定。你可以把它理解成前端项目的“交通规则”——没有这些规则,车也能开,但迟早会堵死。
2.2 三个核心原则:响应式、可扩展、可维护
我把“rea”拆解成三个维度来理解,这样在实际操作中更容易落地。
响应式(Responsive)在这里不仅仅指页面适配不同屏幕尺寸,更指的是代码层面对变化的响应能力。当业务逻辑变了,你需要改几个文件?当一个接口的返回结构变了,有多少地方会报错?好的架构应该让这些变更的影响范围尽可能小。具体到实现上,就是要把“变化的”和“不变的”隔离开,把“经常一起变的”放在一起。
可扩展(Extensible)指的是当新需求来的时候,你是在现有基础上“加东西”,而不是“改东西”。这两者的区别很大。加东西意味着你只需要新增文件、新增配置,原有代码不动;改东西意味着你要理解原有逻辑,然后小心翼翼地修改,生怕碰坏什么。前者是开方式的,后者是封闭式的。我个人的经验是,如果一个新页面的开发需要修改超过三个已有文件,那说明架构的扩展性已经出问题了。
可维护(Maintainable)是最容易被忽视但最重要的一个。可维护性体现在很多细节上:命名是否清晰、依赖关系是否明确、测试是否容易写、新人是否能快速上手。我判断一个项目可维护性的标准很简单:让一个没接触过这个项目的人,在半小时内找到“用户登录”这个功能的全部相关代码。如果他能做到,说明结构是清晰的;如果他翻了半天还在问“这个逻辑到底在哪”,那就需要重构了。
2.3 为什么不用现成的框架或模板
你可能会问,市面上已经有那么多脚手架和模板了,为什么还要自己折腾一套架构?这个问题我认真想过。现成的方案当然有它的价值,比如 Create React App 帮你省去了构建配置的麻烦,Next.js 帮你解决了服务端渲染的问题。但它们解决的是“从零到一”的问题,而“rea”式架构要解决的是“从一到一百”的问题。
更重要的是,每个项目的业务场景都不一样。电商项目的核心是商品和订单,社交项目的核心是用户和关系链,工具类项目的核心是功能和权限。这些差异决定了它们的代码组织方式不可能完全一样。直接套用别人的架构,就像穿别人的鞋——能穿,但不一定合脚。
我自己的做法是,先理解“rea”这三个原则,然后根据项目的实际特点来设计具体的目录结构和数据流方案。下面我会详细展开我是怎么做的。
3. 核心细节解析:目录结构、状态管理与组件设计
3.1 目录结构:按“功能”还是按“类型”
这是前端架构里最经典的争论之一。按类型分就是components、hooks、utils、services这样的文件夹;按功能分就是user、product、order这样的模块文件夹,每个模块内部再细分。
我两种都用过,最后的选择是混合模式:顶层按功能分,功能内部按类型分。具体来说,目录结构大概长这样:
src/ modules/ user/ components/ hooks/ services/ types.ts index.ts product/ components/ hooks/ services/ types.ts index.ts shared/ components/ hooks/ utils/ app/ routes/ store/ providers/这样做的理由是:功能模块内部的代码内聚性最高,应该放在一起。比如用户模块的组件、钩子、接口调用,它们之间的关联远比用户模块的组件和商品模块的组件之间的关联要强。把它们放在同一个文件夹下,修改用户逻辑的时候就不需要跨目录跳来跳去。
而shared目录放的是真正跨模块复用的东西,比如通用的按钮组件、日期格式化工具、请求封装等。这里有一个判断标准:如果一个东西被三个以上的模块用到,才考虑放进 shared。如果只有两个模块用,我倾向于先放在其中一个模块里,等第三个模块也需要的时候再提取出来。过早抽象比重复代码更可怕。
app目录则是整个应用的入口层,负责路由注册、全局状态挂载、Provider 嵌套这些“胶水”工作。这一层应该尽量薄,不包含具体的业务逻辑。
3.2 状态管理:什么时候用全局,什么时候用局部
状态管理是前端架构里最容易过度设计的地方。我见过太多项目,明明只有几个页面,却上来就装 Redux,然后写一大堆 action、reducer、selector,最后发现真正跨组件共享的状态只有用户信息那一个。
我的原则是从局部开始,按需升级。具体分三个层次:
第一层是组件内部状态,用useState就够了。比如一个弹窗的显示隐藏、一个表单的输入值,这些状态不需要被其他组件知道,放在组件内部最合适。
第二层是模块内部共享状态,用Context加useReducer的组合。比如用户模块内部,用户信息、权限列表、偏好设置这些状态需要在多个子组件之间共享,但又不需要暴露给商品模块。这时候在模块顶层建一个 Context,把状态和更新方法传下去,既解决了共享问题,又保持了模块的边界。
第三层才是全局状态,比如当前登录用户、主题配置、语言设置这些真正跨模块的东西。这一层我倾向于用轻量级的方案,比如 Zustand 或者 Jotai,而不是 Redux。原因很简单:全局状态越少越好,而 Redux 的模板代码太多,容易让人产生“什么都要放进去”的冲动。
这里有一个我踩过的坑:不要用全局状态来传递页面之间的临时数据。比如从列表页跳到详情页,需要传递一个 ID,很多人会把它存到全局状态里。这样做的问题是,当用户直接刷新详情页的时候,全局状态是空的,页面就挂了。正确的做法是通过路由参数传递,这样刷新也能恢复。
3.3 组件设计:容器与展示的分离还有必要吗
“容器组件”和“展示组件”的分离是 React 社区早期非常流行的一种模式。容器组件负责数据获取和状态管理,展示组件负责渲染 UI。后来随着 Hooks 的普及,这种模式被很多人抛弃了,因为 Hooks 让逻辑复用变得更容易,不需要再通过组件嵌套来实现。
但我的观点是,分离的思想依然有价值,只是实现方式变了。以前是拆成两个组件,现在可以拆成一个自定义 Hook 加一个纯展示组件。比如:
// useUserProfile.ts function useUserProfile(userId: string) { const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { fetchUserProfile(userId) .then(setProfile) .finally(() => setLoading(false)); }, [userId]); return { profile, loading }; } // UserProfile.tsx function UserProfile({ userId }: { userId: string }) { const { profile, loading } = useUserProfile(userId); if (loading) return <Skeleton />; if (!profile) return <Empty />; return ( <div> <Avatar src={profile.avatar} /> <Name>{profile.name}</Name> </div> ); }这样拆的好处是,useUserProfile这个 Hook 可以在任何需要用户信息的地方复用,而UserProfile组件只关心怎么把数据显示出来。测试的时候也方便,Hook 可以单独测试,组件也可以单独测试。
3.4 接口层设计:统一封装与错误处理
接口调用是前端项目里最容易写乱的地方。我见过直接在组件里写fetch的,也见过每个模块各自封装一套请求库的。这两种做法都有问题:前者导致错误处理逻辑重复,后者导致维护成本翻倍。
我的做法是在shared目录下建一个统一的请求封装,然后在各个模块的services目录里定义具体的接口函数。请求封装负责处理通用逻辑:baseURL 拼接、token 注入、超时设置、错误码映射、重试策略。模块的 service 只负责定义“调哪个接口、传什么参数、返回什么类型”。
// shared/api/client.ts const client = { async get<T>(url: string, params?: object): Promise<T> { const response = await fetch(buildUrl(url, params), { headers: buildHeaders(), }); return handleResponse<T>(response); }, // post, put, delete 类似 }; // modules/user/services/profile.ts export async function fetchUserProfile(userId: string) { return client.get<UserProfile>('/api/user/profile', { userId }); }这样做还有一个好处:当后端接口发生变化的时候,你只需要改 service 文件,组件和 Hook 完全不用动。这就是前面说的“把变化隔离在最小范围内”。
4. 实操过程:从零搭建一个“rea”式项目
4.1 初始化与基础配置
假设我们现在要从零开始搭建一个项目,我会按下面的步骤来操作。首先用 Vite 初始化一个 React + TypeScript 的项目,因为 Vite 的构建速度比传统的 Webpack 快很多,开发体验更好。
npm create vite@latest my-app -- --template react-ts cd my-app npm install然后安装几个必要的依赖:路由用 React Router,状态管理用 Zustand,请求库直接用原生的 fetch 封装(不引入 axios,减少依赖体积),样式方案用 CSS Modules(避免样式冲突,又不需要额外的运行时)。
npm install react-router-dom zustand接下来配置路径别名,这样在导入的时候可以用@/modules/user代替../../modules/user,代码更清晰。在vite.config.ts和tsconfig.json里分别加上对应的配置。
// 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'), }, }, });这一步看起来简单,但非常重要。我见过太多项目因为路径太深,导致导入语句变成../../../../components/Button这种,可读性极差。别名配置是一次性工作,收益却贯穿整个项目生命周期。
4.2 搭建模块化目录骨架
按照前面说的混合模式,我先把目录骨架建起来。这里不需要一次性建全,先把核心模块和共享层建好,后续按需扩展。
mkdir -p src/modules/user/{components,hooks,services} mkdir -p src/modules/product/{components,hooks,services} mkdir -p src/shared/{components,hooks,utils,api} mkdir -p src/app/{routes,providers}然后在每个模块下建一个index.ts作为模块的出口文件。这个文件的作用是控制模块的对外暴露接口,外部模块只能从index.ts导入,不能直接深入到模块内部的某个文件。这样做的好处是,当模块内部结构调整的时候,只要index.ts的导出不变,外部代码就不需要修改。
// modules/user/index.ts export { UserProfile } from './components/UserProfile'; export { useUserProfile } from './hooks/useUserProfile'; export type { UserProfile as UserProfileType } from './types';4.3 实现统一请求层与错误处理
请求层的实现是整个项目的基础设施,值得花时间做好。我的实现思路是:底层用 fetch,上面包一层拦截器机制,支持请求前和响应后的处理。
// shared/api/client.ts type RequestConfig = { params?: Record<string, string | number>; body?: unknown; headers?: Record<string, string>; }; async function request<T>( method: string, url: string, config: RequestConfig = {} ): Promise<T> { const { params, body, headers = {} } = config; const finalUrl = params ? `${url}?${new URLSearchParams( Object.entries(params).map(([k, v]) => [k, String(v)]) )}` : url; const response = await fetch(finalUrl, { method, headers: { 'Content-Type': 'application/json', ...headers, }, body: body ? JSON.stringify(body) : undefined, }); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new ApiError(response.status, error.message || '请求失败'); } return response.json(); } export const api = { get: <T>(url: string, params?: Record<string, string | number>) => request<T>('GET', url, { params }), post: <T>(url: string, body?: unknown) => request<T>('POST', url, { body }), // put, delete 类似 };这里有一个细节值得注意:错误处理我定义了一个ApiError类,包含状态码和消息。这样在组件里捕获错误的时候,可以根据状态码做不同的处理,比如 401 跳转登录,403 显示无权限提示,500 显示服务器错误。
4.4 编写第一个模块:用户模块的完整实现
有了基础设施之后,写业务模块就很快了。以用户模块为例,我需要实现三个东西:类型定义、接口服务、展示组件。
类型定义放在types.ts里:
// modules/user/types.ts export type UserProfile = { id: string; name: string; avatar: string; email: string; role: 'admin' | 'editor' | 'viewer'; };接口服务放在services/profile.ts里:
// modules/user/services/profile.ts import { api } from '@/shared/api/client'; import type { UserProfile } from '../types'; export function fetchUserProfile(userId: string) { return api.get<UserProfile>('/api/user/profile', { userId }); } export function updateUserProfile(userId: string, data: Partial<UserProfile>) { return api.post<UserProfile>('/api/user/update', { userId, ...data }); }Hook 放在hooks/useUserProfile.ts里:
// modules/user/hooks/useUserProfile.ts import { useState, useEffect, useCallback } from 'react'; import { fetchUserProfile, updateUserProfile } from '../services/profile'; import type { UserProfile } from '../types'; export function useUserProfile(userId: string) { const [profile, setProfile] = useState<UserProfile | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); const load = useCallback(async () => { setLoading(true); setError(null); try { const data = await fetchUserProfile(userId); setProfile(data); } catch (e) { setError(e instanceof Error ? e.message : '加载失败'); } finally { setLoading(false); } }, [userId]); useEffect(() => { load(); }, [load]); const update = useCallback( async (data: Partial<UserProfile>) => { const updated = await updateUserProfile(userId, data); setProfile(updated); return updated; }, [userId] ); return { profile, loading, error, reload: load, update }; }组件放在components/UserProfile.tsx里:
// modules/user/components/UserProfile.tsx import { useUserProfile } from '../hooks/useUserProfile'; import styles from './UserProfile.module.css'; export function UserProfile({ userId }: { userId: string }) { const { profile, loading, error, reload } = useUserProfile(userId); if (loading) return <div className={styles.skeleton}>加载中...</div>; if (error) return <div className={styles.error} onClick={reload}>{error},点击重试</div>; if (!profile) return <div className={styles.empty}>暂无数据</div>; return ( <div className={styles.container}> <img className={styles.avatar} src={profile.avatar} alt={profile.name} /> <div className={styles.info}> <h3>{profile.name}</h3> <p>{profile.email}</p> <span className={styles.role}>{profile.role}</span> </div> </div> ); }这样一套下来,用户模块的完整功能就实现了。其他模块要使用用户信息的时候,只需要从@/modules/user导入UserProfile组件或者useUserProfileHook 就行,完全不需要关心内部实现。
4.5 路由与页面组装
最后在app层把各个模块组装起来。路由配置我倾向于用集中式的方式,所有路由在一个文件里定义,这样一眼就能看出整个应用的页面结构。
// app/routes/index.tsx import { createBrowserRouter } from 'react-router-dom'; import { UserProfilePage } from '@/modules/user'; import { ProductListPage } from '@/modules/product'; export const router = createBrowserRouter([ { path: '/', element: <Layout />, children: [ { path: 'user/:userId', element: <UserProfilePage /> }, { path: 'products', element: <ProductListPage /> }, ], }, ]);这里有一个经验:页面级组件应该放在模块内部,而不是 app 层。app 层只负责路由映射,不包含具体的页面实现。这样当某个模块被移除或者替换的时候,只需要改路由配置,不需要动 app 层的其他代码。
5. 常见问题与排查技巧实录
5.1 模块之间的循环依赖怎么破
这是模块化架构里最常见的问题之一。比如用户模块需要用到商品模块的某个类型,商品模块又需要用到用户模块的某个函数,两边互相导入,构建工具就会报循环依赖的警告,严重的时候会导致运行时错误。
我的解决思路分三步。第一步是检查这个依赖是否真的必要。很多时候,循环依赖的出现是因为我们把不该放在一起的东西放在了一起。比如用户模块里有一个“获取用户收藏的商品”的功能,这个功能到底属于用户模块还是商品模块?我倾向于把它放在商品模块,因为它的核心是商品数据,用户 ID 只是一个查询参数。
第二步是提取公共依赖到 shared 层。如果两个模块都需要用到同一个类型或工具函数,那就把它提到 shared 里,两边都从 shared 导入,循环就打破了。
第三步是用事件机制解耦。如果两个模块确实需要互相通信,但又不想互相导入,可以引入一个简单的事件总线。用户模块触发事件,商品模块监听事件,两者之间没有直接的代码依赖。这种方式要慎用,因为它会让数据流变得不那么直观,但在某些场景下确实是必要的。
5.2 状态更新后组件不重新渲染
这个问题我遇到过很多次,原因通常有几个。最常见的是直接修改了状态对象的属性,而不是创建一个新对象。React 判断是否需要重新渲染的依据是状态的引用是否变化,如果你直接state.name = 'new',引用没变,React 就认为状态没变。
正确的做法是用展开运算符或者immer这样的库来创建新对象:
// 错误 setProfile((prev) => { prev.name = 'new name'; return prev; }); // 正确 setProfile((prev) => ({ ...prev, name: 'new name', }));另一个常见原因是Context 的 value 每次渲染都创建了新对象。比如<MyContext.Provider value={{ user, setUser }}>,每次父组件渲染的时候,{ user, setUser }都是一个新对象,导致所有消费这个 Context 的组件都重新渲染。解决办法是用useMemo把 value 缓存起来。
5.3 接口请求的竞态问题
竞态问题是指多个请求同时发出,但返回的顺序不确定,导致后发的请求先返回,先发的请求后返回,最终显示的数据是错的。比如用户在搜索框里快速输入,每次输入都触发一次请求,如果第一次请求比第三次请求晚返回,页面就会显示第一次的结果。
解决办法有两种。一种是取消之前的请求,用AbortController来实现:
useEffect(() => { const controller = new AbortController(); fetchData({ signal: controller.signal }) .then(setData) .catch((e) => { if (e.name !== 'AbortError') setError(e); }); return () => controller.abort(); }, [query]);另一种是给请求打标记,只处理最新一次请求的结果:
const requestIdRef = useRef(0); async function load() { const id = ++requestIdRef.current; const data = await fetchData(); if (id === requestIdRef.current) { setData(data); } }两种方式都可以,我通常用第一种,因为取消请求还能节省网络资源。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 组件不重新渲染 | 状态引用未变化 | 检查 setState 是否创建了新对象 | 用展开运算符或 immer |
| 循环依赖警告 | 模块互相导入 | 查看构建工具的警告信息 | 提取公共依赖或使用事件机制 |
| 接口数据错乱 | 请求竞态 | 检查是否有多个请求同时发出 | 使用 AbortController 或请求标记 |
| 样式冲突 | 全局样式污染 | 检查是否有同名 class | 使用 CSS Modules 或命名空间 |
| 打包体积过大 | 未做代码分割 | 分析构建产物 | 路由级懒加载 + 动态导入 |
| 首屏加载慢 | 资源未优化 | 检查网络面板 | 图片压缩、字体子集化、预加载关键资源 |
5.5 几个我踩过的坑和对应的经验
第一个坑是过度抽象。刚开始做架构的时候,我总想着要“设计得足够灵活”,于是定义了很多抽象层和接口。结果就是,写一个简单的功能要改五六个文件,新人看了直摇头。后来我学乖了,抽象要等到重复出现三次以上再做,在此之前,重复代码是可以接受的。
第二个坑是忽略构建工具的配置。很多人只关注业务代码,不关注构建配置,结果项目大了之后构建速度慢得离谱。我的经验是,尽早配置好路径别名、代码分割、依赖预构建这些,后期能省很多时间。
第三个坑是没有统一的代码规范。团队里每个人都有自己的风格,有人喜欢用分号,有人不喜欢;有人用双引号,有人用单引号。这些看起来是小事,但代码审查的时候会浪费大量时间在格式争论上。解决办法是配置 ESLint 和 Prettier,并且在提交代码前自动格式化。
第四个坑是测试覆盖率追求数字。我曾经为了达到 80% 的覆盖率,写了很多没有实际意义的测试,比如测试一个纯展示组件是否渲染了某个 div。后来我意识到,测试应该关注行为而不是实现,应该测试“用户点击按钮后是否发起了请求”,而不是“按钮的 class 名是否正确”。
6. 性能优化与工程化配套
6.1 代码分割与懒加载的实操细节
当项目变大之后,首屏加载时间会成为一个明显的问题。用户打开页面,要等好几秒才能看到内容,体验很差。代码分割是解决这个问题的核心手段。
我的做法是按路由做分割,每个页面打包成一个独立的 chunk,用户访问哪个页面就加载哪个 chunk。在 React Router 里实现起来很简单:
import { lazy, Suspense } from 'react'; const UserProfilePage = lazy(() => import('@/modules/user/pages/UserProfilePage')); const ProductListPage = lazy(() => import('@/modules/product/pages/ProductListPage')); // 在路由配置里用 Suspense 包裹 <Suspense fallback={<PageLoading />}> <Routes> <Route path="/user/:userId" element={<UserProfilePage />} /> <Route path="/products" element={<ProductListPage />} /> </Routes> </Suspense>这里有一个细节:懒加载的组件最好用默认导出,因为lazy函数期望的返回值是一个包含default属性的模块。如果组件用的是命名导出,需要额外处理一下。
另一个细节是预加载。当用户鼠标悬停在某个链接上的时候,可以提前加载对应的 chunk,这样点击的时候就能瞬间打开。实现方式是在链接的onMouseEnter事件里调用import():
function PrefetchLink({ to, children }: { to: string; children: React.ReactNode }) { const prefetch = () => { const path = to.split('/')[1]; if (path === 'user') import('@/modules/user/pages/UserProfilePage'); if (path === 'products') import('@/modules/product/pages/ProductListPage'); }; return <Link to={to} onMouseEnter={prefetch}>{children}</Link>; }6.2 构建产物的分析与优化
代码分割做完之后,还需要分析一下产物的构成,看看有没有可以优化的地方。Vite 自带了一个分析工具,在构建的时候加上--mode analyze参数就能生成可视化报告。
我通常会关注几个指标:每个 chunk 的大小、第三方依赖的占比、是否有重复打包的模块。如果某个 chunk 特别大,就要看看里面是不是包含了不该包含的东西。比如 lodash 如果全量引入,会有几百 KB,但实际上可能只用了几个函数,这时候就应该改成按需引入。
// 不推荐 import _ from 'lodash'; _.debounce(fn, 300); // 推荐 import debounce from 'lodash/debounce'; debounce(fn, 300);还有一个常见的优化点是图片资源。很多项目直接把设计稿导出的 PNG 放进项目里,一张图就好几 MB。我的做法是:能用 SVG 的用 SVG,不能用 SVG 的压缩成 WebP 格式,并且根据显示尺寸生成多套分辨率,用srcset让浏览器自己选择。
6.3 环境变量与多环境部署
项目开发的时候通常有多个环境:本地开发、测试环境、预发布环境、生产环境。每个环境的接口地址、CDN 地址、功能开关可能都不一样。管理这些差异的常见做法是用环境变量。
Vite 里以VITE_开头的环境变量会被注入到客户端代码中。我通常建几个文件:.env.development、.env.staging、.env.production,分别对应不同的环境。
# .env.development VITE_API_BASE_URL=http://localhost:3000 VITE_ENABLE_MOCK=true # .env.production VITE_API_BASE_URL=https://api.example.com VITE_ENABLE_MOCK=false然后在代码里通过import.meta.env.VITE_API_BASE_URL来读取。这里有一个安全提醒:不要在前端环境变量里放敏感信息,比如数据库密码、私钥等,因为这些变量最终会被打包到客户端代码里,任何人都能看到。
6.4 错误监控与日志上报
项目上线之后,你不可能时刻盯着用户的屏幕看有没有报错。所以需要一套错误监控机制,自动捕获异常并上报到服务端。
我通常会在app层做三件事。第一是全局错误边界,捕获 React 组件树里的渲染错误,显示一个友好的错误页面,而不是白屏。第二是全局事件监听,捕获window.onerror和unhandledrejection,把未处理的异常上报。第三是接口错误统一处理,在请求层拦截错误,根据错误类型决定是重试、提示还是跳转。
// app/providers/ErrorBoundary.tsx class ErrorBoundary extends React.Component { state = { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error: Error, info: React.ErrorInfo) { reportError({ error, info, type: 'react' }); } render() { if (this.state.hasError) { return <ErrorPage onRetry={() => this.setState({ hasError: false })} />; } return this.props.children; } }上报的时候要注意脱敏,不要把用户的个人信息、token 等敏感数据传到服务端。同时要做频率限制,避免某个错误疯狂上报把服务打挂。
7. 团队协作与代码规范落地
7.1 代码规范的自动化执行
前面提到过,团队协作最大的摩擦往往来自代码风格的不统一。解决这个问题不能靠口头约定,必须靠工具自动化执行。
我的配置组合是ESLint + Prettier + Husky + lint-staged。ESLint 负责代码质量检查,比如未使用的变量、错误的依赖数组;Prettier 负责格式化,比如缩进、引号、分号;Husky 负责在 git 提交时触发检查;lint-staged 负责只检查本次修改的文件,提高速度。
// package.json { "lint-staged": { "*.{ts,tsx}": ["eslint --fix", "prettier --write"], "*.{css,md}": ["prettier --write"] } }这样配置之后,每次提交代码都会自动格式化和检查,不符合规范的代码根本提交不上去。刚开始可能会有人抱怨麻烦,但一周之后就习惯了,而且代码审查的时候再也不用讨论格式问题,可以把精力放在逻辑上。
7.2 组件文档与示例
当 shared 目录下的通用组件越来越多的时候,新人往往不知道有哪些组件可以用、怎么用。这时候就需要一份组件文档。
我试过几种方案,最后选择了Storybook。它的好处是可以在浏览器里直接看到组件的各种状态,还可以交互式地调整 props,非常直观。每个组件写一个.stories.tsx文件,定义几种典型用法:
// shared/components/Button.stories.tsx export default { title: 'Shared/Button', component: Button, }; export const Primary = () => <Button variant="primary">主要按钮</Button>; export const Secondary = () => <Button variant="secondary">次要按钮</Button>; export const Loading = () => <Button loading>加载中</Button>; export const Disabled = () => <Button disabled>禁用</Button>;写 Storybook 确实需要额外的时间,但收益是长期的。新人可以通过 Storybook 快速了解有哪些组件可用,设计师可以确认组件的视觉效果是否符合预期,测试也可以基于 Storybook 做视觉回归测试。
7.3 代码审查的关注点
代码审查是保证代码质量的重要环节,但很多团队的代码审查流于形式,要么只看格式,要么直接点通过。我总结了几个审查时的关注点,按优先级排列:
第一是逻辑正确性。代码是否实现了需求描述的功能?边界情况是否处理了?错误情况是否有兜底?
第二是架构一致性。新代码是否遵循了项目的目录结构和分层约定?有没有把业务逻辑写在展示组件里?有没有跨模块直接导入内部文件?
第三是可测试性。核心逻辑是否被提取成了纯函数或自定义 Hook?是否容易写单元测试?
第四是性能影响。有没有不必要的重复渲染?有没有在循环里做昂贵的计算?有没有引入体积很大的依赖?
第五才是代码风格。这一项其实应该由工具自动处理,人工审查时不需要花太多精力。
7.4 新人上手指南的编写
一个好的项目应该让新人能在一天之内跑起来并提交第一个 PR。为了达到这个目标,我通常会在项目根目录写一个CONTRIBUTING.md,包含以下内容:
- 项目的基本架构说明,用一张简单的目录树加注释
- 本地开发环境的搭建步骤,包括 Node 版本、依赖安装、环境变量配置
- 常用的开发命令,比如启动开发服务器、运行测试、构建生产包
- 代码提交的规范,比如分支命名、commit message 格式
- 常见问题的解决方法,比如依赖安装失败、端口被占用等
这份文档不需要写得很长,但一定要准确、可执行。我见过很多项目的 README 写得很漂亮,但实际按照步骤操作却跑不起来,这种文档还不如不写。
8. 一些个人体会和后续扩展方向
这套“rea”式架构在我自己的项目里跑了挺长时间,中间也经历过几次调整。最大的感受是,架构不是设计出来的,而是演化出来的。一开始不要追求完美,先把基本的分层和模块边界定下来,然后在实际开发中不断调整。哪些地方别扭了,就说明那里需要优化;哪些地方顺畅了,就说明那里的设计是对的。
另一个体会是,工具和规范是为了解决问题而存在的,不要为了用而用。我见过一些项目,引入了各种先进的工具和框架,但团队成员根本用不起来,最后反而增加了维护成本。选择工具的时候要考虑团队的实际情况,适合的才是最好的。
后续如果继续扩展这套架构,我会考虑几个方向。一是微前端的接入,当项目大到需要多个团队并行开发的时候,可以考虑把不同模块拆成独立的子应用,通过模块联邦的方式组合。二是服务端渲染的集成,如果对首屏性能和 SEO 有要求,可以引入 Next.js 或者 Remix 这样的框架,把现有的模块化结构迁移过去。三是自动化测试的完善,目前主要是单元测试,后续可以补充集成测试和端到端测试,覆盖更完整的用户流程。
不过这些都是后话,先把眼前的项目结构理清楚、把团队的协作流程跑顺,比什么都重要。架构这件事,急不得,也省不得。