本系列討論基準
Angular 以 v20 為主(standalone components、Signals、inject()為預設寫法)。
React 這邊指以使用React Router 為框架的專案為主,非 CRA 或純 Vite SPA。
版本迭代像是變了心的女友,文中若有出入,以官方版本文件為準。
我寫 Angular 三年多,從來沒有認真想過HttpClient 是誰建的。大多數時候就是拿了就用。
這種理所當然,是框架幫你養出來的習慣——直到換去 React,那一行注入不見了,才發現自己從來不知道它背後在做什麼。
所以今天先把 Angular 那套講清楚,再來看 React 的答案。
假設沒有 DI 的世界:
class ProductService {
private http = new HttpClient(); // 我自己 new 一個
}
三個問題:跟 HttpClient 的建構方式綁死了;每個用到的地方都會生一份新的;測試時想換成假的,得改原始碼。
Angular 的寫法是:
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
getProducts() {
return this.http.get<Product[]>('/api/products');
}
}
ProductService 只宣告「我需要一個 HttpClient」,至於那個實例是誰建的、生命週期如何,通通不管。
負責管這些的東西叫 Injector。它維護兩張表:哪個 token 該怎麼建,以及已經建好的實例放在哪。你呼叫 inject(HttpClient) 時,它先查快取,有就給你,沒有就照設定建一份再給你。
| 構件 | 做什麼 | 範例 |
|---|---|---|
| Token | 依賴的識別碼 | class 本身,或 InjectionToken |
| Provider | 告訴 injector 這個 token 對應什麼 | providers: [{ provide: X, useClass: Y }] |
| Injector | 維護對應表、負責建立 | 框架內建,有層級 |
| 注入點 | 取用的語法 | inject(X) |
這四個構件出自同一張設計圖,彼此本來就是為了配合彼此而存在。這件事等一下會變得很關鍵。
它們換來三件事。
生命週期有人管。 providedIn: 'root' 的 service 全 app 只有一份,因為快取只存一份。
可以換掉。 測試時換一行,被測的程式完全不動:
TestBed.configureTestingModule({
providers: [
{ provide: ProductService, useClass: MockProductService }
]
});
Injector 是有層級的。 它不只有一個,而是一棵樹,形狀跟元件樹平行。inject(X) 時先問自己有沒有 X 的 provider,沒有就往上一層問,一路問到 root,第一個找到的就用;到頂都沒有,就是 NullInjectorError。
所以同一個 service,登記的位置不同,共用範圍就不同:
// 全 app 一份
@Injectable({ providedIn: 'root' })
export class CartService {}
// 每個 component 實例各自一份
@Component({
selector: 'app-product-panel',
providers: [PanelStateService], // 👈 關鍵在這
})
export class ProductPanelComponent {
private state = inject(PanelStateService);
}
畫面上三個 panel,就有三份互不干擾的 state——因為每個 component 實例有自己的 injector,各自快取當然獨立。
「共用」跟「獨立」是同一套機制的兩種設定。不用換寫法,只要換登記的位置,交給框架處理。
可能多數 React 專案是這樣:
// lib/api.ts
export const apiClient = axios.create({
baseURL: `${import.meta.env.VITE_API_URL}/api/`,
timeout: 240000,
});
import { apiClient } from '@/lib/api';
九成場景夠用,因為 ES module 本身就是單例——一個 module 在整個 module graph 裡只求值一次,所有 import 拿到的都是同一份。providedIn: 'root' 花力氣做的「全域唯一」,JavaScript 的模組系統就有提供了。
只是要記得:這是模組載入機制的副作用,不是設計當 DI 容器用的。它的範圍是「一個 process 內唯一」。
而且它不是 DI,是硬相依。元件寫死了要用哪個實作,要換掉只能改原始碼,或用 vi.mock 從建置層面動手腳。
DI 的核心價值從來不是「拿到東西」,而是「拿到的是什麼,由外面決定」。
先講清楚,免得你以為 React 藏了一套 DI 沒告訴你:它沒有,它只是剛好有幾個零件,拼起來很像。
ES Module 是為了程式碼組織與 tree-shaking,單例只是附加的;Context 是為了解決 prop drilling,不是為了做可替換的服務層;Hook 是為了封裝有狀態邏輯,不是為了當注入點。這幾個東西被造出來的目的,跟依賴注入通通無關。
Angular 那四個構件是一台組好的車,你今天要開,直接買了上車就走。React 這邊車庫裡沒車,但地上有一堆零件,你可以自己拼一台。
而我第一個月,因為離不開 Angular 給的安全感,就真的想試試。
以簡單的 ApiService 為範例。Angular 的流程是三段:
ApiService → Injector → Component
React 步驟變多了,中間多出來那段就是你要自己動手的部分:
ApiService → Provider 提供 → Context 保存 → useContext 取得 → Component
第一步,服務本身。 跟 @Injectable()、Spring 的 @Service 是同一個東西,只是沒有裝飾器——因為沒有容器要讀它。
class ApiService {
fetchData() {
return '這是從 API 取得的真實資料!';
}
}
第二步,開一個插座。 這行是宣告「未來這裡會放 ApiService」,插座此時是空的。它等同於 Angular 的 token:一個代表依賴的識別碼,本身不含實例。
const ApiServiceContext = createContext<ApiService | null>(null);
第三步,注入真正發生在這裡。
const apiService = new ApiService();
<ApiServiceContext.Provider value={apiService}>
<Dashboard />
</ApiServiceContext.Provider>
value={apiService} 就是註冊,對應 Angular 的 providers: [ApiService]。差別在於 Angular 是宣告(我登記一下,你負責建),React 是指派(我建好了,拿去)。誰負責 new,這是兩邊最大的分水嶺。
第四步,取用。 React 沿著元件樹往上找最近的 Provider——跟 injector 往上找的心智模型一樣,只是一邊找 injector 樹、一邊找元件樹。
const apiService = useContext(ApiServiceContext);
通常包成 hook 把 Context 藏起來:
export function useApi() {
const api = useContext(ApiServiceContext);
if (!api) throw new Error('useApi 必須在 ApiProvider 底下使用');
return api;
}
const api = useApi(); // 👈 這就是 inject(ApiService)
那個 throw 就是 React 版的 NullInjectorError,只是要自己寫。
建立實例的部分也會包進 Provider:
function ApiProvider({ children }: { children: ReactNode }) {
const api = useMemo(() => new ApiService(), []); // 👈 注意這裡
return (
<ApiServiceContext.Provider value={api}>
{children}
</ApiServiceContext.Provider>
);
}
那個 useMemo 不是效能優化,是正確性。少了它,每次 ApiProvider 重新 render 都會 new 一個新的,底下所有 useApi() 拿到的實例就換人了——服務裡的狀態、快取、連線全部歸零。
這種錯在 Angular 不可能發生,因為實例是 Injector 建的、存在快取裡,跟 render 無關。到了 React,「這個東西活多久」變成你要自己處理。最後我把它們砍掉,改回 import,簡單多了。
不是做不出來,是划不來。
前端通常只有 ApiClient、AuthService、Config、Logger 這幾種東西需要唯一實例,數量少、依賴關係淺。我後端寫 Spring Boot,那邊動輒幾百個 Bean 互相注入,沒有容器根本無法管理,@Autowired 是剛需——但前端不是那個規模。
所以 React 的預設立場是:先用 import,等真的需要可替換性再升級。 什麼叫真的需要?同一個介面有多種實作、多租戶要換設定、feature flag 要切行為、想在測試裡避開 module mock。都沒有的話,直接 export const apiClient 就是正確答案。
這不是偷懶,是誠實——不需要的抽象層是多餘成本,不是品質。
Angular 的立場相反:它預設你會需要,所以先幫你裝好。這也不是浪費,因為它面對的是大型、長壽、多人維護的專案,那種情境下可替換性遲早會用到,所以金融業大型系統常用 Angular 來寫,保持一致性。
兩邊都對,只是賭的東西不一樣。
React 做得到 DI,概念都沒少。真正的差別不在能不能,而在預設值:Angular 預設幫你開好,你得學它的規則,不學就拉倒;React 預設不開,你需要時自己拼,而且每個依賴都要重拼一次。
前者的代價是學習曲線,後者的代價是判斷力——你得知道什麼時候該拼、什麼時候不該。
而且我開始察覺一件事:React 把很多原本由框架承擔的責任,還給了 JavaScript 本身。模組單例、閉包、Reference等等——這些在 Angular 裡被包起來的東西,在 React 裡會直接決定你的程式對不對。那個 useMemo 就是一個例子:少寫它不會報錯,只會讓你的服務悄悄換人。寫 React 需要更懂 JS 底層,這件事往後幾天我會慢慢再多思考。
Angular 假設你會把架構寫壞,所以先幫你定好;React 假設你知道自己在幹嘛。 當你真的知道的時候,React 很爽。當你不知道的時候,Angular 就是會救你一命避免犯錯。