開始整理最底層: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 整個檔案替換掉。proxy.conf.json,開發時把 /api 轉到後端。budgets,打包太大就警告,甚至直接讓建置失敗。碰 angular.json 的次數大概不超過十次。每次都是照著文件改一兩行,改完就關掉,從來沒想過它底下是什麼工具在跑。
然後我查資料的時候看到一件事,愣了一下:
Angular 17 之後的建置工具,底下用的是 esbuild;ng serve 的開發伺服器,底下就是 Vite。
也就是說,我其實一直在用 Vite,只是 Angular CLI 把它包起來了,包到我根本不知道它存在。
所以 React 這邊的問題不是「工具不一樣」。是同一套工具,這次沒有人幫我包起來。
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。
本機開發時,前端跑在 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_ 閘門逼我正面理解了它。