使用类似 Vite 或 webpack 之类的构建方案编写需要转译打包的 JavaScript 项目时免不了会在引用
process 时遇到类似这样的报错:Uncaught ReferenceError: process is not defined
这个问题在 JavaScript 的全栈同构项目、尤其是前端和后端代码共存于同一模块时更是重量级。维基百科关于环境变量的原始定义如下:
而在 JavaScript 语言和工具链的语境下情况又复杂得多,下面展开介绍。
命令行程序
const description =
' See https://vite.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated for more details.'
warnCjsUsage()
function warnCjsUsage() {
if (process.env.VITE_CJS_IGNORE_WARNING) return
const yellow = (str) => `\u001b[33m${str}\u001b[39m`
console.warn(
yellow("The CJS build of Vite's Node API is deprecated." + description),
)
}typescript
这里演示的是 Vite 根据
VITE_CJS_IGNORE_WARNING 环境变量判断是否要输出警告信息,其使用方法如下:$ VITE_CJS_IGNORE_WARNING=true pnpm vite dev
plaintext
通常 Node.js 命令行程序无需打包,JavaScript 代码可以读取当前进程所有的环境变量,是使用环境变量最简单的场景。
前端应用
import React, { Fragment, useState } from 'react';
import { Link } from 'react-router-dom';
export function HomePage() {
return (
<Fragment>
<h1>Auth Example</h1>
<p>
<Link href={`${process.env.PUBLIC_SERVER_URL}/admin/collections/users`}>
login to the admin dashboard
</Link>
.
</p>
</Fragment>
);
}typescript
这里在渲染前端页面时需要根据环境变量
PUBLIC_SERVER_URL 的值确定链接的跳转目标,这通常取决于网站最终部署时使用的域名。区别于前面的命令行程序,前端应用的代码不会在 Node.js 环境运行,而是会被转译打包后分发到用户的浏览器中执行。前端代码既无法访问 Node.js 进程的环境变量,也不可能在浏览器中获得有关
PUBLIC_SERVER_URL 的值。编译时静态注入
通常的实践是为应用的构建配置添加
define 配置。define 配置的键可以是任意能被匹配的表达式代码,值则是用于被替换的表达式代码。例如以下配置:export default defineConfig({
define: {
'process.env.PORT': '3000',
'process.env.GREET': '"hello"',
'SOME_VAR': 'window.someVar',
'typeof window': '"undefined"',
'process.env.OBJ': JSON.stringify({ val: 123 }),
}
});javascript
可以实现如下效果:
// 源码
process.env.PORT;
process.env.GREET;
SOME_VAR;
typeof window;
process.env.OBJ;
// 产物
3000;
"hello";
window.someVar;
"undefined";
{"val":123}javascript
这样我们就可以在编译时使用特定值直接替换
process.env.PUBLIC_SERVER_URL 的表达式。export default defineConfig({
define: {
'process.env.PUBLIC_SERVER_URL': JSON.stringify(process.env.PUBLIC_SERVER_URL)
},
})typescript
然后在构建前端应用时为 Node.js 进程配置环境变量:
$ PUBLIC_SERVER_URL=https://example.com/foo pnpm vite dev
plaintext
最终的产物将会类似:
function HomePage() {
return React.createElement(
Fragment,
null,
React.createElement("h1", null, "Auth Example"),
React.createElement(
"p",
null,
React.createElement(
Link,
{ href: `${"https://example.com/foo"}/admin/collections/users` },
"login to the admin dashboard",
),
".",
),
);
}javascript
需要注意的是源码中原先的
process.env.PUBLIC_SERVER_URL 直接被替换成了 "https://example.com/foo",而不是在运行时创建全局 process 对象。// 源码:完全匹配的表达式 `process.env.PUBLIC_SERVER_URL`。
const serverUrl = process.env.PUBLIC_SERVER_URL;
// 产物:可以被正确识别替换,serverUrl 被字面量赋值并可以被其它代码引用读取。
const serverUrl = "https://example.com/foo";
javascript
webpack、Vite、Rspack 等现代构建工具都是以类似方式来注入环境变量,这时仅严格匹配的表达式可以被识别和替换,大部分时候前端应用的
process is not defined 报错也都是由此而来。例如常见的问题代码:// 匹配表达式 `process.env` 而不是实际配置的 `process.env.PUBLIC_SERVER_URL`。
const { PUBLIC_SERVER_URL } = process.env;javascript
注入所有环境变量
既然如此直接匹配整个
process.env 表达式可以么?这样似乎可以保证 process.env 和 process.env.PUBLIC_SERVER_URL 表达式都能被正确识别。我们可以试试这样配置:export default defineConfig({
define: {
'process.env': JSON.stringify(process.env)
},
})typescript
这时你会发现刚才的代码在产物里被转换成了一种稍显丑陋的形式,但似乎不影响使用。
// 源码
`${process.env.PUBLIC_SERVER_URL}/admin/collections/users`
// 产物
`${{'PUBLIC_SERVER_URL': "https://example.com/foo"}.PUBLIC_SERVER_URL}/admin/collections/users`typescript
但如果你的其它地方的一些代码以其它形式引用了
process.env.PUBLIC_SERVER_URL,则可能导致报错。例如下面的代码会导致 JavaScript 引擎解析时将 process.env 替换后的对象识别成块语法,进而导致报错。// 源码
process.env.PUBLIC_SERVER_URL + '/admin/collections/users';
// 产物:导致报错 Uncaught SyntaxError: Unexpected token ':'
{'PUBLIC_SERVER_URL': "https://example.com/foo"}.PUBLIC_SERVER_URL + '/admin/collections/users'typescript
要解决这个问题也很简单,只需要在让替换后的代码被
() 包裹,即可让它被识别为表达式:export default defineConfig({
define: {
'process.env': `(${JSON.stringify(process.env)})`
},
})typescript
产物体积增大
然而更大的问题是注入整个
process.env 对象会导致产物体积增大,以 Ant Design 举例仅 components/ 目录下用到 process.env 的代码就有 130 处以上,其中大部分都是通过 process.env.NODE_ENV 判断开发环境。以真实场景举例仅对 Ant Design 影响导致增加的额外体积就可能高达
1941 byte * 130 = 252.33 kB。$ node -p "JSON.stringify(process.env).length"
1941
plaintext
环境变量泄密
开发使用的机器的环境变量中会存在的内网域名、文件路径、用户名,以及在 CI 流程中编译机器储存的各种密钥,如果被打包到前端产物中都会导致 泄密。
例如如果选择注入整个
process.env 并在 Github Action 运行构建发布:steps:
- shell: bash
env:
SUPER_SECRET: ${{ secrets.SuperSecret }}
run: |
pnpm buildyaml
那么最终用户将会在你的前端产物里看到:
`${{
"HOME": "/Users/asuk",
"LC_CTYPE": "UTF-8",
"LOGNAME": "asuk",
"LaunchInstanceID": "A3D0E361-321F-4101-95AA-C47F1C935406",
"TMPDIR": "/var/folders/fz/A3D0E361-321F-4101-95AA-C47F1C935406/T/",
"USER": "asuk",
"_": "/Users/asuk/.local/state/fnm_multishells/A3D0E361-321F-4101-95AA-C47F1C935406/bin/node",
"PUBLIC_SERVER_URL": "https://example.com/foo",
"DB_PASSWORD": "YOUWILLNEVERKNOW",
"SUPER_SECRET": "GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk="
}.PUBLIC_SERVER_URL}/admin/collections/users`typescript
约定式注入
Vite 自动将环境变量暴露在import.meta.env对象下,作为字符串。为了防止意外地将一些环境变量泄漏到客户端,只有以VITE_为前缀的变量才会暴露给经过 vite 处理的代码。例如下面这些环境变量:VITE_SOME_KEY=123 DB_PASSWORD=foobarplaintext只有VITE_SOME_KEY会被暴露为import.meta.env.VITE_SOME_KEY提供给客户端源码,而DB_PASSWORD则不会。console.log(import.meta.env.VITE_SOME_KEY) // "123" console.log(import.meta.env.DB_PASSWORD) // undefinedjavascript
另外需要注意的是通过
import.meta.env 访问环境变量并不是语言或者运行时定义的行为,可能是由 Vite 首创的一种替代 process.env 的方式。因为能帮助区分 Node.js 运行时环境变量 与 Vite 静态注入的环境变量,可以一定程度上缓解上文提到的问题。上述三个工具通过约定自动注入环境变量的行为分别是:
ㅤ | 定义 process.env | 定义 import.meta.env | 约定前缀 | 自定义前缀 |
Vite | ❌ | ✅ | VITE_ | ✅ 通过 envPrefix 配置 |
Next.js | ✅ | ❌ | NEXT_PUBLIC_ | ❌ 不支持 |
Rsbuild | ✅ | ✅ | PUBLIC_ | 🚧 通过 loadEnv 工具函数 |
服务端应用
服务端应用访问环境变量需要分情况讨论。部署 Node.js 服务端应用的早期范式不会对服务端应用本身的代码进行打包,可能仅会使用 TypeScript 编码并转译,将应用本身的产物连同
node_modules/ 的所有依赖包一起构建成 Docker 镜像并分发到服务器集群。├── node_modules/
│ └── ...
├── dist/
│ ├── index.js
│ ├── utils.js
│ └── middlewares/
│ └── foo.js
└── package.json
plaintext
由于不存在打包器介入的环节,并不会有像前端应用中需要考虑编译注入的环境变量,通常只需要为相同的变量在开发环境和生产环境分别配置不同的值。例如下面的代码就依赖于服务器的
PORT 环境变量来选择服务的端口。import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.json({ message: 'Hello Bun!' })
})
const port = parseInt(process.env.PORT!) || 3000
console.log(`Running at http://localhost:${port}`)
export default {
port,
fetch: app.fetch,
}typescript
随着前端基建的发展,部分服务端项目选择使用打包器构建产物,以减小服务器产物和镜像的体积。这时同样可以和前端应用一样配置
define 能力来注入环境变量,但额外的困难是还需要同时考虑直接访问运行时环境变量的情况。import { drizzle } from 'drizzle-orm/node-postgres';
let pgUrl: string;
if (import.meta.env.NODE_ENV === 'production') {
pgUrl = process.env.PG_URL;
} else {
pgUrl = 'postgresql://localhost:5432'
}
export const db = drizzle(pgUrl);javascript
这段代码会根据运行环境选择不同的数据库连接 URL:环境变量
NODE_ENV 为 production 时即为生产环境,这时使用从环境变量 PG_URL 获取的 PostgreSQL 数据库连接字符串;在非生产环境(开发、测试等)中,默认使用本地数据库连接 'postgresql://localhost:5432'。当使用 Vite 构建并启用 库构建 时,通过
import.meta.env 引用的环境变量会被静态替换,而 process.env 开头的则会保留到产物中以获取服务端 Node.js 运行时的值。当执行
pnpm vite build 时其产物将类似:import { drizzle } from 'drizzle-orm/node-postgres';
let pgUrl: string;
if (true /* 'production' === 'production' */) {
pgUrl = process.env.PG_URL;
}
export const db = drizzle(pgUrl);javascript
以上约定仅针对 Vite,其它的很多打包器则可能需要自行配置
define 来管理不同环境变量的行为。全栈应用
最近流行的 Next.js、Remix 等全栈框架让环境变量问题变得更复杂,特别是在同一个文件中同时写前端和后端代码时尤其明显。
import type { ActionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { getStripeSession, getDomainUrl } from "~/utils/stripe.server";
export const action = async ({ request }: ActionArgs) => {
const stripeRedirectUrl = await getStripeSession(
process.env.PRICE_ID as string,
getDomainUrl(request),
);
return redirect(stripeRedirectUrl);
};
export function Buy() {
return (
<Form method="post">
<Link to={`${process.env.PUBLIC_SERVER_URL}/details`} reloadDocument>
Learn More
</Link>
<button type="submit">Buy</button>
</Form>
);
}typescript
对于上面的代码我们知道前端渲染
Buy 组件时会用到 PUBLIC_SERVER_URL ,所以按照前文的经验需要在 define 中配置它来注入对应的值到产物。由于 Remix 使用了服务端渲染,组件逻辑也会在 Node.js 服务中执行,因此在服务端访问 PUBLIC_SERVER_URL 时也会使用静态注入的值、而不是直接从 Node.js 进程读取。而对
PRICE_ID 仅在服务端代码中被使用,它可以有多种处理方式,既可以在编译机器中设定 PRICE_ID 的值并配置 define 以注入到代码。也可以直接保留,使其在处理请求时读取在线上服务集群机器中的环境变量值。具体需要按照项目和工作流而定。宏替换
从编译原理角度来看,webpack 的 DefinePlugin 和 Vite 的 define 配置实现了一种在编译时执行的全局常量替换机制,本质上是一种宏替换(Macro Substitution)操作。
作为宏替换,define 能力并不只能用来注入环境变量到产物,还可以用于从编译时注入任何可序列化数据甚至代码到产物中。此外对于打包器来说,它还会影响 TreeShaking 的行为。
TreeShaking 与 DCE
Webpack 和 Vite 都支持通过 TreeShaking 和 DCE 来优化产物体积,其中 TreeShaking 主要移除未使用的导出内容,而 DCE 则更广泛地识别并删除不会执行的代码片段。当我们使用
define 能力进行常量替换时,会直接影响 TreeShaking 和 DCE 的结果。例如当我们定义一个常量为
false 时:export default defineConfig({
define: {
'process.env.NODE_ENV': '"production"',
'DEBUG': 'false',
},
})typescript
在代码中的条件分支就可能会被识别为永远不会执行的死代码:
import { enableDevTools, setupHotReload } from './dev';
if (DEBUG) {
console.log('只在调试模式下输出');
}
if (process.env.NODE_ENV !== 'production') {
// 开发环境特定的代码
enableDevTools();
setupHotReload();
}javascript
宏替换会提示打包器对相关代码进行优化:
// import { enableDevTools, setupHotReload } from './dev';
if (false) {
// console.log('只在调试模式下输出');
}
if (false /* "production" !== 'production' */) {
// enableDevTools();
// setupHotReload();
}typescript
这也是为什么许多库会使用
process.env.NODE_ENV 来区分开发和生产版本的代码路径,从而实现自动优化产物体积。条件编译
define 也可以用于实现简单的条件编译机制,让开发者可以基于编译时常量来决定哪些代码会被包含在最终产物中。这种技术在同构应用或跨平台库中特别有用,可以让同一份源码针对不同环境生成不同的产物。
npm 包
条件编译的典型例子就是 React 根据环境变量导出不同的模块,通常各种打包器都会内置
process.env.NODE_ENV 的 define 配置。基于此 React 可以为开发时提供带有调试能力的版本,而在生产环境换成体积和性能更优化的版本。'use strict';
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react.production.js');
} else {
module.exports = require('./cjs/react.development.js');
}typescript
总结
在 JavaScript 项目中,理解和正确管理环境变量对构建可靠应用至关重要。不管是前端、后端还是全栈项目,环境变量都会影响代码的可移植性、安全性和性能。
开发者可以通过打包工具的 define 功能在编译时注入环境变量,并利用 TreeShaking 和 DCE 技术减小代码体积。尤其在需要同时运行在多种环境的项目中,了解环境变量的工作原理能帮助我们避免常见问题,写出更稳定的代码。

