跳到主要内容

5 篇博文 含有标签「Vite」

查看所有标签

Vite 环境变量读不到?变量必须加 VITE_ 前缀才暴露给前端

· 阅读需 5 分钟

在 Vite 项目的 .env 里写好了 API_URL=http://localhost:3005,前端代码里 import.meta.env.API_URL 打印出来却是 undefined,接口请求全部落到错误地址。

在为客户构建 AI Agent SaaS 平台时遇到此问题,记录根因与解法。

TL;DR​

Vite 只把 VITE_ 前缀的环境变量暴露给客户端代码,这是防止服务端密钥被打进浏览器 bundle 的安全设计。

# ❌ 不会暴露给前端
API_URL=http://localhost:3005

# ✅ 暴露给前端
VITE_API_URL=http://localhost:3005

代码里对应改为 import.meta.env.VITE_API_URL。TypeScript 项目再加一步 env.d.ts 类型声明,补全智能提示。

问题现象​

两个典型表现:

表现一:变量是 undefined

// .env: API_URL=http://localhost:3005
console.log(import.meta.env.API_URL) // undefined
console.log(import.meta.env) // 里面有 BASE_URL、MODE、PROD...,就是没有 API_URL

.env 文件确实被加载了(MODE、PROD 这些内置变量都在),唯独自己写的变量不见——说明不是加载失败,是暴露规则把它拦下了。

表现二:加了前缀但拼错访问名

// .env: VITE_API_URL=...
const url = import.meta.env.VITE_APIURL // undefined —— 大小写必须完全一致

服务端 Node.js 项目里「环境变量读出来是 undefined」另有常见根因(dotenv 加载顺序),前端和后端这两类 undefined 根因不同,别混着排查。

根因:Vite 的暴露规则是一道安全边界​

Vite 的设计:.env 里可能同时存在两类值——前端要用的公开配置(接口地址)和绝不能进浏览器的密钥(数据库连接串、第三方 API key)。如果全部暴露,一次疏忽就把密钥打进了公开的静态产物。

所以 Vite 定了硬规则:只有 VITE_ 前缀的变量会出现在 import.meta.env 里,其余变量只在 vite.config.ts(Node 侧)通过 loadEnv 可见。

暴露的实现方式也值得知道:构建时静态替换。Vite 打包时直接把 import.meta.env.VITE_API_URL 替换成字符串字面量,运行时根本没有「读环境变量」这个动作。两个推论:

  1. 值会明文出现在产物 JS 里——VITE_ 变量天然是公开的
  2. 运行时改环境(改容器 env、改系统变量)不影响已构建的产物——换环境必须重新构建,或把配置改成运行时注入(如 window.__CONFIG__)

解法:加前缀 + 类型声明​

第一步:.env 里的变量加 VITE_ 前缀。 命名保持语义,前缀只是暴露标记:

# .env
VITE_API_URL=http://localhost:3005

第二步:代码统一走 import.meta.env.VITE_XXX。 建议收敛到一个配置模块,别在组件里散着读:

// src/config.ts
export const API_URL = import.meta.env.VITE_API_URL

第三步(TypeScript 项目):src/env.d.ts 补类型声明,拿到完整智能提示:

/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_API_URL: string
}

interface ImportMeta {
readonly env: ImportMetaEnv
}

开发、生产两套地址用模式文件分开,Vite 按场景自动选:

.env.development    # dev server 用
.env.production # npm run build 用
.env # 两者都加载,放公共配置

改完任何 .env 文件必须重启 dev server——环境变量只在启动时加载一次,热更新不覆盖它们,这是「明明改了却没生效」的第二大来源。

注意事项

VITE_ 变量等于公开信息:值会被明文内联进客户端 bundle,任何人可见。接口地址、功能开关可以放;数据库连接串、第三方密钥绝不放,密钥只走服务端环境变量。前端需要受保护的配置时,由后端接口下发,而不是塞进 .env。

常见问题​

Vite 环境变量配置在哪个文件里?​

项目根目录的 .env 系列:.env 两种场景都加载,.env.development 只在 dev server 生效,.env.production 只在构建时生效。改完任何 .env 文件都必须重启 dev server——Vite 只在启动时加载一次环境变量,热更新不覆盖它们。

为什么 VITE_ 前缀的变量会泄露?不能放密钥吗?​

不能放密钥。VITE_ 变量在构建时被静态内联进客户端 bundle,任何访问页面的人打开源码就能看到明文。前缀机制的设计目的就是把「允许公开的配置」(接口地址、功能开关)和「必须留在服务端的密钥」分开,密钥走后端环境变量。

import.meta.env 和 process.env 在 Vite 里有什么区别?​

浏览器端代码只能用 import.meta.env——Vite 会把 VITE_ 前缀变量静态替换进去;process.env 是 Node.js 的 API,只在 Vite 配置文件(vite.config.ts)和 SSR 场景可用,前端代码里写 process.env.XXX 打包后就是 undefined。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

本地能调通线上 404?Vite Proxy 不参与生产,需要 Nginx 反代

· 阅读需 5 分钟

Vite 项目本地开发接口全通,npm run build 部署到服务器后,页面能打开,但所有 /api 请求返回 404——或者更隐蔽:返回的是 index.html,前端把 HTML 当 JSON 解析直接报错。

在为客户构建 AI Agent SaaS 平台时遇到此问题,前后端分离部署,记录根因与解法。

TL;DR​

vite.config.ts 里的 server.proxy 只属于 dev server。 npm run build 产出纯静态文件,没有任何代理层;生产环境由 Nginx 接住 /api:

location /api {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}

加在 SPA fallback(try_files ... /index.html)能命中的范围之外,nginx -t 校验后 nginx -s reload。

问题现象​

两种表现,同一个根因:

表现一:直接 404

GET https://example.com/api/agents  → 404 Not Found

表现二:返回 index.html(更隐蔽)

GET /api/agents  → 200 OK,响应体却是 <!DOCTYPE html>...
前端 JSON.parse 失败:Unexpected token '<'

第二种是 SPA fallback 吃掉了请求:Nginx 找不到 /api/agents 对应的静态文件,try_files $uri /index.html 把它兜底到了前端入口页。状态码还是 200,等前端解析响应时才炸,排查时容易被「请求成功了」迷惑。

根因:proxy 是 dev server 的运行时功能​

vite.config.ts 里的这段配置:

// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': 'http://127.0.0.1:3000',
},
},
})

它生效的场合只有一个:npm run dev 启动的开发服务器。Vite dev server 是一个 Node 进程,server.proxy 是这个进程里的转发中间件——浏览器请求 localhost:5173/api/agents,dev server 收到后转手发给 127.0.0.1:3000,把响应带回来。

npm run build 之后,产物是 dist/ 下的一堆静态文件,浏览器直接从 Nginx 拿文件——这个链路里没有 Vite 进程,server.proxy 配置留在 vite.config.ts 里,根本不随构建走。

所以生产的请求走向是:浏览器 → Nginx → ?。Nginx 按 location 匹配,没有 /api 规则时,静态文件找不到 /api/agents 这个路径,要么 404,要么被 SPA fallback 兜到 index.html——两种表现就是这么来的。

解法:Nginx 承担生产代理​

第一步:server 块加反代 location。

server {
listen 80;
server_name example.com;
root /var/www/app/dist;

# API 反代:前缀匹配,优先于 SPA fallback
location /api {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

# SPA fallback:其余路径全给前端入口
location / {
try_files $uri $uri/ /index.html;
}
}

要点两个:

  • proxy_pass 指向后端真实地址端口(示例的 3000 换成你的后端端口);/api 前缀请求会原样转发,后端路由也要带 /api 前缀
  • Nginx 前缀 location 按最长匹配优先,/api 天然赢过 /,两块不用调顺序;但如果你的 API 路径有更深的公共前缀,保证前缀匹配覆盖即可

第二步:验证后生效。

nginx -t          # 语法校验
nginx -s reload # 平滑重载

# 冒烟验证:应返回后端数据而非 HTML
curl -i https://example.com/api/health

curl 看到 JSON 响应、Content-Type: application/json,代理就通了;看到 text/html 说明请求还在被 fallback 接住。

顺带的收益:反代后前端和后端同域,开发时靠 Vite proxy 绕开的跨域问题在生产也自然消失,不需要再配 CORS。

这一篇与 部署后前端没更新?Nginx 缓存与构建产物检查 是部署排查的姊妹篇:那篇管「上线的代码是旧的」,这篇管「接口在生产没有代理层」,两处都查完,部署类 404 基本穷尽。

注意事项

location /api 的路径拼写必须和后端路由前缀完全一致。如果后端路由是 /api/agents 而 Nginx 反代配的是 /apis,或者 proxy_pass 末尾多了一个 /(会触发路径重写,/api/agents 变成 /agents 到达后端),都会得到新的 404。改完先用 curl -i 打一条真实接口确认响应头,再放流量。

常见问题​

生产环境 Nginx 反向代理 /api 怎么配置?​

在 server 块里加一个前缀 location:location /api { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; },然后 nginx -t 校验、nginx -s reload 生效。反代块的 /api 必须与后端路由前缀一致,且优先级高于 SPA 的 fallback location。

Vite 的 proxy 配置怎么确认生效了?​

dev server 启动日志不会打印 proxy 规则,直接验证请求走向:浏览器 Network 面板里请求 URL 仍是 http://localhost:5173/api/xxx 但响应数据来自后端,或用 curl http://localhost:5173/api/xxx 能拿到后端返回,就是生效了。proxy 只在 dev server(默认 5173 端口)上工作。

build 后 Vite 的 proxy 配置会打包进产物吗?​

不会。server.proxy 是 dev server 的运行时中间件,npm run build 产出的是纯静态文件,没有任何代理能力。生产环境的「代理」需要 Nginx 这类反向代理服务器承担,配置写在 Nginx 的 location 里而不是 vite.config.ts。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Chrome 扩展热重载后消息被处理两次?WXT HMR 多实例叠加监听器

· 阅读需 3 分钟

在为客户构建电商数据采集 Chrome 扩展时遇到此问题,记录根因与解法。

TL;DR​

WXT 框架 HMR 热重载时,content script 重新执行但旧的 window.addEventListener('message', ...) 不会被清除。每热重载一次就多一个监听器实例,导致每条 postMessage 被处理 N 次。

解法:注册新监听器前,从 window 变量取出旧监听器引用并 removeEventListener。

问题现象​

浏览器控制台显示同一条消息被两个不同实例捕获:

content.js:114 [CCL] CCL_SHOP_REPORT_DAILY daily caught - 实例: nxctn6
content.js:2 [CCL] CCL_SHOP_REPORT_DAILY daily caught - 实例: t6jce7

每条 postMessage 被处理两次,导致后台发送重复请求。

根因​

WXT (基于 Vite 的 Chrome 扩展框架) 开发模式下,修改 content script 后触发 HMR:

  1. 新的 content script 模块被加载执行
  2. 新的 window.addEventListener('message', messageListener) 被注册
  3. 旧的监听器函数仍然存在于内存中——HMR 不负责清理 DOM 事件监听器

结果:window 上挂载了多个独立的 message 监听器,每条 postMessage 触发所有实例。

解决方案​

在 content script 入口处,注册新监听器前移除旧的:

const instanceId = Math.random().toString(36).slice(2, 8);

// 取出旧监听器引用
const prevListener = (window as any).__cclMessageListener;
if (prevListener) {
window.removeEventListener('message', prevListener);
}

// 定义新监听器
const messageListener = (event: MessageEvent) => {
// ... 处理逻辑
};

// 存储当前引用(供下次 HMR 取用)
(window as any).__cclMessageListener = messageListener;

// 注册
window.addEventListener('message', messageListener);

关键点:removeEventListener 必须传入和 addEventListener 相同的函数引用。把函数存到 window 变量上,下次 HMR 时就能取出旧引用并正确移除。这样无论热重载多少次,始终只有一个活跃的 message 监听器。

如果你同时遇到 postMessage 的 targetOrigin 安全问题或Cookie 取值导致数据全零,建议一并排查。

注意事项

  • 此问题不仅限于 WXT,所有支持 content script 热重载的框架(Plasmo、CRXJS 等)都可能遇到
  • window 上的变量在页面刷新前一直存在,HMR 只替换脚本模块不清除 window 属性
  • 生产构建不存在此问题(content script 只加载一次),但开发时会产生难以排查的重复请求