Angular最近几个版本都更新了哪些有意思的功能? Signal 篇

755 阅读11分钟

Signals • Overview • Angular

Signal是什么

Angular 从 16 开始,引入了 signal。

Angular Signals 是一个信号体系,能够细粒度地跟踪你的状态在整个应用中的使用方式和位置,从而使框架能够优化更新频率。

翻译成白话来说就是Signal可以定义一个状态,Angular会知道这个状态在系统中所有被使用的位置,当这个状态变化的时候,会更新页面。

Signal都有哪些功能

基础使用

默认情况下 Signal 的类型是 WritableSignal,也就是可写的 Signal

const count: WritableSignal<number> = signal(0);

// Signals are getter functions - calling them reads their value.
console.log('The count is: ' + count());
// Output 0

// Setting the value directly 
count.set(1);
// Output 1

// update by prev value
count.update(value => value + 1);
// Output 2

定义只读的 Signal

通过 asReadonly() 可以定义 signal 为只读的,此时 Signal 的类型为 Signal<number>, 它没有 setupdate 属性,无法修改值。

readonly pageSize = signal(10).asReadonly();

Or

  readonly pageSize:Signal<number>;
  constructor() {
    // You can get value from other sync code
    this.pageSize = signal(value).asReadonly();
  }

如果你需要从某个异步操作的结果设置默认值, 就只能放弃 readonly 修饰符。

  pageSize!: Signal<number>;
  constructor() {   
    Promise.resolve().then(() => {
      this.pageSize = signal(10).asReadonly();
    }); 
  }

这种写法,你同样无法调用 this.pageSizesetupdate 方法更新值,但是无法避免在代码中直接修改 pageSize 为新的 signal 对象,这点比较难受。

/// 在 xxx function 内部直接修改引用
this.pageSize = signal(10).asReadonly();

自定义 Compare 函数

你可以自定义一个返回 bool 值的函数,它会被用于检查新旧值是否不同。

  data = signal(
    { id: 1, name: 'zhangsan' },
    {
      equal: (prev, cur): boolean => {
        return prev.id === cur.id;
      },
    }
  );

上面的例子中,只要对象的 id 是不变的,那么 Angular 就会始终认为 data 没有发生变化。默认情况下,Angular 只会比较引用是否相等。

计算属性 Computed

const count: WritableSignal<number> = signal(0);
const doubleCount: Signal<number> = computed(() => count() * 2);

count 变化时,angular 会自动更新 doubleCount, 并且计算属性是只读的。

计算属性是惰性并且支持缓存的,当你访问 doubleCount 时,计算函数才会被执行,并且缓存执行结果,在count 值不发生变化的前提下,后续访问 doubleCount 都会直接返回之前缓存的值。

计算属性还会根据代码逻辑动态推导应该追踪哪个 signal, 在下面的例子中,

  • 如果 showCount 的初始值为 true, 那么逻辑会读取 count 的值,把 count 也标记成依赖项,此时当 showCountcount变化时,都会导致缓存的值无效,从而触发 conditionalCount 被重新计算.
  • 如果 showCount 的初始值为 false, 代码逻辑不会读取 count, 即使 count 值发生变化,也不会触发 conditionalCount 重新计算。
  • 如果刚开始为 true,count会被标记为依赖项, 之后如果把 showCount 的值修改为 false, 那么 count 将被移出依赖项,依赖项是动态推导的。
const showCount = signal(false);
const count = signal(0);
const conditionalCount = computed(() => {
  if (showCount()) {
    return `The count is ${count()}.`;
  } else {
    return 'Nothing to see here!';
  }
});

我们应该尽量使用 computed 替换 getter

因为 compute 有缓存机制,而 getter 没有。而且你很难知道 getter 的依赖关系何时发生变化,Signal 则要求你显式的更新状态(call set() or update())。

Effects

每当一个或多个依赖的信号值发生变化时, Effects 都会被调用,与 computed 一样,它会动态跟踪依赖关系。但 effects 至少会运行一次(在初始化阶段)。

默认情况下,您只能在注入上下文(您可以访问注入函数)中创建 effect()。满足此要求的最简单方法是在组件、指令或服务构造函数中调用 effect

@Component({...})
export class EffectiveCounterComponent {
  readonly count = signal(0);
  constructor() {
    // Register a new effect.
    effect(() => {
      console.log(`The count is: ${this.count()}`);
    });
  }
}

另外你还可以给 effects 分配一个属性

@Component({...})
export class EffectiveCounterComponent {
  readonly count = signal(0);
  private loggingEffect = effect(() => {
    console.log(`The count is: ${this.count()}`);
  });
}

除此之外,如果需要在构造函数之外创建 effects,可以通过配置传递 Injectoreffects

@Component({...})
export class EffectiveCounterComponent {
  readonly count = signal(0);
  constructor(private injector: Injector) {}
  initializeLogging(): void {
    effect(() => {
      console.log(`The count is: ${this.count()}`);
    }, {injector: this.injector});
  }
}

你创建的 effects 会随着组件被销毁时一起被销毁,如果你想要手动控制,可以使用 .destroy()手动销毁。

untracked

如果你不想某个信号被追踪,可以使用 untracked 包裹它,这样即使该信号的值发生变化,也不会触发 effect 调用。

effect(() => {
  console.log(`User set to ${currentUser()} and the counter is ${untracked(counter)}`);
});

effect(() => {
  const user = currentUser();
  untracked(() => {
    // If the `loggingService` reads signals, they won't be counted as
    // dependencies of this effect.
    this.loggingService.log(`User set to ${user}`);
    
    // The counter will not be tracked.
    console.log(`User set to ${user} and the counter is ${counter()}`);
  });
});

cleanup

创建 effects 时,函数可以选择接受 onCleanup 函数作为其第一个参数。此 onCleanup 函数允许您注册一个回调,该回调在 effects 被销毁时调用。

effect((onCleanup) => {
  const user = currentUser();
  const timer = setTimeout(() => {
    console.log(`1 second ago, the user became ${user}`);
  }, 1000);
  onCleanup(() => {
    clearTimeout(timer);
  });
});

effects 的使用场景

  • 记录显示的数据及其变化情况,用于分析或作为调试工具。
  • 保持数据与 window.localStorage 同步。
  • 添加无法用模板语法表达的自定义 DOM 行为。
  • 对 、图表库或其他第三方 UI 库执行自定义渲染。
  • 用于异步场景

什么时候不能用 effects

如果你的 effects 代码会修改 signal 的状态,那么建议不要使用 effect。这可能会导致 ExpressionChangedAfterItHasBeenChecked 错误、无限循环更新或不必要的更改检测周期。 由于这些风险,Angular 默认会阻止您在 effects 中设置 signal 的值。如果一定要改,可以在创建 effets 时设置 allowSignalWrites 标志来启用它。

相反,可以使用 computed 来模拟依赖于其他状态的状态。

tips:很多博客都在说一定避免使用 effects,但在很多场景下使用 effects 的代码比 computed 具有更好的可读性,感兴趣的可以查看这篇博客,写的很好,而且给出了一些适合使用 effects 的场景。不要盲目的使用 effects,但也不要直接摒弃它。

Angular's effect(): Use Cases & Enforced Asynchrony - Rainer Hahnekamp

使用基于 signal 的全新 input, output, viewQuery

在之前的版本,我们定义输入输出属性和视图查询,都是使用基于装饰器的: @Input, @Output, @ViewChild等,而现在你可以使用基于 Signal 实现的全新API。

Signal Input

默认情况下,Input 属性是可选的,你可以显式的指定一个初始值,否则 Angular 将默认使用使用 undefined。

import {Component, input} from '@angular/core';
@Component({...})
export class MyComponent {
  // optional
  firstName = input<string>();         // InputSignal<string|undefined>
  // set initial value
  age = input(0);                      // InputSignal<number>
  // required
  lastName = input.required<string>(); // InputSignal<string>
}

你也可以给 input 属性起别名

  // child component
  gender = input(18, { alias: 'studentGender' });
  
  // parent component
  <child [studentGender]="20"></child>

当然现在的 input 依然带有严格的类型检查。

image.png

signal input 是只读的,它的类型继承自 Signal

export class InputSignal<T> extends Signal<T> { ... }`.

所以它也支持计算属性

age = input(0); 
ageText = computed(() => `The age of the students is ${this.age()}`);

但使用 computed 需要你重新定义一个属性,如果你不希望属性含义因为命名发生变化,想要使用原始属性名,或者需要对输入值进行强制解析和纠正,那么你可以配置 transform

class MyComp {
  disabled = input(false, {
    transform: (value: boolean|string) => typeof value === 'string' ? value === '' : value,
  });
}

如果 transform 会改变输入属性的含义,或者转换不是纯函数,则不应该使用 transform。相反,对于具有不同含义的转换,请使用 computed,对于不纯的代码,请使用 effect,它在输入属性的值发生变化时运行。

双向绑定

双向绑定的写法也发生了变化,可以使用新的 model api。

    
@Component({ /* ... */ })
export class AppChild {
  // Can be subscribed to using `(checkedChange)="handler()"` in the template.
  checked = model(false);
}

上述代码,Angular会自动生成 checkedChange 事件,当 checked 的值发生变化,会自动 emit 最新的值。

使用时,

@Component({
  /* ... */
  // `value` is a model input.
  // The parenthesis-inside-square-brackets syntax (aka "banana-in-a-box") creates a two-way binding
  template: '<app-child [(checked)]="isChecked" />',
})
export class ParentComponent {
  isChecked = signal(true);
}

注意这里直接用的 isChecked,而不是 isChecked()

Signal Output

output 的用法跟之前装饰器差不多,没什么特别的。

   panelClosed = output<void>();
   this.panelClosed.emit();
    
   valueChanged = output<number>();
   this.valueChanged.emit(1);

有个好玩的是 ouput 是可以通过 subscribe 订阅的,你可以直接在外部通过组件的实例去订阅它。

const someComponentRef: ComponentRef<SomeComponent> = viewContainerRef.createComponent(/*...*/);
someComponentRef.instance.someEventProperty.subscribe(eventData => {
  console.log(eventData);
});

当 Angular 销毁带有订阅的组件时,它会自动清除事件订阅,当然你也可以手动取消订阅,因为它就是一个Observable 对象。

Signal View Query

整体的用法跟使用装饰器基本一致,只是语法的不同

child = viewChild(ChildComponent);
children = viewChildren(ChildComponent);
    
item = contentChild(CustomMenuItem);
items = contentChildren(CustomMenuItem);

但是基于 signal 的实现,让它可以结合 computed 使用

 childText = computed(() => this.child()?.text);
    
 itemTexts = computed(() => this.items().map(item => item.text));

由于目标组件可能被 @if 隐藏,导致无法找到该组件,因此 query 返回的值类型中会包含 undefined

但是当你可以确保该组件始终存在时,可以使用 .required,此时返回的值类型就不会包含 undefined了。

header = viewChild.required(CustomCardHeader); 
    
body = contentChild.required(CustomCardBody);

还有个以前没注意到的配置: descendantsContentChild 只会找你的子组件,当你想找孙组件或者更深层次的组件时,就可以把 descendants 设为 true,这个配置同样也支持装饰器那种写法。

@Component({
  selector: 'custom-expando',
  /*...*/
})
export class CustomExpando {
  toggle = contentChild(CustomToggle,{descendants: true});
}
   
@Component({
  selector: 'user-profile',
  template: `
    <custom-expando>
      <some-other-component>
        <!-- custom-toggle 不会被发现,除非你配置了 descendants -->
        <custom-toggle>Show</custom-toggle>
      </some-other-component>
    </custom-expando>
  `
})
export class UserProfile { }

Angular 会按需延迟计算基于 signal 的查询结果。这意味着除非你的代码显式的读取 Signal,否则不会收集查询结果。

迁移

在 Angular version 19中,全新的 input, output, queryView已经是稳定版本了, 并且 Angular 提供了脚本帮你从旧代码中迁移。

// 分别迁移
ng generate @angular/core:signal-input-migration  
ng generate @angular/core:signal-queries-migration  
ng generate @angular/core:output-migration
    
// 一起迁移
ng generate @angular/core:signals

或者在编辑器中手动转换。

image.png

linkedSignal

linkedSignal 的工作原理与 signal 类似,但 linkedSignal 不是传递默认值,而是传递一个计算函数,就像 computed 一样。当追踪的值改变时,linkedSignal 就会根据传递的计算函数,重新计算结果。

这里就不过多描述用法了,可以查看官网示例:Dependent state with linkedSignal • Angular ,目前属于开发者预览阶段,并不是稳定版本。

linkedSignal 的语法跟 computed 差不多,都接收一个箭头函数作为参数。官方的解释也是说 linkedSignal 会在依赖项发生变化时重新计算值。但它跟 computed 有一个最大的区别就是它是可写的,而计算信号是只读的。它返回的类似是 WritableSignal

const shippingOptions = signal(['Ground', 'Air', 'Sea']);
const selectedOption = linkedSignal(() => shippingOptions()[0]);
console.log(selectedOption()); // 'Ground'

// linkedSignal 是可写的,可以 set 值
selectedOption.set(shippingOptions()[2]);
console.log(selectedOption()); // 'Sea'
    
// 当依赖项发生变化时,会重新根据传入的函数计算结果,所以此时读取它的值返回的是 Email 
shippingOptions.set(['Email', 'Will Call', 'Postal service']);
console.log(selectedOption()); // 'Email'

它还支持:

  1. 自定义 compare 函数,用于检查依赖项是否发生变化,用法跟 effects 的 compare 差不多。
  2. 可以获取到依赖项上一次的值和 linkedSignal 前一次的计算结果,从而返回你期待的结果。
@Component({/* ... */})
export class ShippingMethodPicker {
  shippingOptions: Signal<ShippingMethod[]> = getShippingOptions();
  
  selectedOption = linkedSignal<ShippingMethod[], ShippingMethod>({
    // 每当此值发生变化,就会调用用 computation 函数进行计算
    source: this.shippingOptions,
    computation: (newOptions, previous) => {
      // 如果 newOptions 包含之前选中的选项,则保留该选择,否则返回第一个值
      // previous包含两个属性,previous.source 是 source 的前一个值
      // previous.value 是前一个计算结果
      return newOptions.find(opt => opt.id === previous?.value?.id) ?? newOptions[0];
    },
    equal: (a, b) => a.id === b.id,
  });
}

Resource - 将异步请求与 Signal 结合使用

大多数 Signal API 都是同步的 — signal、computed、input等。但是,应用程序通常需要处理异步数据。Resource 提供了一种将异步数据和 Signal 共用的办法。

你可以使用 Resource 执行任何类型的异步操作,最常见的用例是从服务器获取数据。下面的示例创建了一个 Resource 来获取一些用户数据。

userId = signal<number | undefined>(undefined);

fetchUserPromise = ({ id }: { id?: number }) => {
    if (id === 1) {
      return Promise.resolve({ firstName: 'John', id });
    } else {
      return Promise.resolve({ firstName: 'Doe', id });
    }
};

// loader不接受 Observeable,使用该方法作为loader会报错。
fetchUserObservable = ({ id }: { id?: number }) =>
of({ firstName: 'John', id });

userResource = resource({
// 只要 userId 发生变化,就会调用 loader 重新请求值。
// 你也可以直接传递一个 computed属性给 request
request: () => ({ id: this.userId() }),
// 定义一个获取数据的异步 loader。
loader: ({ request }) => this.fetchUserPromise(request),
});

firstName = computed(() => this.userResource.value()?.firstName);  
    

当 userId 发生变化时,会自动获取数据,除此之外你还可以通过 .reload() 手动获取。

this.userResource.reload();

loader 除了 request 之外,还有 previous, abortSignal 两个参数。

  • previous 包含了上一次请求结果的状态的枚举值,注意是 response status, 而不是 response!一共有6个状态,成功,失败,加载中等等。
  • abortSignal 也并非给你提供了手动终止请求方法,而是让你可以订阅请求被终止的事件,获取被终止的原因等等。

除了 .value 外, Resource 实例本身还提供了一些类似于 isLoading,和 hasValue 的属性。

对这些感兴趣的可以查看官方文档:Async reactivity with resources • Angular

另外在实际使用中,发现一个问题,loader 允许的类型是 PromiseLike, 它居然不接受 Observable

总的来说 Resource 给人一种半成品的感觉,不过目前仍然属于开发者预览阶段,后面应该会逐渐完善。

Signal 与 Rxjs 之间的转换

目前属于开发者预览阶段。

Observable to Signal

import { Component } from '@angular/core';
import { interval } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
  template: `{{ counter() }}`,
})
export class Ticker {
  counterObservable = interval(1000);
  // initialValue 会在 Observable 没有发出值之前被使用,如果没有定义,默认返回 undefined
  counter = toSignal(this.counterObservable, {initialValue: 0});
}

如果 Observable 发出错误,那么当你在读取 Signal 时,会抛出错误。

有一些 Observables 可以保证值是同步发出的,例如 BehaviorSubject,它拥有默认值。那么在这种情况下,可以指定 requireSync: true,这样你就不需要再设置 initialValue 了。

  counter = toSignal(this.counterObservable, { requireSync: true });

toSignalasync 管道类似,它会立刻订阅 Observable, 当调用 toSignal 的组件或服务被销毁时,由 toSignal 创建的订阅会自动取消。如果你的 Observable 会自动完成,那么你可以通过配置 manualCleanup: true 来覆盖默认销毁策略。

另外 toSignal 默认情况下需要在注入上下文中运行。如果注入上下文不可用,您可以手动指定要使用的注入器,这点跟 effects 一致,没看懂的可以去看 effects 章节的开头部分。

Tips: toSignal 会创建一个订阅。您应避免对同一个 Observable 重复使用 toSignal,而应重用它返回的信号。

Signal to Observable

可以使用 toObservable 创建一个跟踪 Signal 值的 ObservableSignal 通过 effects 进行监控,当值发生变化时,该 effects 将值发送到 Observable

import { Component, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
@Component(...)
export class SearchResults {
  query: Signal<string> = signal(QueryService).query;
  query$ = toObservable(this.query);
  results$ = this.query$.pipe(
    switchMap(query => this.http.get('/search?q=' + query ))
  );
}

跟 toSignal 相同,toObservable 默认情况下也需要在注入上下文中运行。

在订阅时,第一个值(如果可用)会同步发出,而所有后续值都将异步发出。

Observable 不同的是即使多次更新信号的值,toObservable 也只会在信号稳定后发出该值。

const obs$ = toObservable(mySignal);
obs$.subscribe(value => console.log(value));
mySignal.set(1);
mySignal.set(2);   
// 只有3会被log
mySignal.set(3);

扩展知识点

Signal 与 变更检测

Signal 的值发生变化时,Angular 会更新视图,实际上也是通过触发变更检测是实现的,可以看一下这个链接,当你使用 no ngzone 模式时,signal就不工作了。Angular 16 Signal update doesn't update view - Stack Overflow

针对 signal 触发的变更检测这里有必要举个例子解释一下。

假设有三个组件,分别是

  1. signal-parent.component.ts
  2. signal.component.ts
  3. signal-child.component.ts

1. 正常 Case

我们在 app 的模板里面使用 signal-parent,然后在 signal-parent 中使用 signal,依此类推。

signal-parentsignal的代码是一致的,在 ngOnInit 中添加一个定时器,到时间后更新 name 属性为 xxx Name Changed,然后在模板中绑定 name 属性。 他们唯一的区别是 signal-childsignal-parent 定时器为 1s , signal 组件为 2s。

  ngOnInit(): void {
    setTimeout(() => {
      this.name = 'Parent Name Changed';
    }, 1000);
  }

在正常情况下,2s后,三个组件的显示的 name 都会变成 name changed。

2. 把 Signal 组件设置为 OnPush策略

现在我们在 Signal 中做一些修改,在 signal 组件中设置

changeDetection: ChangeDetectionStrategy.OnPush,

这就意味现在的 signal 组件只有如下几种情况才会触发变更检测:

  1. 组件的输入属性(@Input)发生变化。
  2. 组件内部触发事件(如按钮点击)。
  3. 使用 ChangeDetectorRef 手动触发变更检测。

因为我们只是手动在 ngOnInit 中修改的 name 属性值,无法触发变更检测,所以此时再查看页面渲染,你会发现只有 signal-parentname 属性被重新渲染了。

至于signal-child,由于 signal组件没有触发变更检测,那么作为子组件,它肯定也不会触发变更检测。Angular的变更检测是从组件树的根节点自上而下递归遍历的。

App=>Signal Parent=>Signal=>Signal Child

image.png

3. 在 signal 组件中使用 signal 操作 name 属性

我们在 Signal 组件中添加使用 signal 定义的属性 signalName,然后在模板中绑定它。同时在定时器中修改 signalName 属性。

  // signal.template.html
  {{ signalName() }}
  {{ name }}

  // signal.component.ts
  signalName = signal('Signal Name');
  
  ngOnInit(): void {
    setTimeout(() => {
      this.signalName.set('Signal Name Changed');
      this.name = 'Name Changed';
    }, 2000);
  }

这时候你会发现现在 signalsignal-child组件的 name 都被重新渲染了。这是因为 signal 的变化会触发变更检测,从而导致signal-child也重新渲染了。

image.png

4. 把 signal-parent 组件也设置为 OnPush策略

此时的结果为

image.png

signal-parent 的 name 属性并没有重新渲染,因为设置了 OnPush。

signal 触发了变更检测,但是并没有从根节点开始,而是从 signal 属性所属组件开始往下进行遍历。

此时变更检测的顺序为 Signal=>Signal Child。

5. 使用 dom 事件触发变更检测

为了验证这个猜测,我们在 signal 组件中添加一个 button, 通过click button来触发变更检测。

// signal.component.html
<button type="button" (click)="onClick()">Button</button>

// signal.component.ts
onClick(): void {}

现在 signal-parentsignal组件设置了 OnPushsignal-child 变更检测策略为默认值。

此时的渲染结果跟上一个case一样。

但当你点击 button 按钮,由于这次是组件内部触发 Dom Click事件,即使组件被设置为 OnPush策略,也会触发变更检测,所以这次从根节点开始,遍历组件树触发渲染更新,所有的 name 都被重新渲染了。

image.png

6. 添加 ngDoCheck 钩子,检查一下变更检测是不是按我们预期的触发

我们为所有组件添加 ngDoCheck 钩子,使用 console.log 来标记当前组件触发了变更检测,然后在定时器里面也添加 log。

  ngDoCheck(): void {
    console.log('Signal-xxx: DoCheck', this.name);
  }
  
  setTimeout(() => {
      this.name = 'xxx Name Changed';
      console.warn('signal-xxx: Timer called after 1s ----------');
  }, 1000);

此时 signal-parent

  • timer 1s
  • OnPush 策略

signal

  • timer 2s
  • OnPush 策略

signal-child

  • timer 1s
  • Default 策略

我们来看 Log 的结果:

image.png

你会发现有点反直觉:

为什么 SignalParent 设置了 OnPush,还是会频繁触发 DoCheck 呢?

为什么 Signal 组件的 DoCheck 没有触发,但是视图更新了呢?

我们先理清 log 触发的原因:

  • 第一组 DoCheck 被触发是因为 angular 初始化。

  • 第二组是因为根组件(AppComponent)渲染时,触发组件树遍历进行变更检测,但由于 SignalParent 设置了 OnPush,所以只有 AppSignaParent 触发了,而SignalSignalChild 跳过本次检查。

  • 第三组和第四组是因为 Timer 回调导致的,但由于 SignaParent 设置了 OnPush,所以同上。

  • 最后一组 Signal 2s 触发 Timer 回调,所以 AppSignaParent 触发了 DoCheck,而 Signal 组件由于 signalName 属性被修改,导致触发了变更检测,SignalChild 是 default 策略,所以触发 DoCheck

那现在的情况是什么原因呢? 跟朋友讨论了一波,又查了一堆网上能找到的资料之后,来尝试理解一下。

为什么 SignalParent 设置了 OnPush,还是会频繁触发 DoCheck 呢?

目前有两种说法

  1. 第一种异步事件导致组件树触发了DoCheck,从 AppComponent 遍历到 SignalParent,即使它设置了 OnPush 策略, DoCheck 照样触发,但是不会标记当前组件为该视图需要更新。所以上面第四种case SignalParent 模板的 name 属性并没有被重新渲染。

  2. 第二种说法是 DoCheck 除了在变更检测之前运行,还会在angular 组件无法捕获自身变化时运行。由于 SignalParent 设置了 OnPush 策略,导致从根组件开始依次进行的变更检测不能捕获自身的变化,所以触发了 DoCheck勾子。

设置了 OnPush 策略的子孙组件是直接跳过异步事件导致的变更检测的,这点在两种说法下都被认可了。

目前第二种说法我没在官方文档上找到对应的介绍,持保守意见。

为什么 Signal 组件的 DoCheck 没有触发,但是视图更新了呢?

这是因为 signal 属性值更新会导致 angular 把当前组件标记为该视图需要更新

由于 SignalParent 组件设置了 OnPush,所以 Signal 作为子组件会跳过 DoCheck

但同样因为 signal 属性值更新,导致Angular会把当前组件当作组件树根节点,往下递归遍历进行变更检测,从而触发 SignalChildDoCheck

总结

signal 的特殊之处,就在于它会把当前组件标记为需要更新,并且会导致 angular 从当前组件层级开始往下进行检测和渲染更新,不会触发整个组件树。这也就是官方文档中提到的,细粒度地跟踪你的状态,使框架能够优化渲染更新。

如果当前组件还设置了其它的变更检测策略如 OnPush,也会按照该策略的默认行为去执行。

如果有大佬发现不对,请指正!上述 Case 在线代码地址 stackblitz

tips: Angular 的变更检测页面重新渲染是两个不同的过程。

下期预告

1. 在模板中定义变量

@let user = user$ | async;
@if (user) {
  <h1>Hello, {{user.name}}</h1>
  <user-avatar [photo]="user.photo"/>
  <ul>
    @for (snack of user.favoriteSnacks; track snack.id) {
      <li>{{snack.name}}</li>
    }
  </ul>
  <button (click)="update(user)">Update profile</button>
}
  1. Auto CSP - 根据 index.html 中的脚本自动生成基于哈希的严格 CSP 。

  2. 新的生命周期函数 afterRender and afterNextRender