Angular 19 正式发布 の 新功能介绍

前言

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

 

'this' in Template

Signal 有一个非常烦人的类型问题。

export class AppComponent {
  protected readonly person = signal<{ name: string } | null>(null);
}

有一个 nullable person 对象,它是 Signal。

现在我们要使用它。

@if (person()) {
  <p>{{ person().name }}</p>
}

看上去很自然,但是 IDE 会直接报错

不使用 Signal 就没有这个问题

export class AppComponent {
  protected readonly person : { name: string } | null = null; 
}

没有报错。

为什么 Signal 会报错呢?

因为 person() 是方法调用,对 TypeScript 来说,方法调用不一定每一次都会返回相同的值 (没有这样的假设,我们也无法告诉 TypeScript 我们有这样一个潜规则)。

所以就报错了。

我们有两个解决思路,第一个是用惊叹号

等价于使用 any...😔

另一个思路是使用 @let

@let personValue = person();

@if (personValue) {
  <p>{{ personValue.name }}</p>
}

把 person() 调用的结果放入 @let variable,接着使用 variable,这样 TypeScript 就能确定值是相同的。

为什么 personValue 后面要附上一个 ‘Value’ 呢?

因为 @let person = person(); 会撞名字

于是!v19 推出了 'this' 关键字

@let person = this.person();

@if (person) {
  <p>{{ person.name }}</p>
}

这样就破解了😱😱😱

是不是很震惊,Angular 团队怎么能这么聪明,竟然可以拐这么一大圈。

其实这对他们来说是小菜一碟,当年为了解决 Change Detection 问题,还发明了 Zone.js 呢🙄。

当然,Signal 的这个问题,不仅仅发生在 Template,在组件里也是一样。

我们也可以用同样的解决方法

constructor() {
  const { person } = this;
  if (person()) {
    console.log(person().name); // 报错
  }
}
constructor() {
  const person = this.person();
  if (person) {
    console.log(person.name); // 不会报错了。
  }
}

相关 Github Issue – signals: TypeScript and nullability Feb 22, 2023 vote 89...又是一个 n 年高赞的 issue...🙄

 

Hot Module Replacement for Styles & Template

是的,Angular 至今都没有完整的 HMR 方案。

v19 by default,Styles 会自动开启 HMR,修改 .scss 文件不会导致整个页面 reload。

Template 的 HMR 任处于 preview 状态,要手动开启,开启后,修改 .html 文件就不会导致整个页面 reload 了。

$env:NG_HMR_TEMPLATES=1; ng serve --open

我测试了一下,好像有 bug...修改 .html 后,页面完全没有更新...🙄

 

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。

注:如果我们想强制它创建 root effect 也行。

export class AppComponent {
  constructor() {
    effect(() => {}, { forceRoot: true })
  }
}

使用 forceRoot: true。当然我们无法强制创建 view effect,因为 view effect 依赖 ViewContext。

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

细节提醒:

App Template

<p>{{ firstName() }}</p>

<app-parent [firstName]="firstName()">
  <app-child [firstName]="firstName()" />
</app-parent>

<p>{{ firstName() }}</p>

当 Parent OnInit 被调用时,App template 方法是还没有执行完的。

此时,第一行的 <p> 已经更新 DOM 了,Parent 的 @Input firstName 也已经赋值了。

但 Child 的 @Input firstName 还没有赋值,最后一行的 <p> 也还没有更新 DOM。

此时,我们可以 contentChild 拿到 Child 实例,但是读取 firstName 会是 undefined,因为它还没有被赋值。

等到 App template 方法执行完以后 (Child @input firstName 赋值了,最后一行 <p> 也赋值了)。

Parent 的 AfterContentInit 被调用,此时我们 contentChild Child 实例读取 firstName 就有值了。

而 view effect callback 是在 AfterContentInit 之前,App template 方法执行完毕之后被执行的。

App template start --> Parent OnInit --> App template end --> Parent effect callback --> Parent AfterContentInit

所以 effect callback 里可以拿 contentChild Child 实例 firstName。

问:如果我们在 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,又是这么一轮。

ɵmicrotaskEffect

ɵmicrotaskEffect 是 v18 版本的 effect,使用 microtask 作为 execution timing。

ɵ <-- 代表 private,仅适用于 Angular 框架内部。

假如我们想从 v18 升级到 v19,又没有时间去检查之前使用的 effect,那可以把全场的 effect 换成 ɵmicrotaskEffect,这样就可以保持 v18 版本的 execution timing 了。

类似的还有 toObservableMicrotask 函数。

注:这些都只是过渡方案哦,估计多一两个版本 Angular 就会删除掉它们了。

总结

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 > 更详细的话,这里还有一个环境 -- template 方法执行完 > 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

 

View effect execution timing の 掉坑记

我以为在详细阅读 Angular 源码后,我应该是不太可能掉坑里的。

但,我错了,我太低估了 Angular 团队挖坑的能力。

我已经多次提到,Angular 团队是一群按耐不住挖坑冲动的人,使用它们的框架在真实项目上一定要特别小心防范,尤其是那些 "preview" 功能,尽可能就完全不要使用。

这里分享一个掉坑记。

App Template

<app-parent>
  <app-child value="a" />
  <app-child value="b" />
  <app-child value="c" />
</app-parent>

Parent 组件

export class ParentComponent {
  private readonly children = contentChildren(ChildComponent);

  constructor() {
    effect(() => {
      console.log('effect', this.children()[0].value()); // a
    });
  }
}

上一 part 我们说过,effect 的触发时机是在 afterContentInit 的前一脚,我们可以把它俩看作是在同一个时机触发,所以 effect 里面可以读取 contentChild 组件的 @Input。

但是!!!

如果我 wrap 一层 @for 的话...

<app-parent>
  @for (value of ['a', 'b', 'c']; track value) {
    <app-child [value]="value" />
  }
</app-parent>

直接就报错了

effect(() => {
  console.log('effect', this.children()[0].value()); // ERROR RuntimeError: NG0950: Input is required but no value is available yet.
});

WHY🤔?

我们认认真真仔仔细细的再看一遍上面看过的源码...

看到了吗?看到了吗?

只要这两行代码调换位置就不会报错了。

厉害吧,我们的 Angular 团队,挖坑手法是不是越来越精湛了呢🙄

当然,掉坑的不只有我,Github 上早就有 Issue 了 -- "Input is required but no value is available yet" with toObservable if the content is dynamic

已经过 10 天了,看样子他们没打算承认是他们故意挖的坑,或许最后还会硬说 by design 就是这样...🙄

无论如何,还是期望 Angular 团队能做出调整,不然 effect 就不能完全替代 afterContentInit 了,这样又多了一件半天掉的东西...🙄

 

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 这些显然就是强求了。

Don't use effects 🚫 2.0

除了不应该在 effect 内修改其它 Signal 外,使用 effect 来 "监听" state change 也不是一个好主意,WHY🤔?

假设我们有一个表单

当 input focus > input blur > input 就算是 touched 了,会有一个粉色效果。

当 reset form > input 就变回 untouched。

App Template

<form>
  <input my-input placeholder="Enter value" />
  <button type="reset">reset form</button>
</form>

MyInput 组件

@Component({
  selector: 'input[my-input]',
  imports: [],
  templateUrl: './my-input.component.html',
  styleUrl: './my-input.component.scss',
  host: {
    '[style.backgroundColor]': `touched() ? 'pink' : null`, // touched styles
    '(blur)': 'touched.set(true)', // when blur, set to touched
  },
})
export class MyInputComponent {
  protected readonly touched = signal(false);

  constructor() {
    const input: HTMLInputElement = inject(ElementRef).nativeElement;
    const destroyRef = inject(DestroyRef);

    afterNextRender(() => {
      const form = input.closest('form');            // 找 form 
      if (form) {
        fromEvent(form, 'reset')                     // 监听 reset 
          .pipe(takeUntilDestroyed(destroyRef))      // 退订 reset
          .subscribe(() => this.touched.set(false)); // reset to untouched
      }
    });
  }
}

代码很简单,直观。

好,我们来破坏它😈。

监听 blur 事件 set touched 对比 effect focused change set touched 有区别吗?

在上述例子中,出来的效果是一样的,但内部的执行却差很多。

监听事件,和监听 signal state change 有两大区别:

  1. 触发的时机

    监听事件的话,user blur 事件触发后,立马就 set touched 了,然后才到 Angular 渲染 -- ApplicationRef.tick > refreshView 那些。

    监听 state change 的话,user blur 事件后,先 set focused,然后 Angular 渲染,在 refreshView 阶段会触发 view effect,此时才 set touched。

    由于是在 refreshView 阶段修改 Signal,这会导致 refreshView 再跑一轮。

    结论:主要就是看这个时机对你的程序逻辑有没有影响,性能方面的微差,可以不计较。

  2. 触发的顺序

    我们换一个例子

    @Component({
      selector: 'input[my-input]',
      imports: [],
      templateUrl: './my-input.component.html',
      styleUrl: './my-input.component.scss',
      host: {
        '(mousedown)': 'clicking.set(true)', 
        '(mouseup)': 'clicking.set(false)',            
        '(focus)': 'focused.set(true)',
        '(blur)': 'focused.set(false)',
      },
    })
    export class MyInputComponent {
      protected readonly focused = signal(false); 
      protected readonly clicking = signal(false); 
    
      constructor() {
        const focus$ = toObservable(this.focused).pipe(skip(1), filter(focused => focused));
        focus$.subscribe(() => console.log('focus'));
    
        const clicking$ = toObservable(this.clicking).pipe(skip(1), filter(clicking => clicking));
        clicking$.subscribe(() => console.log('mousedown'));
      }
    }

    问:focus 和 mousedown 哪一个 console 先触发?

    答:focus。虽然 mousedown 事件比 focus 事件早触发,但是 console 的执行顺序是依据 effect 注册的顺序。

    结论:主要就是看这个执行顺序对你的程序逻辑有没有影响。

从上面这两点可以看出,使用 effect 监听 state change 来模拟监听原事件,和直接监听原事件还是有挺大区别的,所以我们需要谨慎使用丫。

另外,使用 RxJS BehaviourSubject 就不会遇到上述的问题,因为 BehaviourSubject 不像 Signal 那样会强制延后 (等到 refreshView 以后) 执行,它可以选择立马同步执行,或者延后。

所以,如果想非常灵活的监听 state change,我们可以结合使用

export class AppComponent {
  // 1. 用 RxJS BehaviorSubject 做基础
  protected readonly valueBS = new BehaviorSubject<string>('default value');
  // 2. 转成 Angular Signal (readonly)
  protected readonly value = toSignal(this.valueBS);
}

一个 state 但有 2 个定义,一个用 BehaviorSubject,另一个用 readonly Signal。

如果想读取当前 value

constructor() {
  console.log(this.valueBS.value); // 'default value'
  console.log(this.value());       // 'default value'
}

两个都可以读取 (建议统一使用 signal 做读取)。

写入 value 的话,只能透过 BehaviorSubject,因为不是 WritableSignal。

this.valueBS.next('new value');
console.log(this.value());       // 'new value'

假如我们想监听 state change,并且要求是要 "同步" 发布的话。

const valueChange$ = this.valueBS.pipe(skip(1));
valueChange$.subscribe(() => console.log('value changed sync immediately'));

那就 subscribe BehaviorSubject。

如果我们想搭配 computed or effect,那就使用 Signal。

const computedValue = computed(() => this.value() + '_computed');
toObservable(this.value).pipe(skip(1)).subscribe(() => console.log('value changed async'));

虽然乱了点,但勉强还能用,只不过...如果遇上 input, model 那就没办法了。

 

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

小心坑 の linkedSignal 替代 effect ?

在上一 part Don't use effects 🚫 2.0 中,我给了一个使用 effect 的例子。

const blur$ = toObservable(this.focused).pipe(skip(1), filter(focused => !focused));
blur$.subscribe(() => this.touched.set(true));

每当 focused 从 true 变成 false (注:一开始就 false 不算 touched 哦),touched 就变成 true,这有点像 computed 的味道 (一个 Signal 依赖另一个 Signal)。

与此同时,在 form reset 的时候,touched 要 set 回 false,这是 WritableSignal 的味道。

两个加起来不就是 LinkedSignal 吗?

那我可以使用 LinkedSignal 来替代上述 effect 的场景?我们来试试看。(提醒:这个例子使用 effect 已经不顺风水了,我们只是想看一路错到底会怎样)

protected readonly touched = linkedSignal({
  source: this.focused, // 依赖 focused Signal
  computation: (currFocused, prev) => {
    const prevFocused = prev?.source; 
    return prevFocused === true && currFocused === false; // 从有 focus 变成没有 focus,就算 touched
  }
});

看上去还行,我们测试看看。

效果是对的,但是!它内部的执行是有区别的。

我们把 touched 的 binding 去掉,单独看它 LinkedSignal 的特性

window.setTimeout(async () => {
  this.focused.set(true);         // 模拟 focus
  await firstValueFrom(timer(0)); // delay

  this.focused.set(false);        // 模拟 blur
  await firstValueFrom(timer(0)); // delay

  console.log(this.touched());    // false
}, 2000);

模拟 focus 和 blur 之后,touched 依然是 false。

假如我们使用的是 effect

const blur$ = toObservable(this.focused).pipe(
  pairwise(),
  filter(
    ([prevFocused, currFocused]) => prevFocused === true && currFocused === false // 从有 focus 变成没有 focus,就算 touched
  )
);
blur$.subscribe(() => this.touched.set(true));

touched 的结果是 true,为什么呢🤔?

因为 LinkedSignal 和 Computed 的特性是 pull (拉),而 effect 的特性是 push (推)。

当我们执行

this.focused.set(true);

LinkedSignal 没有拉,所以它根本不知道 focus 了,而 effect 是被推送的,所以 effect 知道 focus 了。

看,拉一下后,LinkedSignal 的结果就不同了。另外 LinkedSignal 不需要任何 delay,effect 则需要,因为 effect 会延迟触发。

结论:

Signal,Computed,LinkedSignal,effect 它们各自有各自的特性,如果搞不清楚这些特性,在实战中就会傻傻分不清楚,好像用哪个都可以,但某天遇上一些特殊场景时,就突然失灵了。

这是 Angular 长久以来的问题,一定要搭配 Best Practice 才能避开它们挖的坑🙄。

 

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,别误会哦,此 Signal 非彼 Signal,它和 Angular 的 Signal 一毛钱关系也没有,它是用来 abort fetch 请求的,是原生 JS 的东西。

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 新的。(提醒:所谓的 abort 掉,意思是透过 abortSignal 参数,外部我们记得要把 abortSignal 链接上 fetch 才有效哦)

如果一直保持这种节奏,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 里面
posted @ 2024-10-28 22:55  兴杰  阅读(1493)  评论(7编辑  收藏  举报