iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Modern Web

《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記系列 第 7 篇

Day 7|循環依賴:forwardRef() 是解藥也是警訊

  • 分享至 

  • xImage
  •  

隨著專案規模逐漸變大,模組與服務之間的交互會越來越頻繁。有時候,我們不知不覺中就會創造出一個「雞生蛋、蛋生雞」的窘境。

今天,我們就要來面對 NestJS 開發者幾乎都會撞見的經典錯誤——循環依賴(Circular Dependency)。

什麼是循環依賴?

在 NestJS 中,當兩個以上的模組或服務互相需要對方,最後繞了一圈指回自己時,就發生了循環依賴。

想像一個影視製作組,裡面有兩個關鍵角色:

  • 演員 (ActorService):「編劇,請先給我完整的劇本,我才決定要不要簽約接演。」
  • 編劇 (WriterService):「演員,請先讓我確認是由誰演出,我才能根據你的形象寫出對應的台詞。」

當導演想要宣布開拍時,卻發現演員在等劇本,編劇在等演員。

雙方大眼瞪小眼,整部戲永遠無法開鏡。

https://ithelp.ithome.com.tw/upload/images/20260921/20184306RODvWzsEUE.jpg

排雷指南:簽下「合作意向書」的 forwardRef()

使用 forwardRef() 就像是讓編劇和演員雙方先簽一份「合作意向書」。

大家先假設這部戲會順利開拍(延遲解析依賴),彼此先登記個名字,等到正式開機後(類別載入並定義完成後),再來慢慢磨合劇本與演出。

https://ithelp.ithome.com.tw/upload/images/20260921/20184306MKDClEIL79.jpg

模組層級應用

若是模組互相依賴,雙方 imports 都加上 forwardRef():

@Module({
  imports: [forwardRef(() => UserModule)],
  // ...
})
export class PostModule {}

服務層級應用

若是服務互相注入,需在雙方建構子透過 @Inject() 搭配使用:

@Injectable()
export class PostService {
  constructor(
    @Inject(forwardRef(() => UserService))
    private userService: UserService,
  ) {}
}

⚠️ 注意:
處理循環依賴時,必須「雙方」都加上 forwardRef()。
雖然有時只加單邊程式也能跑,但那是因為當前框架的模組載入順序碰巧沒出錯,一旦調整了匯入順序,程式隨時會炸開。

陷阱一:初始化順序的不確定性

雖然 forwardRef() 解決了依賴關係的「引用」問題,但實例化與生命週期執行的順序依然是不可預期的。

一旦你在 constructor 或 onModuleInit() 等生命週期 Hook 裡第一時間呼叫對方,就很可能拿到內部狀態還是初始值的物件。

讓我們用兩個互相注入並實作 OnModuleInit 生命週期 Hook 的服務來觀察這個現象:

@Injectable()
export class InitAService implements OnModuleInit {
  public dataReady = false;

  constructor(
    @Inject(forwardRef(() => InitBService))
    private readonly bService: InitBService,
  ) {}

  onModuleInit() {
    this.dataReady = true;
    console.log(`[InitAService] B 的狀態: ${this.bService.dataReady}`);
  }
}
@Injectable()
export class InitBService implements OnModuleInit {
  public dataReady = false;

  constructor(
    @Inject(forwardRef(() => InitAService))
    private readonly aService: InitAService,
  ) {}

  onModuleInit() {
    this.dataReady = true;
    console.log(`[InitBService] A 的狀態: ${this.aService.dataReady}`);
  }
}

實際執行時,你可能會在控制台看到這樣的輸出結果:

[InitAService] B 的狀態: false
[InitBService] A 的狀態: true

陷阱二:REQUEST scope 風險

當 forwardRef() 循環依賴牽涉 Scope.REQUEST provider 時,Nest 會在請求進來後動態建立依賴子樹。由於建構順序不確定,其中一方可能拿到尚未完成建構的物件,甚至取得 undefined 依賴。

這種現象不一定在每個 Nest 版本與依賴圖中以相同方式出現,因此不要讓程式依賴循環兩邊 constructor 的執行順序。如果想深入理解實際發生過的問題,可參考 NestJS issue #5778

陷阱三:Barrel File(index.ts)的隱性循環

為了匯入方便,我們常在資料夾底下建立 index.ts 來統整匯出(稱作 Barrel File)。

但如果同一個資料夾內的 A 檔案,透過 index.ts 去引入同資料夾的 B 檔案,就會造成檔案層級的隱性循環依賴。這會導致模組載入時拿到尚未初始化完成的類別(undefined),進而引爆 NestJS 的 DI 錯誤——此時即使加上 forwardRef() 也無濟於事。

// index.ts
export * from './barrel-a.service';
export * from './barrel-b.service';

錯誤寫法:

// barrel-a.service.ts
import { BarrelBService } from '.'; // ❌ 從同目錄的 index.ts 引入,易引發循環載入

@Injectable()
export class BarrelAService {
  constructor(private readonly bService: BarrelBService) {}
}

正確寫法:

// barrel-b.service.ts
import { BarrelAService } from './barrel-a.service'; // ✅ 直接引用具體檔案路徑

@Injectable()
export class BarrelBService {
  constructor(private readonly aService: BarrelAService) {}
}

forwardRef() 不是萬靈丹

在架構設計上,互相依賴代表著模組之間的「高耦合」。

當你發現專案裡到處充滿了 forwardRef() 時,這其實是程式碼架構發出的強烈警訊:職責是否不清?是不是介入了彼此太多邏輯?

與其過度依賴 forwardRef(),更乾淨的做法是:

  • 抽離共用服務: 將互相依賴的邏輯抽成獨立的共用服務,讓兩方都「單向依賴」這個新服務。
  • 透過事件驅動(Event Emitter): 若兩邊不需要同步取得彼此結果,可以由一方發出事件、另一方監聽,藉此移除兩個服務之間的直接引用。
  • 改用 ModuleRef:讓其中一方改在執行期向 DI 容器動態索取對方。這雖然能解開依賴圖上的循環,但並未降低業務邏輯的耦合,且針對 Scope Provider 需額外處理上下文(詳細用法可參閱 Day 3)。

總結

  1. 必須雙向宣告:forwardRef() 像一份「合作意向書」,讓雙方先登記名字、延後解析依賴。只宣告單邊通常只是運氣好避開載入順序,一旦調整匯入順序,依賴解析依然會失敗。
  2. 解決「引用關係」,不處理「執行順序」:forwardRef() 讓 Nest 能解析循環依賴中的類別參考,但不保證 constructor 或生命週期 Hook 的相對執行順序。
  3. Scope.REQUEST 會放大時序風險:當 request-scoped provider 位於循環依賴中,物件會在每個請求發生時動態建立,這會讓建構階段的時序與賦值變得不可靠。
  4. 對 Barrel File 的循環無效:index.ts 造成的循環屬於檔案載入層級,會讓 NestJS 拿到未初始化的 undefined 類別而報錯。唯一解法是改為直接引用具體檔案路徑。
  5. 頻繁出現是架構警訊:forwardRef()應作為緊急止痛藥而非萬靈丹。當專案滿是 forwardRef() 時,應優先抽離共用服務或改用事件驅動進行徹底解耦。

參考資料


上一篇
Day 6|型別的騙局:`ConfigService.get<number>()` 拿到的為什麼還是字串?
下一篇
Day 8|蔓延的病毒:Scope.REQUEST 是如何悄悄吞噬你的效能?
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言