前端框架搞响应式搞了十年,最后殊途同归——大家都在写 Signals。Solid 从第一天就是 Signals 架构,Preact 半路加了 @preact/signals,Angular 在 v16 直接官宣 signal(),连 TC39 都坐不住了,要把 Signals 塞进语言规范。
它们长得像,骨子里是一回事吗?
Angular Signals:渐进式改造的务实路线
Angular 的 Signals 实现跟 Solid 的哲学截然不同。Solid 是"一切皆 Signal"的激进路线,Angular 走的是"Signal 是一个新选项,跟已有体系共存"的渐进路线。
Angular 的调度:微任务 + 组件树协调
Angular 的 Signal 更新不是同步的,也不是简单的事件循环批量。它走的是微任务批量 + 组件树自上而下协调的路线。
具体流程是:signal.set() 只做脏标记,不立即重新计算 computed。在微任务队列中安排一次变更检测,从根组件开始自上而下遍历组件树,遇到标记为脏的组件才检查其 Signal 依赖、重新计算 computed、更新 DOM。
const name = signal('Alice')
const greeting = computed(() => `Hello, ${name()}`)
name.set('Bob')
name.set('Charlie')
name.set('Dave')
// → 三次 set 合并成一次变更检测,greeting 只算一次 → "Hello, Dave"
这种策略的好处是:无论在事件处理器、setTimeout 还是 Promise 回调中,多次 set 都会被自动合并,不需要手动 batch。代价是更新延迟到微任务——如果你在 set 之后立即读 DOM,拿到的是旧值。Angular 选择这个策略,是因为要兼容已有的组件树生命周期。
对 effect() 的克制态度
Angular 对 effect() 的态度很谨慎——官方文档明确说这是"逃生舱",能不用就不用。体现在 API 设计上,effect() 必须在注入上下文中创建:
@Component({ /* ... */ })
export class MyComponent {
count = signal(0)
constructor() {
effect(() => console.log('count:', this.count())) // 构造函数中有注入上下文
}
someMethod() {
// 普通方法中没有注入上下文,直接调 effect() 会报错
// 需要手动传入 injector:
// effect(() => { ... }, { injector: this.injector })
}
}
这是有意为之的摩擦。
三大实现的调度策略对比
调度策略的差异是这三个框架 Signals 实现的核心分水岭。同样一段状态更新代码,在三个框架中执行时机和顺序可能完全不同。
同步、异步、还是混合
事件触发 → set signal
Solid:同步执行(事件内自动 batch)
→ batch 结束 → 同步 flush 所有 effect
Angular:异步调度(微任务)
→ set → 标记脏 → queueMicrotask → 变更检测 → 更新
Preact:混合模式
→ 直接绑定:同步更新文本节点(绕过 VDOM)
→ .value 读取:组件级调度(通过 VDOM diff)
把这三种策略放到同一个场景下看更直观。假设一个表单有 10 个字段,用户触发了一次"全部重置":
Solid:事件处理器内自动 batch,10 次 set 合并,依赖这些字段的 effect 只执行一次。换成 setTimeout 调用就需要手动 batch,否则触发 10 次更新。
Angular:10 次 set 都只是标记脏,在下一个微任务中统一做一次变更检测。
Preact:如果用了直接绑定(JSX 中传 signal 对象),10 个文本节点同步更新,不触发组件 re-render。如果用了 .value,需要 batch 包裹,否则组件可能 re-render 多次。
菱形依赖:Glitch-free 怎么保证
响应式系统有一个经典难题——菱形依赖。当一个 computed 依赖的多个上游共享同一个源头时,更新顺序不对就会出现错误的中间态:
const a = signal(1)
const b = computed(() => a.value * 2) // b = 2
const c = computed(() => a.value * 3) // c = 3
const d = computed(() => b.value + c.value) // d = 5
// a 变为 2 时,d 应该等于 4 + 6 = 10
// 但如果 d 在 b 更新后、c 更新前被计算 → d = 4 + 3 = 7(错误的中间态)
依赖关系形成了一个菱形:a 分叉到 b 和 c,再汇聚到 d。三个框架都解决了这个问题,方式不同。
Solid 用拓扑排序——按依赖图的层级顺序执行更新,保证 d 在 b 和 c 都更新后才重新计算。这是 push 模型下的经典解法。
Angular 用 pull-based 惰性求值——d 只在被读取时才重新计算,读取时会先递归检查 b 和 c 是否需要更新。读 d 之前先把上游全拉到最新,天然不会出现中间态。
Preact 也是 pull-based 模型,额外加了版本号机制——每个 signal 有一个单调递增的版本号,computed 在求值时通过比较版本号判断依赖是否已经更新过了。
Push vs Pull 的本质差异
这三个框架表面上都叫"Signals",底层的推拉模型配比其实不一样:Push 模型的特点是"源头变了就主动通知下游"。
Pull 模型反过来,"有人读的时候才去检查上游有没有变"。
实际上三个框架都是混合模型:computed 用 pull(惰性),effect 用 push(主动)。区别在于配比和默认倾向——Solid 更偏 push,它的编译器会生成细粒度的 effect 来驱动 DOM 更新;Angular 更偏 pull,变更检测时才从模板"拉取" signal 的值。
设计权衡:为什么调度无法统一
TC39 提案留白调度的原因
TC39 提案不做调度,这不是疏忽,是刻意为之。设想一下:如果 TC39 强制规定"所有 effect 在微任务中执行",Solid 的同步更新场景就没法做了;如果规定同步执行,Angular 的组件树协调又会被打破;如果规定用 requestAnimationFrame,动画场景合适了,表单交互又会有延迟感。
调度策略跟框架的渲染管线是一体两面。
强行统一调度,就像要求所有快递公司用同一种分拣流程——京东的自营仓和菜鸟的网格仓,底层逻辑根本不一样。
各方案的边界条件
每种实现都有碰壁的地方,了解这些边界在选型时比看 API 有用得多。
Solid 的 async/await 困境:纯运行时依赖收集的固有限制——await 会让 JavaScript 引擎挂起当前函数并清空调用栈,恢复时全局追踪栈上的 observer 已经不在了。
createEffect(async () => {
const val = count() // 这里的依赖能追踪到
await fetch('/api')
const val2 = other() // await 之后追踪上下文丢失,other 变化不会触发此 effect
})
这不是 bug,是机制决定的。Solid 官方建议在 effect 中把所有 signal 读取放在第一个 await 之前,或者用 createResource 处理异步场景。
Angular 的双系统心智负担:Signal 和 RxJS Observable 并存。虽然提供了 toSignal() 和 toObservable() 做桥接,但团队中一半人习惯用 Observable 处理异步流、另一半人用 Signal 处理同步状态,代码风格容易分裂。在一个真实的 Angular 16+ 项目中(比如一个中后台系统),你可能会看到同一个 service 里 BehaviorSubject 和 signal() 混用,维护起来很头疼。
Preact 的直接绑定局限:直接绑定模式只对文本内容生效。需要根据 signal 值动态切换 CSS 类名、控制元素显隐、或者传递 props 给子组件时,还是得走 .value 路线触发组件 re-render。也就是说,性能最优的路径覆盖面有限,复杂 UI 逻辑中很难全程使用。
从"框架特性"到"语言能力"还有多远
TC39 Signals 提案要真正落地到浏览器,还有几道坎要过。
JS 引擎级优化的想象空间
一旦 Signals 成为语言原语,JS 引擎可以做目前用户态代码做不到的优化。
依赖图可以用引擎内部的数据结构表示,不需要 JavaScript 对象和 Set 的开销。computed 的缓存失效检查可以在 JIT 层面优化,减少属性查找。垃圾回收也可以更智能地处理不再被引用的 signal 和它们的订阅关系——目前框架实现中,忘记清理的 effect 订阅是常见的内存泄漏来源。
这些优化在用户态框架中是不可能实现的。这也是 TC39 提案最大的远期价值——不是统一 API,而是打开引擎级优化的大门。
框架间共享依赖图
如果 TC39 Signals 落地,一个有意思的可能性是:不同框架的组件可以共享同一个响应式依赖图。
// 未来场景:一个页面同时用了 Solid 和 Angular 组件
const sharedState = new Signal.State({ user: null })
// Solid 组件读取 sharedState → 注册 Solid 的调度器
// Angular 组件读取 sharedState → 注册 Angular 的调度器
// sharedState 变化时,两个框架各自按自己的方式更新
这对微前端场景有实际价值。目前不同框架间传递状态要走 CustomEvent、全局变量或者额外的状态管理层。有了标准 Signals,跨框架的响应式状态共享就变成了原生能力,不需要中间层。
对现有框架的迁移成本
三个框架的迁移难度差异明显。
Solid 的 createSignal 和 createMemo 跟 TC39 的 Signal.State / Signal.Computed 语义最接近,换成标准 API 的薄封装就行,兼容成本最低。
Angular 需要把 signal()、computed() 的底层实现从自研切换到标准 Signals,上层 API 保持不变。工作量集中在框架内部,对应用代码几乎透明。
Preact Signals 的情况最微妙——它的双路径模式(直接绑定 vs .value 读取)是在自己的 signal 实现上做的深度定制。标准 API 没有"把 signal 对象直接当值用"这个能力,Preact 需要在标准 Signals 之上额外包装一层,复杂度比另外两家高。