昨天我們先試著理解,在 Template 中使用 Signal() 時,Angular 是如何建立依賴關係,並在 Signal 發生變化後,知道哪些 View 可能受到影響。
不過前面其實還沒有正式介紹 Signal 本身怎麼使用。因此今天會先回到基礎,從 Signal() 的基本使用方式開始,再接著看看它在更新資料時,Angular 內部大致做了哪些事情。
和昨天一樣,除了基本用法之外,我們也會搭配一些原始碼,試著從實作角度理解它背後的運作方式。
大致上可以先把 Signal 分成兩種類型:
Writable Signal:可以讀取,也可以修改,一般會持續變動的狀態適合使用。Readonly Signal:只能讀取,不能直接從外部呼叫 set() 或 update() 修改資料。當我們想把某個 Signal 提供給其他地方使用,但又不希望外部隨意改動時,就很適合用這種方式。不過要注意,如果 Signal 裡面放的是物件,直接去修改物件裡的屬性,這件事情其實還是可以做得到,Readonly 並不會幫你把物件本身也一起鎖住喔!
我們可以快速透過一個簡單的範例,看看 Writable Signal 與 Readonly Signal 在實際開發上的差別。
補充:這裡的
@Service()是 Angular 22 新增的寫法,可以先理解成建立 Root Service 的簡化方式,概念上類似過去常見的@Injectable({ providedIn: 'root' })。
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 提供的方法來操作。
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);
在 Component 中,我們可以透過 counterService.count() 讀取目前的值,但不能直接使用 set() 或 update() 修改它。
這個差別其實很重要,因為它不只是在區分「能不能修改」,也會直接影響我們後面怎麼設計資料流。
簡單來說,就是先把「誰可以改資料」這件事情限制清楚,外部只負責讀取,真正的修改邏輯則集中在 Service 內部處理。
前面我們先認識了 Writable Signal 與 Readonly Signal,不過實際在 Angular 開發裡,現在除了 Signal 本身之外,也有不少新的 API 開始採用 Signal,或是類似的函式式寫法。
如果先從平常開發的使用情境來看,大致可以分成下面幾類。
signal():建立一般可以讀取與修改的狀態。computed():根據其他 Signal 計算出衍生值,本身不能直接修改。linkedSignal():根據其他 Signal 建立有依賴關係的狀態,同時本身也可以被修改。effect():當依賴的 Signal 發生變化時,自動執行一些額外操作,例如紀錄 log、同步 localStorage,或更新第三方圖表套件。resource():用來把 API 等非同步資料整合進 Signal 的響應式流程,讓資料載入、狀態與更新可以一起管理。可以先把「內容投影」想像成 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 原始碼中,實際又是怎麼串起來的。
Writable Signal 主要可以透過 set() 與 update() 來修改資料。
Angular 在更新 Signal 前,會先比較新舊值是否相同,預設使用 Object.is();如果判定相同,就不會繼續傳遞這次變更。

set():直接指定新的值,適合已經知道下一個值的情境。const count = signal(0);
count.set(10);
console.log(count()); // 10
update():根據目前的值計算下一個值,適合需要依照現有狀態進行修改的情境。const count = signal(0);
count.update((value) => value + 1);
console.log(count()); // 1
前面我們知道,Writable Signal 可以透過 set() 與 update() 修改資料。
從 Angular 原始碼來看,兩者最後都會走到相同的更新流程,差別只在於 update() 會先執行傳入的 callback function,根據目前的值計算出新的結果。
建立 Signal 時,可以額外傳入 equal 函式,用來決定新舊值是否相同;如果沒有指定,則會使用預設的 Object.is() 進行比較。
Angular 會先透過 equal 判斷這次的值是否真的發生變化,再決定要不要繼續往下傳。
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];
}
export function signalUpdateFn<T>(
node: SignalNode<T>,
updater: (value: T) => T,
): void {
signalSetFn(node, updater(node.value));
}
可以看到,set() 和 update() 最後都會走到 signalSetFn(),接下來就從這裡看看 Angular 怎麼判斷這次更新要不要繼續往下傳。
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 已經發生變化。
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才會增加。
比如說:
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 與祖先路徑,這部分留到之後再繼續往下看。
signal()、computed()、set() 與 update() 的基本用法,以及 equal 的比較行為。createSignal()、signalSetFn()、signalUpdateFn() 與 signalValueChanged() 等實作。Producer、Consumer、version 與 Push/Pull 的底層設計。viewChild()、viewChildren()、contentChild()、contentChildren() 等查詢 API。Signal API。