iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

Angular 22 Signal 進化論系列 第 17 篇

Day 17:effect() 不是拿來同步 Signal 的,Angular 的 Side Effect 該怎麼寫?

  • 分享至 

  • xImage
  •  

effect() 是 Angular signal 裡很容易被誤用的一個 API。因為寫法直覺,只要讀取 signal,就能在值改變時執行一些事情,所以很常被拿來同步狀態、打 API,甚至再去修改其他 signal。

這些寫法雖然能動,但用多了會讓狀態之間的關係變得難追。這篇會從 effect() 的執行方式開始,整理相依追蹤、cleanup,以及哪些情況真的需要使用 effect()。

effect() 的基本行為

先看一個最簡單的例子:

import {
  Component,
  effect,
  signal,
} from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <button (click)="increase()">
      {{ count() }}
    </button>
  `,
})
export class AppComponent {
  readonly count = signal(0);

  constructor() {
    effect(() => {
      console.log('count:', this.count());
    });
  }

  increase() {
    this.count.update(
      value => value + 1
    );
  }
}

effect() 執行時,會記錄同步讀取過的 signal。上面的程式讀取了 this.count(),所以 count 會成為相依,之後只要它改變,Angular 就會再次安排 effect() 執行。

和其他 signal API 一樣,effect() 會根據執行時讀取到的 signal 建立相依。不過它的用途不是產生新的狀態,而是在相依改變後執行一段程式。

computed() 推算新狀態,effect() 把狀態交給外部系統

effect() 不會在每次 set() 時立刻執行

第一次使用 effect() 時,很容易把它理解成 signal 每次呼叫 set(),effect() 就會立刻執行,但實際上不是。

import {
  Component,
  effect,
  signal,
} from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <p>num:{{ num() }}</p>
  `,
})
export class AppComponent {
  readonly num = signal(2);

  constructor() {
    effect(() => {
      console.log(this.num());
    });

    this.num.set(4);

    setTimeout(() => {
      this.num.set(8);
    }, 1000);

    this.num.set(12);
  }
}

執行後會先印出 12,一秒後再印出 8,中間的 2 和 4 不會依序出現。

原因是 effect() 不會在每次 set() 當下同步執行,而是由 Angular 排程。set(4) 和 set(12) 都發生在 effect() 再次執行以前,所以等它真正執行時,讀到的已經是 12。

effect() 不是每次 set() 都立刻執行,讀到的是執行當下的最新值

所以 effect() 看到的是執行當下的最新狀態,不是每一次發生過的變化。如果每個值都不能漏掉,例如操作事件、WebSocket 訊息,或需要保留完整變化順序的資料流,就不適合只靠 effect(),這類需求通常更適合使用 RxJS、Output 或其他事件機制。

effect() 只追蹤同步讀取的 signal

effect() 的相依追蹤只會發生在同步執行期間。

effect(() => {
  console.log(this.user());

  setTimeout(() => {
    console.log(this.theme());
  });
});

這個 effect() 會追蹤同步讀取的 this.user(),但不會追蹤 setTimeout() 裡的 this.theme(),因為 Callback 執行時,原本的 Reactive Context 已經結束。subscribe() 的 Callback 和 await 之後的程式也是一樣。

只有同步讀取的 Signal 會成為相依,setTimeout()、subscribe()、await 之後都不會

例如下面這段程式:

effect(() => {
  const id = this.userId();

  this.userService
    .getUser(id)
    .subscribe(() => {
      console.log(this.theme());
    });
});

this.userId() 是在 effect() 本體同步執行時讀取,所以會成為相依;this.theme() 則是在 subscribe() 的 Callback 裡讀取,因此 theme 改變時不會讓 effect() 重新執行。

這和前面介紹過的 Reactive Context 是同一件事:Angular 只會記錄同步執行期間讀取到的 signal。

effect() 裡建立訂閱要記得 cleanup

如果在 effect() 裡建立 Subscription,就要注意 effect() 可能會重新執行。

readonly form =
  viewChild.required<NgForm>('form');

effect(() => {
  this.form()
    .form
    .valueChanges
    .subscribe(value => {
      console.log(value);
    });
});

每次 effect() 重跑,都會建立新的 Subscription,而前一次建立的 Subscription 不會自動取消。這時可以透過 onCleanup() 處理:

effect((onCleanup) => {
  const form = this.form();

  const sub =
    form.form.valueChanges.subscribe(
      value => {
        console.log(value);
      }
    );

  onCleanup(() => {
    sub.unsubscribe();
  });
});

onCleanup() 會在下一次 effect() 執行前呼叫,effect() 被銷毀時也會執行,因此可以用來清掉前一次建立的資源。

相依改變時先執行 onCleanup() 取消舊資源,再重新執行 effect()

除了 Observable,也可以用來清除 Timer:

effect((onCleanup) => {
  const id = setInterval(() => {
    console.log(this.count());
  }, 1000);

  onCleanup(() => {
    clearInterval(id);
  });
});

Angular 會管理 effect() 本身的生命週期,但裡面建立的 Subscription、Timer 或其他外部資源,仍然要處理各自的清理。

不想追蹤某個 signal,可以使用 untracked()

有時候 effect() 裡需要讀取某個 signal,但不希望它成為觸發條件。

effect(() => {
  console.log(
    'user:',
    this.user(),
    'theme:',
    this.theme()
  );
});

這裡的 user 和 theme 都會成為相依。假設需求只是 User 改變時記錄 Log,並順便把目前的 Theme 一起印出來,就不希望 Theme 改變時也重新執行 effect()。

這時可以使用 untracked():

effect(() => {
  const user = this.user();
  const theme = untracked(this.theme);

  console.log(
    'user:',
    user,
    'theme:',
    theme
  );
});

這樣只有 this.user() 會成為相依,而 theme 雖然一樣可以讀取,但它的變化不會讓 effect() 再次執行。

explicitEffect 明確指定相依

如果希望直接寫出哪些 signal 會觸發副作用,也可以使用 ngxtension 提供的 explicitEffect()。

一般的 effect() 會依照 Callback 裡實際讀取的 signal 自動建立相依:

effect(() => {
  console.log(
    this.id(),
    this.keyword()
  );
});

這裡的 id 和 keyword 都會觸發 effect()。

改成 explicitEffect() 後,可以把要追蹤的 signal 直接列在第一個參數:

explicitEffect(
  [this.id],
  ([id]) => {
    console.log(id);
    console.log(this.keyword());
  }
);

這時只有 this.id 會觸發 Callback,即使裡面讀取了 this.keyword(),它也不會被加入相依。

如果裡面建立了 Subscription,也可以透過 cleanup 清理:

explicitEffect(
  [this.id],
  ([id], cleanup) => {
    const sub =
      this.userService
        .getUser(id)
        .subscribe();

    cleanup(() => {
      sub.unsubscribe();
    });
  }
);

另外還可以透過 defer: true 跳過第一次執行:

explicitEffect(
  [this.id],
  ([id]) => {
    console.log(
      'id changed:',
      id
    );
  },
  {
    defer: true,
  }
);

這樣只有後續 id 改變時才會執行 Callback。

explicitEffect() 的重點是讓相依來源更明確,適合用在希望清楚控制觸發條件的副作用;它改變的是相依追蹤方式,不會改變這段邏輯本身適不適合使用 effect()。

不要用 effect() 搬動狀態

effect() 很常被拿來讀取一個 signal,再把結果寫進另一個 signal。

readonly firstName =
  signal('Antonio');

readonly lastName =
  signal('Ling');

readonly fullName =
  signal('');

effect(() => {
  this.fullName.set(
    `${this.firstName()} ${this.lastName()}`
  );
});

這段程式可以正常運作,但 fullName 完全由 firstName 和 lastName 決定,沒有必要另外維護一份 Writable Signal。

這類可以直接從其他狀態推算出來的值,可以直接使用 computed():

readonly fullName =
  computed(
    () => `${this.firstName()} ${this.lastName()}`
  );

重點不是看到 effect() 就一定要改成 computed(),而是當 effect() 裡又去 set() 另一個 signal 時,可以先確認這份狀態是不是真的需要另外保存。

打 API 也不一定需要 effect()

另一個常見寫法,是用 signal 當查詢條件,在 effect() 裡發送 API 請求:

readonly searchId =
  signal('');

readonly person =
  signal<Person | null>(null);

effect(() => {
  const id = this.searchId();

  this.personService
    .getPerson(id)
    .subscribe(person => {
      this.person.set(person);
    });
});

一開始看起來不複雜,但搜尋功能通常還會加入 debounce、取消上一筆請求、避免重複查詢、錯誤處理,以及 Component Destroy 時取消訂閱等需求。

如果需求包含這些非同步流程,RxJS 會更適合處理:

readonly searchId =
  signal('');

private readonly searchId$ =
  toObservable(this.searchId);

readonly person$ =
  this.searchId$.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(id =>
      this.personService.getPerson(id)
    ),
    takeUntilDestroyed()
  );

如果畫面最後還是希望使用 signal,可以再透過 toSignal() 轉回來:

readonly person =
  toSignal(this.person$, {
    initialValue: null,
  });

如果只是「參數改變後重新取得資料」,前面介紹過的 resource()、httpResource()、rxResource() 也可以直接處理這類情境,並提供 Loading、Error、Value 等狀態。

重點不是 API 一定要改用哪一種,而是這類非同步資料取得,不一定需要靠 effect() 監聽參數、訂閱請求,再手動把結果寫回另一個 signal。

改畫面樣式也不一定需要 effect()

effect() 也常被拿來直接操作 DOM。

readonly fontSize =
  signal(16);

effect(() => {
  this.renderer.setStyle(
    this.element.nativeElement,
    '--main-font-size',
    `${this.fontSize()}px`
  );
});

如果只是把 signal 對應到畫面樣式,可以直接透過 Angular 的 Binding:

@Component({
  selector: 'app-root',
  host: {
    '[style.--main-font-size]':
      'fontSizePx()',
  },
})
export class AppComponent {
  readonly fontSize =
    signal(16);

  readonly fontSizePx =
    computed(
      () => `${this.fontSize()}px`
    );
}

這樣狀態和畫面的關係直接寫在 Host Binding 上,不需要再透過 effect() 手動修改 DOM。

如果是第三方元件、Canvas,或 Angular Binding 不方便處理的外部 API,再考慮使用 effect()。

什麼時候才需要 effect()?

前面的例子可以整理出一個比較簡單的判斷方式:如果事情仍然屬於 Angular 內部的狀態、非同步資料或畫面綁定,通常可以先使用對應的 API。

effect() 比較適合處理的是 signal 改變後,需要把結果交給 Angular 以外的 API。

例如把 Theme 寫進 Local Storage:

effect(() => {
  localStorage.setItem(
    'theme',
    this.theme()
  );
});

記錄 Log:

effect(() => {
  console.log(
    'current user:',
    this.user()
  );
});

或更新第三方圖表:

effect(() => {
  this.chart.update(
    this.chartData()
  );
});

這些操作的目的都不是建立另一份 Angular 狀態,而是讓外部系統跟著目前的 signal 狀態更新。

依需求選擇 computed()、linkedSignal()、RxJS/Resource、Template Binding,最後才是 effect()

本日結語

effect() 本身沒有問題,真正要注意的是裡面放了什麼。

如果是在維護另一份狀態、處理非同步資料或更新 Angular 可以直接綁定的畫面,可以先看看有沒有更合適的 API;如果是在 signal 改變後通知 Local Storage、Logging、Canvas 或第三方 Library,就比較符合 Side Effect 的用途。

另外,effect() 不是每次 set() 都同步觸發的事件監聽器。它會由 Angular 排程執行,只追蹤同步執行期間讀取到的 signal,而裡面建立的 Subscription、Timer 或其他外部資源,也要記得透過 onCleanup() 處理。

資料來源


上一篇
Day 16:即將登場的 Route Resources,Angular Router 為什麼需要新的資料載入方式?
系列文
Angular 22 Signal 進化論 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言