前言
说到环境变量,在业务开发中很常见,为了避免硬编码,我们会把配置放入环境变量中,在运行或者构建的过程中读取。它使用很方便,也很灵活,但是可能潜藏了一些隐患,比如:
- 部署后 API 404,一查发现是
API_URl(小写 L)写错了; .env忘了带,服务一启动就“看起来很正常”,结果业务全是空值;PORT="abc"没报错,但服务死活起不来
这些都不是代码不够聪明,而是 env 没有类型安全。
现实: 环境变量从来不是“类型安全”
无论是 process.env 还是 import.meta.env,本质上都是一堆字符串。它们不会校验、不知道你期望的类型,也不会在构建阶段提醒你“你写错了”。
所以我们需要的不是“更炫酷的写法”,而是:
让错误更早暴露,让类型更清晰,让 server/client 边界更明确。
process.env vs import.meta.env:
process.env:运行时读取、进程级别,适合服务端import.meta.env:构建期注入,更常见于前端
以 Vite 为例,只有带 VITE_ 前缀的变量才能进客户端,且全部都是字符串。这意味着:你看到的是“类型”,实际得到的是“字符串形态的意志”。
为了让环境变量能够类型安全,我们需要辅助一些工具实现。
手搓方案:Zod + dotenv
这是最轻量的一种解决方案:
import { z } from "zod";
// 仅需要 .env 支持的时候才需要
import "dotenv/config";
const envSchema = z.object({
API_URL: z.string().url(),
PORT: z.coerce.number().default(4321),
});
export const env = envSchema.parse(process.env);
它的好处很直白:
- 依赖少,核心依赖只有
Zod,并且Zod本身也足够轻量 - 规则清晰,只要熟练掌握 zod,可以在 schema 里定义任意复杂的规则,可以玩出花来
但也有缺点:
- server / client 边界需要你自己约定
- 多项目会出现样板重复
工具化方案: T3 Env
T3 Env (https://env.t3.gg/) 基于 Zod,把“类型 + 访问边界 + 运行时校验”打包给你,尤其适合想减少重复劳动的项目。而且官方还提供 Next.js 和 Nuxt.js 的集成方案,在这些框架中使用也很方便。
注意,它并不是什么框架专属,不与任何开发框架绑定。@t3-oss/env-core 在任何 Nodejs 工程都能随意使用:
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
},
clientPrefix: "PUBLIC_",
client: {
PUBLIC_APP_NAME: z.string(),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,
});
这里有两个关键词值得高亮:
clientPrefix:客户端变量必须有前缀,和 Vite 的VITE_类似,避免不小心把秘密变量暴露给客户端emptyStringAsUndefined:避免PORT=这种“空字符串假装有值”的骚操作
T3 Env 的 Next.js 集成
如果你在 Next.js,用 @t3-oss/env-nextjs 会更顺滑,尤其是 server/client 的拆分体验:
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
},
client: {
NEXT_PUBLIC_APP_NAME: z.string(),
},
experimental__runtimeEnv: process.env,
});
它内部默认配置的 clientPrefix 就是 NEXT_PUBLIC_,和 Next.js 的行为对齐,并且在 Next.js 的运行时环境中自动读取 process.env。
Astro:官方自带“类型安全”
Astro 直接提供一个官方虚拟模块:astro:env,用户管理环境变量。
配置在 astro.config.mjs 里,用 envField 定义类型、上下文、访问级别:
// astro.config.mjs
import { defineConfig, envField } from "astro/config";
export default defineConfig({
env: {
schema: {
API_URL: envField.string({ context: "client", access: "public" }),
API_SECRET: envField.string({ context: "server", access: "secret" }),
},
},
});
使用时要从 astro:env/client 与 astro:env/server 分开引入:
import { API_URL } from "astro:env/client";
import { API_SECRET } from "astro:env/server";
const data = await fetch(`${API_URL}/users`, {
headers: {
Authorization: `Bearer ${API_SECRET}`,
},
});
它本质上是基于 zod 做的简易封装,底层依旧使用 zod 校验。
astro:env 的几个实用小贴士:
- access:
public会在构建期内联进 bundle,secret留在运行时,前者相当于import.meta.env的行为,后者相当于process.env的行为 access只对 server 变量有意义,client 一律 public,也无需声明astro:env只能在 Astro 运行时上下文使用(组件、路由、中间件)- 类型会在
astro dev/astro build时生成,也可以使用astro sync在不运行项目的情况下生成类型定义
总结
本文我们讨论了环境变量的“类型安全”,以及如何实现。简单来说,期望项目依赖尽可能轻量,可以使用 Zod + dotenv 的手搓方案;如果想要更完善的功能和更少的重复劳动,可以使用 T3 Env;而在 Astro 项目中,官方自带的 astro:env 已经足够好用。