Angular Signals 全面指南:细粒度状态追踪、响应式上下文与源码级原理剖析
Angular Signals(信号)是 Angular 内置的一套细粒度响应式状态系统:它以「读取即追踪」的方式记录状态在何处、以何种方式被使用,从而让框架能够精确地只更新真正依赖该状态的部分渲染。本文以官方信号概览文档(overview.md)为核心骨架,深入源码(packages/core/src/render3/reactivity 与底层原语 packages/core/primitives/signals/src),从可写信号、计算信号、响应式上下文到相等性函数与类型检查,完整讲清 Angular Signals 的 API 用法与底层实现原理。读完你可以直接在自己的组件与服务中正确使用 signal、computed、effect,并理解它们何时、为何会触发更新。
什么是 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] 三元组,再把 set、update、asReadonly 挂到 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 用三个私有符号 UNSET、COMPUTING、ERRORED 分别表示「尚未计算」「正在计算」「计算抛错」三种内部状态,并通过 dirty 标志决定读取时是否需要调用 producerRecomputeValue 重算。求值期间的异常会被捕获并缓存在 node.error 中,在后续读取时重新抛出。若派生过程中递归读取自身,则会抛出编译期可感知的循环检测错误(Detected cycle in computations.)。
值得一提的优化细节:重算完成后若新旧值经相等性函数判定为「语义相等」,节点会保留旧值且不递增版本号,因此下游依赖不会被无效通知——计算信号自身也默认带相等性保护。
计算信号不是可写信号
你不能直接给计算信号赋值,也就是说:
doubleCount.set(3);
会直接产生编译错误,因为 doubleCount 并非 WritableSignal。类型层面,computed() 的返回类型是只读的 Signal<T>(见 api.ts 的 isWritableSignal 判断逻辑),这正是 set 调用无法通过类型检查的原因。
计算信号的依赖是动态的
只有派生函数执行期间真正被读取到的信号才会被纳入依赖追踪。例如下面这个 computed,只有当 showCount 为 true 时才会读取 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!';
}
});
当你读取 conditionalCount 而 showCount 为 false 时,会返回 "Nothing to see here!",根本不会读取 count。因此之后即使更新 count,conditionalCount 也不会被重新计算。
如果你把 showCount 设为 true 再读取 conditionalCount,派生函数会重新执行并走进读取 count 的分支,返回包含 count 值的消息;此后修改 count 就会使 conditionalCount 的缓存失效。
注意,依赖既可以被新增,也可以被移除。若之后又把 showCount 设回 false,count 将不再被视为 conditionalCount 的依赖。
响应式上下文 Reactive contexts
响应式上下文(reactive context)是一种运行时状态:Angular 在上下文中监控每一次信号读取,从而建立依赖关系。 其中读取信号的代码是消费者(consumer),被读取的信号是生产者(producer)。
Angular 会在以下场景自动进入响应式上下文:
- 执行
effect、afterRenderEffect的回调时; - 求值
computed信号时; - 求值
linkedSignal时; - 求值
resource的 params 或 loader 函数时; - 渲染组件模板时(包括 host 属性 上的绑定)。
在这些操作期间,Angular 会建立一条“活的”连接:只要被追踪的信号发生变化,Angular 最终会重新运行对应的消费者。
从源码角度理解这一机制,核心在 packages/core/primitives/signals/src/graph.ts:模块级维护了一个 activeConsumer 全局变量;信号 getter 被调用时通过 producerAccessed(node) 把当前活跃消费者与信号节点连接起来,形成双向链表(producers/consumers)。ReactiveNode 的 kind 字段显式区分了 '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.name 的 RuntimeError。这种守卫常用于「禁止在响应式上下文里执行订阅」之类的场景(例如 RxJS 互操作的 toSignal)。
不追踪依赖地读取信号(untracked)
极少数情况下,你可能希望在 computed 或 effect 这类响应式函数中执行一段「可能会读取信号」的代码,却不建立依赖。
例如:当 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(signal、computed、input 等)都是同步的,而真实应用往往要面对异步数据。Resource 提供了一种把异步数据接进信号体系、同时仍能同步读取其结果的方式,官方教程见 Async reactivity with resources,仓库对应文档为 resource.md。
在非响应式 API 上执行副作用
当需要「对状态变化做出反应」时,同步或异步的派生通常是首选;但它无法覆盖全部场景——例如你需要把信号变化桥接到 setTimeout、DOM 事件、日志或命令式图表库等非响应式 API。这类场景应使用 effect 或 afterRenderEffect,详见仓库中的 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),因此「模板读取」天然复用同一套依赖图,与 computed、effect 的追踪机制完全一致。
高级主题
信号相等性函数 Signal equality functions
创建信号时,你可以可选地传入一个相等性函数,用来判断新值与旧值是否真的不同:
import isEqual from 'lodash/isEqual';
const data = signal(['test'], {equal: isEqual});
// 尽管这是另一个不同的数组实例,深比较函数会认为两者相等,
// 因此信号不会触发任何更新。
data.set(['test']);
相等性函数既可以提供给可写信号,也可以提供给 computed 信号(两者的创建选项分别定义在 signal.ts 与 computed.ts 的 equal?: 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.ts:isSignal 检查「值是个函数且带有 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 文档。
继续深入
要亲手验证以上机制,建议直接阅读仓库中的底层实现与配套测试:
- 可写信号公开 API:packages/core/src/render3/reactivity/signal.ts;
- 计算信号公开 API 与类型:packages/core/src/render3/reactivity/computed.ts、api.ts;
- 响应式依赖图核心:packages/core/primitives/signals/src/graph.ts;
- 底层信号与计算节点:packages/core/primitives/signals/src/signal.ts、computed.ts;
untracked与断言工具:packages/core/primitives/signals/src/untracked.ts、packages/core/src/render3/reactivity/asserts.ts;- 配套指南:effect、linked-signal、resource、debounced。
总体上,Angular Signals 的设计把「状态」与「读取位置」显式建模为一张运行时依赖图:读取建立依赖、写入通过相等性判断后逐级通知、computed 惰性记忆化、untracked 提供逃生舱、异步边界之外不追踪。掌握这套心智模型之后,无论是编写高性能的 OnPush 组件,还是用 effect/resource 桥接异步世界,你都能准确预判信号究竟会不会(以及何时会)触发更新。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00