Signals 跨框架收敛:TC39 提案、Solid、Angular、Preact 的实现差异与调度策略对比

前端框架搞响应式搞了十年,最后殊途同归------大家都在写 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 用拓扑排序------按依赖图的层级顺序执行更新,保证 dbc 都更新后才重新计算。这是 push 模型下的经典解法。

Angular 用 pull-based 惰性求值------d 只在被读取时才重新计算,读取时会先递归检查 bc 是否需要更新。读 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 里 BehaviorSubjectsignal() 混用,维护起来很头疼。

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 的 createSignalcreateMemo 跟 TC39 的 Signal.State / Signal.Computed 语义最接近,换成标准 API 的薄封装就行,兼容成本最低。

Angular 需要把 signal()computed() 的底层实现从自研切换到标准 Signals,上层 API 保持不变。工作量集中在框架内部,对应用代码几乎透明。

Preact Signals 的情况最微妙------它的双路径模式(直接绑定 vs .value 读取)是在自己的 signal 实现上做的深度定制。标准 API 没有"把 signal 对象直接当值用"这个能力,Preact 需要在标准 Signals 之上额外包装一层,复杂度比另外两家高。

相关推荐
进击的尘埃2 小时前
从多仓到 Monorepo 的渐进式迁移:Git 历史保留、依赖收敛与缓存调优
javascript
SuperEugene2 小时前
TypeScript+Vue 实战:告别 any 滥用,统一接口 / Props / 表单类型,实现类型安全|编码语法规范篇
开发语言·前端·javascript·vue.js·安全·typescript
gis开发4 小时前
cesium 中添加鹰眼效果
前端·javascript
bluceli4 小时前
JavaScript动态导入与代码分割:优化应用加载性能的终极方案
javascript
kyriewen4 小时前
原型与原型链:JavaScript 的“家族关系”大揭秘
前端·javascript·ecmascript 6
滴滴答答哒4 小时前
layui表格头部按钮 加入下拉选项
前端·javascript·layui
乌索普-4 小时前
基于vue2的简易购物车
开发语言·前端·javascript
走粥4 小时前
使用indexOf查找对象结合Pinia持久化引发的问题
开发语言·前端·javascript
不甜情歌5 小时前
搞懂 Promise:告别回调嵌套,再也不怕异步代码乱成麻
前端·javascript