使用 inject() 簡化建構式注入的內容

Angular 14 將加強原有的 inject 功能,讓我們在元件的建構式內可以直接透過呼叫 inject(...) 取得原本所有可以在建構式注入的內容,這種做法可以大幅簡化原來建構式的程式,但也可能帶來一些問題,今天就來看一下神奇的 inject() 新用法。

inject() 使用方式

先來看看古早的寫法:

// inject 來自 @angular/core,之後就不特別說明
import { inject } from '@angular/core';

@Component( ... )
export class ChildComponent {
  constructor(http: HttpClient) {
    http.get(...).subscribe(console.log);
  }
}

上面程式我們在建構式中注入了 HttpClient,在對應的模組內只要有 imports: [HttpClientModule],那麼 HttpClient 就是一個可以被注入的 token。

有了 inject() 之後,我們可以改成這樣寫:

@Component( ... )
export class ChildComponent {
  constructor() {
    const http = inject(HttpClient);
    http.get(...).subscribe(console.log);
  }
}

神奇吧!這個 inject() 允許我們在建構式的階段去取得原本元件內所有可以被注入的 token,讓我們不用再把要注入的內容寫到 constructor 中;當然,這只能作用在建構式階段,因此可以在建構式以及屬性初始化時呼叫 inject()

@Component( ... )
export class ChildComponent {
  // 初始化屬性也屬於建構式執行的階段
  post$ = inject(HttpClient).get<any>(...);

  constructor() {
    const http = inject(HttpClient);
    http.get(...).subscribe(console.log);
  }
}

在類別內宣告屬性同時給予初始值,最終會被 TypeScript 編譯成在建構式處理,因此

export class ChildComponent {
  title = 'test';
}

實際上可以視為

export class ChildComponent {
  constructor() {
    this.title = 'test';
  }
}

不過在其他的方法內呼叫時,就會出現錯誤

@Component( ... )
export class ChildComponent {
  ngOnInit() {
    // ngOnInit 不屬於 class 本身的建構式階段
    // 因此呼叫 inject() 會出錯
    inject(HttpClient);
  }

  someMethod() {
    // 在這裡使用 inject() 當然也不行
    inject(HttpClient);
  }
}

錯誤訊息大概看起來如下圖:

那麼在建構式呼叫 inject(),感覺不過就是把原來在建構式的注入改成由 inject() 取得而已,有什麼好處呢?

好處在我們可以開始把一些邏輯抽出成 function,同時改成在該 function 取得要注入的 token 實體,例如:

const getPosts = () => {
  const http = inject(HttpClient);
  return http.get<any[]>('...');
}

之後在元件內就變得超簡單:

@Component( ... )
export class ChildComponent {
  posts$ = getPosts();
}

可以看到元件內的 constructor 消失了,因為要注入的邏輯已經連同 inject() 移動到 getPosts() 內了,如果善用這個功能,未來有機會什麼都不用注入,就能完成過去要注入很多東西才能完成的功能,同時重複的一些邏輯也可以抽出來,大幅減少重造輪子的力氣啊!

inject() 使用案例

由於這是新功能,目前還沒有很多明確的範例可以參考,以下就自己的經驗和想像提供一些案例。

取得路由參數

我們經常透過 ActivatedRoute.paramMap 來取得路由的參數,因此注入 ActivatedRoute 是一個必要且常見的做法,通常還會搭配一些 map operator 來取得參數資料,建在我們可以輕易把這些邏輯都抽出來了!

export const getRouteParam = (id: string) => {
  const route = inject(ActivatedRoute);
  return route.paramMap.pipe(
    map(paramMap => paramMap.get(id))
  );
}

@Component( ... )
export class ChildComponent {
  id$ = getRouteParam('id');
}

非同步驗證器

過去我們在設計 Angular 表單的非同步驗證器時,如果需要使用 API 呼叫,必須把它當作參數傳入,例如:

const myAsyncValidator = (http: HttpClient) => {
  return (source: AbstractControl) => {
    return http.post('path/to/validation', { value: source.value });
  };
}

也因此元件內就必須自行注入 HttpClient,當有了 inject() 後,就可以簡化了:

const myAsyncValidator = () => {
  // 注意,要在這裡就取得 HttpClient
  // 如果放在下面的驗證 function 內,會變成每次驗證都取得 HttpClient
  // 造成不是在建構式中取得 HttpClient 的錯誤
  const http = inject(HttpClient);
  return (source: AbstractControl) => {
    return http.post('path/to/validation', { value: source.value });
  };
}

@Component({
  ...,
  template: `
    <input [formControl]="name" />
    <div *ngIf="name.pending">Pending</div>
    <div *ngIf="name.invalid">{{ name.errors | json }}</div>
  `,
})
export class AppComponent {
  name = new FormControl('', null, myAsyncValidator());
}

動態產生元件

過去我們需要主動注入 ViewContainer 來動態產生元件,對於許多新手來說看到這個 ViewContainer 是相對不友善的,但抽出來後可以變成:

const componentCreator = () => {
  const vc = inject(ViewContainer);
  return (component: any) => {
    return vc.createComponent(AdComponent);
  }
}

@Component({ ... })
export class AppComponent {
  createBanner = componentCreator();
  banners = {
    A: BannerAComponent,
    B: BannerBComponent
  };

  someMethod(bannerType: string) {
    this.createBanner(this.banners[bannerType]);
  }
}

整個可讀性是不是就大幅提升了!

簡易狀態管理

事先聲明,以下的程式碼未必是一種好方法,因為可能有封裝過度的嫌疑,造成程式看起來更加複雜,僅當作參考。

(但我自己覺得這樣寫很酷 😎)

全域的狀態,我們通常會抽成一個 service,然後在元件內注入,之後在對應的事件方法內呼叫 service,之後也許可以寫成這樣:

先建立一個簡單的狀態管理程式:

export interface TodoItem {
  id: number;
  text: string;
  completed: boolean;
}

@Injectable({ providedIn: 'root' })
export class TodoListStoreService {
  private _todoList$ = new BehaviorSubject<TodoItem[]>([]);

  get todoList$() {
    return this._todoList$.asObservable();
  }

  addTodo(text: string) {
    this._todoList$.next(/* 實作邏輯 */);
  }

  toggleTodo(id: number) {
    this._todoList$.next(/* 實作邏輯 */
  }
}

上面程式我們建立一個 TodoListStoreService 來管理全域狀態,這也是很常見的一種利用 service 管理全域狀態的方法,接著我們可以實作一個 useTodoListStore() 的方法,來幫助我們取得 TodoListStoreService,並且只回傳想要公開的部分:

export const useTodoListStore = () => {
  const todoListStoreService = inject(TodoListStoreService);
  return {
    todoList$: todoListStoreService.todoList$,
    addTodo: todoListStoreService.addTodo,
    toggleTodo: todoListStoreService.toggleTodo
  };
}

之後在元件內只要呼叫這個 useTodoListStore() 就好:

@Component({
  ...,
  template: `
    <input
      #input
      type="text"
      (keyup.enter)="todoListStore.addTodo(input.value); input.value = ''; input.focus()" />
    <ul>
      <li *ngFor="let todo of todoListStore.todoList$ | async">
        <label [style.text-decoration]="todo.completed ? 'line-through' : 'none'">
          <input
            type="checkbox"
            [checked]="todo.completed"
            (change)="todoListStore.toggleTodo(todo.id)" />
          {{ todo.text }}
        </label>
      </li>
    </ul>
  `,
})
export class AppComponent {
  // 全部需要的程式都包裝到 `useTodoListStore()` 內了
  todoListStore = useTodoListStore();
}

由於不用再注入 TodoListStoreService 了,加上元件內的畫面操作都跟公開出來的來源有關,因此在元件內就可以徹底將 this 從元件中移除,只剩下樣板而已,大幅度簡化元件本身的程式碼!

本日小結

由於這是新出來的功能,整體到底會讓 Angular 的寫作習慣變成如何還需要觀察,但透過 inject() 我們確實「有機會」讓建構式變得更加精簡,有些過去一定要注入才能使用的邏輯未來都可以把 token 和基礎邏輯都抽出來,不過這也可能造成在開發時期如果一下要注入,一下又改用 inject(),這種不一致的寫法很有可能會造成混淆。

而站在更快速完成功能的角度來看,假設前面提到如 getRouteParam 等未來也由 Angular 本身內建,那麼我們很有可能可以大幅減少初期的學習曲線,畢竟要注入一堆 DI token,然後才能依照邏輯寫程式,還遠不及單純使用如 getRouteParam 來的簡單易懂,且更具有宣告性,從「元件變得更簡單」這點來看 inject() 的發展還是值得看好的!!

如果您覺得我的文章有幫助,歡迎免費成為 LikeCoin 會員,幫我的文章拍手 5 次表示支持!