前端框架搞响应式搞了十年,最后殊途同归------大家都在写 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。
ts
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() 必须在注入上下文中创建:
ts
@Component({ /* ... */ })
export class MyComponent {
count = signal(0)
constructor() {
effect(() => console.log('count:', this.count())) // 构造函数中有注入上下文
}
someMethod() {
// 普通方法中没有注入上下文,直接调 effect() 会报错
// 需要手动传入 injector:
// effect(() => { ... }, { injector: this.injector })
}
}
这是有意为之的摩擦。
三大实现的调度策略对比
调度策略的差异是这三个框架 Signals 实现的核心分水岭。同样一段状态更新代码,在三个框架中执行时机和顺序可能完全不同。
同步、异步、还是混合
arduino
事件触发 → 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 依赖的多个上游共享同一个源头时,更新顺序不对就会出现错误的中间态:
ts
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 已经不在了。
ts
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 落地,一个有意思的可能性是:不同框架的组件可以共享同一个响应式依赖图。
ts
// 未来场景:一个页面同时用了 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 之上额外包装一层,复杂度比另外两家高。