就在上个月(2023 年 2 月),Angular 终于也坐不住了,正式加入了 Signals 大军。
在 RFC: Angular Reactivity with Signals 上,Angular 为我们带来了 Angular Signals,意在提供一种细粒度响应式能力,以尝试摆脱传统的基于 zone.js 的变更检测机制。这或许是迫于竞争压力,又或是跟随业界「趋势」。
本文将介绍 Angular Signals 并给出一些个人理解和批评。
浅尝
直接上菜,示例摘自 Twitter @sarah_edo:
// 嗯,也可以在类外面用,虽然意义不明
const myValue = signal(10000);
effect(() => {
console.log('MyValue changed', myValue());
});
@Component({
selector: 'my-app',
standalone: true,
template: `
<div>Count: {{ count() }}</div>
<div>Double: {{ double() }}</div>
<button (click)="inc()">Increase</button>
<button (click)="reset()">Reset</button>
<br>
<!-- <test-arrays /> -->
<!-- <test-objects /> -->
`,
imports: [TestArraysCmp, TestObjectsCmp],
})
export class App {
count = signal(0);
double = computed(() => this.count() * 2);
countType = computed(() => (this.count() % 2 === 0 ? 'even' : 'odd'));
constructor() {
effect(() => {
console.log('Count changed', this.count());
console.log(this.count(), 'is', this.countType());
});
}
inc() {
this.count.update((c) => c + 1);
}
reset() {
this.count.set(0);
}
}
接触过 Solid.js 的朋友们估计会很熟悉,这味太冲了。
简单解释一下,调用 signal(default)
后获得一个包装函数,其是一个 getter,同时提供 set
(设置值)、update
(基于之前的值更新)和 mutate
(突变对象内层属性)等 setter 方法。当 getter 被调用时,Angular Core 会构建依赖图追踪依赖,在 setter 调用时触发副作用。
此外,Angular Signals 还提供 computed
,用于派生计算属性,以及 effect
声明副作用。其用法和 Solid.js、Vue Composition API 类似。
## 品鉴
Angular 选择 Signals 这个陈年烂货确实引发了一些争议。当时看到 RFC 的时候我的心情是「难以置信」「惊恐」,堪称魔幻。Signals 这种 primitive 在语法上天然更适合那些 Hooks 向、函数式的框架。当然不是说 Angular 就不能用,而是这就像你用勺子吃面条,多少有点别扭。
Signals 本质上是一种 value 的容器或者包装,只暴露 getter 和 setter 访问器,在调用对应访问器的时候注册依赖追踪或触发订阅。其性质上和 Vue Refs 类似,都是一种基于运行时的响应式实现。
但为什么说更适合类 Hooks 的框架呢?除了语法层面上的相似,最主要的原因是 Signals 可以对闭包内定义的原始数据类型变量追加响应性(虽然这是在增加心智负担且损害 DX 的前提下做到的)。这个道理很简单:
// 无法观察
let count = 1;
count++;
// 通过包装追加响应性
const count = ref(1);
count.value++;
// or
const [count, setCount] = createSignal(1);
setCount((prev) => prev + 1);
但 Angular... 容我吐槽一下:我记得你应该是 OOP 吧,你需要面向闭包吗?他喵的来凑什么热闹?简直是做了违背祖宗的决定,改的亲妈都不认了。
如果只是为了引入响应性的话,做法远不止上 Signals 这一种,无论是运行时还是编译时都可以做。有没有更契合 Angular 的方式呢?早就有前人的经验摆在面前,而且是 DX 无损的。比如 Vue:
export default {
data() {
return {
count: 1,
};
},
methods: {
change() {
// 没错,
// 我不需要加 ()
// 也不需要加 .value
// 因为 `count` 已经是对象的属性,可以被观察
this.count++;
},
},
}
OOP 风格的响应式是相对成熟的,改写成 Class 形式也是一样。那么对于 RFC 提到的其他特性,不违背祖宗的话,到底能不能实现?
能,太能了,心智负担更低,而且更 Angular:
@Component()
export default class AppComponent {
// 或者命名为 count$,呃,一些陈旧的习俗
@Observable count = 1;
@Computed get double() {
return this.count * 2;
}
@Computed get countType() {
return this.count % 2 === 0 ? 'even' : 'odd';
}
// 通过 watch 来注册副作用
// 抱歉,我接受不了 effect() 那种隐式自动推导依赖项的形式
// 虽然那种也能实现
// (但深入思考一下,effect 真的有必要存在吗?🤔)
@Watch(['count'])
someCountEffect() {
console.log('Count changed', this.count);
}
inc() {
this.count++;
}
}
// PS: 其实这些是从 Vue 类组件和 MobX 上抄来魔改的
这只是我拍脑袋得到的一种设计,远没有 Signals 那么激进,仅以此抛砖引玉。除此之外,社区也还有很多尝试,如基于 Rx 的响应式方案等,或许更 Angular?
但 Angular Signals?恕我直言,依托答辩。
结语
对于 Angular 在探索和改进上的努力,尤其是将响应性能力提上日程,还是要给予肯定的。但其交出的答卷却让人大跌眼镜,在明显有更贴合自身设计的方式和社区尝试面前,Angular 团队还是选择了更「潮流」,但未必更契合的方案。谁知道是不是间接受到知名 Signals 传教士 Hevery 的影响呢 [doge]?(典:useSignal 是 Web 框架的未来)
我看了不少讨论,并没获得有说服力的理由,或许 Angular 团队有基于他们场景和限制的一些考量。但这个能力,更多的感觉还是「跟风」和「将就」。从设计角度上说,我不看好这个特性,也不会去使用它。而且,新特性将导致社区的进一步分化,诸如 zone or signal, rx or signal 等等,更多的选择也意味着更大的心智负担和项目维护压力。
Good luck, Angular.
好多年没有进行技术写作了,最近几年,前端领域虽然有惊喜,但让人无语的趋势和风气也越来越多。打算开始随便写一点对娱乐圈的无逻辑吐槽,不求净化环境,但希望能引起思考和讨论,也算是我这个系列的初衷吧。
最后也抛出几个和本文相关的「暴论」,不打算解释,留给各位读者思考,说不定,有启发呢?
- zone.js 这套东西 ,方向其实没错
- Signals 绝不是前端的未来
还不快抢沙发