☰
React项目目录结构详解:从CRA到Vite的职责与修改边界
2026/10/6 14:20:54 网站建设 项目流程

不少读者搭完 React 开发环境、跑起第一个 Demo 之后,都会对着满屏的目录发呆:node_modules、public、src、package.json、jsconfig.json……这些东西到底归谁管,哪些能随便动,哪些动了就起不了服务?我刚开始带新人的时候,收到最多的提问恰恰不是合字母语法,而是“这个文件是干嘛的”。这篇不讲课,就是专门把 React 项目目录结构拆开揉碎,结合真实的 create-react-app 和 Vite 脚手架产物,把每个文件、每个文件夹的职责和改动边界讲清楚,让你拿到一个新工程不再发怵。

1. 新建完项目别急着写代码:先认清这份目录树是谁的主场

1.1 create-react-app 拉出来的初始结构到底长什么样

我用最经典的npx create-react-app my-app生成一个项目,展开之后通常是这样:

my-app/ ├── node_modules/ ├── public/ │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src/ │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── reportWebVitals.js │ └── setupTests.js ├── .gitignore ├── package.json ├── package-lock.json └── README.md

先说最重要的结论:你未来 90% 的代码都写在src里,node_modules是依赖安装包的大仓库,public是静态文件区,根目录下一堆配置文件是给构建工具和编辑器看的。这个定位一旦记住,后面所有细节都有了锚点。

我第一次搭建项目的时候,就因为不理解“public里的文件不参与打包”这个点,把组件逻辑硬塞进public目录里的 JS 文件,结果引了半天都引不进来。后来才明白,public里的东西原样拷贝到build输出目录,不经过 Babel、Webpack 这一整条编译链,自然不能写 JSX,更不能做模块导入导出。

1.2 package.json 才是真正决定项目怎么跑的核心

package.json放在根目录不是随便定的。它相当于项目的中枢神经系统,规定了三件事:命令怎么跑、依赖装哪些、项目叫什么版本。

{ "name": "my-app", "version": "0.1.0", "private": true, "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" } }

很多人不理解为什么 CRA 项目里看不到webpack.config.js,因为 Webpack 配置全被封在react-scripts这个工具包里。你执行npm start,其实就是调用了react-scripts start,它会按一套预设好的配置去启动开发服务器。这样对新手友好,你不用先懂 Webpack 才能跑项目。

dependencies和devDependencies的区别也值得记牢:前者是运行时依赖,比如react、react-dom,打包进线上代码;后者是构建期工具,比如eslint、typescript,只在开发阶段起作用。CRA 默认把测试库放进了 dependencies,很多团队会手动调整,这就是后话了。

必须提一下package-lock.json。它的作用是锁定依赖树的精确版本,保证你队友npm install出来的依赖跟你一致。团队协作里如果漏提交这个文件,很容易出现“我本地好好的,你那里跑不起来”的玄学问题。所以package-lock.json一定要提交进 git 仓库。

1.3 为什么现在越来越多新项目改用 Vite

说实话,现在新开 React 项目,我个人的推荐已经从 CRA 转向 Vite 了。CRA 维护节奏慢,依赖升级滞后,构建速度也明显比 Vite 慢。Vite 建出来的结构更精简:

vite-project/ ├── index.html ├── public/ ├── src/ │ ├── assets/ │ ├── components/ │ ├── App.jsx │ ├── main.jsx │ └── ... ├── .gitignore ├── package.json └── vite.config.js

最大的结构差异是index.html的位置:CRA 把它放在public里,Vite 则放在根目录。原因在于 Vite 把index.html当作依赖图的入口,开发服务器启动后所有源码都从<script type="module" src="/src/main.jsx">开始加载,后面的内容以这种原生的 ES Module 方式直接跑在浏览器里。

相比之下,CRA 用 Webpack 把所有模块打成一个 bundle,开发阶段也要先经过完整的打包流程再做热更新,几十个模块还好,项目一大就能明显感觉到启动慢。用 Vite 之后,开发服务器冷启动基本在几百毫秒内,改动响应也是毫秒级,体感确实不一样。

两种脚手架的定位其实没有高下之分,选择取决于团队。如果你要维护老项目,大概率还是 CRA;如果是从零起新项目,一步到位走 Vite 会省很多等待时间。

2. 根目录的隐藏文件不是摆设:index.html、gitignore、jsconfig 的真相

2.1 index.html 在 React 项目里到底扮演什么角色

不管是 CRA 还是 Vite,都绕不开index.html。它是整个单页应用(SPA)唯一的 HTML 文件,里面最关键的一行是<div id="root"></div>。React 启动时会通过ReactDOM.createRoot(document.getElementById('root'))把这个空 div 接管,随后整个页面都由 JS 动态渲染。

很多人对“只有一个 HTML”这件事不习惯,总想多建几个.html去区分页面。这不怪大家,传统多页应用就是这样做的。但 React 花了这么大劲搞虚拟 DOM、组件化,核心目的就是让页面切换不再刷新整张页面,所有路由都是在前端内部“演”出来的。唯一的 HTML 文件意味着打包时只有一个入口,静态资源的版本号可以统一管理,CDN 缓存策略更容易做。

修改 CDN / 服务器配置的时候也容易出问题:如果你的项目部署之后发现刷新某个路由变成 404,那不是 React 的问题,是服务器需要把所有非静态资源的请求都重写到index.html。这个知识点面试经常考,实际操作中也很常见。

2.2 是.gitignore拦住了哪些危险文件

.gitignore虽然不起眼,但它是项目安全的防线。

# dependencies /node_modules # production /build # misc .DS_Store *.local # env files .env.local .env.*.local

node_modules必须忽略,因为一个项目动辄几百 MB,只有package.json和package-lock.json才能重建它。build目录是本地构建产物,同样不该进仓库。.env.local这类本地环境变量文件里通常藏着本机独有的密钥,提交了等于把隐私送进仓库历史。

我见过一个真实事故:同事把.env.production误提交到了 git,里面生产环境的数据库地址和密钥直接泄露,最后全团队加班轮换凭证。从那以后,我在每个项目里都会先确认.gitignore里的环境变量规则是否完整。

2.3 环境变量文件的使用规则,CRA 和 Vite 不一样

React 项目里配置接口环境地址常用的方式是通过.env文件。CRA 有一个硬性规定:变量名必须带REACT_APP_前缀,否则代码里读取不到。例如:

REACT_APP_API_BASE_URL=https://dev.example.com REACT_APP_SENTRY_DSN=xxx

然后在代码中通过process.env.REACT_APP_API_BASE_URL读取。Vite 则要求以VITE_前缀开头,读取方式也不一样:

const apiBase = import.meta.env.VITE_API_BASE_URL;

这个区别经常让跨脚手架的人犯迷糊。CRA 的REACT_APP_来自 Webpack 的 DefinePlugin 注入机制,只有显式暴露给浏览器的变量才会被打包进代码;Vite 同样为了安全性,只暴露带VITE_前缀的变量。

实际操作中我习惯建三个文件:.env.development对应开发环境、.env.production对应线上环境、.env.local放本地临时值,同时让.gitignore把.env.local排除在版本控制之外。这样既不会把敏感信息提交上去,又能保证不同环境的配置切换顺畅。

2.4 jsconfig.json 或 tsconfig.json:让编辑器读懂你的目录别名

很多人打开jsconfig.json不知道它是干嘛的。它让 VSCode 之类编辑器知道你设置了路径别名,从而提供自动补全和跳转支持。最常见的场景是配置@指代src:

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

配置完jsconfig.json,还要在相应构建工具里同步启用别名。CRA 从 3.0 开始支持通过jsconfig.json或tsconfig.json的paths配置解析别名,不用写额外 Webpack 配置。Vite 则需要在vite.config.js里手动配置:

import path from 'path'; export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, 'src') } } });

配置好之后,引入深层层级文件时就不用写一长串../../../components/Button,直接@/components/Button就行。这个优化虽然看起来不起眼,但在项目重构时能少改很多路径,后期收益非常大。

3. src 才是每天写代码的主战场:入口、组件、资源和工具函数的排座次

3.1 从 index.js 到 App.js 的启动链路,一分钟理清

CRA 的src/index.js长这样:

import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <React.StrictMode> <App /> </React.StrictMode> ); reportWebVitals();

它做了三件事:导入全局样式、渲染根组件App、上报性能指标。App.js才是业务层的顶层组件,所有的路由、页面、全局弹窗最终都会挂到App下面。所以“入口是 index.js,起点是 App.js”,这个关系必须记住。

有一点经常让新手怀疑自己写错了代码:StrictMode包裹下,开发环境某些useEffect会执行两次,函数组件的渲染函数也会被调用两次。这不是 bug,而是 React 故意为之,用来暴露不纯的渲染逻辑。上线构建后这些双重调用会自动消失。

Vite 对应的启动文件叫main.jsx,逻辑基本类似。文件后缀jsx和js的区别在 Vite 模板里体现得很明显:凡是包含 JSX 语法的文件,建议统一用jsx后缀,这样编辑器和构建工具都能精准识别处理范围。

3.2 样式文件别混着用:index.css 是全局,App.css 是根组件的“私货”

CRA 初始模板里有两个样式文件:index.css和App.css。index.css在index.js里被导入,属于全局样式,适合放重置浏览器默认风格的代码,比如清除默认 margin、统一字体。App.css则由App.js导入,只服务于App组件。

这种划分看似简单,但往后写组件时容易“哪个文件都想去摸一下”。我更推荐的做法是:全局样式保留一个global.css,每个组件配一个同名样式文件,并且组件文件用 CSS Modules 或 Tailwind 这类局部作用域方案,避免样式命名冲突。CRA 原生支持xxx.module.css文件:

import styles from './Button.module.css'; export default function Button() { return <button className={styles.btn}>点击</button>; }

这样生成的类名是唯一的,写死了也不会污染其他组件。Vite 同样原生支持module.css,两套脚手架在这一点的体验是一致的。

3.3 reportWebVitals 和 setupTests:功能不熟也不要乱删

reportWebVitals是基于web-vitals库的性能上报工具,可以监测 LCP(最大内容绘制)、FID(首次输入延迟)、CLS(布局偏移)等前端性能指标。CRA 模板把它接好了,只要传入回调函数就能把数据发给监控平台。

reportWebVitals(console.log);

开发阶段可以这样打印看看,上线前替换成真实上报通道。setupTests.js是 Jest 测试环境的初始化文件,默认引入@testing-library/jest-dom,它给断言库扩展了toBeInTheDocument、toHaveClass等方法。如果你团队完全没有写测试的计划,把这两个文件删掉不影响开发;但我还是建议留下来,因为 React 项目越写越大,测试早晚会补上。

3.4 从默认四件套到通用目录:components、hooks、utils、services 怎么铺

CRA 的初始src只有入口、根组件和样式,一旦业务铺开,你一定会自己建目录。我在真实项目里最常用的一套基础骨架是这样:

src/ ├── api/ # 接口请求定义,按业务模块拆分 ├── assets/ # 静态资源,图片、字体、SVG ├── components/ # 通用可复用组件 ├── features/ # 按业务功能划分的模块 ├── hooks/ # 自定义 hooks ├── layouts/ # 页面布局组件 ├── pages/ # 路由页面级组件 ├── store/ # 全局状态管理 ├── utils/ # 通用工具函数 ├── App.jsx ├── main.jsx └── ...

每个文件夹的职责边界要尽量清晰:components里只放不掺业务数据的通用 UI,比如Button、Modal、Table;features里放业务功能模块,比如features/auth、features/order;hooks集中放复用逻辑,比如获取窗口大小的useWindowSize、请求数据的useFetch;utils只放纯函数工具,例格式化日期、防抖节流。api单独抽出来,组件里不直接写fetch或axios请求,而是调用api/auth.js里封装好的方法。

这套结构不是官方强制标准,但它是社区多年验证过的“默认共识”。第一次写项目可以从简化版入手,先建components、hooks、utils三个文件夹,后面业务复杂了再逐步拆出features、store、api,每拆一步都能感受到代码好找很多。

4. 目录结构背后是一套 React 组织哲学:就近、分层与依赖方向

4.1 “就近原则”是目录规划的第一法则

组件和它配套的样式、测试、子组件应该放在同一个文件夹里,这就是 React 社区常说的 nearest placement / colocation。我举个简单例子,假设Button组件有模块样式和测试,组织方式应该是:

components/ └── Button/ ├── Button.jsx ├── Button.module.css ├── Button.test.jsx └── index.js

比起把所有样式丢进一个styles文件夹、所有测试丢进tests文件夹,这种就近布局的好处是:改动一个组件时,旁边的文件就是这次改动可能影响到的所有上下文。你在删除Button时,只需要删一个目录,不会在别处留下孤儿文件。

这条原则同样适用于页面级组件:pages/Home/下可以放它的专属子组件、样式和接口请求。业务越来越大的时候,目录依然能保持局部性和自治性。

4.2 状态管理的代码该放哪一层,context 和 store 都要有归宿

React 的全局状态管理方案主要有两种:自带 Context 和 Redux Toolkit。目录结构规划上,我建议 Context 相关代码集中在src/store/contexts/或src/contexts/下,按逻辑分离,比如AuthContext.jsx、ThemeContext.jsx。这样使用的时候一个import就能找到它。

Redux Toolkit 的经典目录是 feature-based 模式:

src/ ├── app/ │ └── store.js └── features/ ├── auth/ │ ├── authSlice.js │ └── authAPI.js └── order/ ├── orderSlice.js └── orderAPI.js

这种模式的优势在于,一个业务功能的状态、接口、操作都集中在一个 feature 目录里,想了解某个业务的全貌,只要看这一个目录就行。大项目里 Redux Toolkit 官方推荐的也是这种 features 文件夹结构,而不是把所有 reducer 堆在一个文件夹里盲猜。

4.3 接口请求层为什么要独立成 services,而不是在组件里写 fetch

很多初学者习惯直接在组件里写:

const res = await fetch('/api/user'); setUser(await res.json());

小 Demo 没问题,项目一大就会失控:每个组件都写一遍请求逻辑,出错处理、token携带、加载状态都重复。我会把请求统一收敛到api或services目录,先封装一个http.js实例:

import axios from 'axios'; const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }); http.interceptors.request.use(config => { config.headers.Authorization = `Bearer ${localStorage.getItem('token')}`; return config; }); http.interceptors.response.use( response => response.data, error => { if (error.response?.status === 401) { window.location.href = '/login'; } return Promise.reject(error); } ); export default http;

然后每个业务模块一个接口文件,比如api/auth.js、api/order.js,导出具体的请求函数。组件里只调用fetchUserList()这种业务化函数,完全不关心 URL 和拦截器。这样接口变更时只需要改一个文件,测试也好 mock。

4.4 不同规模项目的目录演进路线

目录结构不是一成不变的,它应该跟着项目体量走。你可以把这条路线当作评估标准:

项目规模目录策略说明
小型 Demo只有App.jsx加一两个组件文件不建目录,先把组件文件放平
中型项目components+hooks+utils+api通用代码与业务代码开始分离
大型项目再增加features+store+layouts+pages按业务模块自治,团队可并行开发
超大型项目可能触发 monorepo 拆分多个应用共享公共库,用 pnpm workspace 等方案隔离

后端同学常吐槽前端没有工程规范,实际上 React 的目录演进本身就是一套成熟的工程化降噪流程。每次项目变复杂,先问自己“这个代码放哪里最不容易找错”,答案往往就是标准答案。

5. 实战调整与高频问题:默认文件怎么处理、目录多深合适、面试怎么答

5.1 新脚手架的默认文件到底删不删

CRA 初始模板里的logo.svg、App.css这些演示文件,很多团队会“高抬贵手”留着,其实没必要。它们的作用只是让你跑起来时看到一个旋转的 React logo,一旦进入真实业务,留着反而是噪音。我自己的习惯是:

  • logo.svg删除,几乎不会被用到
  • App.css可以在初始化组件时顺手去掉内容,改成业务需要的样式
  • setupTests.js保留,如果团队计划写测试
  • reportWebVitals.js先留着,等接入监控平台时再统一使用

不要为了图省事把index.css也删掉,里面如果没有内容,就在里面补一份简单基础重置。很多“莫名缺边距”的问题,归根结底是缺少全局样式兜底。

5.2 目录嵌套最多几层,深了怎么办

日常开发里我会盯着一件事:目录深度不要超过四层。例如:

src/features/checkout/components/CheckoutPaymentForm.jsx

这已经是四层了。如果第五层还继续套娃,比如再加一个constants子目录,阅读代码时脑子里要维护的上下文就太重,改动的文件也容易散落各处。

超过这个深度,通常意味着业务模块拆错了粒度。解决办法是往上抽公共部分到components或utils,或者把当前模块再拆成两个独立 feature。我见过最离谱的情况是一个项目里出现了七层深的目录,光导入路径就写了两百个字符,可维护性几乎为零。

5.3 公共组件与业务组件混放,是很多人后期失控的根源

普通团队最容易犯的错,是把业务组件塞进通用components目录。比如把“订单表格”放到了components/OrderTable.jsx,里面直接读取了全局 store 和接口。这种组件一旦被复用,它会顺带把订单相关的依赖全部拉进来,组件边界就此瓦解。

我的划分标准很简单:如果组件离开某个业务上下文就没法使用,它就是业务组件,应该放在features对应模块下;如果组件像积木一样可以自由组合,那才是通用组件。

components/ Button/ Modal/ Input/ features/ auth/ LoginForm.jsx RegisterForm.jsx order/ OrderTable.jsx

这样定位清晰之后,重构时也更好找责任人:通用组件坏了,所有业务都会受影响,需要慎重改动;业务组件坏了,影响半径局限在单模块内,修起来心态完全不一样。

5.4 面试和实际工作中最常被问的目录结构问题

Q: 为什么 React 项目只有一个 HTML 文件?

A: 因为 React 是单页应用,页面内容由 JavaScript 在#root节点上动态渲染。路由切换不刷新页面,只更新组件树,这样一个入口带来的额外收益是构建产物可以被哈希和 CDN 缓存。

Q: public 和 src 里的资源有什么区别?

A:src里的资源会经过打包器处理,可以做压缩、tree-shaking、文件名哈希;public里的文件原样拷贝到build目录,不参与编译,引用时要写绝对路径。经验法则是:需要被打包器优化的放src/assets,大体积且不常变的第三方静态文件放public。

Q: 删除 import 的组件之后构建为什么报错?

A: 因为打包器在编译期会做静态分析,找不到模块就立即抛错。这类错误大部分不是写错代码,而是文件路径没对齐,建议处理时先看报错里指向的具体路径,再去确认对应文件是否存在。

Q: 新项目应该选 CRA 还是 Vite?

A: 维护中的老项目稳妥起见继续沿用,新项目我建议直接选 Vite。不是新鲜感驱动,而是开发启动速度和热更新差距在日积月累中会显著影响开发效率。CRA 官方也说它不再是创建新应用的推荐方式,这是现实趋势。

5.5 搭建目录骨架时的两个实用小建议

第一个建议是:index.js文件在每个组件目录里放一个,用来统一导出组件,外部引用时只写@/components/Button而不是深层路径。前端习惯了这种“目录即接口”的风格后,重构时更换组件实现,调用方完全不用动。

第二个建议是目录名统一使用小写加连字符,组件文件名统一使用 PascalCase。比如checkout-form目录、CheckoutForm.jsx文件,工具函数和 hooks 用 camelCase,如useDebounce.js。一致性看起来是老生常谈,但没有它,代码搜索全靠猜。

我在实际操作中的体会是,目录结构从来不是“好看不好看”的问题,它直接影响改 bug 的速度、接手项目的成本、以及团队并行开发的冲突概率。与其等项目膨胀再返工,不如从创建第一个文件夹开始就按这套共识来摆。等你把features、components、hooks、api各归其位之后,你会发现问“这个文件该放哪”的次数会越来越少,新人也更容易顺着目录结构读懂整个项目的骨架。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询