1. 为什么我坚持从零搭建React项目
如果你在搜索引擎里敲下“react项目搭建”,大概率会得到一堆脚手架工具的使用教程。但真正把React项目从零到一搭过一遍的人,和只会用脚手架的人,在面对问题时的心态和解决速度是完全不同的。这篇文章我想把自己多次搭建React项目的完整思路、踩坑记录、以及一些面试高频考点背后的原理串起来,给准备入坑React、或者已经用了一段时间但想更深入了解底层的朋友,提供一份可以直接“抄作业”的实操笔记。
这套内容适合谁?你如果已经能用框架写出页面,但对webpack、babel、react-dom.render这些名词仍然半懂不懂;或者你正在准备react面试题,想要把react与vue的区别、fiber的作用这些概念真正理解而不是死记硬背;又或者你正准备做一个自己的react项目,但不确定技术选型和目录结构,那这篇文章会给你一个完整且可复现的参考。我们不会止步于“能跑起来”,而是要把关键环节的为什么讲清楚。
很多人问,现在不是有create-react-app、Vite这些现成工具吗,为什么还要自己搭一遍?我的看法是:脚手架给你的是一个“黑盒结果”,而你缺的往往是“拆开黑盒看一眼”的能力。比如项目里出现“minified react error #130”或者“react离线文档打不开”这种问题,如果没手动配过一次项目,你连错误信息里那句“visit https://reactjs.org/doc”提示都只能干瞪眼。手工搭一次,相当于给这些熟悉又陌生的概念做一个脱敏处理,后续再遇到任何工程化问题,你都会有排查方向。
2. 搭项目之前的第一步:技术选型与版本规划
2.1 先搞清楚react、react-dom和构建工具的关系
很多新手上来就是npm install react react-dom,然后写个Hello World就以为完事了。但要想项目能长期维护,选型阶段就得想明白各层的职责。
- react:核心库,负责定义组件、处理虚拟DOM、调度更新。它本身不依赖浏览器环境,所以服务端渲染也能用它。
- react-dom:负责把React组件渲染到浏览器DOM上,react-dom/client里的createRoot是React 18之后推荐的渲染入口。
- 构建工具:负责把JSX、TypeScript、ES6+语法转换成浏览器能认的代码,常见选择是Webpack和Vite。React团队自己的脚手架和好多中后台项目用的是Webpack,但Vite因为启动速度快,这几年越来越流行。
我在实际项目里推荐的第一组合是:react + react-dom + vite + typescript。Vite开发时不需要打包整个项目,启动速度和热更新体验比Webpack好得多;生产构建时又用Rollup,产物体积也可控。如果你想服务端渲染或者需要高度定制webpack的插件体系,那还是选Webpack更稳妥。
2.2 版本选型不能只挑最新版
React 18对比16/17最大变化是并发渲染(Concurrent Rendering)和自动批处理,新项目用18及以上版本基本没有顾虑。但要注意生态兼容性。react-router-dom如果要用v6,最好搭配React 16.8及以上版本,因为v6的useNavigate、useRoutes这些API都依赖hooks;如果项目要接老的第三方组件库,有些可能还没适配React 18的新渲染方式,这时就要去查一下对方有没有对应版本说明。
实操中,我会在package.json里把react和react-dom固定为大版本,比如"react": "^18.2.0",而不用"latest",避免某天依赖更新导致生产环境出问题。锁版本这件事,踩过坑的都懂,不锁版本下一个npm install就可能让整个项目静悄悄坏掉。
2.3 用npm还是pnpm
如果你只给自己搭小项目,npm够用了。但如果是团队项目,我现在基本用pnpm。原因很简单:pnpm的依赖组织方式是硬链接加内容寻址存储,一是装包速度快,二是不会出现node_modules里同一个包被复制好多份的情况,三是它天然避免“幽灵依赖”问题。
第一次用pnpm的时候注意,有些老项目里用npm安装但没把package-lock.json加入.gitignore,切换包管理器会导致lock文件冲突。建议从项目一开始就确定使用哪个包管理器,并在README里写明。
3. 手写一套最小可运行的React工程
3.1 初始化package.json与安装核心依赖
先创建一个项目目录,在目录里执行:
mkdir react-demo && cd react-demo npm init -y然后安装核心依赖和开发依赖:
npm install react react-dom npm install -D vite @vitejs/plugin-react typescript @types/react @types/react-dom这里有个细节:@types/react和@types/react-dom是TypeScript的类型定义包,一定装到devDependencies里。有次我同事图省事直接npm install @types/react,没加-D,结果部署时prod依赖一装,项目照样能跑,但构建镜像硬生生大了几十兆。
Vite官方提供了react模板,其实也可以用:
npm create vite@latest react-demo -- --template react-ts但为了让你理解每个文件的作用,我建议至少手写一遍关键配置。等理解透了再回头用模板加速,这样就不是“会用”,而是“懂了”。
3.2 配置TypeScript和Vite
创建tsconfig.json,这是整个TypeScript项目的“交通规则”。我一般会这样配置:
{ "compilerOptions": { "target": "ESNext", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ESNext"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] }看到“jsx”: "react-jsx"这一项没有?这是React 17之后推荐的JSX转换方式,编译时自动引入react/jsx-runtime,不需要每个文件都import React。如果你在写老项目,可能看到的是"jsx": "react",那需要手动import React。这两个模式的差别,久而久之就会变成“react为什么每次都返回一个render函数”这类疑问的出发点。
然后创建vite.config.ts:
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], resolve: { alias: { '@': '/src' } }, server: { port: 3000, open: true } })记得alias里的写法:在vite中,‘@’指向src目录,但路径要写项目根的绝对路径。如果你用了tsconfig里的paths,两边要保持一致,否则编辑器不报错,build时才报错。
3.3 从HTML入口到React渲染的完整链路
Vite要求一个index.html作为入口,通常放在项目根目录。内容很简单:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>React Demo</title> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body> </html>关键就是那个script标签里的type="module",它是Vite开发服务器的加载入口,浏览器原生支持ES Module后才有的写法,所以Vite开发服务器才不用像Webpack那样先打包再启动。
src/main.tsx是React真正开始工作的地方:
import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' import './index.css' ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> <App /> </React.StrictMode> )React 18之前我们用ReactDOM.render,React 18之后推荐createRoot().render()。createRoot返回一个Root对象,后续更新走的是这个root上的render,这跟React 18并发渲染机制有关。StrictMode是一个开发模式的严格检查组件,它会让组件渲染两次(只在开发模式、只在effects上),用来暴露潜在问题。新手第一次见到“为什么我的console.log打印了两次”时,不要慌,先看看是不是StrictMode在起作用。
3.4 App组件与模块化目录结构
接下来建src/App.tsx:
function App() { return ( <div> <h1>我的第一个React应用</h1> </div> ) } export default App整个工程的最小闭环就完成了。在此基础上,我习惯把目录按功能拆成这样:
src/ api/ # 接口请求统一封装 assets/ # 静态资源 components/ # 通用组件 hooks/ # 自定义hooks layouts/ # 页面布局 pages/ # 页面组件 router/ # 路由配置 store/ # 全局状态管理 types/ # TypeScript类型定义 utils/ # 工具函数这个结构不需要照搬,但“按职责划分”这个思路很重要。项目一旦长到几十个页面,目录如果还是按个人当时的想法随意堆,过三个月你自己都找不着东西。
4. 工程化细节:TypeScript、路径别名与代码规范
4.1 为什么“react + typescript”是黄金搭档
在前面的热词里,“react typescript”出现的频率非常高。原因很简单:React组件的props、state、context,本质都是数据契约,而TypeScript能把这份契约用代码写出来。
比如你写一个用户列表组件:
interface User { id: number name: string email?: string } interface UserListProps { users: User[] onSelect: (user: User) => void } function UserList({ users, onSelect }: UserListProps) { return ( <ul> {users.map(user => ( <li key={user.id} onClick={() => onSelect(user)}> {user.name} </li> ))} </ul> ) }这个组件的调用方如果传错类型,IDE直接红波浪线提示,就问你这种开发体验香不香?带类型系统还有一个额外好处:重构的时候不用害怕漏改关联文件,编译器帮你兜底。这在做react项目搭建时,几乎是我最看重的一件事。
4.2 组件类型与hooks类型的常见坑
写类型定义的时候,有几个容易搞混的地方,我列一下:
React.FC在@types/react 18之后不再推荐给组件标注,因为它隐式加了个children属性,而且函数组件本身可以有类型推断,显式标注反而限制灵活性。我自己现在直接写function App()或者const App = () =>,让类型推论工作。- hooks的类型标注:
useState可以传泛型useState<User | null>(null),这样state的初始值为null,但取值时你依然需要判空,类型是User | null。如果直接用useState(null),后面你赋值user对象时会直接报错,因为类型被推断成了null。 useRef在React 18 + TypeScript下,要区分“保存DOM节点”和“保存普通值”两种场景。保存DOM节点用useRef<HTMLDivElement>(null),拿到的ref.current类型是HTMLDivElement | null;保存可变值用useRef(0),但注意ref.current是readonly的,改不了,需要改成useRef<number | null>(null)这种。
4.3 代码规范与提交前检查
我们团队项目里通常还加上ESLint和Prettier。ESLint用于检查代码错误和风格不一致,Prettier负责格式化。现在可以这样装:
npm install -D eslint prettier eslint-plugin-react-hooks eslint-plugin-react-refresh @typescript-eslint/parser @typescript-eslint/eslint-plugin配置.eslintrc.cjs:
module.exports = { root: true, env: { browser: true, es2020: true }, extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended' ], ignorePatterns: ['dist', '.eslintrc.cjs'], parser: '@typescript-eslint/parser', plugins: ['react-refresh'], rules: { 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }] } }再加一个简单的Prettier配置.prettierrc:
{ "semi": false, "singleQuote": true, "printWidth": 100, "trailingComma": "es5" }semi设为false,就是不写分号,singleQuote是单引号,这两个偏好团队内部统一即可,不必追求“绝对正确”。关键是“统一”。
还有一个很容易被忽视的细节:给IDE配好“保存时自动格式化”,并且统一行尾序列。在Windows上写项目,默认换行是CRLF,而Linux/macOS是LF,如果不统一,每次git提交都会出现大量“全文件被修改”的假象。解决方法是在项目根目录加.editorconfig:
root = true [*] end_of_line = lf insert_final_newline = true5. 路由与状态管理:按需选型而非无脑上全套
5.1 路由方案:react-router v6实践
大多数中后台项目都逃不掉路由。react-router-dom现在是绝对主流,v6相对v5的一大变化是API全面函数化。
安装和基本用法:
npm install react-router-domimport { createBrowserRouter, RouterProvider } from 'react-router-dom' const router = createBrowserRouter([ { path: '/', element: <Layout />, children: [ { index: true, element: <Home /> }, { path: 'about', element: <About /> }, { path: 'user/:id', element: <UserDetail /> } ] } ]) function App() { return <RouterProvider router={router} /> }createBrowserRouter是v6.4引入的数据路由API,它和loader、action结合后,可以直接在路由层面做数据预获取。这在做SSR或者首屏性能优化时非常有价值。
路由懒加载写法也推荐,用React.lazy加Suspense拆包:
import { lazy, Suspense } from 'react' const About = lazy(() => import('./pages/About')) function App() { return ( <Suspense fallback={<div>加载中...</div>}> <About /> </Suspense> ) }这样首屏不会把About页面的代码一起下载,Bundle体积小了,首屏自然更快。
5.2 状态管理:什么时候需要Redux,什么时候只要useState
这是我被问得最多的一个问题:“我要不要上Redux?”我的答案很直接:先用useState和useReducer撑住,等真正出现跨层级、跨页面的状态同步需求时再说。
什么场景适合Redux?比如登录态、权限、全局主题、购物车这种被很多地方共享的数据,或者像撤销重做那种需要记录状态快照的复杂交互。如果只是一个表单页面的局部状态,你上Redux就是增加心智负担和样板代码。
如果不想一上来就引入Redux太重,可以试试zustand。它不需要Provider包裹,API极其简单:
import { create } from 'zustand' interface CounterState { count: number increment: () => void } const useCounterStore = create<CounterState>((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })) }))状态管理的本质是把“共享数据的变化”变得可追踪、可预测。选什么工具是你的偏好,但理解这个本质不会变。
6. 理解Fiber:从裁缝视角看懂React的核心更新机制
6.1 Fiber是什么,解决了什么问题
热词里“react与vue的区别 fiber的作用”几乎是面试必问。每次有人问我,我习惯用“修改一份长文档”来类比。
你看Vue那边,它依赖响应式系统,数据一变,组件自己知道要改哪里,精确到颗粒度;而React的思路是“我不管哪里变了,我从根节点开始重新执行一遍渲染函数,拿新的结果跟旧结果比对,再把差异更新到真实DOM上”。这就带来一个问题:如果一个组件树很深、组件很多,全量递归渲染耗时太长,浏览器一卡,体验就崩了。
React 16之前的Stack Reconciler就是递归更新,同步执行,中途无法打断;而Fiber把“递归更新”重构成了“可中断的链表式更新”。每个组件对应一个Fiber节点,节点之间通过child、sibling、return这几个指针形成链表结构。更新的工作被拆分到一个个小单元,调度器可以按优先级决定先做哪些、后做哪些,甚至把低优先级任务放到浏览器空闲再执行。
这就是并发渲染的基础。React 18的useTransition、useDeferredValue都是利用了这个调度能力,让大计算量任务不阻塞用户输入。
6.2 从Fiber看“react为什么每次都返回一个render函数”
热词里有句“react为什么每次都返回一个render函数”,这其实也是理解Fiber的一把钥匙。你说的是函数组件吧?因为函数组件本质就是“一个纯函数,接收props,返回React元素”。React底层的协调过程需要不断调用这个函数来获取最新的React元素树,与之前的Fiber树做对比。
可以看看这个顺序:
- 首次渲染:React创建Fiber树,调用函数组件,生成虚拟DOM树,最终提交给DOM。
- 更新时:React不重新渲染整个页面,而是基于新旧状态,重新调用函数组件生成新虚拟DOM,再通过diff过程定位变化点,更新真实DOM。
所以,每次更新“都返回一个新的render结果”,不是bug,而是React赖以工作的根本机制。这也是为什么函数组件要遵循“纯函数”原则,不能在里面写副作用,副作用要放在useEffect里处理,否则多次调用会引发不可预测的结果。
6.3 React与Vue差异梳理
顺手把面试里常问的对比归纳一下:
| 对比维度 | React | Vue |
|---|---|---|
| 更新机制 | 虚拟DOM + 调度器 + diff | 响应式依赖追踪 + 虚拟DOM |
| 状态流向 | 单向数据流,自顶向下 | 响应式数据驱动,同样自成体系 |
| 模板语法 | JSX,更贴近JavaScript | 模板语法,有指令系统 |
| 优化手段 | shouldComponentUpdate、memo、useMemo、useCallback | computed、watch、v-memo |
| 上手曲线 | 需要理解JSX、hooks等 | 模板更像HTML,对后端转前端更友好 |
| 生态范围 | react-router、zustand、redux等选择极多 | vue-router、pinia等相对集中 |
这两者没有绝对优劣,更多是团队偏好和技术栈衔接问题。
7. SSR数据预获取:首屏体验的进阶之路
7.1 理解SSR的痛点
react ssr 数据预获取这个话题,能上热词说明挺多人对这块感兴趣。SSR(服务端渲染)最大的价值是SEO友好和首屏更快,但它的难点也很多:服务端没有window、document,生命周期不完全一致,请求时机和数据序列化都要处理。
所谓“数据预获取”,就是在服务端渲染前,把组件需要的数据先请求好,填进页面模板里,省去浏览器端“渲染组件再发请求再渲染”这一整轮往返。
7.2 一个最小化的预获取思路
现在Next.js这类框架已经把这块封装得很好了,但如果你用React自己搭SSR,核心步骤其实可以拆成三块:
- 根据当前路由匹配到的组件,提前执行数据加载函数,拿到数据。
- 把数据作为props传给组件,并在store或context里存一份初始值。
- 最重要的是把这份数据序列化后,塞进HTML里的script标签,比如
window.__INITIAL_STATE__ = {...},让浏览器端也能拿到同一份数据,避免客户端二次请求。
这里就涉及一个经典问题:数据幂等。服务端请求和客户端请求如果从不同环境访问同一个接口,可能造成数据不一致,所以接口设计时要特别注意缓存策略。
实际项目里我一般选择直接用成熟框架,但如果团队需要深度定制,理解上面三步比套框架更有价值。
8. 常见报错和排查技巧实录
8.1 “minified react error #130”这类报错怎么处理
热词里有一条:“dsh-better-sidebar: minified react error #130; visit https://reactjs.org/doc”。不少人在生产环境看到react的压缩错误码一脸懵,因为生产环境React错误信息被压缩了,只给一个编号和官网地址,官网页面有时又打不开,这就是“react离线文档”为什么会成为搜索热词的原因。
遇到这类报错,处理策略我总结为三步:
- 打开React官方错误解码页面(error decoding页面),把错误码输进去,获取详细错误信息。但你可能会发现官网被墙或页面结构变化,这时一个好办法是切换到开发环境跑一遍,因为开发模式下React会给出详细的英文错误信息和组件栈。
- 确认触发场景。比如“Error #130”通常和hook的调用顺序有关,或者是在更新state时调用了已经卸载的组件。把操作路径复现出来,基本就锁定了组件。
- 看组件栈而不是看调用栈。React的错误堆栈里组件名会非常清晰,能帮你快速定位到是哪个组件、哪一行触发的。
8.2 react离线文档与本地调试
说到react离线文档,我个人的习惯是把react、react-dom的类型定义文件当作离线文档用。装完@types/react后,在node_modules/@types/react/index.d.ts里几乎能看到所有类型API的解释和示例。你IDE里Command+点击一个类型名,跳转过去就是一手的类型说明,比网上改来改去的二手博客准确得多。
此外浏览器里的React Developer Tools是调试必备。Components面板可以看到完整的组件树和每个组件的props/state,Profiler面板可以录制交互,查看每次渲染耗时和性能瓶颈,排查慢渲染问题非常高效。
8.3 react-native启动白屏和低端机卡顿的问题
虽然本篇主题是react项目搭建,但热词里的“react native 启动白屏”“安卓低端机很卡”还是值得提一下。RN项目的优化方向大致相同:减少启动时JS执行量、拆包按需加载、图片用缓存策略、减少不必要的重渲染。花无百日红,每个平台都会有自己的瓶颈,但优化思路始终是:先测量,再优化,不要凭空猜。
8.4 react图表选型与集成
最后补充一个react生态里的图表热词。图表库常见的有echarts-for-react、recharts、ant-design-charts。选型时我一般看两点:定制需求是否复杂到必须用echarts,以及是否要支持大量数据可视化。recharts轻量,适合快速做中小型图表;echarts功能强,但集成时需要封装一层以适配React的生命周期,特别是容器尺寸变化时,要手动调用resize方法。图表组件的封装,核心思路是:用一个组件接收data和options,内部管理实例的创建、更新和销毁,对外暴露统一的配置项接口。
9. 一些实操中的补充建议
最后分享几个我在搭项目过程中沉淀的小习惯,不一定每条都适用所有人,但至少能帮你在早期少走一些弯路。
第一,package.json的scripts字段,我建议自定义几条常用命令,比如“dev”“build”“preview”“lint”“type-check”。其中type-check是单独跑tsc --noEmit,用来确认类型是否正确,不必等编辑器提示。
第二,环境变量管理。Vite里只有以VITE_开头命名的变量才会被打进客户端代码。要区分开发环境和生产环境,就建.env.development和.env.production,里面写各自的值,别在代码里硬编码接口地址。
第三,组件按文件夹组织,每类组件文件不要放一堆零散的tsx。一个Button组件,我习惯放Button/index.tsx、Button/index.less或style.css、Button/types.ts,这样后续查找和维护成本低。热词里提到的“dsh-better-sidebar”这类第三方组件,也要注意它的样式文件是否被正确引入,很多问题其实是样式丢失而不是组件报错。
从零搭一个React项目,表面上看只是安装依赖、写点配置,但其实这是一次跟React底层架构、工程化工具链、类型系统、路由状态管理的全面接触。脚手架能给你一个项目,但只有理解项目里每个环节为什么存在,你才能在面对报错和性能瓶颈时,举重若轻。