Angular 响应式内核全解析:@angular/core_primitives_signals公共 API 参考指南
【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular
导读
本指南以 Angular 仓库中的 API 基线报告 goldens/public-api/core/primitives/signals/index.api.md 为核心骨架,结合实现目录 packages/core/primitives/signals 下的 README 与源码,逐条拆解 Angular 响应式信号(Signal)内核所导出的全部公共符号——从ReactiveNode响应图抽象、createSignal/createComputed/createLinkedSignal三大创建原语,到Watch/Effect副作用调度机制。读完你将能对照 API 名称快速定位其实现文件、理解每一个导出符号在推拉(push/pull)算法与动态依赖追踪中所扮演的角色,为阅读或扩展 Angular 响应式运行时打下基础。
一、这份文档是什么:API 基线与包入口
index.api.md是由微软 API Extractor 目录中,作用是锁定每个 npm 包对外暴露的公共类型签名——任何破坏性的公共 API 变更都会在与基线比对时暴露出来,是 Angular 公共 API 面(public API surface)的质量门禁。
报告所描述的包名为@angular/core_primitives_signals,其入口文件位于 packages/core/primitives/signals/index.ts。从入口代码可以看出,它是一个纯 re-export 聚合层:
export {ComputedNode, createComputed, ERRORED} from './src/computed'; export {ValueEqualityFn, defaultEquals} from './src/equality'; export { REACTIVE_NODE, Reactive, ReactiveHookFn, ReactiveNode, ReactiveNodeKind, SIGNAL, ... } from './src/graph'; export {SIGNAL_NODE, SignalGetter, SignalNode, createSignal, signalGetFn, ...} from './src/signal'; export {Watch, WatchCleanupFn, WatchCleanupRegisterFn, createWatch} from './src/watch'; export {setAlternateWeakRefImpl} from './src/weak_ref'; export {untracked} from './src/untracked'; export {runEffect, BASE_EFFECT_NODE, BaseEffectNode} from './src/effect'; export {installDevToolsSignalFormatter} from './src/formatter';对应源码文件均位于 packages/core/primitives/signals/src 目录:graph.ts、signal.ts、computed.ts、linked_signal.ts、watch.ts、effect.ts、equality.ts、untracked.ts、weak_ref.ts、errors.ts、formatter.ts。需要说明的是,这个包属于 Angular 内部的基础原语层,仓库里面向用户的高层 API(signal()、computed()、effect()等)正是建立在它之上,由@angular/core的其它模块完成封装与暴露。
提示:报告中
// @public标记的条目属于正式公共导出;带(undocumented)的表示该条目在声明中没有 docblock 注释,本文的说明取自对应源码中的注释与实现,仅在本仓库范围内可验证。
二、设计总览:生产者 / 消费者与响应图
阅读本 API 之前,需要先理解它的核心抽象。根据 packages/core/primitives/signals/README.md,Angular 的信号实现建立在两个抽象之上:
- 生产者(Producer):能够派发变更通知的值源,例如各种信号;
- 消费者(Consumer):依赖若干生产者的响应式上下文。
两者都通过实现ReactiveNode接口参与"响应图(reactive graph)",一个ReactiveNode可以同时以两种身份出现——例如computed既是消费者(读取其它信号)又是生产者(向依赖者提供新值)。普通WritableSignal只使用生产者侧的 API,因为它不消费其它信号。
响应图是双向的,但两个方向记录的信息不同:
- 消费者总是记录它依赖的生产者(
producers链表); - 生产者只为"活跃(live)"的消费者反向记录边(
consumers链表)。
在 Angular 中,effect(以及模板对应的伪 effect)是活跃消费者;computed本身并不天然活跃,只有当它被某个活跃消费者依赖时才"升级"为活跃节点。
三、节点模型与图基础设施
3.1ReactiveNode:参与响应图的最小单位
ReactiveNode是贯穿整个 API 的核心接口,每个响应式单元(信号、computed、effect、watch、模板)都以它为基础,graph.ts 中的字段注释给出了完整的语义:
| 字段 | 类型 | 含义(据源码注释) |
|---|---|---|
version | Version | 节点产出值的版本;每当产出与上一次语义不等的新值时自增 |
lastCleanEpoch | Version | 该节点被验证为干净的 epoch;用于在"没有任何信号被写入"时跳过轮询 |
dirty | boolean | 节点作为消费者时是否脏;只有活跃消费者在收到依赖通知后才会置脏 |
recomputing | boolean | 是否正在重建生产者链表 |
producers/producersTail | ReactiveLink \| undefined | 依赖的生产者双向链表及其尾部游标;重算期间producersTail用于标记已访问到的位置 |
consumers/consumersTail | ReactiveLink \| undefined | "活跃"消费者的链表,其长度相当于节点的引用计数 |
consumerAllowSignalWrites | boolean | 以该消费者为 active consumer 时是否允许写入信号(用于禁止在 computed 计算函数内写信号) |
consumerIsAlwaysLive | boolean(只读) | 是否为常驻活跃消费者(effect/watch 为true) |
consumerMarkedDirty/consumerOnSignalRead | 方法 | 消费者被置脏、或读到一个信号时被回调的钩子 |
producerMustRecompute/producerRecomputeValue | 方法 | 判断是否需要重算,以及实际执行重算 |
kind | ReactiveNodeKind | 节点类别(DevTools 识别用) |
debugName? | string | 调试名,DevTools 中用于标识节点 |
其中ReactiveNodeKind的合法取值在源码中定义为'signal' | 'computed' | 'effect' | 'template' | 'linkedSignal' | 'afterRenderEffectPhase' | 'childSignalProp' | 'unknown'。Version是一个品牌化数字类型number & {__brand: 'Version'},用于在类型层面防止版本号与普通数字混用。
3.2 原型节点常量与信号判别
graph.ts 提供了三个重要的"原型/哨兵"导出:
SIGNAL:unique symbol,即Symbol('SIGNAL')。源码注释指出它用于"把信号与普通函数区分开",以支持自动解包(auto-unwrap)等场景。REACTIVE_NODE:一个全字段带默认值的ReactiveNode原型(version: 0、dirty: false、kind: 'unknown'、各方法为空实现)。其余节点类型通过Object.create(SIGNAL_NODE)/...REACTIVE_NODE的展开方式继承默认行为。Reactive与isReactive(value):Reactive是{ [SIGNAL]: ReactiveNode }的结构化契约;isReactive通过判断value[SIGNAL] !== undefined来识别一个值是否为响应式对象。
signal.ts还导出了写信号(writable signal)专用的SIGNAL_NODE,它展开REACTIVE_NODE,追加equal: defaultEquals、value: undefined并设置kind: 'signal'。
3.3 active consumer 与通知阶段守卫
响应式依赖追踪依赖一个全局概念——"当前活跃消费者":
setActiveConsumer(consumer)/getActiveConsumer():设置/读取当前活跃消费者,返回切换前的旧值;graph.ts 用一个模块级变量activeConsumer承载。isInNotificationPhase():当前是否处于"通知阶段"。graph.ts 在推送变更时会临时置位inNotificationPhase,期间读取信号会抛出断言错误(producerAccessed中显式检查),避免在更新响应图的过程中读到中间状态。producerUpdatesAllowed():依据活跃消费者的consumerAllowSignalWrites判断当前是否允许写信号;当 active consumer 的该字段为false时(例如 computed 计算过程中)写入会被拒绝。这正是错误报告 APIsetThrowInvalidWriteToSignalError(fn)的用武之地——由高层框架注入真正的报错逻辑(errors.ts)。
3.4 依赖追踪与生命周期管理 API
graph.ts 导出的函数按职责可以分为几组,它们共同实现了"增量重建生产者链表"与"liveness"管理:
| 函数 | 角色 | 行为要点 |
|---|---|---|
producerAccessed(node) | 生产者侧 | 信号被读取时调用:把读取节点记录为 active consumer 的依赖(复用ReactiveLink,避免重复建边);通知阶段内调用会抛错 |
producerIncrementEpoch() | 全局 | 源生产者(非 computed)值变化时递增全局 epoch 计数器 |
producerUpdateValueVersion(node) | 生产者侧 | 拉取(pull)阶段确保 version 最新:必要时触发重算并标记干净 |
producerNotifyConsumers(node) | 生产者侧 | 向所有活跃消费者递归传播"脏"通知 |
producerMarkClean(node) | 生产者侧 | 清除 dirty 并记录lastCleanEpoch = epoch |
producerUpdatesAllowed() | 守卫 | 写信号合法性检查(见 3.3) |
consumerBeforeComputation(node)/resetConsumerBeforeComputation(node) | 消费者侧 | 计算前把节点设为 active consumer 并清空/重置依赖链表游标 |
consumerAfterComputation(node, prev)/finalizeConsumerAfterComputation(node) | 消费者侧 | 计算后恢复上一个 active consumer,并清除本次运行中不再被引用的陈旧依赖边 |
consumerDestroy(node) | 消费者侧 | 断开与响应图的全部连接(释放引用、截断链表) |
consumerMarkDirty(node) | 消费者侧 | 置脏并继续向下游传播 |
consumerPollProducersForChange(node) | 消费者侧 | 轮询所有依赖,比对lastReadVersion与producer.version,判断是否真的发生了变化 |
关键实现点(依据 graph.ts):
- 增量依赖重建:节点重算期间维护一个链表游标
producersTail。每读一个新依赖,就与上一次运行在该位置记录的依赖比对——相同则直接更新lastReadVersion与knownValidAtEpoch并复用旧边;不同则替换。一次运行结束后,finalizeConsumerAfterComputation把producersTail之后仍然残留的旧边一次性清除。这使得dynamiccomputed 这类"依赖集随运行而变"的节点也能正确处理依赖增删。 - Liveness 传递:内部函数
consumerIsLive(node)定义为node.consumerIsAlwaysLive || node.consumers !== undefined。当一个节点因被活跃消费者依赖而"变活"时,producerAddLiveConsumer会递归地把它自己从属的边也注册到上游,从而把 liveness 沿依赖链向上传递。 - 内存安全:由于非活跃节点不会在生产者侧建立反向边,
signal -> computed之间不存在硬引用,用户丢弃 computed 后它可被 GC 回收;这正是 README 中"非活跃 computed 通过轮询上游而非被动通知"来感知变化的根本原因。
3.5 钩子与扩展点
ReactiveHookFn:(node: ReactiveNode) => void形状的回调类型。setPostProducerCreatedFn(fn)/runPostProducerCreatedFn(node):注册/调用"生产者节点创建后"的钩子,供框架在节点诞生时附加行为(如 DevTools 追踪)。setPostSignalSetFn(fn)/runPostSignalSetFn(node):注册/调用"写信号被更新后"的钩子(源码注释称其可用于"同步执行 effect"等场景)。installDevToolsSignalFormatter():安装信号调试格式化能力,index.ts在包加载时(ngDevMode未定义或为真)即调用一次;结合graph.ts中debugName/kind字段注释可知其服务于 DevTools 中信号的识别与展示。setAlternateWeakRefImpl(impl):为WeakRef底层实现提供替换点(导出自weak_ref.ts),从函数名与包用途推断,这是为需要弱引用语义但又想注入自定义实现的宿主环境预留的扩展口。
四、三大响应式原语
4.1 源信号:createSignal
createSignal(initialValue, equal?)返回一个三元组[getter, setter, updater](见 signal.ts):
SignalGetter<T>:(() => T) & { readonly [SIGNAL]: SignalNode<T> }——零参函数,调用即触发signalGetFn(内部先producerAccessed(node)登记依赖,再返回node.value)。SignalSetter<T>/SignalUpdater<T>:(newValue) => void与(updateFn: (value) => value) => void。
SignalNode<T>在ReactiveNode基础上仅追加两个字段:value: T与equal: ValueEqualityFn<T>。
createSignal的实现把节点原型设为SIGNAL_NODE,然后创建 getter、setter、update 三个闭包函数并返回,同时在ngDevMode下给 getter 安装toString(输出[Signal ...: value]便于调试)。高层 APIsignal()最终映射到它——README 中给出了用法:
const counter = signal(0); counter.set(2); counter.update((count) => count + 1);写路径signalSetFn/signalUpdateFn的实现(关键步骤):
producerUpdatesAllowed()守卫,不允许则抛"非法写信号"错误;- 用
node.equal比较新旧值——注意signalUpdateFn先执行updater(node.value)得到新值再走 set 路径; - 若语义不等:写
node.value,然后signalValueChanged依次执行node.version++、producerIncrementEpoch()、producerNotifyConsumers(node)(向活跃消费者推送脏通知)、并触发postSignalSetFn。
这就是"push"阶段发生的全部动作:只推送失效信息,不执行任何下游重算。
4.2 派生值:createComputed
createComputed<T>(computation: () => T, equal?: ValueEqualityFn<T>)创建惰性记忆化的派生信号(computed.ts),高层对应computed():
const counter = signal(0); const isEven = computed(() => counter() % 2 === 0);ComputedNode<T>在ReactiveNode上追加四字段:value: T、error: unknown、computation: () => T、equal。其默认原型COMPUTED_NODE(未导出的模块内部常量)使用三个哨兵值UNSET/COMPUTING/ERRORED(源码中为Symbol,其中ERRORED随 API 一并导出):
- 首算前值为
UNSET,因此producerMustRecompute恒要求重算; - 计算开始时值置为
COMPUTING;若计算中再次读到自身,producerRecomputeValue会抛出"Detected cycle in computations.",用于检测循环依赖; - 计算抛错则值为
ERRORED并缓存error,此后每次 getter 读到ERRORED都会重抛该错误,直到依赖变脏触发重算; - 计算成功后,在
setActiveConsumer(null)下执行node.equal(oldValue, newValue)(即 equality 函数内的信号读取不会被追踪为依赖),若语义相等则回滚旧值且不递增 version,下游因此不会重复执行。
getter 闭包遵循标准的"拉取"模式:先producerUpdateValueVersion(node)(必要时递归重算并刷新 version),再producerAccessed(node)登记依赖,最后返回node.value。由于 computed 是惰性求值,@angular/core_primitives_signals为它预留了轮询验证机制——详见后文"glitch-free 与 value versioning"。
4.3 联动信号:createLinkedSignal
createLinkedSignal是仓库中较新的原语(linked_signal.ts),其类型签名引入了两个辅助类型:
export type ComputationFn<S, D> = (source: S, previous?: PreviousValue<S, D>) => D; export type PreviousValue<S, D> = {source: S; value: D};createLinkedSignal<S, D>(sourceFn: () => S, computationFn: ComputationFn<S, D>, equalityFn?: ValueEqualityFn<D>): LinkedSignalGetter<S, D>LinkedSignalNode<S, D>记录了四份状态:sourceValue(用于计算时使用的源值)、value(当前状态)、source(源读取函数)、computation(派生函数)。它的语义是:当source变化时,依据"新源值 + 上一次的 {source, value}"推导出重置后的值。在producerRecomputeValue中,实现先读取node.source()得到newSourceValue,若旧值有效则组装previous = {source: node.sourceValue, value: oldValue}传给computation(newSourceValue, previous),再更新sourceValue,随后执行与 computed 相同的 equality 优化与循环检测逻辑。
与只读的 computed 不同,联动信号还暴露两个受控写入口:
linkedSignalSetFn(node, newValue):先producerUpdateValueVersion(node)让当前值新鲜,再经由signalSetFn写入并producerMarkClean(node);linkedSignalUpdateFn(node, updater):同样先刷新版本;若当前为ERRORED则重抛错误(此时没有值可作update基底),否则走signalUpdateFn再标记干净。
从实现看,写操作不会破坏源驱动语义——一旦源再次变化,值仍会被新的计算覆盖。
五、副作用:Watch 与 Effect
5.1createWatch:可定制的副作用原语
Watch是"运行于响应式上下文、但调度权交给实现者"的消费者原语(watch.ts),effect()即构建于其上:
createWatch(fn: (onCleanup: WatchCleanupRegisterFn) => void, schedule: (watch: Watch) => void, allowSignalWrites: boolean): WatchWatch接口对外提供四方法:notify()(等价于consumerMarkDirty(node))、run()(执行响应式表达式)、cleanup()、destroy()。WatchCleanupFn/WatchCleanupRegisterFn是fn内用于注册清理逻辑的类型,清理函数会在下一次执行前被调用(watch.ts 的run中先执行node.cleanupFn()再运行fn),用于"取消"上一次运行启动的遗留工作。
WATCH_NODE默认原型把consumerIsAlwaysLive设为true(effect 因此成为活跃消费者),并让consumerMarkedDirty回调直接调用node.schedule(node.ref)——这正是调度解耦的落点:脏通知只负责把 watch 交给调度器排队,何时真正run()由上层决定。destroy()会调用consumerDestroy断开响应图连接并清空引用,置fn/schedule为null使后续 run/notify 变成 noop。
run()内部还有一道重要的"去伪"逻辑:if (node.version > 0 && !consumerPollProducersForChange(node)) return;——即先轮询所有依赖,若没有任何依赖真正变化,则跳过本次执行(对应下文 equality 语义的优化)。注意run与notify都禁止在通知阶段同步执行。
5.2BaseEffectNode与runEffect
effect.ts 提供的是构建在 Watch 之上的精简层:
BaseEffectNode extends ReactiveNode:追加fn: () => void、destroy()、cleanup()、run()四个成员;BASE_EFFECT_NODE:Omit<BaseEffectNode, 'fn' | 'destroy' | 'cleanup' | 'run'>形状的默认原型——展开REACTIVE_NODE,设置consumerIsAlwaysLive: true、consumerAllowSignalWrites: true、dirty: true、kind: 'effect';runEffect(node):通用执行入口,流程为dirty = false→ 若version > 0且轮询确认无变化则提前返回 →version++→consumerBeforeComputation→ 执行cleanup()与fn()→consumerAfterComputation恢复现场。
README 中给出的完整使用示例(counter.set(1)后 effect 再次打印):
const counter = signal(0); effect(() => console.log('The counter is:', counter())); // The counter is: 0 counter.set(1); // The counter is: 1源码说明指出 effect 不与 set 同步执行,而是由框架调度,因此确切的执行时机是不保证的(unspecified)。
5.3untracked
untracked(nonReactiveReadsFn)(untracked.ts)在一个非追踪上下文中执行任意函数,函数内读到的信号不会被注册为外层 effect/computed 的依赖:
effect(() => console.log(`counter: ${counter()}, untrackedCounter: ${untracked(untrackedCounter)}`), ); untrackedCounter.set(1); // effect 不重跑(被 untracked 遮蔽) counter.set(1); // 触发重跑六、相等性语义与 Value Versioning
6.1ValueEqualityFn与defaultEquals
ValueEqualityFn<T> = (a: T, b: T) => boolean是所有信号/computed 共享的可选比较器类型。默认实现位于 equality.ts:
export function defaultEquals<T>(a: T, b: T) { return Object.is(a, b); }默认采用Object.is(引用相等 +NaN语义)。当 equality 判定新旧值"相等"时,会发生两件事(README 明确列出):阻断信号值的更新、跳过变更传播。
6.2 为什么值相等时 effect 可以被跳过
这是 push/pull 实现里最微妙的一环。以 README 的例子为例:counter从1变为3时,isEven = computed(() => counter() % 2 === 0)仍为false,理想情况下依赖它的日志 effect 不应重跑。但问题在于:脏标记在 push 阶段就已通过producerNotifyConsumers传播,等 effect 真正运行、再去读isEven()时已经"太晚"。
解决方案是Value Versioning:
- 每个生产者维护单调递增的
version,仅当产出语义新值时才递增(equality 判断相等则跳过); - 消费者在追踪依赖时记录每个依赖的
lastReadVersion; - 消费者触发计算前调用
consumerPollProducersForChange:先比对lastReadVersion !== producer.version,若相等还需对每个上游调用producerUpdateValueVersion,强制上游(递归地)刷新自己的 version,再二次比对。
于是,若所有依赖的 version 都没有变化,消费者即可断定"没有发生实际变更"并跳过副作用执行——这就是 watch.ts / effect.ts 中node.version > 0 && !consumerPollProducersForChange(node)提前 return 语句背后的机制。全局epoch计数器(任何源信号被 set 都会producerIncrementEpoch)则用于短路:若lastCleanEpoch === epoch,说明自上次验证后不可能有任何源变化,可免去一轮轮询。
七、理解 push/pull 与 glitch-free 执行
把以上 API 串起来看,Angular 保证glitch-free(无毛刺)执行的思路分为两个阶段(README "Push/Pull Algorithm" 一节):
- Push(推送失效):
signalSetFn等写路径触发producerNotifyConsumers,脏标记沿活跃消费者链(consumers链表)同步传播到所有下游,effect 只在此阶段被"调度"而不是执行。此阶段不运行任何副作用、不重算任何派生值,因此不可能观察到中间态; - Pull(拉取重算):应用或框架真正读取信号时,
signalGetFn/computed getter 经由producerUpdateValueVersion惰性重算被失效的派生值。重算顺序由读取路径决定,必然发生在副作用执行之前。
以 README 的经典例子收尾:counter从 0 变 1 时,无论effect何时运行,它读取evenOrOdd()都会先触发该 computed 的重算,因此永远不会打印出 "1 is even" 这种不一致状态——这正是consumerBeforeComputation/consumerAfterComputation包裹执行、producerAccessed动态登记依赖这套 API 组合的最终成果。
八、速查总表:本包完整公共 API
以下按主题汇总 index.api.md 中的全部导出(各条目说明均可在上文章节与 源码目录 中找到出处):
图与节点基础设施(graph.ts):SIGNAL、Reactive、ReactiveNode、ReactiveNodeKind、ReactiveHookFn、Version、REACTIVE_NODE、isReactive、getActiveConsumer、setActiveConsumer、isInNotificationPhase、setPostProducerCreatedFn、runPostProducerCreatedFn、setThrowInvalidWriteToSignalError(errors.ts)。
生产者/消费者原语(graph.ts):producerAccessed、producerIncrementEpoch、producerMarkClean、producerNotifyConsumers、producerUpdateValueVersion、producerUpdatesAllowed、consumerAfterComputation、consumerBeforeComputation、consumerDestroy、consumerMarkDirty、consumerPollProducersForChange、finalizeConsumerAfterComputation、resetConsumerBeforeComputation。
信号(signal.ts):SignalGetter<T>、SignalNode<T>、SIGNAL_NODE、createSignal、signalGetFn、signalSetFn、signalUpdateFn、setPostSignalSetFn、runPostSignalSetFn。
派生信号(computed.ts):ComputedNode<T>、createComputed、ERRORED(哨兵)。
联动信号(linked_signal.ts):ComputationFn<S, D>、PreviousValue<S, D>、LinkedSignalGetter<S, D>、LinkedSignalNode<S, D>、createLinkedSignal、linkedSignalSetFn、linkedSignalUpdateFn。
Watch / Effect(watch.ts、effect.ts):createWatch、Watch、WatchCleanupFn、WatchCleanupRegisterFn、BaseEffectNode、BASE_EFFECT_NODE、runEffect。
相等性与工具(equality.ts、untracked.ts、weak_ref.ts、formatter.ts):ValueEqualityFn<T>、defaultEquals、untracked、setAlternateWeakRefImpl、installDevToolsSignalFormatter。
对想要深入验证本文结论的读者,建议按此顺序阅读:先读 README 建立算法模型,再对照 graph.ts 理解节点与边的运作,最后回到本 API 基线文件,逐条核对符号与你读到的实现是否一一对应。
【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考