聊到React项目的起步阶段,脚手架这几个字几乎每次都会被摆上台面。它不是一个简单的配置文件,而是把一整套前端工程化底座直接端到你面前:开发服务器、热更新、构建打包、代码规范、环境变量管理,这些原本要折腾一周的事情,一条命令就全部到位。这篇内容适合刚接触React、或已经在用React但一直没搞懂脚手架原理的开发者。看完你能明白脚手架到底在背后替你干了什么,为什么选型时大家会纠结,以及创建项目之后第一件事应该做什么。
1. 脚手架到底解决了什么问题
1.1 没有脚手架的年代,项目是怎么起飞的
讲个真实经历。我第一次接触前端工程化是在一个传统项目里,当时的流程是:手动下载React和ReactDOM的UMD包,用script标签引入,然后在全局对象上找API。状态管理靠的是自己写的事件总线,样式靠手写CSS。项目能跑,但不管是调试还是上线,都非常吃力。JSX要编译,ES6要转译,模块化要打包,这些事没有工具链根本做不了。
后来参与一个从零启动的中型项目,团队里没人专职搞工程化,光是搭建开发环境就用了一个多星期。装打包工具、配语法转译、调各种加载器、处理图片字体、搞定热更新、再接入代码检查和格式化工具,每一环都是手工配置。配置文件动辄两三百行,每加一个依赖都要祈祷版本兼容。那段时间最大的感受是:开发业务之前,先把时间耗在了环境上。这显然不是健康的状态。
脚手架的出现,本质上是把这一整套“环境搭建”工作固化成模板。你不需要理解打包器内部怎么组织插件链,不需要手写完整的转译配置,只需要回答“我想要什么框架、什么语言、什么工具”,脚手架就把一个能直接跑起来的最小工程交给你。这就像买房带精装修,你接手的是可以直接入住的状态,而不是从毛坯开始。对刚入行的前端来说,脚手架拉低了起步门槛;对老手来说,它省掉的是重复劳动。
1.2 脚手架帮你把哪些活提前干完了
以React生态里最常见的脚手架为例,它至少替你完成了五件事。
第一,项目目录结构标准化。src、public、node_modules、配置文件,该有的目录一个不少,而且遵循约定俗成的规范,任何接手的人都能快速看懂。这一点在团队协作里价值极大,不需要每次入职都靠人肉文档解释项目结构,也不用担心不同成员建出来的项目长成不同样子。
第二,开发服务器和热更新。你改一行代码,浏览器自动刷新,页面状态尽可能保留。这在体验上理所当然,背后其实是开发服务器在实时监听文件变更,通过WebSocket推送更新信号,再在浏览器端执行模块替换。这个链路如果让你自己从零实现,至少要研究好几天,而且大概率会踩到内存泄漏、更新错乱之类的坑。
第三,生产构建和代码压缩。脚手架内置了针对生产环境的构建配置,包括无用代码剔除、代码分割、压缩混淆、资源指纹等。你自己配置这些优化项时,每一项都是一篇深水区文章,而脚手架在创建项目的那一刻就已经默认开启。很多开发者没意识到,自己享受的这些性能优化是脚手架免费送的。
第四,基础工程规范。代码检查、格式化、TypeScript支持、测试工具,脚手架生成的模板里基本预置了起步配置。这些配置不一定完美,但给了你一个可以运行的基线,比从零开始一点点查文档要省太多时间。后续可以在这个基线上做增量调整,而不是推倒重来。
第五,环境变量管理。开发环境、测试环境、生产环境的切换,脚手架用.env文件帮你处理了。这个机制背后涉及环境文件解析、变量替换和打包时的静态注入,如果手动做,涉及多个工具库的联动,踩坑概率非常高。有了脚手架,你只需要关注每个环境应该配什么值。
1.3 脚手架不是银弹,它也有边界
当然,我得说句公道话,脚手架不是万能的。它最大的局限在于:模板是通用的,而你的项目是特殊的。通用模板要覆盖大多数场景,所以在极端情况下,你可能需要弹出配置,或者直接换用更可定制的工具链。
我见过不少团队,用了脚手架之后连配置文件都不敢动,把它当成一个黑盒。项目一旦遇到特殊需求,比如需要自定义构建产物结构、集成特殊的静态资源处理插件,就在脚手架的配置层里疯狂打补丁,最后配置文件面目全非,升级困难,维护成本飙升。这个现象在大量使用创建应用中非常普遍,本质上是把工具的边界当成了项目的边界。
正确的姿势是:把脚手架当成起点,而不是终点。创建项目之后,主动去读它的配置文件,搞清楚每个配置项是干什么的,然后在需要的时候做定制。这样既不浪费脚手架的效率,也不被它的模板束缚。脚手架给了你一个地基,但房子怎么盖,最终还是你自己的事。
2. 主流React脚手架怎么选
2.1 三大主流方案盘点
现在React生态里最常见的选择,在我看来有三类。
第一类是基于传统打包器的老牌脚手架。它最老牌、用户最多、资料最全,设计哲学是零配置起步,几乎所有东西都藏在封装层里,普通用户能看到的配置接口非常少。好处是简单,坏处是定制性差,想要深度改配置就得弹出配置,而弹出之后你就脱离了官方维护,所有依赖管理回到自己手里,后续的升级全靠自己解决。
第二类是基于Vite的脚手架。Vite这几年上升势头非常猛,它利用浏览器原生模块能力,开发时不需要打包整个项目,而是按需加载模块,所以冷启动速度和热更新速度都非常快。创建React项目的命令是npm create vite,然后选择react模板即可。它的配置文件非常直观,插件的生态也在快速跟上,目前已经成了新项目的默认选择。
第三类是元框架脚手架,最典型就是Next.js。它本质上是React之上的一层框架,自带路由、服务端渲染、数据获取、图片优化等能力,生成的工程比普通单页应用脚手架要厚重得多。如果你做的是需要SEO的内容型应用、或需要服务端渲染的项目,这个方向通常比纯单页应用更合适。但如果你只是写一个内部管理系统,它的复杂度就是额外负担。
2.2 什么时候无脑选Vite
我现在的推荐策略很简单:除非你有明确理由不用,否则新项目默认选Vite。
原因很实际。Vite的启动速度优势不是百分之几的差别,而是数量级的差别。一个中大型React项目中,老牌脚手架冷启动可能要十几秒甚至几十秒,而Vite几乎可以做到秒开。这不是体验问题,是开发效率问题。你每天要重启开发服务器、切换分支、跑测试,每一次等待都在消耗注意力和心流状态。
另外,Vite对现代前端生态的适配比老牌方案更积极。新的类库通常优先兼容Vite,遇到问题时社区的讨论也更活跃。而且Vite默认用高性能工具做依赖预构建、用成熟方案做生产打包,这套组合现在几乎成了新项目的标配。从依赖安装体积看,同样一个React项目用Vite创建,整体更轻量,安装更快。
我实际对比测试过,同样的项目在两套脚手架下分别初始化,Vite的依赖数量更少,配置更短,构建产物质量也不差,开发期的反应速度更是差距明显。从维护角度看,Vite的配置是显式的、文档化的,不像老牌方案把配置藏在封装层里,出了问题就无从下手。
2.3 遗留项目和老团队怎么选
不过Vite也不适合所有人。如果你所在的团队对传统打包器生态有深度依赖,比如有一批自定义的加载器、有历史沉淀的构建插件,短期内迁移到Vite的成本可能高于收益。这种时候,继续使用老牌脚手架反而是更稳妥的选择。工具迁移不是换个命令那么简单,那些年沉淀下来的定制逻辑里,可能藏着很多只有当事人才知道的修补。
另一种情况,是团队里大部分人对前端工程化理解不深。这种团队反而适合用封装度高的方案,把所有配置藏起来,避免误操作。很多配置暴露出来之后,被不懂原理的人乱改一通,项目跑不起来了还得别人收拾烂摊子。工具选择要综合考虑团队水平,不是越先进越好,而是越匹配越好。
如果是老项目迭代,不要轻易在项目中途换脚手架。从传统打包器迁移到Vite,涉及加载器兼容、填充策略差异、构建行为差异,中途切换的回归测试成本远高于重写一个项目。老项目继续增量开发,新项目再考虑用新工具,这是更理性的做法。稳字当头,技术债务可以在合适的时间用新项目来消化。
2.4 选型决策速查表
我整理了一个简单的决策表,可以拿来直接对照,方便快速判断自己该怎么选。这是基于我个人经验的总结,不一定绝对,但大概率符合多数场景。
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 新项目,团队无历史包袱 | Vite | 启动快、配置直观、社区活跃 |
| 老团队,熟练传统打包器 | 老牌脚手架或定制方案 | 迁移成本高,维持现有体系 |
| 需要服务端渲染、内容型站点 | Next.js | 框架自带能力,多于纯单页应用 |
| 纯组件库开发 | Vite的库模式 | 构建配置简单,产物清晰 |
| 快速原型验证 | Vite | 秒级启动,随时丢弃重来 |
| 大型极复杂工程 | 定制脚手架 | 通用模板难以满足独特需求 |
3. 实操:为一个React项目打好地基
3.1 环境准备:Node版本与包管理器
在真正动手创建项目之前,先检查一下本机环境。脚手架对Node版本有明确要求,Vite官方要求Node版本在18以上,低于这个版本会直接报错或者运行时行为异常。我在实践中的建议是保持Node的长期支持版本,最好用版本管理工具来切换,避免多个项目之间互相污染。不同项目需要的Node版本可能不一样,没有版本管理工具的话迟早会踩坑。
包管理器方面,npm、yarn、pnpm都可以,我个人的倾向是pnpm。它的硬链接机制能大幅减少node_modules的磁盘占用,而且默认的隔绝式依赖结构能避免很多“幽灵依赖”问题。不过要注意,pnpm在少数老依赖上偶尔会有兼容问题,如果你创建的项目依赖非常老旧,可以退回npm,省得折腾。没有绝对的优劣,只有当前项目合不合适。
另外提一句:很多脚手架创建时会把包管理器信息写入配置里,优先使用你执行命令时用的那个包管理器。这意味着同一个项目里最好不要混用不同的包管理器,避免锁文件不一致导致的依赖解析问题。项目里有几个人混用不同管理器,很快就会有人遇到“我这跑得好好的,你怎么报错”的灵异事件。
3.2 用Vite创建React项目的完整命令
具体操作非常直接。打开终端,进到你准备放项目的目录,执行:
npm create vite@latest my-react-app -- --template react-ts这里my-react-app是项目名,--template react-ts意味着生成React加TypeScript的模板。如果你不需要TypeScript,可以用react模板。命令执行后,Vite会帮你在当前目录下生成一个最小的React工程。注意,如果你指定的项目目录已存在且有内容,会提示是否覆盖,操作前确认好。
然后进入项目,安装依赖:
cd my-react-app npm install依赖安装完成后,启动开发服务器:
npm run dev终端里会打印出本地访问地址,默认是5173端口,打开浏览器就能看到React应用跑起来。整个过程比我第一次手动配置打包器的那一周,快出不止一个数量级。这里多说一句,如果你在国内网络环境遇到安装慢的问题,把镜像源换成国内镜像会有明显改善,但团队项目里锁文件要保持一致,不要在个人环境里随意改源。
3.3 生成的项目结构逐层拆解
依赖安装并跑起开发服务器后,别急着写业务代码,先花十分钟把生成的项目文件过一遍。Vite生成的React项目结构非常简洁,核心文件就几个。
根目录的index.html是整个应用的入口页面,注意它不在public目录里,因为Vite将其视为模块图的一部分,脚本通过module方式引入。src/main.tsx是应用的启动入口,负责挂载React根组件。src/App.tsx存放应用根组件,刚才浏览器里看到的内容就是它渲染出来的。vite.config.ts是Vite的配置文件,路径别名、代理、插件都在这里声明。tsconfig.json是TypeScript的编译配置。
对比老牌脚手架生成的结构,你会发现Vite更透明。老方案的核心打包配置藏在封装包里,你根本看不到,要改就必须弹出配置;而Vite的配置明文写在根目录,想改什么直接编辑配置文件,改动后通常要重启开发服务器让配置生效。这一点在排错时的体验差异非常大,看得见才查得着。
3.4 开发、构建、预览三个命令的闭环
Vite提供的脚本通常有这几个:dev启动开发服务器,build执行生产构建,preview在本地预览生产构建的结果。其中preview我认为是经常被忽略但很有用的命令。
我通常在交付前会执行一次build加preview的组合,因为有些问题只有生产构建才会暴露,比如代码分割导致的加载顺序变化、资源引用路径问题、某些插件在生产环境的特殊行为。开发和生产构建的差异是前端的老话题,Vite也一样存在,别因为开发时表现好就跳过生产验证。一般流程是:本地验收用dev,提测前跑build和preview,确认无误后再走发布流程,看起来麻烦,实际能挡掉不少线上事故。
构建产物默认输出到dist目录,部署的时候把这个目录的内容放上服务器就行。如果你的项目部署在子路径下,记得在配置里设置base字段,否则资源请求路径会以根路径开头,页面打开一片空白。这个坑我见过太多次,十有八九是部署环境变了而构建配置没跟上。
4. 工程化改造:让脚手架更贴合真实项目
4.1 路径别名的配置
新生成的Vite项目默认没有配置路径别名,这意味着你写import语句时只能写相对路径,比如../../../components/Button。这种代码在深层目录结构里非常痛苦,移动文件后所有引用全部要改,而且阅读体验也差。路径别名是每个真实项目都应该第一时间做的事。
配置路径别名的思路是在vite.config.ts中添加resolve.alias,同时同步更新TypeScript配置。Vite配置文件的写法如下:
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'node:path' export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src') } } })紧接着,tsconfig.json中要添加paths与baseUrl的声明。否则编辑器会提示找不到模块@/xxx,因为类型系统不知道这个别名映射。这一步最大的坑在于:只改构建配置不更新类型配置,导致编辑器报错模块找不到;或者反向操作,两个配置不一致,编辑器不报错但构建失败。我建议配置完之后立刻跑一次build验证,别等到写了几十个文件再发现。
4.2 环境变量与多环境切换
React项目通常至少有开发环境、测试环境、生产环境,对应后端接口地址、日志级别、埋点开关都可能不同。脚手架提供的机制是.env文件。Vite读取的环境变量必须以VITE_为前缀才会暴露给前端代码,否则会直接被过滤掉。这个设计是为了防止把敏感变量误暴露到浏览器端。
例如在项目根目录下创建.env.development、.env.production这样的文件:
# .env.production VITE_API_BASE_URL=https://api.example.com VITE_APP_ENV=production在代码里就可以通过import.meta.env.VITE_API_BASE_URL来访问。注意这个读取方式是静态解析的,所以访问时必须写完整的变量名,动态拼接比如import.meta.env[VITE_${name}]在构建时可能无法正确替换。我在项目里就把这个规则写进了代码规范,让成员理解为什么不能图省事做动态访问。
多环境文件的优先级也要了解。同名配置项下,优先级从高到低是:命令行环境变量、.env.local、.env.[mode]、.env。其中.env.local通常用于覆盖本地调试配置,而且不应该提交进版本控制,记得把它加进忽略清单。团队协作中最怕的就是有人把本地配置推到仓库里,导致别人拉下来构建出一个错误环境的包。
4.3 代码规范与提交检查
脚手架生成的项目默认带代码检查配置,但强度通常不够,格式化工具和提交钩子也得补上。我的做法是创建项目后立刻做一轮“规范补全”:装格式化工具统一代码风格,装提交钩子和暂存区检查工具做git提交前的自动检查,只对暂存区文件做检查,避免全量检查拖慢提交速度。
暂存区检查工具的配置看起来是这个样子的:
{ "lint-staged": { "src/**/*.{ts,tsx}": ["eslint --fix", "prettier --write"] } }再配上一个提交信息规范工具,让团队提交信息保持统一格式。这一套配下来,团队不同成员写出的代码风格基本一致,code review的压力也会小很多。规范的过程要提前做,项目代码写到几万行之后再统一风格,改动成本会非常高。这里我特别强调:代码规范是给团队用的,不是给个人用的,所以配置时最好拉上团队成员一起定,而不是Leader自己拍脑袋定一版强制大家遵守。
4.4 开发代理的配置
本地开发时,前端和后端通常不在同一个端口,跨域问题必然会出现。Vite提供了server.proxy配置,把开发服务器的请求转发到后端,代码里仍然写相对路径,形式上等同于同源请求。这个配置可以说是前后端联调的基本盘。
server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } }这段配置的意思是,所有以/api开头的请求都会被转发到localhost:3000。changeOrigin的作用是让请求头里的host字段改为目标地址,很多后端会校验这个字段,不设置就无法正常代理。rewrite则是把/api前缀去掉后再转发,具体是否要保留前缀,取决于后端接口的实际路径设计。
代理配置排查起来不算复杂,但容易出现两类问题:一是代理的目标服务没启动或端口不对,二是后端接口有路径前缀没和rewrite对应上。如果发现代理不生效,先用浏览器开发者工具看请求是否到达了代理层,再做下一步排查。后端先自查一个简单的健康检查接口,可以快速区分问题出在代理层还是后端业务层。
5. 常见问题与踩坑记录
5.1 端口占用与默认端口修改
Vite的默认开发端口是5173,老牌脚手架默认是3000。如果你同时跑多个项目,后启动的那个通常会提示端口被占用并自动换个端口,但有的时候自动切换并不符合预期,比如你恰好有个回调地址写死了端口号。
我的做法是直接在配置里指定端口,避免随机的端口分配。在配置文件的server字段里加上:
server: { port: 4173, strictPort: true }strictPort设为true意味着如果端口被占用,直接报错而不是自动换端口。这种方式适合本地有固定端口约定、或者写死了回调地址的场景。如果你只是随意开发,默认行为即可,不必每个项目都配。不过团队协作时建议统一端口规范,否则你写的联调地址和同事的不一致,排查起来浪费时间。
5.2 热更新失效的排查思路
热更新是脚手架体验的核心,一旦失效,开发效率直线下滑。热更新失效的原因通常不是脚手架自身的问题,而是你的代码破坏了热更新的边界。比如,直接在模块顶层修改了一个非导出函数,或者改了一个被多个模块共享的常量,工具会认为这个变更无法安全地热替换,只能整页刷新。
如果你发现某次改动后页面整体刷新而不是局部替换,先检查是不是触碰了模块级的共享状态。另外,非组件模块的热更新依赖特定接口,如果你引入的某个类库不兼容这套接口,也可能出现“改了不生效”的假象。这种情况最简单的解决办法是重启开发服务器,虽然粗暴,但确实高效。不要小看这个操作,它每天能帮我省下至少折腾十分钟的排查时间。
还有一个容易忽略的点:浏览器缓存。开发模式下工具通过WebSocket推送变更,如果浏览器对某些资源做了强缓存,更新会滞后。遇到诡异的不生效问题,先硬刷新一次,再考虑更复杂的排查。经验法则:先重启服务器,再硬刷新页面,最后才去翻配置。
5.3 包管理器与依赖隔离的纠缠
用pnpm安装依赖时,偶尔会遇到某些包在运行时找不到依赖,明明package.json里声明了,却报模块未找到。这个问题的根源是pnpm默认的严格依赖隔离机制:只有package.json里显式声明的依赖才能被直接引入,其他间接引入的依赖都不会暴露给业务代码。
对于这个问题,最简单的做法是:把被引用的包显式加到package.json的对应依赖项里。不要试图绕过隔离机制,它的存在是为了杜绝幽灵依赖。项目能跑不代表依赖是对的,只是你用其他管理器时碰巧装上了被别的包依赖的包,这种侥幸在升级依赖时迟早会炸。我之前处理过一个线上问题,查到最后就是幽灵依赖在升级后消失导致的。
如果确实遇到某个老依赖对pnpm的支持有问题,次选方案是项目根目录加配置,让pnpm以和传统管理器类似的扁平结构安装依赖。这等于放弃了隔离优势,但能解决部分兼容问题。不建议所有项目都这么配,只有当具体依赖确认存在问题时再动,保持默认的严谨性没什么不好。
5.4 依赖版本锁死与升级节奏
脚手架创建的依赖清单里,版本通常用^前缀表示允许小版本更新。但React生态里依赖之间的兼容性极其敏感,尤其是React核心库、相关类型声明和构建工具之间的版本必须匹配,否则会出现莫名其妙的报错。
我踩过一个真实坑:某个项目几天没动,再次安装依赖后,相关依赖被升级到了新小版本,结果类库之间出现类型冲突,构建直接挂了。排查后才发现是^前缀导致的小版本漂移。从那以后,我的策略是核心依赖锁定精确版本,次核心依赖保留范围前缀,并且手动控制升级节奏。改版本声明、删除依赖目录和锁文件重新安装都是治标不治本的办法,根因是对依赖版本放任自流。
还有个相关技巧:在部署流水线里,一定要提交锁文件并让流水线基于锁文件安装,否则每个人本地解析出来的依赖树都可能不一样,最终产物也无法复现。这个实践虽然简单,但能为后续排查省下大量时间。锁文件也是代码资产,不是可以随意忽略的东西。
5.5 构建产物的再审查
最后聊一个容易被忽略的问题。很多开发者的流程是执行构建命令之后看一眼产物大小就部署了,但其实构建日志里的每一个告警都很有信息量,尤其是那些包体积警告和“模块已被外部化”提示,它们往往意味着某些依赖被重复打包或者引用了浏览器外的原生模块。
我习惯在构建后做三件事:一是查看产物目录结构,确认静态资源有指纹、入口文件体积合理;二是用工具分析产物构成,看有没有把不该打进去的库塞进包里;三是在预览模式下点开主要页面,走一遍核心流程,确认生产构建没有功能问题。这三步做完,再谈部署才比较安心。
关于产物分析,现在有很多现成的可视化工具,生成一个分析报告,能直观看到每个依赖占的体积。组件库、工具库、图表库往往是体积大头,如果某个依赖只用了其中一两个函数却被整包引入,那就值得做按需加载改造。这个优化做完,产物体积可能直接减半,页面加载速度明显提升,用户感知最直接。
最后分享一点个人的心得
如果让我给一个最诚实的建议,那就是不要把脚手架当成黑盒。我见过太多开发者用了几年脚手架,却连配置文件长什么样都没见过,一旦遇到稀奇古怪的构建问题就束手无策。反过来说,当你真正理解了脚手架背后的配置逻辑,你在创建项目的时候就能更有底气地做选择。工具也好,方案也好,它们都只是手段,真正决定项目上限的是你对工程化的理解深度。创建完项目之后,花一点时间读一读生成的配置文件,改一改、跑一跑、让它坏一次再修好,这个过程比任何教程都管用。脚手架是起点,不是终点,这句话值得每个前端开发者记在心里。