首页 / 文章

Localhost并非生产环境:诊断部署时出错的React应用问题

了解为何在本地运行正常的 React 应用部署后会出现故障,以及如何追踪 API 地址、环境变量、CORS、路由、资源文件和身份验证问题所在。

发布者:REACTAPP.TOP阅读约 17 分钟3798 词

来源: Why Your React App Works Locally but Breaks After Deployment: 10 Common Problems and How to Fix Them

一个在 localhost 上运行正常,但一旦部署就出问题的 React 应用,是前端开发中最常见的令人沮丧的情况之一。请求不再返回数据,刷新页面会出现 404 错误,图片消失,登录功能默默失败,或者托管平台拒绝构建在笔记本上能正常编译的代码。这些故障几乎都不是 React 本身导致的,而是源于开发环境与用户实际使用的环境之间的差异。本指南将逐一分析最常见的十种原因,说明每种问题是如何表现的,并提供调试方法以及可在每次发布时重复使用的部署前检查清单。

为何相同代码在生产环境中的表现不同

React 前端很少是整个应用程序。它与 API 进行通信,API 又与数据库交互,而这一链条中的每一个环节都依赖于配置:环境变量、域名、网络规则、认证设置,以及托管平台对传入请求的处理方式。在本地机器上,你可以隐式地控制所有这些内容——你知道后端监听的是哪个端口、加载了哪个 .env 文件,以及 node_modules 目录中有哪些包。但在生产环境中,这些隐含的假设都被明确的规定所取代,任何不匹配都可能导致原本正常的代码出现故障。

阅读以下内容时请牢记这一点:大多数问题都是通过修改配置来解决,而非修改代码。

1. API URL 仍指向 localhost

当已部署的应用程序没有显示任何数据时,首先需要检查的是它的请求被发送到了哪里。在开发阶段,通常会在本地运行后端,并将其后端地址硬编码到前端中:

const API_URL = "http://localhost:8080/api";

这样做是因为浏览器和后端运行在同一台计算机上。一旦前端被部署到类似 https://my-app.example.com 的地址,原来的代码仍会不断向 http://localhost:8080/api 发送请求。问题在于 localhost 会由运行该代码的浏览器来确定,对于访问者来说,它指的是他们自己的机器,而非你的开发环境。这样一来,请求路径实际上就变成了:

User's Browser
      ↓
localhost:8080
      ↓
User's computer

除非该访问者的机器上恰好也在端口 8080 上运行着你的后端,否则所有请求都会失败。

解决办法是停止硬编码地址,而是从环境变量中读取。在 Vite 项目中,可在 env 文件中声明该值:

VITE_API_URL=https://your-backend.example.com/api

然后在代码中通过 import.meta.env 读取它:

const API_URL = import.meta.env.VITE_API_URL;

这样,开发环境可以指向 http://localhost:8080/api,而生产环境构建则可以指向类似 https://api.example.com/api 的地址,且两者无需修改任何代码。这虽只是一个小小的重构,却能避免最常见的部署错误之一:让别人的浏览器去连接你的笔记本电脑。

另一种相应的防护措施是在变量缺失时抛出明显的错误。如果 API_URL 的值为 undefined,请求就会发送到类似 undefined/users 的路径,这会给问题诊断带来困扰。在启动时进行一行简单的检查,一旦发现值缺失就记录日志或抛出异常,就能节省后续的时间。

2. 生产环境变量从未被配置

环境变量是下一个常见的问题来源。在本地开发时,你可能有一个 .env 文件,其中包含了应用程序所需的所有配置:

VITE_API_URL=http://localhost:8080/api
VITE_APP_NAME=MyApp

这些值仅存在于你的电脑上。托管平台不会读取你本地的 .env 文件(而且也不应该读取,因为该文件通常会被排除在版本控制之外)。生产环境构建必须从提供商的环境变量设置中获取自己的配置值:

VITE_API_URL=https://your-backend.example.com/api

在添加或修改该处的值之后,需要触发新的部署。使用 Vite 时,这一步并非可选:只有以 VITE_ 为前缀的变量才会暴露给客户端代码,而 Vite 会在构建时将这些变量替换到打包文件中。如果在控制台修改了变量,对于已经使用旧值构建好的打包文件则没有任何影响。

前端变量并非机密信息

其中隐藏着一条安全准则:.env 文件中的值并不等同于私密信息。任何暴露给客户端的内容都会被编译成 JavaScript,所有访问者都能下载并读取这些代码。数据库密码、私有 API 密钥以及其他服务器端凭证绝不能放在 VITE_* 变量中,应将其保存在后端或服务器端函数之后,由前端通过调用相应接口来使用这些凭证。

3. CORS规则仅允许您的开发源地址

跨域资源共享错误是部署后常见的另一个问题。假设前端从以下地址获取服务:

https://myapp.example.com

而后端则从以下地址提供服务:

https://api.example.com

由于源地址是由协议、主机和端口共同构成的,因此这两个属于不同的源地址。浏览器会执行同源策略,所以为了让前端能够读取API的响应,后端必须通过CORS头部明确允许前端的源地址。

在开发阶段,后端很可能被配置为接受来自Vite开发服务器的请求:

http://localhost:5173

由于一切正常,人们很容易误以为CORS问题已经解决。但实际部署后,前端的源地址却是:

https://myapp.example.com

对浏览器而言,这是一个完全新的源地址。如果后端仍然只列出 http://localhost:5173,那么已部署的前端就会被阻止访问。对于 Spring Boot 后端,可在控制器中配置生产环境源地址,示例代码如下(为 Java 语言):

@CrossOrigin(origins = "https://myapp.example.com")
@RestController
public class UserController {
    // ...
}

为每个控制器添加注解适用于小型服务,但在较大的代码库中,采用单一的集中式 CORS 配置更易于维护,且在添加新接口时也不易遗漏。无论使用何种技术栈,规则都是一样的:允许的源地址必须与实际的生产环境前端源地址完全一致,包括 https 及所有子域名。一种常见且可行的做法是从后端自身的环境配置中读取允许的源地址列表,这样开发、测试和生产环境就能分别允许对应的前端访问。

请记住,CORS 是浏览器的一种机制。在浏览器中因 CORS 错误而失败的请求,在 curl 或 Postman 中却可能成功,这就是为什么在浏览器外部测试 API 可能会给出误导性的正常结果。

4. 刷新客户端路由时返回 404

当路由器运行正常时,这种情况往往会让开发者去检查路由器的代码。假设应用中有如下路由:

/dashboard

你进入首页,点击通往控制面板的链接,页面能够正常显示,你可以自由导航。然后你刷新页面,服务器的响应却是:

404 Not Found

其原因在于单页应用的工作原理。当用户点击链接时,React Router会更新URL并在浏览器中完整渲染对应的组件;此时根本不会向服务器发送对/dashboard的请求。而当用户刷新页面或直接访问该URL时,浏览器会向托管服务器请求/dashboard。如果服务器只认识真实的文件,它会查找该路径下的资源,找不到后就会返回404错误。

解决方案是在服务器端设置一条备用规则:对于那些无法匹配到实际文件的前端路由请求,应将其重写或重定向至 index.html。这样浏览器就会加载应用,React Router 会读取 URL 并渲染对应的页面。React Router 的部署文档中说明了这一要求,而且大多数静态托管服务都提供了相应的设置选项或小型配置文件。因此,当点击操作可行但刷新失败时,请先不要修改路由设置,而是检查服务器端的单页应用重写配置。如果您想回顾一下路由的声明方式,可参阅 React Router 基础。

5. 使用错误大小写的导入路径

有些漏洞要等到在不同操作系统上构建时才会显现出来。假设存在一个名为如下所示的组件文件:

Header.jsx

导入方式如下:

import Header from "./components/header";

唯一的区别在于一个大写字母,这在代码审查中很容易被忽略。不同的文件系统对于这一点是否有影响存在分歧。macOS和Windows的默认设置是不区分大小写的,因此导入可以成功,应用程序也能运行。而大多数构建服务器和容器运行的是Linux系统,其文件系统是区分大小写的,所以Header.jsx和header.jsx被视为两个不同的名称,会导致构建失败并出现模块未找到的错误。

解决方法很简单,就是让每个导入语句与磁盘上的文件内容完全一致。假设:

components/Header.jsx

将导入语句写为:

import Header from "./components/Header";

目录名称也需要同样谨慎处理。还有一个值得注意的陷阱:在大小写不敏感的文件系统中,仅通过大小写更改来重命名文件可能不会被 Git 检测到,因此即使编辑器显示的是新名称,仓库仍会保留旧名称。查看 Git 实际跟踪的名称就能迅速解开这个谜团。

6. 图片和静态资源消失

有时应用本身可以加载,但其图片、字体或图标却无法显示。这通常意味着这些资源是通过在开发服务器上有效但在生产环境输出中并不存在的路径来引用的。

Vite 提供了两种不同的机制,了解自己使用的是哪一种很有帮助。第一种是从源代码中导入资源:

import logo from "./assets/logo.png";

然后使用导入的值作为图片源:

function App() {
    return <img src={logo} alt="Logo" />;
}

由于该资源会经过模块图处理,Vite会在构建时对其进行处理,用生成的文件名将其复制到输出目录中,并提供正确的URL。你无需猜测最终的路径。

第二种机制是public目录,用于那些无需处理即可直接提供的文件:

public/
└── logo.png

该目录下的文件会被复制到构建输出的根目录中,因此需要通过绝对URL路径来引用它们:

<img src="/logo.png" alt="Logo" />

其根本原因在于磁盘上的路径与浏览器能够获取的URL之间的差异。你的编辑器可能确切知道src/assets/logo.png的位置,但浏览器只能理解部署服务器实际提供的URL。如果应用托管在子路径下而非域名根目录下,像/logo.png这样的绝对路径也可能会出问题,而这正是Vite的base选项存在的意义——用于解决这类问题。

当某个资源缺失时,打开浏览器的“网络”标签页并检查出错的请求。你通常会看到构建过程中不存在的路径,或是服务器返回的404错误,这就能告诉你需要修正哪一种机制。

7. 开发服务器并非生产环境构建版本

使用以下方式运行应用:

npm run dev

启动带有热模块替换、详细警告以及按需模块转换功能的开发服务器。实际部署的则是另一样东西:一组经过打包和优化的静态文件。对于 Vite 项目,可通过以下方式生成这些文件:

npm run build

然后由服务器提供生成的输出。由于这两种处理路径对代码的处理方式不同,因此一个应用在某种环境下能够运行:

npm run dev

却可能在另一种环境下失败:

npm run build

如果你能自行发现这种故障,那反而是好事,因为你可以在此之前就修复它,避免问题影响到用户。Vite 还允许你使用以下方式在本地提供构建后的输出:

npm run preview

这与访问者实际看到的内容更为接近,远优于开发服务器的版本。应在每次部署前养成在本地进行构建和预览的习惯,而非让生产环境的首次运行依赖于托管平台。将 npm run build 集成到 CI 流水线中,便能在每次拉取请求时自动获得同样的保障。

8. 本地 node_modules 目录会隐藏缺失的依赖项

长期存在的 node_modules 目录可能会让项目看起来比实际状况更健康。在数周的开发过程中,各种包会不断堆积于此:包括你为测试而安装的包、其他包的间接依赖,以及已废弃分支留下的残留文件。你的代码之所以能正常运行,可能仅仅是因为这些包恰好存在于项目中。

部署环境初始状态是干净的。它只会安装 package.json 和锁文件中指定的内容,不会多装其他东西。因此,如果你的代码中包含:

import axios from "axios";

那么就需要将 Axios 明确列为项目的实际依赖项,例如:

{
  "dependencies": {
    "axios": "^1.0.0",
    "react": "^19.0.0",
    "react-router-dom": "^7.0.0"
  }
}

上述代码片段中的版本号仅为示例,应使用项目实际需要的版本。目标是确保在全新克隆并重新安装后,环境中包含构建和运行应用所需的所有内容。请同时提交 package.json 和锁文件,并保持二者同步,这样每次安装都能使用相同的版本号。

若要在本地重现部署环境,需删除 node_modules 后重新安装,建议使用 npm ci,该命令会严格依据锁定文件进行安装,一旦锁定文件与 package.json 内容不一致就会报错。如果之后构建失败,说明你比服务器更早发现了未被声明的依赖项。

9. 前端正常但后端出错

每当界面显示异常时,人们总倾向于归咎于 React,但实际上问题往往出在更上游的环节。假设已部署的前端能够在以下地址成功加载:

https://myapp.example.com

打开控制面板后发现里面为空。在“网络”标签页中可以看到:

GET https://api.example.com/api/dashboard
500 Internal Server Error

那行代码透露了很多信息。React应用已完成其任务并发送了请求;后端接收到了该请求,但在处理过程中出现了故障。可能的原因是数据库连接中断、缺少服务器端的环境变量、认证问题、未处理的异常,或是配置与本地设置不一致。

典型的全栈请求会经过以下层次:

React Frontend
       ↓
REST API
       ↓
Backend
       ↓
Database

底层的故障会影响顶层功能。如果无法连接到数据库,即使前端代码完全正确,控制面板也会显示异常。正因如此,诊断不应仅停留在浏览器层面。当API调用失败时,先查看“网络”标签页,再查阅后端日志。如果浏览器显示500错误,而服务器日志中则显示:

Database connection failed

你面对的是后端或基础设施问题,而非渲染错误。如果前端能在请求失败时显示明确的错误状态,而不是空白页面,就能让用户和开发者都清楚区分这两种情况。

10. 认证在本地正常,部署后失败

登录流程对环境差异尤为敏感。在本地,其执行顺序可能是:

localhost → Login → Dashboard

而在生产环境中,则会变成:

Production → Login → Dashboard doesn't load

多种早期问题都可能单独导致此状况。前端仍可能调用错误的 API 地址,后端的 CORS 规则可能未包含生产环境地址,或者令牌在新环境中配置不正确。不过,cookies 是最常见的原因。

为什么 cookies 在生产环境中的表现不同

浏览器是否存储并发送 Cookie 取决于其属性:Secure、HttpOnly、SameSite、Domain 和 Path。在本地环境中,所有内容通常共享同一个主机,因此这些属性往往不起作用。而在实际生产环境中,前端与 API 可能位于不同的域名下,并通过 HTTPS 连接,此时浏览器就会开始执行相关规则。例如,标记为 SameSite=None 的 Cookie 只有在同时被标记为 Secure 时才会被接受。

携带凭据的跨源请求也需要双方的配合。客户端必须选择发送凭据,而服务器则必须明确允许该特定源地址使用凭据;带凭据的请求不支持通配符形式的源地址。如果任何一方未能履行其职责,登录请求看似成功,但在后续调用中却不会收到会话cookie。

关键点在于:在本地主机上能正常运行的登录流程仅在最简单的环境中经过测试。需在类似生产环境的HTTPS、域名、cookie设置及CORS规则下再次进行测试。

分层调试方法

当部署出现故障时,最无效的做法是随意修改代码并寄希望于问题能自行解决。有效的做法是在触碰任何代码之前先找出出问题的环节。

从控制台开始

打开浏览器的开发者工具(通常是 F12),然后查看控制台信息。Failed to fetch、CORS违规、未被捕获的JavaScript异常以及脚本或资源对应的404错误等信息,各自指向不同的问题根源。

查看网络标签页

切换到网络标签页并重新加载页面。对于每个请求,你都可以看到具体的URL、HTTP方法、状态码、响应内容,通常还有请求头信息。对于API问题而言,这是最具参考价值的视图。你可能会看到:

GET /api/users
Status: 404

或者:

GET /api/users
Status: 401

或者:

GET /api/users
Status: 500

每个状态码都能帮助缩小搜索范围:

  • 404:该URL下不存在所请求的资源或路由。
  • 401:需要身份验证,或者发送的凭证缺失或无效。
  • 403:服务器已识别出您的身份,但拒绝授权该操作。
  • 500:服务器在处理请求时出现了内部错误。
  • 检查浏览器实际使用的URL

    不要仅凭对源代码功能的推测来判断,而应查看浏览器实际发送的请求。如果生产环境调用的是:

    http://localhost:8080/api/users
    

    而后端实际位于:

    https://api.example.com/api/users
    

    那么您几乎可以确定问题出在第1节提到的API URL问题上。

    通过服务器错误追溯到后端日志

    500错误表示答案在服务器端。后端日志可以显示数据库连接失败、缺少变量,或是前端无法报告的异常。正是这种在证据指向某处时立即深入下一层进行排查的习惯,让部署调试更加高效。

    分析一次真实的故障

    假设有一个全栈应用部署时没有出现错误,但控制面板却显示为空。人们的本能反应会是认为控制面板组件存在故障:

    “肯定是控制面板组件出了问题。”

    与其直接打开该组件,不如先查看“网络”标签页。在那里你会发现:

    GET https://api.example.com/api/dashboard
    Status: 500
    

    这已经排除了前端是根本原因的可能性:请求已经发送。接下来,后端日志显示:

    Database connection failed
    

    有了这两份证据,整个流程就变得清晰明了:

    React Frontend
           ↓
    API request
           ↓
    Backend
           ↓
    Database connection failed
           ↓
    500 response
    

    仪表板组件从未出过问题。花十分钟查看浏览器的工具信息以及服务器日志,就足以替代数小时对代码进行重构的工作。能够同时查看浏览器错误、网络流量和服务器日志,是日常软件工作中最宝贵的技能之一。

    部署前的检查清单

    减少部署时意外情况的最佳方法是在发布之前,追踪请求从浏览器到数据库的每一个传输环节。以下是一个实用的检查清单:

    • 在本地运行npm run build并确认其成功,然后再用npm run preview进行验证。
    • 确认生产环境的API地址已设置正确,且没有请求指向localhost。
  • 确认托管平台上存在所有必需的环境变量,且前端变量中不存在任何机密信息。
  • 如果前端和后端位于不同的域名下,需确保后端允许当前生产环境的前端域名访问。
  • 对于使用 React Router 的应用,应直接打开重要路由并刷新页面,而不要仅从首页跳转过去。
  • 检查图片、字体及其他静态资源在部署后的构建版本中能否正常加载。
  • 对于具有身份验证功能的应用,需测试登录、注销、受保护路由以及已登录页面的刷新功能。
  • 部署后要关注控制台和网络标签页,一旦 API 调用失败还需查看后端日志。
  • 没有哪个检查清单能保证项目完美发布,但这份清单能在问题还容易解决时发现大多数常见问题。若想更全面地了解从项目初期就应该具备的完善设置,适用于生产的 React 基准配置涵盖了相关工具方面的内容。

    为何“在本地能运行”并不能说明太多问题

    你的开发机器是一个受控且容错性较高的环境。你知道后端运行在何处、哪些变量被定义、node_modules中安装了什么以及应用使用的是哪个数据库。而生产环境几乎会改变所有这些:不同的域名、不同的API地址、不同的环境变量、不同的数据库、更严格的CORS和cookie规则,还需要明确告知服务器如何处理客户端路由,通常操作系统也会不同。即便代码本身完全正确,周围的配置出错也可能导致问题。

    正因如此,部署应当被视为另一个需要测试的环境,而非开发完成后才进行的最后一步。预发布环境、CI构建和预览部署的存在都是为了将测试提前进行。

    核心要点

    • 在部署后出现故障时,先查明是哪一层出了问题,再考虑 React 是否存在问题。
    • 如果页面完全无法加载,请查看构建输出和资源文件;如果页面能加载但数据没有,需检查网络标签页及 API 地址。
    • 500 错误会引导你查看后端日志;CORS 错误则会提示后端允许的来源地址。
    • 某些路由在点击时可以正常工作,但刷新后却出现 404 错误,这时需要在服务器端添加单页应用回退机制,而非修改路由配置。
    • 缺失的值通常意味着生产环境变量从未被设置,或者设置了之后没有重新构建应用。
    • 身份验证失败往往与 API 地址、CORS 设置以及 Cookie 属性有关,因此需要逐一检查这三方面。
  • 已部署的应用是由前端、API、后端、数据库、配置、托管服务、网络以及浏览器组成的系统。逐层进行调试,部署故障就不再难以理解了。
  • 遇到这些问题并非编程能力不足的体现,而是每个人了解真实系统构成方式的途径。目标不是永远不出现错误,而是能快速找到错误的实际位置。