快速理解 Angular Service:關於生命週期, providedIn root, lazy Module 那些事

前言

Angular 自從 6 開始,在裝飾器@Injectable()增加了參數providedIn: 'root'
官方文件,亦可看到,除了'root'外,還有'any'
而在@NgModule裡,又有provider可以傳入Service

而這之間到底有什麼差別?該使用什麼方式注入Service才是比較好的作法?
若再考慮效能,該怎麼使用會更好呢?

結論

先上結論,快速重點筆記

  1. providedIn: 'root'是 Angular 6 以後新增的功能,主要是簡化寫法,避免Service須在每個@NgModule.provider重覆書寫的冗事
  2. @Injectable()不提供任何參數,則須在@NgModule.provider裡傳入該Service
  3. Service同時存在於@NgModule.providerprovidedIn,則以@NgModule.provider為主,會覆蓋掉providedIn的設定
  4. 若無特殊需求,大部份情況可以直接無腦使用providedIn: 'root',不必寫@NgModule.provider
  5. 很多人都沒注意到,@Component其實也有provider參數。代表Service限定於該@Component及其子Component下使用。這Service會跟隨@Component創建與消失
  6. 特例:AppModuleAppComponent都是根元素,若Service要寫在provider裡,不論寫在哪用起來體感都一樣。但官方建議寫在AppModule最好
  7. providedIn: 'root'是在整個 Angular 應用中提供單例(singleton)服務,避免產生多個實例
  8. providedIn: 'root'若只有lazy-loading Module使用時,則會跟隨 lazy-load 初始化時才會載入(會存在於子注入器)。其Service生命週期等同於該lazy-loading Module
  9. providedIn: 'root'若已不在使用,但沒刪掉該實體檔案,打包時的搖樹優化將自動搖掉,可以有效降低打包結果的檔案大小。但寫在@NgModule.provider則不會(原理與terser有關。可參考這篇,文末參考資料有簡體中文的文章分享)
  10. 若不希望Service被全域共用,則有兩種寫法:
    1. ★(建議使用) 在Service直接傳入可使用的模組 class。如@Injectable({ providedIn: UserModule })
    2. Service裝飾器不傳入參數,僅寫@Injectable(),但在該@NgModuleprovider寫此Service
    3. Service會被兩個或以上@NgModule使用,則須提升到全域(即'root'),以確保只會存在一個實例
    4. 若希望各@NgModule持有自己的Service實例,可在providedIn傳入'any'。此參數具體用法見後面
  11. providedIn有以下參數:'root', 'any', 'platform', Module class名字
    1. 'root':全域使用
    2. 'any':eagerModule,則會置於全域共用使用同一單例;若lazy Module,則會各自產生自己的實例。若此Service同時被eager 與 lazy 共同使用,則會存在兩個實例。eager使用全域的,lazy使用自己的。故若Service裡面資料有狀態的(stateful),須注意自己的業務邏輯是否符合此情境
    3. Module class名字:相當於寫在@NgModule.provider,即限定此Service僅能在該Module以下使用
    4. 'platform':當一個頁面有多個Angular實例時,可以共用。使用情況極低
  12. @NgModule.provider除了單純傳入陣列外,尚可設定Service產生方式
    1. useClass:單純傳入Service陣列,即為此方式的簡寫。若希望是不同的實作,則使用useClass代換。如單元測試常使用
    2. useExist:相當於為現有的Service取別名(alias name)。兩個不同名字Service,但指向同一個實例。在重構時有機會使用到
    3. useFactory:每次使用時都會產生新的實例。若此Service有在注入其他服務,可在傳入deps: []提供相應注入實例
    4. useValue:單純注入成一個靜態物件
  13. 注入Service的裝飾器調整Service查找狀況
    1. @optional:若找不到Service,不報錯,返回null
    2. @self:僅在當前元件查找,若找不到就會報錯。可搭配@optional避免錯誤
    3. @skipSelf:使用父層的Service,忽略子層的。若父層找不到則報錯。可搭配@optional避免錯誤
    4. @Host:限制Angular搜尋Service的方式,僅查找自己或 component 的 HTML 元素階層往上一層之父元件。可搭配@optional避免錯誤
    5. @self vs @Host 較複雜,可以參考:stack Overflow / Difference between @Self and @Host官方文件 / 依賴注入實戰IT邦幫忙 / 關於 @Self、@Optional、@SkipSelf 的二三事與 @Host 的陷阱

個人推薦的建議作法

為求效能,大部份Module都會寫成lazy Module

我推薦在沒有特殊情況下,單純Service一律使用providedIn
共用的Service毫無疑問就是providedIn: 'root'

而多人維護下最怕的就是Service被意料之外的Module使用了,而該Module owner又是屬於別人
在重構時還得考慮他人使用情況,徒增困擾
故lazy Module自己的Service,在providedIn就直接限定僅自己使用
亦可確保此Service生命週期會跟隨自己的Module產生

Service有較複雜的情境時
再透過@NgModule.provider覆蓋掉providedIn,使用useXXX控制Service產生方式

當有function須跨不同owner Module使用時,則將function搬至'root'層的Service
現有的則注入該rootService,直接call root層使用
以避免跨太遠去改到他人業務範圍的程式碼,造成額外的權責問題
亦不會影響他人現有的業務邏輯

在告知對方有這樣的調整即可
當這是一致的開發習慣時,就算忘記告知
對方看到自己的程式碼變成這樣,也可以迅速理解是此段邏輯有被多人共用了
當自己有需求要調整這function,就不必擔心影響到其他人

簡單介紹:多級注入器

Angular 預設行為,先由自己層查找,找不到再往上一層
一直都找不到時,會一路走到最根層NullInjector(),直接拋出錯誤
若不希望報錯,則搭配使用@optional裝飾器避免錯誤

若純為eager Module使用,在啟動時就會先行註冊進全域
若純為lazy Module,則該Module 初始化時,註冊進自己的子注入器

若同時都使用到,則須看providedIn傳入何種參數

  • 'root'皆使用全域單例
  • 'any'則會存在兩個實例。eager使用全域的,lazy使用自己的。故若Service裡面資料有狀態的(stateful),須注意自己的業務邏輯是否符合此情境

可再搭配@self@skipSelf@Host限制 Angular 搜尋Service 路線與方式

注入Service的裝飾器調整Service查找狀況

  1. @optional:若找不到Service,不報錯,返回null
  2. @self:僅在當前元件查找,若找不到就會報錯。可搭配@optional避免錯誤
  3. @skipSelf:使用父層的Service,忽略子層的。若父層找不到則報錯。可搭配@optional避免錯誤
  4. @Host:限制Angular搜尋Service的方式,僅查找自己或 component 的 HTML 元素階層往上一層之父元件。可搭配@optional避免錯誤
  5. @self vs @Host 較複雜,可以參考:stack Overflow / Difference between @Self and @Host官方文件 / 依賴注入實戰IT邦幫忙 / 關於 @Self、@Optional、@SkipSelf 的二三事與 @Host 的陷阱
    具體內容可參考:官方文件 / 多級注入器

NgModule.provider vs providedIn

NgModule.provider會覆蓋掉providedIn,以便於複雜的情況下時,可以有較高的自定性。比如單元測試時會Mock Service
在大部份情況下,透過providedIn傳參,應足以應付日常需求

providedIn參數說明

providedIn有以下參數:'root', 'any', 'platform', Module class名字

  1. 'root':全域使用
  2. 'any':eagerModule,則會置於全域共用使用同一單例;若lazy Module,則會各自產生自己的實例。若此Service同時被eager 與 lazy 共同使用,則會存在兩個實例。eager使用全域的,lazy使用自己的。故若Service裡面資料有狀態的(stateful),須注意自己的業務邏輯是否符合此情境
  3. Module class名字:相當於寫在@NgModule.provider,即限定此Service僅能在該Module以下使用
  4. 'platform':當一個頁面有多個Angular實例時,可以共用。使用情況極低

具體內容可參考:官方文件 / 在模組中提供依賴

NgModule.provider

除了最直覺的傳入Service 陣列之外,尚可設定實例產生方式

單純傳入Service格式

@NgModule({
   providers:[UserService, LoggerService]
})

設定實例產生方式格式

@NgModule({
   providers:[{ provide: Logger, useClass: Logger }]
})

實例產生方式

  1. useClass:單純傳入Service陣列,即為此方式的簡寫。若希望是不同的實作,則使用useClass代換。如單元測試常使用
  2. useExist:相當於為現有的Service取別名(alias name)。兩個不同名字Service,但指向同一個實例。在重構時有機會使用到
  3. useFactory:每次使用時都會產生新的實例。若此Service有在注入其他服務,可在傳入deps: []提供相應注入實例
  4. useValue:單純注入成一個靜態物件

官方文件 / 依賴提供者

參考資料

stack overflow / purpose of providedIn with the injectable decrator
juri.dev / Angular Services, providedIn and Lazy Modules
Li Mei / Angular性能优化:Tree Shaking
ProvidedIn root, any & platform in Angular
IT邦幫忙 / 關於 @Self、@Optional、@SkipSelf 的二三事與 @Host 的陷阱