正在載入索引…

Day 3:從 set()、update() 看懂 Angular Signal 的基本使用與更新機制

5.5k 字,約 5 分鐘

2026 iThome 鐵人賽:Angular 22 Signal 進化論。 原文發表於 iThome,也可以在下方留言。

文章目錄
  1. Signal 基本認識
    1. counter.ts
    2. main.ts
  2. Angular 中常見的 Signal 相關 API
    1. 狀態與響應式資料
    2. Template 查詢
    3. 元件之間的溝通
    4. Signal 的更新方式
  3. 從原始碼理解 Signal 如何判斷是否需要傳遞變更
    1. 從 createSignal() 函數開始理解
  4. 本日結語
    1. 資料來源

昨天我們先試著理解,在 Template 中使用 Signal() 時,Angular 是如何建立依賴關係,並在 Signal 發生變化後,知道哪些 View 可能受到影響。

不過前面其實還沒有正式介紹 Signal 本身怎麼使用。因此今天會先回到基礎,從 Signal() 的基本使用方式開始,再接著看看它在更新資料時,Angular 內部大致做了哪些事情。

和昨天一樣,除了基本用法之外,我們也會搭配一些原始碼,試著從實作角度理解它背後的運作方式。

Signal 基本認識

大致上可以先把 Signal 分成兩種類型:

  • Writable Signal:可以讀取,也可以修改,一般會持續變動的狀態適合使用。
  • Readonly Signal:只能讀取,不能直接從外部呼叫 set() 或 update() 修改資料。當我們想把某個 Signal 提供給其他地方使用,但又不希望外部隨意改動時,就很適合用這種方式。

不過要注意,如果 Signal 裡面放的是物件,直接去修改物件裡的屬性,這件事情其實還是可以做得到,Readonly 並不會幫你把物件本身也一起鎖住喔!

我們可以快速透過一個簡單的範例,看看 Writable Signal 與 Readonly Signal 在實際開發上的差別。

counter.ts

補充:這裡的 @Service() 是 Angular 22 新增的寫法,可以先理解成建立 Root Service 的簡化方式,概念上類似過去常見的 @Injectable({ providedIn: 'root' })。

ts
import { Service, signal } from '@angular/core';

@Service()
export class CounterService {
  private readonly _count = signal(0);

  readonly count = this._count.asReadonly();

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

  decrease() {
    this._count.update((value) => value - 1);
  }
}

在 CounterService 內部,我們使用 _count 保存真正可以修改的 Writable Signal,再透過 asReadonly() 對外提供一個只能讀取的 count。

這樣其他地方就不能直接修改 _count。如果想改變數值,只能透過 increase()、decrease() 這些由 Service 提供的方法來操作。

main.ts

ts
import { Component, inject } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CounterService } from './app/counter';

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <h2>Counter:{{ counterService.count() }}</h2>

    <button (click)="counterService.decrease()">-1</button>
    <button (click)="counterService.increase()">+1</button>
  `,
})
export class AppComponent {
  counterService = inject(CounterService);
}

bootstrapApplication(AppComponent);

stackblitz 範例連結

在 Component 中,我們可以透過 counterService.count() 讀取目前的值,但不能直接使用 set() 或 update() 修改它。

這個差別其實很重要,因為它不只是在區分「能不能修改」,也會直接影響我們後面怎麼設計資料流。

簡單來說,就是先把「誰可以改資料」這件事情限制清楚,外部只負責讀取,真正的修改邏輯則集中在 Service 內部處理。

Angular 中常見的 Signal 相關 API

前面我們先認識了 Writable Signal 與 Readonly Signal,不過實際在 Angular 開發裡,現在除了 Signal 本身之外,也有不少新的 API 開始採用 Signal,或是類似的函式式寫法。

如果先從平常開發的使用情境來看,大致可以分成下面幾類。

狀態與響應式資料

  • signal():建立一般可以讀取與修改的狀態。
  • computed():根據其他 Signal 計算出衍生值,本身不能直接修改。
  • linkedSignal():根據其他 Signal 建立有依賴關係的狀態,同時本身也可以被修改。
  • effect():當依賴的 Signal 發生變化時,自動執行一些額外操作,例如紀錄 log、同步 localStorage,或更新第三方圖表套件。
  • resource():用來把 API 等非同步資料整合進 Signal 的響應式流程,讓資料載入、狀態與更新可以一起管理。

Template 查詢

可以先把「內容投影」想像成 Vue 的 Slot,可以把外面的畫面內容丟進元件裡,讓元件決定要放在哪個位置。

  • viewChild():取得目前元件 Template 中單一符合條件的元素、Directive 或子元件,查詢結果會以 Signal 提供。
  • viewChildren():取得目前元件 Template 中多個符合條件的元素、Directive 或子元件,查詢結果會以唯讀陣列的 Signal 提供。
  • contentChild():取得由外部投影進目前元件中的單一元素、Directive 或子元件,查詢結果會以 Signal 提供。
  • contentChildren():取得由外部投影進目前元件中的多個元素、Directive 或子元件,查詢結果會以唯讀陣列的 Signal 提供。

元件之間的溝通

  • input():用於父元件傳遞資料給子元件,子元件可以透過 Signal 的方式取得輸入值。
  • output():用於子元件向父元件發送事件。
  • model():用於父子元件之間的雙向綁定,可以理解成 Signal 版本的雙向資料溝通 API。

其實不少概念都能在不同框架中找到相似的做法,雖然底層實作細節不一定相同,但先把這些 API 看過一輪,至少可以知道在 Angular Signal 的開發情境中,手上有哪些工具可以使用。

接著回到今天的主軸,我們會把焦點放回 Signal 本身,先看看它有哪些更新資料的方式,再進一步理解這些更新方式在 Angular 原始碼中,實際又是怎麼串起來的。

Signal 的更新方式

Writable Signal 主要可以透過 set() 與 update() 來修改資料。

Angular 在更新 Signal 前,會先比較新舊值是否相同,預設使用 Object.is();如果判定相同,就不會繼續傳遞這次變更。

  • set():直接指定新的值,適合已經知道下一個值的情境。
ts
const count = signal(0);

count.set(10);

console.log(count()); // 10
  • update():根據目前的值計算下一個值,適合需要依照現有狀態進行修改的情境。
ts
const count = signal(0);

count.update((value) => value + 1);

console.log(count()); // 1

從原始碼理解 Signal 如何判斷是否需要傳遞變更

前面我們知道,Writable Signal 可以透過 set() 與 update() 修改資料。

從 Angular 原始碼來看,兩者最後都會走到相同的更新流程,差別只在於 update() 會先執行傳入的 callback function,根據目前的值計算出新的結果。

從 createSignal() 函數開始理解

建立 Signal 時,可以額外傳入 equal 函式,用來決定新舊值是否相同;如果沒有指定,則會使用預設的 Object.is() 進行比較。

Angular 會先透過 equal 判斷這次的值是否真的發生變化,再決定要不要繼續往下傳。

ts
export function createSignal<T>(
  initialValue: T,
  equal?: ValueEqualityFn<T>,
): [SignalGetter<T>, SignalSetter<T>, SignalUpdater<T>] {
  const node: SignalNode<T> = Object.create(SIGNAL_NODE);

  node.value = initialValue;

  if (equal !== undefined) {
    node.equal = equal;
  }

  const getter = (() => signalGetFn(node)) as SignalGetter<T>;

  const set = (newValue: T) => signalSetFn(node, newValue);
  const update = (updateFn: (value: T) => T) =>
    signalUpdateFn(node, updateFn);

  return [getter, set, update];
}
ts
export function signalUpdateFn<T>(
  node: SignalNode<T>,
  updater: (value: T) => T,
): void {
  signalSetFn(node, updater(node.value));
}

可以看到,set() 和 update() 最後都會走到 signalSetFn(),接下來就從這裡看看 Angular 怎麼判斷這次更新要不要繼續往下傳。

ts
export function signalSetFn<T>(node: SignalNode<T>, newValue: T) {
  if (!producerUpdatesAllowed()) {
    throwInvalidWriteToSignalError(node);
  }

  if (!node.equal(node.value, newValue)) {
    node.value = newValue;
    signalValueChanged(node);
  }
}

這裡的重點就是 equal()。Angular 會先用它比較目前的值和新的值,如果兩者相同,這次更新就會直接停止;只有判斷為不同時,才會更新 node.value,並繼續執行 signalValueChanged(),通知後續流程這個 Signal 已經發生變化。

ts
function signalValueChanged<T>(node: SignalNode<T>): void {
  node.version++;
  producerIncrementEpoch();
  producerNotifyConsumers(node);
  postSignalSetFn?.(node);
}

這裡有兩個需要注意的地方:

  • node.version++:代表這個 Producer 真的產生了一個新的值。
  • producerNotifyConsumers(node):開始通知依賴這個 Producer 的 Consumer。

version 可以理解成這個 Producer 目前產出值的版本。只有當 Producer 真的產生不同的新值時,version 才會增加。

比如說:

ts
const count = signal(1);
const isEven = computed(() => count() % 2 === 0);

console.log(isEven()); // false

count.set(3);

console.log(isEven()); // false

這時 count 從 1 變成 3,代表它真的產生了新的值,所以 count 自己的 version 會增加。

不過 isEven() 重新計算後,結果還是 false,代表它最後產出的值其實沒有改變,因此 isEven 自己的 version 不會增加。

version 可以幫助 Angular 判斷,就算上游資料發生了變化,這個 Producer 最後產出的結果,是不是真的和之前不一樣。

本日結語

回顧 Day 2 整理的簡化流程,今天討論的內容,其實就是其中的:

Step 2:Signal 發生變化:確認是否需要傳遞變更

把今天的內容整理成一張圖,大致會像這樣:

從呼叫 set() 或 update() 開始,Angular 會先取得 newValue,再透過 equal() 比較新舊值:

  • 判定相同:直接停止,不做任何事。
  • 判定不同:更新 node.value,接著讓 version 增加,並透過 producerNotifyConsumers() 通知依賴這個 Producer 的 Consumer。

Signal 並不是每次呼叫 set() 或 update() 都會往下傳遞,而是先確認「值真的變了」,才把變更交給後續流程。

被通知到的 Consumer,接下來就會進入 Step 3 標記受影響的 View 與祖先路徑,這部分留到之後再繼續往下看。

資料來源

留言

留言使用 GitHub 帳號登入(Giscus),留言會存在 GitHub Discussions。