前言
Angular 19 預計會在 11 月中旬釋出,目前 (2024-10-27) 最新版本是 v19.0.0-next.11。
這次 v19 的改動可不小哦,新增了很多功能,甚至連 effect 都 breaking changes 了呢🙄
估計這回 Angular 團隊又會一如既往的大吹特吹了...好期待哦🙄
雖說有新功能,但大家也不要期望太高,畢竟 Angular 這些年走的是簡化風,大部分新功能都只是上層封裝,降低初學者門檻而已。
對於老使用者來說,依舊嗤之以鼻😏
但,有一點是值得開心的。經過這個版本,我們可以確認一件事 -- Angular 還沒有被 Google 拋棄。
因此,大家可以安心學,放心用。
本篇會逐一介紹 v19 的新功能,但不會覆蓋所有功能哦。
我只會講解那些我教過的主題,我還沒教過的 (比如:SSR、Unit Testing、Image Optimization) 通通不會談及,對這部分感興趣的讀友,請自行翻閱官網。
好,話不多說,開始吧🚀
Input with undefined initialValue
這是一個非常非常小的改進。
v18 我們這樣寫的話,會報錯。
export class HelloWorldComponent { readonly age = input<number>(undefined, { alias: 'myAge' }); }
我們必須明確表明 undefined 型別才能透過,像這樣
readonly age = input<number | undefined>(undefined, { alias: 'myAge' });
到了 v19 就不需要了。
原理很簡單,Angular 給 input 加了一個過載方法...
參考:Github – allow passing undefined without needing to include it in the type argument of input
Use "typeof" syntax in Template
這是一個小的改進。
v18 在 Template 這樣寫會報錯
@if (typeof value() === 'string') { <h1>is string value : {{ value() }}</h1> }
Angular 不認識 "typeof" 這個語法。
我們只能依靠元件來完成型別判斷
export class AppComponent { readonly value = signal<string | number>('string value'); isString(value: string | number): value is string { return typeof value === 'string'; } }
App Template
@if (isString(value())) { <h1>is string value : {{ value() }}</h1> }
到了 v19 就不需要了。
@if (typeof value() === 'string') { <h1>is string value : {{ value() }}</h1> }
直接寫就可以了,compiler 不會再報錯,因為 Angular 已經認識 "typeof" 這個語法了😊
從 v18.1 的 @let,到現在 v19 的 typeof,可以看出來,Angular 的方向是讓 Template 走向 Razor (HTML + C#)。
為什麼不是 JSX 呢?因為 JSX 是 JS + HTML,不是 HTML + JS,概念不同,React 層次高得多了。
但無論如何,讓 Template 更靈活始終是好的方向,由使用者自己來分配職責,而不是被框架束縛。
參考:Github – add support for the typeof keyword in template expressions
provideAppInitializer
在 Angular Lifecycle Hooks 文章中,我們學過 APP_INITIALIZER。
它長這樣
app.config.ts
export const appConfig: ApplicationConfig = { providers: [ provideExperimentalZonelessChangeDetection(), { provide: APP_INITIALIZER, multi: true, // 記得要設定 true 哦,不然會覆蓋掉其它模組的註冊 useValue: () => { console.log('do something before bootstrap App 元件'); return Promise.resolve(); }, }, ] };
Angular 一直不希望我們像上面這樣直接去定義 provider,它希望我們 wrap 一層函式,這樣看上去就比較函式式。
所以,v19 以後,變成這樣。
export const appConfig: ApplicationConfig = { providers: [ provideExperimentalZonelessChangeDetection(), provideAppInitializer(() => { console.log('do something before bootstrap App 元件'); return Promise.resolve(); }) ] };
非常好😊,multi: true 這個細節被封裝了起來,這樣我們就不需要再擔心忘記設定 true 了。
provideAppInitializer 的原始碼長這樣
沒啥特別的,就真的只是一個 wrapper 而已。
有一個小知識點:v18 如果使用 useValue 的話,initializerFn 內不可以使用 inject 函式,要用 inject 函式就必須使用 useFactory 代替 useValue。
v19 在這個部分做了一些改動,provideAppInitializer 雖然使用的是 useValue,但 initializerFn 內卻可以使用 inject 函式。
原因是它在執行前 wrap 了一層 injection context,相關原始碼在 application_init.ts
參考:Github – add syntactic sugar for initializers
Use fetch as default HttpClient
在 Angular HttpClient 文章中,我們學過,Angular 有兩種發 http request 的方式。
一種是用 XMLHttpRequest (預設),另一種是用 Fetch。
v19 把預設改成 Fetch 了。
Fetch 最大的問題是,它不支援上傳進度。
如果專案有需求的話,我們可以透過 withXhr 函式,配置回使用 XMLHttpRequest。
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection(),
provideHttpClient(withXhr())
]
};
注:v19.0.0-next.11 預設還是 XMLHttpRequest,withXhr 也還不能使用,可能 v19 正式版會有,或者要等 v20 了。
參考:Github – Use the Fetch backend by default
New effect Execution Timing (breaking changes)
v19 後,effect 有了新的執行時機 (execution timing),這是一個不折不扣的 breaking changes。
升級後,你的專案很可能會出現一些奇葩狀況,讓你找破頭都沒有路...🤭
但是!由於 effect 任處於 preview 階段,所以 Angular 團隊不認為這是個 breadking changes,只怪你聽信了他們的花言巧語,笨鳥先飛,先挨槍...🤭
回顧 v18 effect execution timing
v18 effect 有一個重要的概念叫 microtask。
每當我們呼叫 effect,我們的 callback 函式並不會立刻被執行,effect 會先把 callback 儲存起來 (術語叫 schedule)。
然後等待一個 async microtask (queueMicrotask) 之後才執行 (術語叫 flush)。
export const appConfig: ApplicationConfig = { providers: [ provideExperimentalZonelessChangeDetection(), provideAnimations(), { provide: APP_INITIALIZER, useFactory: () => { const firstName = signal('Derrick'); queueMicrotask(() => console.log('先跑 2')); //先跑 2 effect(() => console.log('後跑 3', firstName())); // 後跑 3,此時 App 元件還沒有被例項化哦 console.log('先跑 1'); // 先跑 1 }, }, ], };
當 signal 變更後,callback 同樣會等待一個 async microtask 之後才執行 (flush)。
除了 microtask 概念,effect 還分兩種。
一種被稱為 root effect,另一種被稱為 view effect。
顧名思義,root 指的就是在 view 之外呼叫的 effect,比如上面例子中的 APP_INITIALIZER。
view effect 則是在元件內呼叫的 effect (更嚴謹的說法:effect 依賴 Injector,假如 Injector 可以 inject 到 ChangeDetectorRef 那就算是 view effect)。
view effect 的執行時機 和 root effect 大同小異。
export class AppComponent implements OnInit, AfterViewInit { readonly v1 = signal('v1'); readonly injector = inject(Injector); constructor() { queueMicrotask(() => console.log('microtask')); // 後跑 2 effect(() => console.log('constructor', this.v1())); // 後跑 3 afterNextRender(() => { effect(() => console.log('afterNextRender', this.v1()), { injector: this.injector }); // 後跑 6 console.log('afterNextRender done'); // 先跑 1 }); } ngOnInit() { effect(() => console.log('ngOnInit', this.v1()), { injector: this.injector }); // 後跑 4 } ngAfterViewInit() { effect(() => console.log('ngAfterViewInit', this.v1()), { injector: this.injector }); // 後跑 5 } }
constructor 階段呼叫了第一個 effect,等待一個 async microtask 之後執行 callback。
此時,整個 lifecycle 都已經走完了,連 afterNextRender 也執行了。
表面上看,view 和 root effect 的執行時機是一樣的,都是等待 microtask,但其實它們有微差,view effect 的 callback 不會立刻被 schedule (root effect 會),它會被壓後到 refreshView 後才 schedule。
為什麼需要壓後?我不清楚,我也不知道具體在什麼樣的情況下,這個微差會被體現出來。但不知道無妨,反正這些都不重要了,v19 有了新的 execution timing...🙄
v19 effect execution timing
我們憋開上層的包裝,直接看最底層的 effect 是怎麼跑的。
effect 的依賴
main.ts
const v1 = signal('value');
effect(() => console.log(v1()));
效果
直接報錯了...不意外,effect 依賴 Injector 嘛。它想要就給它唄。
const v1 = signal('value'); const injector = Injector.create({ providers: [] }); // 建立一個空的 Injector effect(() => console.log(v1()), { injector }); // 把 Injector 交給 effect
效果
還是報錯了...不意外,我們給的是空 Injector 嘛。重點是,我們知道了,它依賴 ChangeDetectionScheduler。
ChangeDetectionScheduler 我們挺熟的,在 Ivy rendering engine 文章中我們曾翻過它的原始碼。
它的核心是 notify 方法,很多地方都會呼叫這個 notify 方法,比如:after event dispatch、markForCheck、signal 變更等等。
notify 之後就會 setTimeout + tick,接著就 refreshView。
結論:v19 之後,effect 的執行時機和 Change Detection 機制是掛鉤的 (v18 則沒有)。
好,我們模擬一個 ChangeDetectionScheduler provide 給它。
const injector = Injector.create({ providers: [ { provide: ɵChangeDetectionScheduler, useValue: { notify: () => console.log('notify'), runningTick: false } satisfies ɵChangeDetectionScheduler } ] });
效果
還是報錯了,這回依賴的是 EffectScheduler。
我們繼續模擬一個滿足它。
type SchedulableEffect = Parameters<ɵEffectScheduler['schedule']>[0]; const injector = Injector.create({ providers: [ { provide: ɵEffectScheduler, useValue: { schedulableEffects: [], schedule(schedulableEffect) { this.schedulableEffects.push(schedulableEffect); // 把 effect callback 收藏起來 }, flush() { this.schedulableEffects.forEach(effect => effect.run()); // run 就是執行 effect callback } } satisfies ɵEffectScheduler & { schedulableEffects: SchedulableEffect[] } } ] });
EffectScheduler 有 2 個介面,一個是 schedule 方法,一個是 flush 方法,這兩個方法上一 part 我們已經有稍微提過了。
至此,呼叫 effect 就不再會報錯了。
最終 main.ts 程式碼
import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { AppComponent } from './app/app.component'; import { effect, Injector, signal, ɵChangeDetectionScheduler, ɵEffectScheduler } from '@angular/core'; bootstrapApplication(AppComponent, appConfig) .catch((err) => console.error(err)); const v1 = signal('value'); type SchedulableEffect = Parameters<ɵEffectScheduler['schedule']>[0]; const injector = Injector.create({ providers: [ { provide: ɵChangeDetectionScheduler, useValue: { notify: () => console.log('notify change detection 機制'), runningTick: false } satisfies ɵChangeDetectionScheduler }, { provide: ɵEffectScheduler, useValue: { schedulableEffects: [], schedule(schedulableEffect) { console.log('schedule effect callback') this.schedulableEffects.push(schedulableEffect); }, flush() { console.log('flush effect'); this.schedulableEffects.forEach(effect => effect.run()); } } satisfies ɵEffectScheduler & { schedulableEffects: SchedulableEffect[] } } ] }); effect(() => console.log('effect callback run', v1()), { injector }); const effectScheduler = injector.get(ɵEffectScheduler); queueMicrotask(() => effectScheduler.flush()); // 自己 delay 自己 flush 玩玩
效果
結論:effect 依賴 Injector,而且 Injector 必須要可以 inject 到 ɵChangeDetectionScheduler 和 ɵEffectScheduler 這兩個抽象類的例項。
ChangeDetectionScheduler & EffectScheduler
我們來理一理它們的關係。
當我們呼叫 effect 的時候,EffectScheduler 會把 effect callback 先儲存起來,這叫 schedule。
接著會執行 ChangeDetectionScheduler.notify 通知 Change Detection 機制。
相關原始碼在 effect.ts
注:這裡我們講的是 root effect 的執行機制,view effect 下一 part 才講解。
notify 以後 Change Detection 會安排一個 tick。相關原始碼在 zoneless_scheduling_impl.ts (提醒:Change Detection 機制在 v19 並沒有改變哦,改變的只有 effect 的執行時機而已)
scheduleCallbackWithRafRace 內部會執行 setTimeout 和 requestAnimationFrame,哪一個先觸發就用哪個。
ChangeDetectionSchedulerImpl.tick 內部會執行 appRef.tick (大名鼎鼎的 tick 方法,我就不過多贅述了,不熟悉的讀友請回顧這篇 -- Ivy rendering engine)
appRef.tick 原始碼在 application_ref.ts
在 refreshView 之前,會先 flush root effect。
好,以上就是 root effect 的第一次執行時機。
對比 v18 和 19 root effect 的第一次執行時機
app.config.ts (v18)
export const appConfig: ApplicationConfig = { providers: [ provideExperimentalZonelessChangeDetection(), provideAnimations(), { provide: APP_INITIALIZER, multi: true, useFactory: () => { const injector = inject(Injector); return () => { const v1 = signal('v1'); effect(() => console.log('effect', v1()), { injector }); queueMicrotask(() => console.log('queueMicrotask')); }; }, }, ], };
app.config.ts (v19)
export const appConfig: ApplicationConfig = { providers: [ provideExperimentalZonelessChangeDetection(), provideAppInitializer(() => { const v1 = signal('v1'); effect(() => console.log('effect', v1())); queueMicrotask(() => console.log('queueMicrotask')); }) ] };
App 元件 (v18 & v19)
export class AppComponent implements OnInit { constructor() { console.log('App constructor'); } ngOnInit() { console.log('App ngOnInit'); } }
效果 (v18 & v19)
顯然,它們的次序是不一樣的...😱
v18 是等待 microtask 後就執行 callback,所以 callback 比 App constructor 執行的早。
v19 effect 雖然很早就 notify Change Detection 了,但是 Change Detection 不會理它,因為此時正忙著 bootstrapApplication。
bootstrapApplication 會先 renderView (例項化 App 元件,此時 App constructor 執行),然後才是 tick。
tick 會先 flush root effect (此時 effect callback 執行),然後才 refreshView (此時 App ngOnInit 執行)。
好,以上就是 root effect 的第一次執行時機。
root effect 的第 n 次執行時機
當 signal 變更後,effect callback 會重跑。
相關原始碼在 effect.ts
WriteableSignal.set 會觸發 consumerMarkedDirty,接著會把 callback schedule 起來,然後 notify Change Detection 機制。
Change Detection 機制會安排下一輪的 tick,通常是 after setTimeout 或者 requestAnimationFrame 看誰快。
如何判斷是 view effect?
v18 是看能否 inject 到 ChangeDetectionRef,能就是 view effect。
v19 也大同小異。
相關原始碼在 effect.ts
如果 Injector 可以 inject 到 ViewContext,那就是 view effect。
如果 inject 不到 ViewContext 那就是 root effect。
ViewContext 長這樣,原始碼在 view_context.ts
這裡它用了一個巧思,__NG_ELEMENT_ID__ 只有 NodeInjector 可以 inject 到,R3Injector 不行。(不熟悉的讀友,可以回顧這篇 -- NodeInjecor)
結論:只要能 inject 到 ViewContext,那就一定是 NodeInjector,就一定 under 元件,那就是 view effect 了。
view effect 的第一次執行時機
相關原始碼在 effect.ts
有 3 個知識點:
-
view effect 不依賴 EffectScheduler
這也意味著,它沒有 schedule 和 flush,它有自己另一套機制。
-
ViewEffectNode (effect callback 也在裡面) 會被儲存到 LView[EFFECTS 23] 裡。 (注:LView 來自 ViewContext)
比如說:我們在 App 元件內呼叫 effect,建立出來的 view effect node 會被儲存到 App 的 parent LView (也就是 root LView) 的 [EFFECTS 23] 裡。
這個儲存動作就類似於 EffectScheduler.schedule,先把 callback 存起來,等待一個執行時機。
-
立刻執行 node.consumerMarkedDirty
簡單說就是把 LView mark as dirty,然後 notify Change Detection,接著 Change Detection 就會 setTimeout + tick 然後 refreshView。
root effect 會在 tick 之後,refreshView 之前執行 effect callback。
而 view effect 則是在 refreshView 內執行 callback,相關原始碼在 change_detection.ts
在 OnInit 之後,AfterContentInit 之前,會執行 view effect callback。不熟悉 lifecycle hooks 的讀友,請回顧這篇 -- Lifecyle Hooks
問:如果我們在 ngAfterContentInit 裡呼叫 effect,那第一次 callback 會是什麼時候執行呢?
答:第二輪的 refreshView。ngAfterContentInit 已經錯過了第一輪的 refreshView,但不要緊,因為在一次 tick 週期裡,refreshView 是會重跑很多次的。
相關原始碼在 change_detection.ts
我們來測試一遍看看,App 元件:
export class AppComponent implements OnInit, AfterContentInit, AfterViewInit { readonly v1 = signal('v1'); readonly injector = inject(Injector); constructor() { console.log('constructor'); // 1. will run after ngOnInit and before ngAfterContentInit (第一輪 refreshView) effect(() => console.log('constructor effect', this.v1())); } ngOnInit() { console.log('ngOnInit'); // 2. will run before ngAfterContentInit (第一輪 refreshView) effect(() => console.log('ngOnInit effect', this.v1()), { injector: this.injector }); } ngAfterContentInit() { console.log('ngAfterContentInit'); // will run after ngAfterViewInit (第二輪 refreshView 了) effect(() => console.log('ngAfterContentInit effect', this.v1()), { injector: this.injector }); } ngAfterViewInit() { console.log('ngAfterViewInit'); } }
效果
view effect 的第 n 次執行時機
signal 變更會觸發 consumerMarkedDirty,於是 mark LView dirty > notify Change Detection > setTimeout > tick > refreshView > effect callback,又是這麼一輪。
總結
v18 effect 跑的是 microtask 機制。
v19 則沒了 microtask,改成和 Change Detection 掛鉤。
root effect 會把 effect callback 儲存 (schedule) 到 EffectScheduler。
view effect 會把 effect callback 儲存到 LView[EFFECT 23]。
root effect 的執行時機是:notify > setTimeout > tick > run effect callback > refreshView > Onint > AfterContentInit > AfterViewInit > afterNextRender
view effect 的執行時機是:notify > setTimeout > tick > refreshView > OnInit > run effect callback > AfterContentInit > AfterViewInit > afterNextRender
考題:假如 effect 內的 signal 沒有變更,但其它外在因素導致了 Change Detection 執行 tick,那...effect callback 會被執行嗎?
答案:當然不會...tick 只是一個全場掃描,effect 會不會執行,LView template 方法會不會執行,這些還得看它們有沒有 dirty。
參考:Github – change effect() execution timing & no-op allowSignalWrites
No more allowSignalWrites
在 v18,如果我們在 effect callback 內去 set signal,它會直接報錯。
export class AppComponent { constructor() { const v1 = signal('v1'); const v2 = signal('v2'); effect(() => v2.set(v1())); // Error } }
我們需要新增一個 allowSignalWrites 配置。
effect(() => v2.set(v1()), { allowSignalWrites: true }); // no more error
v19 不再需要 allowSignalWrites 了,因為 Angular 不會報錯了。
v18 之所以會報錯是因為 Angular 不希望我們在 effect callback 裡去修改其它 signal,不是不能,只是不希望,所以它會報錯,但又讓我們可以 bypass。
Don't use effects 🚫
這個話題是最近 Angular 團隊在宣導的。
YouTube – Don't Use Effects 🚫 and What To Do Instead 🌟 w/ Alex Rickabaugh, Angular Team
Angular 團隊的想法是 -- effect 是用來跟外界 (out of reactive system) 同步用的。
比如說,當 signal 變更,你想要 update DOM,想要 update localstorage,這些就是典型的 out of reactive system。
但如果你是想 update 另一個 signal...這就有點不太順風水,像是在圈子裡 (inside reactive system) 自己玩。
不順風水體現在幾個地方:
-
可能出現無限迴圈
上一 part 有提到,一個 tick 週期,最多能跑 10 次 synchronizeOne 方法,100 次 refreshView。
會有這個限制就是因為怕程式寫不好,進入無限迴圈,避免遊覽器跑當機...
- 跑多輪 refreshView 肯定不比跑一輪來的省時省力。
那如果我們真的要同步 signal 怎麼辦?computed 是一個辦法。
當然 computed 有它的侷限,而且也未必適合所有的場景。
上面 YouTube 影片中,Alex Rickabaugh 給了一個非常瞎的例子,它嘗試用 computed 來替代 effect。
最終搞出來的寫法是 signalValue()()...雙括弧🙄(JaiKrsh 的評論),而且這個寫法還會導致 memory leak🙄(a_lodygin 的評論)。
結論:在 effect callback 裡,去修改 signal 是否合適?我想 Angular 團隊也還沒有定數,目前大家的 balance 是 -- 能避開是很好,但也不強求,像整出雙括弧,memory leak 這些顯然就是強求了。
afterRenderEffect
afterRenderEffect,顧名思義,它就是 afterRender + effect。
注:是 afterRender + effect,而不是 effect + afterRender 哦,主角是 afterRender。
我們把它看作是 afterRender,它倆有一模一樣的特性:
-
SSR 環境下,不會執行。
-
執行的時機是 tick > refreshView (update DOM) > run afterRender callback > browser render
它倆唯一的不同是 -- afterRender callback 會在每一次 tick 的時候執行,而 afterRenderEffect 只有在它依賴的 signal 變更時,它才會執行。
export class AppComponent { constructor() { afterRender(() => console.log('render')); // 每一次 tick 都會執行 callback (比如某個 click event dispatch,它就會 log 'render' 了) const v1 = signal('v1'); // 只有在 v1 變更時才會執行 callback,click event dispatch 不會,除非 click 之後修改了 v1 的值才會。 // 當然,至少它會執行第一次啦,不然怎麼知道要監聽 v1 變更。 afterRenderEffect(() => console.log('effect', v1())); } }
逛一逛原始碼
原始碼在 after_render_effect.ts
如果我們站在 effect 的視角去看的話,spec.earlyRead / write / mixedReadWrite / read 這些等同於 effect callback。
AfterRenderManager 等同於 root effect 的 EffectScheduler 或 view effect 的 LView[EFFECT 23],作用是儲存 effect callback。
只要 callback 依賴的 signal 變更,它就 notify Change Detection 機制。
總結
afterRenderEffect 非常適合用於監聽 signal 變更,然後同步到 DOM。
它和 effect 的執行時機完全不同,它的執行時機和 afterRender 則一模一樣。
也因為有了這個新功能,effect 的使用場景就變少了。
難怪 Angular 團隊敢嚷嚷著 "Don't use effect",因為他們有了針對性的替代方案嘛。
參考:Github – introduce afterRenderEffect
linkedSignal
linkedSignal 有點不倫不類,它有點像是 writable computed,又有點像 writable signal + effect ("同步"值),又帶有 previous value 的功能...🤔
總之就是一個四不像就對了...但,它很有用哦。
main.ts
const firstName = signal('Derrick'); const lastName = signal('Yam'); const fullName = linkedSignal(() =>firstName() + ' ' + lastName()); console.log(fullName()); // 'Derrick Yam' firstName.set('Alex'); console.log(fullName()); // 'Alex Yam'
上述例子中,linkedSignal 的表現和 computed 一模一樣。
好,厲害的來了
const firstName = signal('Derrick'); const lastName = signal('Yam'); const fullName = linkedSignal(() => firstName() + ' ' + lastName()); console.log(fullName()); // 'Derrick Yam' // 直接 set fullName fullName.set('new name'); console.log(fullName()); // 'new name' firstName.set('Alex'); console.log(fullName()); // 'Alex Yam'
linkedSignal 可以像 writable signal 那樣直接賦值😱。
當 computed 依賴的 signal 變更,它又會切換回到 computed 值。
我們在 v18 做不出一模一樣的效果,勉強的做法是 signal + effect
const fullName = signal(''); effect(() => fullName.set(firstName() + ' ' + lastName()), { allowSignalWrites: true });
但 effect 是非同步的,而且沒有 computed lazy excute 的概念,所以最終效果任然有很大的區別。
writeable computed 的原理
const fullName = linkedSignal(() =>firstName() + ' ' + lastName()); fullName.set('new name'); firstName.set('Alex'); console.log(fullName()); // 'Alex Yam'
我們先直接給 fullName 賦值,接著再給 fullName 的依賴 (firstName) 賦值。
linkedSignal 顯示的是 computed 的結果,正確。
接著反過來再試一遍
firstName.set('Alex'); fullName.set('new name'); console.log(fullName()); // 'new name'
linkedSignal 顯示的是 signal set 的結果,正確。
哎喲,很聰明嘛,linkedSignal 視乎能感知到 firstName 和 fullName 賦值的順序。
它是怎麼做到的呢?原始碼在 linked_signal.ts
linkedSignal computed 的部分和 computed 機制一模一樣。
當我們呼叫 linkedSignal() 取值的時候,它會去檢查依賴 signal (a.k.a Producer) 的 version,如果 version 和之前記入的不同,就代表 producer 變更了,那就需要重新執行 format / computation 獲取新值。
linkedSignal set 的部分和普通的 signal.set 不同,它多了一個步驟 producerUpdateValueVersion()。
我們曾經翻過 producerUpdateValueVersion 的原始碼,它的作用就是上面說的,檢查 producer version > 重新執行 computation > 獲取新值。
const firstName = signal('Derrick'); const lastName = signal('Yam'); const fullName = linkedSignal(() => { console.log('run computation'); return firstName() + ' ' + lastName(); }); fullName.set('Alex'); // 這裡會觸發 log 'run computation'
看,我們沒有讀取 fullName 的值,只是 set 了 fullName,但 computation 卻執行了,這點就和 computed 有很大區別了。
這也是 linkedSignal 能感知到 firstName 和 fullName 賦值順序背後的秘密。
source & previous
WritableSignal 有一個叫 update 的方法
const v1 = signal(1); v1.set(v1() + 1); // set 的寫法 v1.update(v => v + 1); // update 的寫法,引數 v 是當前 v1 的 value '1'
在 update 的時候,我們可以獲取到當前 signal 的 value,然後拿它來完成後續的計算。
而 computed 沒有這個概念。
const v2 = computed(() : number => { const currValue = v2(); // 這裡不能拿 current value,因為會死迴圈... return currValue + 1; });
不僅如此,即便只是想拿其它 signal 的 before / after value 也辦不到...
const v1 = signal('v1'); const v2 = signal('v2'); const v3 = computed(() => { // 我想拿到 v1, v2 的 before / after value...做不到 return v1() + ' ' + v2(); });
用 RxJS 表達的話,大概長這樣
const v1 = new BehaviorSubject('v1'); const v2 = new BehaviorSubject('v2'); const v3$ = combineLatest( [ v1.pipe(pairwise(), startWith([undefined, v1.value] as const)), v2.pipe(pairwise(), startWith([undefined, v2.value] as const)), ] ).pipe( // 可以拿到 before / after value map(([[prevV1, currV1], [prevV2, currV2]]) => { return 'new value'; }) );
為了彌補這些缺陷,linkedSignal 引入了 source 和 previous 概念。
const v1 = signal('v1'); const v2 = signal('v2'); const v3 = linkedSignal({ source: () => ({ v1: v1(), v2: v2() }), computation: (currentSource, previous) => { console.log('current source', currentSource); console.log('previous source', previous?.source); // 第一次是 undefined console.log('current v3', previous?.value); // 第一次是 undefined return 'v3'; // next v3 } });
如果想監聽某 signal 的 before / after value,那就把它們放進 source 裡面。
computation 就是原本的 computed formula,只是它多了一些 arguments。
current source 就是 source() 最新的值。
previous.source 就是上一次跑 computation 時記入的舊值。(類似 RxJS 的 pairwise)
previous.value 就是當前 v3() 最新的值。(類似 WritableSignal.update)
有了這些 arguments,我們就可以做到像 WritableSignal.update 還有 RxJS pairwise 的效果了。
相關原始碼在 linked_signal.ts
總結
linkedSignal 確實有點四不像,感覺它是為了彌補 computed 和 effect 的缺陷,硬加上去的功能。
不過,無論如何,在被迫 optional RxJS 的情況下,還能推出類似 RxJS 的功能,Angular 團隊已經很不錯了👍。
參考:Github – introduce the reactive linkedSignal
Resource API
Resource 有點像是 async 版的 linkedSignal。
它適用的場合是 -- 我們想監聽一些 signal 變更,然後我們想做一些非同步操作 (比如 ajax),最後得出一個值。
每當 signal 變更,自動發 ajax 更新值。
就這樣一個簡單的需求,如果不引入 RxJS,硬硬要用 effect 去實現的話,程式碼會非常醜😩。
這也是為什麼 Angular 團隊會推出這個 Resource API,他們想要 optional RxJS,但 effect 又設計得不好。
最終只能推出像 Resource API 這種上層封裝的功能,把骯脹的程式碼藏起來,讓新手誤以為 "哇...用 Angular 寫程式碼真是太簡潔了,棒棒棒"🙄。
好,我們來看例子
App 元件
export class AppComponent { constructor() { // 1. 這是我們要監聽的 signal const filter = signal('filter logic'); // 2. 這是我們的 ajax const getPeopleAsync = async (filter: string) => new Promise<string[]>( resolve => window.setTimeout(() => resolve(['Derrick', 'Alex', 'Richard']), 5000) ) } }
我們要監聽 filter signal,每當 filter 變更就發 ajax 依據 filter 過濾出最終的 people。(具體實現程式碼我就不寫了,大家看個形,自行腦補丫)
resource 長這樣
const peopleResource = resource({ request: filter, loader: async ({ request: filter, previous, abortSignal }) => { const people = await getPeopleAsync(filter); return people; } });
request 就是我們要監聽的 signal。
如果想監聽多個 signal,我們可以用 computed wrap 起來,或者直接給它一個函式充當 computed 也可以,像這樣
request: () => [signal1(), signal2(), signal3()],
loader: async ({ request : [s1, s2, s3], previous, abortSignal }) => {}
loader 是一個 callback 方法,每當 request 變更,它就會被呼叫。
我們在 loader 裡面依據最新的 filter 值,傳送 ajax 獲取到最終的 people 就可以了。(提醒:返回一定要是 Promise 哦,RxJS Observable 不接受)
另外,loader 引數裡有一個 abortSignal,這個是用來 abort fetch 請求的。
previous 則是當前 resource 的狀態。是的,resource 還有狀態呢。
resource 一共有 6 個狀態
-
Idle
初始狀態,此時 resource value 是 undefined
-
Error
loader 失敗了,比如 ajax server down,此時 resource value 是 undefined
-
Loading
loader 正在 load 資料,比如 ajax 還沒有 response,此時 resource value 是 undefined
-
Reloading
每當 request 變更,loader 就會重新去 load 資料,這個叫 Loading (此時 value 會被設定成 undefined)。
還有一種是我們手動呼叫 resource.reload() 方法,在 request 沒有變更的情況下讓 loader 去 load 資料,這個叫 Reloading,
此時 resource value 不會被設定成 undefined,它會保持當前的值。
reload 方法下面會再講解。
-
Resolved
loader succeeded,此時 resource value 是 loader 返回的值
-
Local
Resource 是 linkedSignal,loader 是它的 link,我們也可以直接給 resource set value 的,這種情況就叫 local
不同狀態 loader 的處理過程可以不相同,這是 previous 的用意。
resource 常用的屬性
const peopleResource = resource({ request: filter, loader: async ({ request: filter, previous, abortSignal }) => { const people = await getPeopleAsync(filter); return people; } }); peopleResource.value(); // ['Derrick', 'Alex', 'Richard'] peopleResource.hasValue(); // true peopleResource.error(); // undefined peopleResource.isLoading(); // false peopleResource.status(); // ResourceStatus.Resolved
如果在 first loading 那 value 就是 undefined,hasValue 就是 false,isLoading 就是 true,以此類推。
另外 resource 還能 reload
peopleResource.reload();
不等 request 變更,手動 reload 也會觸發 loader ajax 獲取新值。
還有 Resource 類似 linkedSignal,它也可以直接 set 和 update。
peopleResource.set(['Jennifer', 'Stefanie']); peopleResource.value(); // ['Jennifer', 'Stefanie'] peopleResource.status(); // ResourceStatus.Local
逛一逛原始碼
Resource 只是一個上層封裝,它底層就是 effect,沒有什麼大學問,我們隨便逛一下就好了。
原始碼在 resource.ts
到這裡,已經可以看出它的形了,effect callback 裡面肯定會呼叫 request.request 和 request.reload,所以每當 request 或 reload 變更,effect callback 就會執行。
小心坑 の request changes vs reload
有一點,我想提醒大家。
request 變更是 switchMap 行為。
意思是,假如 loader 還沒有 load 完,request 又變更了。在這種情況下,它會 abort 掉之前的,重新在 load 新的。
如果一直保持這種節奏,resource 會一直處於 Loading 狀態,value 一直是 undefined。
reload 是 exhaustMap 行為。
當我們呼叫 resource.reload 時,如果當前 loader 正在 loading,它可不會 abort 掉,重新 load 新的哦。
它會直接 return false,表示 skip 掉這次 reload 的要求...🙄
為什麼 Angular 團隊要刻意設計得不一致呢?
我也不知道耶,估計是他們認為這樣比較符合日常需求吧,又或者是...壓抑不住挖坑的衝動...🙄
總結
Resource 是一個上層封裝的小功能,有點像是 linkedSignal 的 async 版,主要用於 -- 監聽 signal + async compute value。
目前是 Experimental 階段,估計還無法用在真實的專案上。
從它的實現程式碼中,我們可以看到 effect 的不優雅,同時懷念 RxJS 的便利。
真心希望 Angular 團隊能儘快找到良藥,不要讓使用者繼續折騰了。
參考:Github – Experimental Resource API
rxResource
rxResource 是 RxJS 版的 Resource。
我們提供的 loader callback 要返回 Observable,不能返回 Promise。
哎喲,不要誤會哦。
它沒有支援 stream 概念。它底層只是簡單的 wrap 了一層 resource 呼叫而已。
使用 firstValueFrom 把 loader 返回的 Observable 切斷,轉換成 Promise,僅此而已...🙄
總結
本篇簡單的介紹了一些 Angular 19 的新功能,還有 effect execution timing 的 breakiing changes。
等正式版推出後,如果有更動,我會回來補上。
目錄
上一篇 Angular 18+ 高階教程 – 國際化 Internationalization i18n
下一篇 TODO
想檢視目錄,請移步 Angular 18+ 高階教程 – 目錄
喜歡請點推薦👍,若發現教程內容以新版脫節請評論通知我。happy coding 😊💻
如果想監聽某 signal 的 before / after value,那就把它們放進 source 裡面