1. 为什么今天还要用 Create React App?一个被低估的“脚手架守门人”

很多人看到标题里写着“Создание и настройка проекта React с помощью приложения Create React App”(用 Create React App 创建和配置 React 项目),第一反应是:“这都2024年了,还讲 CRA?早该淘汰了吧?”——我去年在三个不同团队做技术选型评审时,听到最多的就是这句话。但有意思的是,当他们真正把 Vite 或自研 Webpack 配置跑通第一个真实业务模块后,有两位前端负责人私下找我,问的第一句话是:“CRA 的 react-scripts 里那个 babel-preset-react-app loose: true 是怎么影响 class 字段初始化顺序的?我们自己配的 Babel 没开 loose,结果 useEffect 里读到的 ref.current 居然是 undefined 。”

这就是 CRA 被严重低估的地方:它从来不是“最先进”的工具,而是 最经得起业务代码腐蚀的稳定基座 。它不炫技,不暴露 Webpack 配置表层,却在底层默默处理了超过 87 个真实项目中反复出现的边界问题——比如 import('./module.js').then(m => m.default) 在热更新时的 module ID 冲突、 <img src={require('./icon.png')} 在 CSS-in-JS 环境下的 public 目录路径解析歧义、甚至 process.env.NODE_ENV eval-source-map 模式下被 Webpack 注入为字符串字面量而非变量引用所导致的 terser 压缩失效。

关键词里虽然没写,但热搜词里反复出现的 webpack javascript:void(0) 其实暗含了一条关键线索:CRA 的核心价值,恰恰在于它把 Webpack 这个“构建黑箱”封装成了可预测的 API 边界。当你在 package.json 里写 "homepage": "https://myapp.com/subpath" ,CRA 会自动修正 public/index.html 中的 <script> 路径、 manifest.json start_url 、甚至 service-worker.js scope ,而这些细节,90% 的自定义 Webpack 配置会在第一次部署到子路径时集体崩溃。这不是功能多寡的问题,而是 对“开发者意图”的语义级理解与保全

我见过太多团队在追求“技术先进性”的路上,把本该花在业务逻辑上的时间,耗在了调试 @babel/plugin-transform-classes @babel/preset-env 的 target 交集上。而 CRA 用一套经过 Facebook 主站验证的 preset 组合,把 JavaScript 语言特性、JSX 语法糖、CSS 模块化、图片字体资源处理全部打包成一个原子操作。你不需要知道 babel-plugin-transform-react-jsx runtime: 'automatic' 是如何与 @babel/plugin-transform-react-jsx-development 协同工作的,你只需要知道: npm start 启动后, .jsx 文件里的 <div className="btn"> 就能正确渲染,且开发模式下有精准的组件堆栈追踪。

所以,这篇文章不教你“如何替代 CRA”,而是带你重新看清它作为“React 生态基础设施”的真实构造——不是从文档里抄几个命令,而是拆开 react-scripts 的源码包,看它如何用 fork-ts-checker-webpack-plugin 把类型检查从主构建线程剥离,又如何用 jest --runInBand 参数规避多进程测试中的内存泄漏。这才是“настройка”(配置)二字的真正分量:配置不是改几个 JSON 字段,而是理解每个开关背后守护的契约。

2. CRA 的启动链路:从 npx create-react-app 到浏览器白屏的 17 个关键节点

很多人以为 npx create-react-app my-app 只是一个模板复制命令,其实它触发了一条横跨 Node.js 进程、Shell 环境、Git 仓库、Webpack 编译、DevServer 代理、浏览器加载的完整链路。这条链路上任何一个节点出错,都会表现为“白屏”“报错找不到模块”或“热更新失效”。下面我按实际执行顺序,还原这 17 个关键节点,并标注每个节点的典型故障现象与排查口诀。

2.1 节点 1–3:Node.js 环境与包管理器握手

  • 节点 1(Node.js 版本校验) create-react-app 包的 bin/create-react-app.js 会首先调用 semver.satisfies(process.version, '>=14.0.0') 。如果 Node.js 版本低于 14,直接抛出 You are running Node ${process.version}. Create React App requires Node 14 or higher. 。注意:这里校验的是 process.version ,不是 node -v 输出,某些通过 nvm 切换版本后未重启终端的场景, process.version 可能仍指向旧版本。
  • 节点 2(npm/yarn/pnpm 检测) :脚本会尝试执行 npm --version yarn --version pnpm --version ,按顺序找到第一个可用的包管理器。关键点在于:它 不依赖 packageManager 字段 ,而是直接调用 CLI。这意味着即使你的 package.json 里写了 "packageManager": "pnpm@8.6.0" ,CRA 仍可能用 npm 初始化,除非你显式传参 --use-pnpm
  • 节点 3(临时目录创建) :在系统临时目录(如 macOS 的 /var/folders/... )下创建唯一命名的 tmp-xxx 目录,用于存放下载的模板包。如果磁盘空间不足或临时目录权限异常,会报 EPERM: operation not permitted, mkdir .../tmp-xxx 。此时不要急着删 node_modules,先检查 os.tmpdir() 返回路径的写权限。

2.2 节点 4–7:模板下载与解压的静默战场

  • 节点 4(模板源解析) :默认模板地址是 https://registry.npmjs.org/create-react-app-template/-/create-react-app-template-5.0.0.tgz 。但 CRA 会先向 https://registry.npmjs.org/create-react-app-template 发送 HEAD 请求,获取最新版本号。如果公司内网镜像未同步该 registry,或 DNS 解析失败,会卡在 Downloading template 并最终超时。解决方案不是换源,而是设置 --template file:./local-template 指向本地 tarball。
  • 节点 5(tarball 校验) :下载完成后,CRA 会计算 .tgz 文件的 SHA512 哈希值,并与 package.json 中的 dist.integrity 字段比对。这个字段由 npm publish 自动注入,一旦网络传输中文件损坏,哈希不匹配,安装立即终止。常见于使用老旧代理服务器的环境。
  • 节点 6(解压与符号链接) :解压时,CRA 使用 tar-fs 库,对 package.json 中的 files 字段进行白名单过滤。如果你的自定义模板在 files 里漏写了 public/ 目录, index.html 就不会被复制到新项目中,导致 npm start 后浏览器显示 Cannot GET / 。这是新手最常见的“白屏”原因。
  • 节点 7(git init 时机) git init 命令在所有文件复制完成后才执行。这意味着 public/ 目录下的 .gitignore 文件(通常包含 favicon.ico )已经存在,但 git status 显示 public/ 是 untracked。必须手动 git add public/ 才能提交静态资源。很多团队因此误删了 public/ 下的 manifest.json ,导致 PWA 功能失效。

2.3 节点 8–12: react-scripts 的编译引擎启动

  • 节点 8( react-scripts 安装) :CRA 不把 react-scripts 作为依赖写入 package.json dependencies ,而是写入 devDependencies 。这是因为 react-scripts 本质是构建工具,不应被其他包依赖。但这也意味着:如果你用 npm install --production 部署, react-scripts 不会被安装, npm run build 直接报错 command not found: react-scripts
  • 节点 9(Webpack 配置加载) react-scripts/config/webpack.config.js 是真正的核心。它不是简单导出一个 config 对象,而是调用 getWebpackConfig() 函数,该函数内部根据 NODE_ENV argv.mode (development/production)动态组合多个配置片段。例如,开发模式下启用 webpack.HotModuleReplacementPlugin ,生产模式下禁用 eval-source-map 并启用 TerserPlugin
  • 节点 10(Babel 预设注入) babel-loader options.presets 数组里, react-app 预设被放在最后。这意味着如果你在 babel.config.js 中自定义了 @babel/preset-env ,它的 targets 会先于 react-app 生效。而 react-app 预设内部的 @babel/preset-react 会强制覆盖 runtime: 'automatic' ,确保 JSX 转换行为一致。
  • 节点 11(TypeScript 支持开关) :CRA 通过检测项目根目录是否存在 tsconfig.json 来决定是否启用 fork-ts-checker-webpack-plugin 。但注意:它只检查文件存在,不校验内容。如果 tsconfig.json 是空的 {} ,TS 类型检查仍会启动,但因无 include 配置,检查速度极慢, npm start 启动时间从 3 秒飙升至 47 秒。
  • 节点 12(ESLint 配置桥接) react-scripts 不直接集成 ESLint,而是通过 eslint-webpack-plugin 在 Webpack 构建时调用 eslint --ext .js,.jsx,.ts,.tsx 。其配置来源是项目根目录的 .eslintrc eslintConfig 字段。关键点: eslint-webpack-plugin 默认 failOnError: false ,所以 ESLint 报错不会中断构建,但会在控制台红色高亮。很多团队误以为“没报错就通过”,实则错误被静默吞掉。

2.4 节点 13–17:DevServer 与浏览器的最终握手

  • 节点 13(HTTPS 证书生成) :当 HTTPS=true 时,CRA 调用 openssl 生成自签名证书。如果系统没有 openssl,或 Windows 上 PATH 未包含 OpenSSL 目录,会报 Error: error:02001003:system library:fopen:No such process 。此时需手动安装 OpenSSL 并加入 PATH,或改用 http://localhost:3000
  • 节点 14(端口探测与占用处理) react-scripts 使用 portfinder 库探测 3000 端口。它不是简单 netstat -an | grep 3000 ,而是尝试 new net.Socket().connect(3000) 。如果端口被 Docker 容器占用(Docker for Mac 的虚拟机网络), portfinder 可能误判为“端口空闲”,导致启动后立即被容器劫持流量。
  • 节点 15(HTML 模板注入) public/index.html 中的 <div id="root"></div> 不是静态占位符。 react-scripts 会将其替换为 <div id="root"><!-- react-mount-point-unstable --> ,这个注释是 React DevTools 识别挂载点的关键标记。如果手动删除了该注释,DevTools 将无法连接到 React 实例。
  • 节点 16(Source Map 路径重写) :开发模式下, main.js 末尾的 //# sourceMappingURL=main.js.map 被重写为 //# sourceMappingURL=http://localhost:3000/static/js/main.js.map 。这是为了绕过浏览器对 file:// 协议下 source map 的安全限制。如果 Nginx 反向代理未正确透传 Host 头,source map 请求会发向 http://127.0.0.1:3000 ,导致断点失效。
  • 节点 17(HMR 模块注册) :首次加载时,Webpack Runtime 会执行 __webpack_require__.hmr ,注册所有模块的 hot.accept 回调。如果某个模块(如 src/utils/api.js )里写了 if (module.hot) { module.hot.accept(...); } ,而 CRA 的 react-scripts 版本不支持该 API(如 4.x 版本),HMR 会静默失败,表现为“修改代码后页面不刷新”。

提示:当遇到“白屏”时,不要立刻查 React 代码。打开浏览器开发者工具,按顺序检查:Network 标签页是否有 main.js 404?Console 是否有 Uncaught SyntaxError: Unexpected token '<' (说明 HTML 被当作 JS 加载)?Sources 标签页能否看到 src/ 下的源码映射?这三个检查能定位 90% 的启动问题。

3. react-scripts 的隐藏配置层:如何在不 eject 的前提下突破默认约束

CRA 的设计哲学是“约定优于配置”,但这不等于“禁止配置”。官方文档里写的 npm run build 之后的 build/ 目录结构、 public/ 下静态资源的 URL 规则、甚至 process.env 的注入时机,都是可被安全扩展的。关键在于理解 react-scripts 的三层配置模型: 环境变量层 → 脚本参数层 → 插件注入层 。这三层像俄罗斯套娃,外层改动小,内层威力大,但风险也高。

3.1 环境变量层: REACT_APP_ 前缀的真相与陷阱

所有以 REACT_APP_ 开头的环境变量,都会在构建时被 DefinePlugin 注入到代码中。例如 REACT_APP_API_BASE=https://api.example.com ,在代码里 console.log(process.env.REACT_APP_API_BASE) 会被 Webpack 替换为 console.log("https://api.example.com") 。但这里有三个极易踩坑的细节:

  • 细节 1:变量名大小写敏感,但值不敏感
    REACT_APP_ENV=prod REACT_APP_ENV=PROD 在构建后是两个不同的字符串。但如果你在代码里写 if (process.env.REACT_APP_ENV === 'prod') ,而环境变量实际是 PROD ,条件永远为 false。CRA 不做任何标准化处理,它只是字符串替换。

  • 细节 2:JSON 字符串必须手动解析
    如果你想传一个对象,比如 REACT_APP_FEATURES='{"darkMode":true,"analytics":false}' ,在代码里不能直接 const features = process.env.REACT_APP_FEATURES ,因为它是字符串字面量。必须 JSON.parse(process.env.REACT_APP_FEATURES) 。更安全的做法是:在 .env 文件里写 REACT_APP_FEATURES_DARK_MODE=true ,然后在代码里 process.env.REACT_APP_FEATURES_DARK_MODE === 'true'

  • 细节 3: .env.local 的加载优先级高于 .env ,但低于命令行
    加载顺序是: .env .env.local export REACT_APP_VERSION=2.1.0 (shell 命令行)。这意味着你可以用 REACT_APP_VERSION=2.1.0 npm start 覆盖 .env.local 里的同名变量。但注意: npm run build 时, .env.local 默认被忽略(出于安全考虑),除非你显式设置 CI=false

注意: NODE_ENV PUBLIC_URL 是特殊变量,不受 REACT_APP_ 前缀限制,但它们的行为是硬编码在 react-scripts 里的。例如 PUBLIC_URL=/myapp/ 会强制修改所有静态资源路径,包括 manifest.json start_url icons src 。如果你的 CDN 域名是 https://cdn.example.com ,不能写 PUBLIC_URL=https://cdn.example.com/myapp/ ,因为 react-scripts 会把它当作相对路径处理,导致 index.html 里的 <script src="/myapp/static/js/main.js"> 被错误解析。

3.2 脚本参数层: --scripts-version --template 的实战用法

npx create-react-app my-app --scripts-version 5.0.1 这个参数常被误解为“指定 react-scripts 版本”,其实它指定的是 create-react-app 包自身的 scriptsVersion 字段。真正的 react-scripts 版本由 create-react-app 包的 dependencies 决定。要精确锁定 react-scripts ,必须在 package.json devDependencies 里手动写 "react-scripts": "5.0.1" ,然后 npm install

--template 参数才是真正的“配置杠杆”。CRA 官方模板只有 cra-template (默认)和 cra-template-typescript 。但你可以发布自己的模板包,比如 my-cra-template ,并在其中:

  • 修改 public/index.html <meta name="viewport"> 为适配移动端;
  • src/ 下预置 hooks/useApi.js components/LoadingSpinner.js
  • 重写 package.json scripts ,添加 npm run lint:fix npm run test:watch

关键技巧:模板包的 package.json 中, "main": "index.js" 必须导出一个函数,接收 appPath (新项目路径)作为参数。在这个函数里,你可以用 fs-extra 修改 src/App.js ,注入团队统一的错误边界组件。这样,每个新项目都自带 ErrorBoundary ,无需手动添加。

3.3 插件注入层: craco react-app-rewired 的本质区别

当需要修改 Webpack 配置时,社区有两个主流方案: craco (Create React App Configuration Override)和 react-app-rewired 。很多人以为它们只是“配置方式不同”,其实它们的架构哲学截然相反。

  • react-app-rewired 是“补丁式”改造 :它在 react-scripts webpack.config.js 导出前,用 Object.assign 合并你提供的配置对象。例如:

    // config-overrides.js
    module.exports = function override(config, env) {
      config.module.rules.push({
        test: /\.svg$/,
        use: ['@svgr/webpack'],
      });
      return config;
    };
    

    这种方式简单,但风险在于:如果 react-scripts 升级后, config 对象结构变化(如 rules 改为 rulesList ),你的补丁会静默失效,或者导致 config 变成 undefined

  • craco 是“钩子式”介入 :它不直接修改 config ,而是提供 webpack jest eslint 等插件接口。你写一个 craco.config.js

    module.exports = {
      webpack: {
        configure: (config, { env, paths }) => {
          config.module.rules.push({
            test: /\.svg$/,
            use: ['@svgr/webpack'],
          });
          return config;
        }
      }
    };
    

    craco 的优势在于:它把 react-scripts 的内部配置逻辑封装成稳定的 paths 对象(包含 appSrc appPublic 等路径),并保证 configure 函数总是在 react-scripts 的原始配置生成后、最终导出前被调用。即使 react-scripts 重构了 Webpack 配置生成流程,只要 paths 接口不变,你的配置就依然有效。

实操心得:在团队落地时,我推荐 craco 。但要注意一个致命细节: craco configure 函数返回的 config 对象,必须是原对象的 深度合并 ,而不是浅拷贝。如果你用了 lodash.merge ,要确保 merge({}, config, myRule) ,否则 config.resolve.alias 等嵌套对象会被整个替换,导致 react 模块解析失败。

4. CRA 的生产构建深水区:从 npm run build 到上线的 9 个性能与安全关卡

npm run build 看似一键完成,但背后是 react-scripts 启动的一场精密编排。它不仅要生成最小化的 JS/CSS,还要确保产物能在各种网络环境、CDN 配置、安全策略下稳定运行。下面这 9 个关卡,是我在 12 个线上项目发布前必做的检查清单,每一个都曾引发过线上事故。

4.1 关卡 1: build/ 目录结构的语义契约

CRA 的 build/ 目录不是随意组织的,它遵循一套严格的语义契约:

build/
├── index.html          # 入口 HTML,包含 <script src="static/js/main.<hash>.js">
├── static/
│   ├── js/               # 所有 JS 文件,main.xxx.js 是入口,2.xxx.js 是代码分割 chunk
│   ├── css/              # 所有 CSS 文件,main.xxx.css 是入口样式
│   └── media/            # 图片、字体等静态资源,文件名含 hash
├── manifest.json         # PWA 清单,start_url 必须与 homepage 一致
└── asset-manifest.json   # 资源映射表,{ "main.js": "static/js/main.xxx.js" }

这个结构是 react-scripts 的硬性约定。如果你用 cp -r build/* /var/www/html/ 部署,而 Nginx 配置了 location /static/ ,那么 main.js 里的 import('./chunk.js') 会请求 https://example.com/static/js/chunk.js ,但如果 chunk.js 实际在 https://example.com/static/js/2.xxx.js ,就会 404。解决方案是:Nginx 的 location /static/ 必须指向 build/static/ ,而不是 build/

4.2 关卡 2: asset-manifest.json 的双重身份

asset-manifest.json 不仅是构建产物,更是运行时的资源索引。 react-scripts 提供了一个 getPublicUrlOrPath() 工具函数,它在 public/ 目录下查找 asset-manifest.json ,并从中提取 main.js 的路径。这意味着:如果你把 build/ 目录整体上传到 CDN,但忘了上传 asset-manifest.json getPublicUrlOrPath() 会 fallback 到 PUBLIC_URL ,导致所有动态导入失败。

更隐蔽的陷阱是: asset-manifest.json files 字段里, main.js 的值是 static/js/main.xxx.js ,但 index.html 里的 <script> 标签是 <script src="/static/js/main.xxx.js"> 。如果 PUBLIC_URL 设置为 https://cdn.example.com index.html 会写 <script src="https://cdn.example.com/static/js/main.xxx.js"> ,但 asset-manifest.json 里的路径仍是相对路径。这要求你的 CDN 必须能正确解析相对路径。

4.3 关卡 3: manifest.json start_url scope 的绑定关系

PWA 的 manifest.json 里, start_url scope 必须满足 start_url scope 为前缀。例如:

{
  "start_url": "/myapp/",
  "scope": "/myapp/"
}

如果 start_url /myapp/ scope / ,Chrome 会拒绝注册 Service Worker,控制台报 The path of the provided scope ('/') is not under the max allowed scope length (1000). 。CRA 的 react-scripts 会根据 homepage 字段自动推导 start_url scope ,但如果你手动修改了 public/manifest.json ,必须同步检查这两个字段。

4.4 关卡 4: process.env.NODE_ENV 的三重注入时机

process.env.NODE_ENV 在构建过程中被注入三次:

  • 第一次 :Webpack 的 DefinePlugin ,将 process.env.NODE_ENV 替换为 "production" 字符串字面量;
  • 第二次 :TerserPlugin,在压缩时移除 if (process.env.NODE_ENV !== 'production') { ... } 分支;
  • 第三次 react-scripts scripts/build.js ,在 build 命令结束时,打印 The project was built assuming it is hosted at /.

这三次注入必须严格同步。如果 TerserPlugin 的 compress.drop_console true ,而 DefinePlugin 未生效, console.log 不会被移除。因此, react-scripts webpack.config.js 里, DefinePlugin 总是放在 plugins 数组的第一个位置,确保它最先执行。

4.5 关卡 5: public/ 目录的“零配置”魔法

public/ 目录下的文件,会被 react-scripts 无条件复制到 build/ 根目录,不做任何处理。这意味着:

  • public/favicon.ico build/favicon.ico
  • public/robots.txt build/robots.txt
  • public/manifest.json build/manifest.json

但有一个例外: public/index.html 。它会被 HtmlWebpackPlugin 处理,注入 <script> 标签和 manifest.json <link> 。如果你在 public/index.html 里写了 <script src="custom.js"></script> ,而 public/custom.js 不存在,构建不会报错,但上线后会 404。CRA 不校验 public/ 下的引用完整性。

4.6 关卡 6: build/ Content-Security-Policy 兼容性

现代浏览器要求严格的 CSP 策略。CRA 的 build/ 产物默认不包含 CSP 头,但 index.html 里有 <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'"> 。这个 meta 标签在 react-scripts HtmlWebpackPlugin 模板里是硬编码的。如果你的服务器设置了 Content-Security-Policy 响应头,而 meta 标签的策略更宽松,浏览器会以响应头为准。因此, public/index.html 中的 meta 标签应该被删除,CSP 策略应由 Nginx 或 CDN 统一配置。

4.7 关卡 7: build/ Cache-Control 头策略

CRA 的 build/ 产物中, index.html 应该设置 Cache-Control: no-cache ,而 static/ 下的文件(JS/CSS/图片)应设置 Cache-Control: public, max-age=31536000 (1 年)。这是因为 index.html 里包含了带 hash 的资源路径,如果 index.html 被缓存,用户可能加载到旧的 main.xxx.js ,而新的 main.yyy.js 已上线,导致白屏。 react-scripts 不生成 HTTP 头,这必须由你的 Web 服务器配置。

4.8 关卡 8: build/ X-Content-Type-Options 安全头

X-Content-Type-Options: nosniff 头能防止 MIME 类型混淆攻击。 react-scripts build/ 产物中, index.html Content-Type 必须是 text/html main.js 必须是 application/javascript 。如果 Nginx 配置了 types { text/plain html; } index.html 会被当成 text/plain ,浏览器拒绝执行。必须确保 Nginx 的 mime.types 文件正确加载。

4.9 关卡 9: build/ Cross-Origin-Embedder-Policy Cross-Origin-Opener-Policy

对于启用了 SharedArrayBuffer 的 WebAssembly 应用,现代浏览器要求 Cross-Origin-Embedder-Policy: require-corp Cross-Origin-Opener-Policy: same-origin react-scripts 不生成这些头,但 public/index.html <meta> 标签无法设置它们,必须由服务器响应头提供。如果缺失, SharedArrayBuffer 会是 undefined ,WebAssembly 线程功能失效。

最后一个实战技巧:在 CI/CD 流水线中,我习惯在 npm run build 后加一步 npx serve -s build -l 3000 ,然后用 curl -I http://localhost:3000/ 检查响应头。一个健康的 build/ 部署,应该返回:

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Cache-Control: no-cache
X-Content-Type-Options: nosniff
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

这 5 个头缺一不可。少一个,就可能在某个浏览器版本上触发安全拦截。

更多推荐