Angular 19 "要" 来了⚡
  8CgHZpOr8DYB 2天前 3 0

前言

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 玩玩
View Code

效果

结论: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 个知识点:

  1. view effect 不依赖 EffectScheduler

    这也意味着,它没有 schedule 和 flush,它有自己另一套机制。

  2. ViewEffectNode (effect callback 也在里面) 会被保存到 LView[EFFECTS 23] 里。 (注:LView 来自 ViewContext)

    比如说:我们在 App 组件内调用 effect,创建出来的 view effect node 会被保存到 App 的 parent LView (也就是 root LView) 的 [EFFECTS 23] 里。

    这个保存动作就类似于 EffectScheduler.schedule,先把 callback 存起来,等待一个执行时机。

  3. 立刻执行 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) 自己玩。

不顺风水体现在几个地方:

  1. 可能出现无限循环

    上一 part 有提到,一个 tick 周期,最多能跑 10 次 synchronizeOne 方法,100 次 refreshView。

    会有这个限制就是因为怕程序写不好,进入无限循环,避免游览器跑死机...

  2. 跑多轮 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,它俩有一模一样的特性:

  1. SSR 环境下,不会执行。

  2. 执行的时机是 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 个状态

  1. Idle

    初始状态,此时 resource value 是 undefined

  2. Error

    loader 失败了,比如 ajax server down,此时 resource value 是 undefined

  3. Loading

    loader 正在 load 资料,比如 ajax 还没有 response,此时 resource value 是 undefined 

  4. Reloading

    每当 request 变更,loader 就会重新去 load 资料,这个叫 Loading (此时 value 会被设置成 undefined)。

    还有一种是我们手动调用 resource.reload() 方法,在 request 没有变更的情况下让 loader 去 load 资料,这个叫 Reloading,

    此时 resource value 不会被设置成 undefined,它会保持当前的值。

    reload 方法下面会再讲解。

  5. Resolved

    loader succeeded,此时 resource value 是 loader 返回的值

  6. 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 里面
【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 2天前 0

暂无评论