首页
/ Angular Signals 全面指南:细粒度状态追踪、响应式上下文与源码级原理剖析

Angular Signals 全面指南:细粒度状态追踪、响应式上下文与源码级原理剖析

2026-09-06 19:01:16作者:裘晴惠Vivianne

Angular Signals(信号)是 Angular 内置的一套细粒度响应式状态系统:它以「读取即追踪」的方式记录状态在何处、以何种方式被使用,从而让框架能够精确地只更新真正依赖该状态的部分渲染。本文以官方信号概览文档(overview.md)为核心骨架,深入源码(packages/core/src/render3/reactivity 与底层原语 packages/core/primitives/signals/src),从可写信号、计算信号、响应式上下文到相等性函数与类型检查,完整讲清 Angular Signals 的 API 用法与底层实现原理。读完你可以直接在自己的组件与服务中正确使用 signalcomputedeffect,并理解它们何时、为何会触发更新。

什么是 Signal

Signal(信号)是一个「值的包装器」,当值发生变化时会通知所有感兴趣的消费者。 它几乎可以包裹任何内容,从原始值(primitive)到复杂的数据结构都可以。

读取信号值的方式是调用它的 getter 函数,正是「通过函数调用取值」这一设计让 Angular 得以追踪该信号被使用的位置——这是整个响应式系统的基石。从源码看,一个 Signal 在类型上被定义为「一个带有私有标记的函数」:

// packages/core/src/render3/reactivity/api.ts
export type Signal<T> = (() => T) & { [SIGNAL]: unknown };

其中 SIGNAL 是一个 unique symbol(定义于 packages/core/primitives/signals/src/graph.ts),用于把「普通函数」与「信号」区分开。对应地,底层 createSignal 创建的 getter 会先调用 signalGetFn,其中第一步就是 producerAccessed(node) 记录本次读取。

信号可以分成两类:可写信号(writable)只读信号(read-only)

可写信号 Writable signals

可写信号提供了直接更新其值的 API。用 signal 函数传入初始值即可创建:

const count = signal(0);

// 信号是 getter 函数——调用它即可读取当前值。
console.log('The count is: ' + count());

signal() 的公开 API 签名定义在 packages/core/src/render3/reactivity/signal.ts 中,返回类型为 WritableSignal<T>。该接口在 Signal<T> 基础上追加了三个成员:

  • set(value: T):直接把信号设置为新值;
  • update(updateFn: (value: T) => T):基于当前值计算新值;
  • asReadonly(): Signal<T>:返回只读视图。

实际实现是:signal() 调用底层原语的 createSignal(initialValue, options?.equal) 得到一个 [getter, set, update] 三元组,再把 setupdateasReadonly 挂到 getter 函数上返回。

修改可写信号的值有两种方式,直接 .set()

count.set(3);

或者用 .update() 基于旧值计算新值:

// 把 count 加 1。
count.update((value) => value + 1);

从底层实现(signal.ts)看,set/update 并不是无脑赋值:signalSetFn 会先调用 producerUpdatesAllowed() 校验当前是否允许写入,再通过相等性函数 node.equal(node.value, newValue) 判断新旧值是否「真正不同」,只有不同才更新 node.value 并触发 signalValueChanged——后者依次完成「版本号自增、全局 epoch 递增、通知所有下游消费者」三步。这意味着给信号设置一个与当前值相等的值不会产生任何通知(详见下文「Signal equality functions」)。

把可写信号转换成只读

WritableSignal 上的 asReadonly() 会返回该信号的只读版本,适用于「想对外暴露一个信号的值,却不希望调用方直接改它」的封装场景:

@Injectable({providedIn: 'root'})
export class CounterState {
  // 私有的可写状态
  private readonly _count = signal(0);

  readonly count = this._count.asReadonly(); // 公开的只读信号

  increment() {
    this._count.update((v) => v + 1);
  }
}

@Component({
  /* ... */
})
export class AwesomeCounter {
  state = inject(CounterState);

  count = this.state.count; // 可读,但不可写

  increment() {
    this.state.increment();
  }
}

只读信号会如实反映原可写信号的每一次变化,但它本身不能通过 set()update() 修改。从实现看,asReadonly 会在内部缓存一个共享的只读 getter(signalAsReadonlyFn),避免重复创建;它读取的仍然是同一个底层 SignalNode

需要特别强调:只读信号没有任何内置机制来阻止对值的“深修改”(deep-mutation)。如果信号的值是对象或数组,调用方拿到只读信号后仍然可以修改这个对象的内部属性——asReadonly 只封锁了 set/update 这条替换入口,并不提供不可变数据保护。

计算信号 Computed signals

计算信号是只读信号,它的值由其他信号派生而来,通过 computed 函数配合一个派生函数定义:

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

doubleCount 依赖 count:只要 count 更新,Angular 就知道 doubleCount 也“需要”更新。它对应公开实现 packages/core/src/render3/reactivity/computed.ts 与底层 packages/core/primitives/signals/src/computed.ts

计算信号是惰性求值 + 记忆化的

doubleCount 的派生函数直到第一次读取 doubleCount 时才会真正执行。计算结果会被缓存,之后再次读取 doubleCount 直接返回缓存值而不重新计算。当你修改了 count,Angular 会把 doubleCount 的缓存标记为失效,下一次读取它时才按需重新计算。

正是这种机制,让你可以在计算信号里放心地做昂贵的派生计算(例如数组过滤)。底层 computed.ts 用三个私有符号 UNSETCOMPUTINGERRORED 分别表示「尚未计算」「正在计算」「计算抛错」三种内部状态,并通过 dirty 标志决定读取时是否需要调用 producerRecomputeValue 重算。求值期间的异常会被捕获并缓存在 node.error 中,在后续读取时重新抛出。若派生过程中递归读取自身,则会抛出编译期可感知的循环检测错误(Detected cycle in computations.)。

值得一提的优化细节:重算完成后若新旧值经相等性函数判定为「语义相等」,节点会保留旧值且不递增版本号,因此下游依赖不会被无效通知——计算信号自身也默认带相等性保护。

计算信号不是可写信号

你不能直接给计算信号赋值,也就是说:

doubleCount.set(3);

会直接产生编译错误,因为 doubleCount 并非 WritableSignal。类型层面,computed() 的返回类型是只读的 Signal<T>(见 api.tsisWritableSignal 判断逻辑),这正是 set 调用无法通过类型检查的原因。

计算信号的依赖是动态的

只有派生函数执行期间真正被读取到的信号才会被纳入依赖追踪。例如下面这个 computed,只有当 showCounttrue 时才会读取 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!';
  }
});

当你读取 conditionalCountshowCountfalse 时,会返回 "Nothing to see here!",根本不会读取 count。因此之后即使更新 countconditionalCount 也不会被重新计算。

如果你把 showCount 设为 true 再读取 conditionalCount,派生函数会重新执行并走进读取 count 的分支,返回包含 count 值的消息;此后修改 count 就会使 conditionalCount 的缓存失效。

注意,依赖既可以被新增,也可以被移除。若之后又把 showCount 设回 falsecount 将不再被视为 conditionalCount 的依赖。

响应式上下文 Reactive contexts

响应式上下文(reactive context)是一种运行时状态:Angular 在上下文中监控每一次信号读取,从而建立依赖关系。 其中读取信号的代码是消费者(consumer),被读取的信号是生产者(producer)

Angular 会在以下场景自动进入响应式上下文:

  • 执行 effectafterRenderEffect 的回调时;
  • 求值 computed 信号时;
  • 求值 linkedSignal 时;
  • 求值 resource 的 params 或 loader 函数时;
  • 渲染组件模板时(包括 host 属性 上的绑定)。

在这些操作期间,Angular 会建立一条“活的”连接:只要被追踪的信号发生变化,Angular 最终会重新运行对应的消费者。

从源码角度理解这一机制,核心在 packages/core/primitives/signals/src/graph.ts:模块级维护了一个 activeConsumer 全局变量;信号 getter 被调用时通过 producerAccessed(node) 把当前活跃消费者与信号节点连接起来,形成双向链表(producers/consumers)。ReactiveNodekind 字段显式区分了 'signal' | 'computed' | 'effect' | 'template' | 'linkedSignal' | 'afterRenderEffectPhase' 等节点类型——组件模板在底层就是一个消费者节点,这也解释了为何模板里读取的信号变化会触达对应的视图更新。此外还有一个全局 epoch 计数器:每次源信号被 set 都会递增,帮助系统判断缓存依赖是否仍然有效。

断言不在响应式上下文中

Angular 提供 assertNotInReactiveContext 辅助函数,用于断言某段代码不在响应式上下文中执行。调用时传入当前函数自身的引用,这样断言失败时错误消息能指向正确的 API 入口,比一段笼统的响应式上下文报错更清晰、更可操作:

import {assertNotInReactiveContext} from '@angular/core';

function subscribeToEvents() {
  assertNotInReactiveContext(subscribeToEvents);
  // 到这里可以安全继续——订阅逻辑
}

其实现位于 packages/core/src/render3/reactivity/asserts.ts:它检查 getActiveConsumer() !== null,若当前确实处于响应式上下文,就抛出携带 debugFn.nameRuntimeError。这种守卫常用于「禁止在响应式上下文里执行订阅」之类的场景(例如 RxJS 互操作的 toSignal)。

不追踪依赖地读取信号(untracked)

极少数情况下,你可能希望在 computedeffect 这类响应式函数中执行一段「可能会读取信号」的代码,却不建立依赖

例如:当 currentUser 变化时记录 counter 的值。你可以写出同时读取两个信号的 effect

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

但这样只要 currentUser counter 任意一个变化都会打印日志。如果这个 effect 只应在 currentUser 变化时触发,那么对 counter 的读取只是“顺带的”,counter 的变动不应触发新的日志。

untracked 包裹该读取即可阻止它被追踪:

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

untracked 在 effect 需要调用某些“外部代码”,而这些代码中读取的信号不应成为 effect 依赖时同样很有用:

effect(() => {
  const user = currentUser();
  untracked(() => {
    // 如果 `loggingService` 内部读取了信号,它们不会被计为
    // 本 effect 的依赖。
    this.loggingService.log(`User set to ${user}`);
  });
});

untracked 的底层实现非常直白(packages/core/primitives/signals/src/untracked.ts):把当前 activeConsumer 暂时置为 null 再执行回调,无论回调成功还是抛错,finally 都会恢复之前的消费者——期间发生的所有信号读取因此都不会被记录为依赖。

响应式上下文与异步操作

响应式上下文只对同步代码有效。任何跨越异步边界之后发生的信号读取都不会再被追踪为依赖:

effect(async () => {
  const data = await fetchUserData();
  // 响应式上下文到这里已丢失——theme() 不会被追踪
  console.log(`User: ${data.name}, Theme: ${theme()}`);
});

为了让所有信号读取都能被追踪,应在 await 之前完成读取——包括把信号作为参数传给被 await 的函数(因为参数是在同步阶段求值的):

effect(async () => {
  const currentTheme = theme(); // 在 await 之前读取
  const data = await fetchUserData();
  console.log(`User: ${data.name}, Theme: ${currentTheme}`);
});
effect(async () => {
  // 同样有效:信号作为函数参数在 await 之前完成读取
  await renderContent(docContent());
});

进阶派生:linkedSignal 与 Resource

computed 只覆盖「只读派生」这一场景。如果你需要的是依赖于其他信号的可写状态,应该使用 linkedSignal——它把上游信号与本地可写状态“链接”起来,上游变化时同步重置本地值。相关官方教程见仓库中的 linked-signal 指南

另外,所有信号 API(signalcomputedinput 等)都是同步的,而真实应用往往要面对异步数据。Resource 提供了一种把异步数据接进信号体系、同时仍能同步读取其结果的方式,官方教程见 Async reactivity with resources,仓库对应文档为 resource.md

在非响应式 API 上执行副作用

当需要「对状态变化做出反应」时,同步或异步的派生通常是首选;但它无法覆盖全部场景——例如你需要把信号变化桥接到 setTimeout、DOM 事件、日志或命令式图表库等非响应式 API。这类场景应使用 effectafterRenderEffect,详见仓库中的 effect 官方指南。两者的差别在于:effect 在依赖变化后调度执行,适合一般副作用;afterRenderEffect 则绑定渲染时机,适合读取并修改 DOM 等需要精确控制执行点的场景(对应实现见 packages/core/src/render3/reactivity/after_render_effect.ts)。

OnPush 组件中读取信号

当你在一个 OnPush 组件的模板中读取信号时,Angular 会把该信号记录为这个组件的依赖。之后只要信号值变化,Angular 就会自动 标记 该组件,确保它在下一次变更检测运行时得到更新——这正是 Signal 让 OnPush 变得「不用再手动操心」的关键:模板里读过的信号一变,组件自动被标记为脏。关于 OnPush 组件与跳过子树优化的更多内容,可参考官方「Skipping component subtrees」最佳实践指南。

从渲染管线看,模板消费信号依赖在底层以 'template' 类型的响应式节点存在(见 graph.ts 中的 ReactiveNodeKind),因此「模板读取」天然复用同一套依赖图,与 computedeffect 的追踪机制完全一致。

高级主题

信号相等性函数 Signal equality functions

创建信号时,你可以可选地传入一个相等性函数,用来判断新值与旧值是否真的不同:

import isEqual from 'lodash/isEqual';

const data = signal(['test'], {equal: isEqual});

// 尽管这是另一个不同的数组实例,深比较函数会认为两者相等,
// 因此信号不会触发任何更新。
data.set(['test']);

相等性函数既可以提供给可写信号,也可以提供给 computed 信号(两者的创建选项分别定义在 signal.tscomputed.tsequal?: ValueEqualityFn<T>)。

默认情况下,信号使用引用相等性,即 Object.is() 比较。底层默认实现 equality.ts 正是:

export function defaultEquals<T>(a: T, b: T) {
  return Object.is(a, b);
}

值得串联理解前面两处行为:signalSetFn 用相等性函数做“先判断再通知”的闸门;computed 重算后也用相等性函数决定是保留旧值(不 bump 版本)还是发布新值。这意味着自定义深比较的相等性函数能有效减少无效的依赖通知。另外,创建选项里还支持 debugName?: string:在 ngDevMode(开发模式)下会用于信号与计算信号的 toString() 输出,并显示在 Angular DevTools 中以辅助定位。

信号类型检查 Type checking signals

isSignal 可以判断一个值是不是 Signal

const count = signal(0);
const doubled = computed(() => count() * 2);

isSignal(count); // true
isSignal(doubled); // true
isSignal(42); // false

想进一步判断它是否可写,用 isWritableSignal

const count = signal(0);
const doubled = computed(() => count() * 2);

isWritableSignal(count); // true
isWritableSignal(doubled); // false

这两个判断函数都定义在 packages/core/src/render3/reactivity/api.tsisSignal 检查「值是个函数且带有 SIGNAL 标记」;isWritableSignal 则在此之上再检查该函数是否具备 set 方法。由于可写信号在创建时会把 set 挂载到 getter 函数上(见 signal.ts),这个运行时判断才能成立——注意它判断的是「有没有 set 方法」而非类型声明,因此在处理动态数据(如外部传入的未知值)时非常实用。

与 RxJS 互操作

Angular Signals 与 RxJS 之间提供了官方互操作能力:既可以把 Observable 转成信号(toSignal),也可以把信号转成 Observable(toObservable)。这套能力由 @angular/core/rxjs-interop 提供,仓库中的实现位于 packages/core/rxjs-interop。其中 toSignal 的实现刻意调用了 assertNotInReactiveContext,以保证不会在响应式上下文中意外创建订阅。详细互操作说明可参阅仓库内的 RxJS interop 文档。

继续深入

要亲手验证以上机制,建议直接阅读仓库中的底层实现与配套测试:

总体上,Angular Signals 的设计把「状态」与「读取位置」显式建模为一张运行时依赖图:读取建立依赖、写入通过相等性判断后逐级通知、computed 惰性记忆化、untracked 提供逃生舱、异步边界之外不追踪。掌握这套心智模型之后,无论是编写高性能的 OnPush 组件,还是用 effect/resource 桥接异步世界,你都能准确预判信号究竟会不会(以及何时会)触发更新。

登录后查看全文
热门项目推荐
相关项目推荐