iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 15 篇

Day 15|我的環境變數是 undefined

  • 分享至 

  • xImage
  •  

我的環境變數是 undefined

開始整理最底層:API 的網址不該寫死在程式碼裡,應該放進環境變數。

照著網路上的範例,在專案根目錄建了一個 .env:

API_URL=http://localhost:8080

然後在 API client 裡讀它:

const apiClient = axios.create({
  baseURL: import.meta.env.API_URL,
});

所有請求都打到了 undefined/api/files。

我 console.log(import.meta.env) 印出來看,裡面有 MODE、DEV、PROD,就是沒有 API_URL。檔案沒放錯地方,名字也沒打錯。

在 Angular,我從來不需要煩惱這件事。environment.ts 裡寫什麼,程式裡就讀得到什麼。


一、ng 一個指令,背後是一整份設定

先回頭看 Angular 幫我做了哪些事。

ng serve                           # 開發伺服器
ng build --configuration production # 正式建置

這兩個指令背後,是 angular.json 裡一大包我幾乎沒打開過的設定:

  • 環境切換:environment.ts 和 environment.prod.ts,建置時用 fileReplacements 整個檔案替換掉。
  • API 代理:proxy.conf.json,開發時把 /api 轉到後端。
  • 建置預算:budgets,打包太大就警告,甚至直接讓建置失敗。
  • 輸出位置、最佳化、source map:全部有預設值。

碰 angular.json 的次數大概不超過十次。每次都是照著文件改一兩行,改完就關掉,從來沒想過它底下是什麼工具在跑。

然後我查資料的時候看到一件事,愣了一下:

Angular 17 之後的建置工具,底下用的是 esbuild;ng serve 的開發伺服器,底下就是 Vite。

也就是說,我其實一直在用 Vite,只是 Angular CLI 把它包起來了,包到我根本不知道它存在。

所以 React 這邊的問題不是「工具不一樣」。是同一套工具,這次沒有人幫我包起來。


二、Vite:設定就是程式碼

React Router 框架模式的建置設定長這樣:

// vite.config.ts
import { reactRouter } from '@react-router/dev/vite';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  plugins: [reactRouter(), tsconfigPaths()],
});

差別一眼就看得出來:angular.json 是一份宣告式的設定檔,你填欄位;vite.config.ts 是一段程式碼,你組裝外掛。

reactRouter() 本身就是一個 Vite 外掛,路由、SSR、型別產生都是它接上去的。tsconfigPaths() 讓 ~/lib/api 這種路徑別名能用。平常下的 react-router dev、react-router build,底下跑的也都是 Vite。

Angular CLI 替你決定了所有外掛跟順序;Vite 給你一個空的 plugins 陣列,要什麼自己放。


三、環境變數:VITE_ 前綴是一道閘門

回到開場的 bug。答案是:Vite 只會把 VITE_ 開頭的變數暴露給前端程式碼。

VITE_API_URL=http://localhost:8080
baseURL: import.meta.env.VITE_API_URL,   // 這樣就讀得到了

一開始我覺得這個規則很多餘,後來才懂它在防什麼。

.env 裡常常會放一些不該給前端看的東西:資料庫密碼、第三方服務的密鑰。所有被前端程式碼讀到的變數,都會原封不動地被打包進 JavaScript,任何人打開瀏覽器的開發者工具都看得到。

所以 Vite 預設全部擋住,只放行你明確標記「這個可以公開」的那些。那個前綴不是命名習慣,是一道安全閘門。

Angular 沒有這個問題,是因為 environment.ts 本來就是前端程式碼的一部分,你一開始就知道它會被打包。Vite 的 .env 則是前後端可能共用的檔案,所以需要一個明確的界線。

在框架模式裡,伺服器端讀得到全部

React Router 框架模式多了一層:loader 跟 action 是在伺服器上跑的。

// routes/files.tsx
export async function loader() {
  const token = process.env.INTERNAL_API_TOKEN;   // 伺服器端,不需要前綴
  // ...
}

伺服器端的程式碼可以直接讀 process.env,不需要 VITE_ 前綴,也不會被打包進瀏覽器。秘密放伺服器端讀,公開的才加 VITE_。

補上型別

import.meta.env.VITE_API_URL 預設的型別是 string | undefined,而且拼錯字不會有任何警告。加一個型別宣告:

// app/env.d.ts
interface ImportMetaEnv {
  readonly VITE_API_URL: string;
}

這樣拼錯就會被 TypeScript 抓到。Angular 的 environment.ts 本來就是一個 TypeScript 物件,天生有型別;Vite 這邊要自己補。

.env 也分環境

Angular 用不同的檔案區分環境:environment.ts、environment.development.ts,再透過 angular.json 裡的 fileReplacements 決定建置時換成哪一份。

Vite 也是用不同的檔案,只是規則是看檔名:

.env                  ← 所有環境都會載入
.env.local            ← 所有環境,但只在本機(不進版控)
.env.development      ← 開發模式才載入
.env.production       ← 建置模式才載入
.env.production.local ← 建置模式,只在本機

決定用哪一份的是模式(mode)。開發伺服器預設是 development,建置預設是 production。同一個變數出現在多個檔案時,越具體的越優先:

.env.[mode].local  >  .env.[mode]  >  .env.local  >  .env

所以最常見的配置是這樣:

# .env.development
VITE_API_URL=http://localhost:8080

# .env.production
VITE_API_URL=https://files.example.com

react-router dev 自動讀開發那份,react-router build 自動讀正式那份,程式碼完全不用改。

如果還有測試環境,就自訂一個模式:

# .env.staging
VITE_API_URL=https://files-staging.example.com
react-router build --mode staging

這就對應 Angular 的 ng build --configuration staging。

另外,檔名帶 .local 的是只留在自己電腦上的覆寫,記得加進 .gitignore。例如每個人本機的後端 port 不一樣,就寫在 .env.development.local,不會影響到別人。

程式裡也可以用 import.meta.env.MODE 知道現在是哪個模式,或用 import.meta.env.DEV、import.meta.env.PROD 這兩個布林值做判斷。

一個兩邊都有、但我以前沒注意的坑

前端的環境變數,是建置的時候就寫死的。

我後端寫 Spring Boot,習慣了 application-dev.yml、application-prod.yml 這種 profile:同一個 jar 檔,啟動時指定 profile,就讀不同的設定。

前端不是這樣。VITE_API_URL 在 vite build 的瞬間就被替換成字串,寫死在產出的 JavaScript 裡。要換網址,只能重新建置。

Angular 的 environment.ts 其實也一樣,只是我以前每個環境都各建置一次,從來沒撞到。但如果團隊想要「建置一次,部署到測試跟正式兩個環境」,兩邊都得另外處理,例如啟動時再去讀一份 config.json。


四、代理:接上 Spring Boot

本機開發時,前端跑在 5173,Spring Boot 跑在 8080。直接打會遇到 CORS,所以需要一個代理。

Angular:

// proxy.conf.json
{
  "/api": {
    "target": "http://localhost:8080",
    "changeOrigin": true
  }
}

Vite:

// vite.config.ts
export default defineConfig({
  plugins: [reactRouter(), tsconfigPaths()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
});

幾乎一模一樣。這不意外——Angular 的開發伺服器底下本來就是 Vite,那份 proxy.conf.json 最後也是被轉成類似的設定。

唯一的差別是位置:Angular 放在一個獨立的 JSON 檔,Vite 直接寫進設定程式碼裡。


五、建置產出

ng build 產出的是一包靜態檔案,丟到任何能放檔案的地方就能跑。

react-router build 預設產出兩包:

build/
  client/    ← 瀏覽器要下載的 JS、CSS
  server/    ← 伺服器端要跑的程式(loader、SSR)

因為框架模式預設開啟 SSR,所以需要一個 Node 伺服器來跑 server/ 那一包。

如果專案不需要 SSR,例如一個只在公司內網用、打包後要放進 Spring Boot 靜態資源資料夾的後台系統,可以關掉:

// react-router.config.ts
export default {
  ssr: false,
} satisfies Config;

這樣就只產出靜態檔案,部署方式跟 Angular 差不多。這個選擇會影響很多事——loader 在哪裡跑、process.env 還讀不讀得到——所以最好在專案一開始就決定。

至於 Angular 的 budgets,Vite 只有一個比較陽春的對應:build.chunkSizeWarningLimit,單一檔案超過設定大小就警告。沒有「超過就讓建置失敗」這種嚴格模式,要的話得另外裝工具。


六、對照一下

事情 Angular React Router + Vite
設定在哪 angular.json(宣告式) vite.config.ts、react-router.config.ts(程式碼)
開發伺服器 ng serve(底下是 Vite) react-router dev(底下是 Vite)
環境變數 environment.ts + fileReplacements .env + VITE_ 前綴
區分環境 --configuration staging .env.[mode] + --mode staging
環境變數型別 天生就有 要自己宣告 ImportMetaEnv
伺服器端秘密 不適用(純前端) loader 裡用 process.env
API 代理 proxy.conf.json server.proxy
路徑別名 tsconfig 的 paths,直接生效 tsconfig + 外掛讀取
建置產出 一包靜態檔 client + server,可關 SSR
大小限制 budgets,可以讓建置失敗 chunkSizeWarningLimit,只會警告

今天的結論

原本以為,Angular CLI 跟 Vite 是兩個世界。

查完才知道,ng serve 底下跑的就是 Vite。兩邊用的工具差不多,真正的差別在於誰擁有那份設定。

Angular CLI 把設定包進 angular.json,替你做好所有決定,你只需要偶爾改一兩個欄位。Vite 把設定攤開成一段程式碼,每一個外掛、每一條代理規則,都是你自己放進去的。

前者讓你不用懂,後者逼你得懂。

那個 undefined 的環境變數就是最好的例子:在 Angular,我從來不需要知道「前端的變數會被打包進 JavaScript、任何人都看得到」,因為 environment.ts 的設計讓這件事不會出錯。到了 Vite,那道 VITE_ 閘門逼我正面理解了它。


上一篇
Day 14|以為手刻的東西,一個套件五分鐘解決
下一篇
Day 16|沒有 NgModule 之後,牆要自己蓋
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言