如果你是一个关注前端最新资讯的开发者,今年应该会经常听到 Signals 这个词,翻译成中文叫信号。特别是 Angular 前创始人 MIŠKO HEVERY (现任 Builder IO CTO) 在二月份发布了一篇 Signals 是 Web 框架的未来 的文章,引起了社区的广泛讨论,那么这篇文章是我在 PingCode 1024 开发者大会上的技术分享《Angular 响应式 Signals》的文字稿,Angular 正在逐渐变得更加的优秀,2023 年是 Angular 伟大复兴重要的一年。
前端框架的响应式
说到现代前端框架,大家脑海中肯定会想到 Angular、React、Vue 三大框架(在国内很多人已经把 Angular 排除在外了),最近几年虽然前端 Web 框架基本稳定,但是也逐渐涌现出一些优秀的新框架出来,比如 Qwik,SolidJS、Svelet,还有一些更加小型的框架 Alpine 和 Web Components 的 Lit 和 Stencil。
不管是何种框架,核心逻辑都是写声明式的 UI, Model 驱动视图,更新模型后视图会自动更新,这个在前端框架中叫 Reactivty,也就是响应式,或许叫视图层的响应式,那么接下来我们通过一个示例说明一下 React、Vue、Angular 这三个框架的响应式是什么样的。
我们的示例很简单,一个 Counter 组件定义一个 count 的状态,然后在视图中展示 count,视图中还存在一个按钮,点击的时候调用 increase 给 count + 1,然后触发视图的更新。
React 响应式
首先我们看下 React:
export function Counter() {
const [count, setCount] = useState(0);
function increase() {
setCount(count + 1);
}
return (
<div>
<span>Count: {count}</span>
<button onClick={increase}>Increase</button>
</div>
);
}
这是一个 React 的函数式组件 Counter,通过 useState 函数定义了一个状态,返回了 count 和 setCount 函数,同时定义了一个 increase 函数,调用 setCount 给 count + 1,然后视图中绑定了 count 状态,点击 Increase 按钮调用 increase 函数给 count + 1,那么这个示例在 React 中的渲染过程如下:
# Initial render
<Counter />
Count: 0
# Render on click increase A
<Counter />
Count: 1
# Render on click increase A
<Counter />
Count: 2
初次渲染时会执行 Counter 组件函数,count 是 0,视图显示 0 ,点击 Increase 按钮后会触发这个 Counter 组件的重新渲染,此时 count 变为 1 ,视图显示 1,再次点击也是一样的,只要组件执行了 setCount 函数都会触发组件的重新渲染,这是 React 框架当前响应式。
Vue 选项式 API 响应式
实现同样的功能采用 Vue 的选项式 API 如下:
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
data() {
return {
count: 0
}
},
methods: {
increase: function () {
this.count++
}
}
})
</script>
<template>
<div>
<p>Count is: {{ count }}</p>
<button @click="increase">Increase</button>
</div>
</template>
定义组件的时候把数据存储到 data 函数中,然后点击按钮的时候直接更新 data 的 count。
Vue 渲染过程如下:
# Initial render
<Counter />
Count: 0
# Render on click increase A
(blank)
Count: 1
# Render on click increase A
(blank)
Count: 2
初始化渲染会实例化 Counter 组件,执行 data 函数,然后视图显示 0,当点击 Increase 按钮时不会重新触发整个组件的重新渲染,框架直接监控到 count 的变化,更改视图 Count 为 1,再次点击是一样的,这里需要把数据定义在 data 函数中, Vue 会通过 Object.defineProperty 或者 Proxy 代理拦截所有属性的可变操作,然后根据模板的绑定对应进行视图更新。
Angular 响应式
最后看一下 Angular 框架:
@Component({
selector: 'app-counter',
template: `
<span>Count: {{ count }} </span>
<button (click)="increase()">Increase</button>
`,
standalone: true
})
export class Counter {
count = 0;
increase() {
this.count++;
}
}
定义一个 Counter Class 组件包含了属性 count,然后模板绑定 count,点击的时候调用 Class 中的 increase 函数,并直接修改 count,渲染过程如下:
# Initial render
<app-counter />
Instantiate Counter
Count: 0
# Render on click increase A
Detect Change for Counter
Count: 1
# Render on click increase A
Detect Change for Counter
Count: 2
第一次初始化的时候会实例化 Counter 类,然后赋值 count 为 0,并渲染,点击按钮之后执行 increase 函数给 count + 1,然后 Angular 的 zone.js 会拦截浏览器的事件从根组件开始往下进行变更检测,当检测到 Counter 组件的时候发现 count 已经是 1 了,此时更新视图。
React vs Angular
假如我把 React 的 useState 定义在 Counter 组件外部,视图中直接绑定 JS 级别的变量:
const [count, setCount] = useState(0);
function increase() {
setCount(count + 1)
}
export function Counter() {
return (
<>
<span>Count: {count}</span>
<button onClick={increase}>Increase</button>
</>
);
}
这种写法在 React 中直接 ESLint 就报错了:
React Hook "useState" cannot be called at the top level. React Hooks must be called in a React function component or a custom React Hook function.eslintreact-hooks/rules-of-hooks
即使忽略了 ESLint,运行时也会报错,那么说明 React 的 useState 必须要定义在函数式组件内部,且不能是动态的,那么稍微改造一下 React 的示例,不通过 useState 定义状态,而是直接定义一个 counter 对象呢?
const counter = {
count: 0,
increase: () => {
counter.count++;
},
};
export function Counter() {
return (
<>
<span>Count: {counter.count}</span>
<button onClick={counter.increase}>Increase</button>
</>
);
}
这种写法第一次会渲染 count 为 0,后续点击按钮的时候不会更新 UI,因为组件永远不会重新渲染,同样的示例我们在 Angular 框架中如下:
const counter = {
count: 0,
increase: () => {
counter.count++;
},
};
@Component({
selector: 'app-counter',
template: `
<span>Count: {{ counter.count }} </span>
<button (click)="counter.increase()">Increase</button>
`,
standalone: true
})
export class Counter {
counter = counter;
}
只要把 counter 对象绑定到 Class 组件的属性中,模板就可以通过这个属性访问 counter,点击也会触发视图更新,前面说过 Angular 是通过 Zone.js 代理了浏览器所有事件,包括点击,每次点击都从根组件往下进行变更检测,所以会触发视图的更新。
通过这三个示例我们得出了三个框架的响应式范围依次为:
- 在 React 框架中必须手动调用 useState 返回的 setState 函数才会触发整个组件的重新渲染,而且这个 useState 函数必须定义在组件内部,必须使用不可变的方式进行更新,因此 React 的响应式范围最小,约束最大
- 在 Vue 框架的选项式 API 中,组件的状态必须通过约定的方式定义在 data 中。只要操作 data 的数据,就会触发 UI 更新。Vue 的响应范围比 React 广一些,约束较小
- 在 Angular 中,任何地方的状态只要通过 Class 组件属性访问到,就能够触发更新,响应式范围最大,约束最小。
这是因为 Angular 采用了自动全局变更检测的策略,所以视图的状态可以定义在任何地方,可以通过可变的方式在任何地方进行修改,状态是普通的 js 对象,没有包装器或者 getter setter 访问器,Vue 中的 data 看上去是普通对象最终框架改为了代理对象。
Angular 基于 Zone.js 响应式的问题
这种全局自动变更检测带来的开发体验其实是最棒的,但是经过 Angular 这十年的实践还是有很多问题:
- 第一就是采用 Zone.js 是一个黑盒,需要不断的维护浏览器新的 API,而且有一些 ES 特性是模拟不了,比如 async await,维护成本很高,另外就是需要提前加载 Zone.js 打补丁,会增加构建体积和运行时间,如果使用了一些第三方库,频繁绑定浏览器事件容易出现性能问题,所以需要 ngZone runOutsideAngular 避免性能陷阱
- 全局变更检测策略每次都要从根组件向所有子组件检测,性能不好,为了提升性能引入了 OnPush 组件,使用了 OnPush 组件后只有该组件的输入参数引用变化或者当前组件内部触发的事件会触发变更检查,否则就需要手动通过 markForCheck 或者 detectChanges 触发变更
- 第三就是单向数据流,Angular 假设数据流和 Dom 顺序是相同的,所以从组件树往下检查,但是在前端应用场景不总是如此,比如表单验证状态,需要等所有表单项都验证一遍后才可以确定表单最终的状态,一旦出现这种子组件更新了已经检测完毕的父组件数据会报 ExpressionChangedAfterItHasBeenCheckedError 错误,这就是 Angular 的表单 ngModel 为什么是 Promise 微任务的原因
- 随着应用越来越大,性能问题就变得重要,这种变更检查无法做更细粒度的视图更新
Zone.js 设计之初想要对开发者透明,但是现在看开发者需要了解上述的知识才能够开发一个健壮的前端应用,所以如何解决当前响应式的这些问题呢,那么有如下几个方案:
- Improving zone.js
- setState-style APIs
- Signals
- RxJS
- Compiler-based reactivity
- Proxies
第一个方案就是改进 Zone.js,这个方案第一个会被 Pass,Zone.js 基本不可扩展,第二个就是像 React 那样的 setState 风格的 API,第三就是 Signals,第四就是 RxJS,第五基于编译做响应式,这个是 Svelet 框架目前的模式,第六就是通过代理。
Signals 介绍
什么是 Signal?
Angular 最终选择了 Signals,A signal is a way to store the state of your application(信号是应用程序中一种存储状态的方式),既然是存储状态,我们拿它和普通的状态对比一下:
- useState 函数返回了当前状态和 setter 函数
- useSignal 函数返回的是一个状态的 getter 访问器和 setter 函数
直接返回状态就是 State Value,如果返回的是 getter 访问器,那么其实是 State Reference,当返回了 getter 访问器后,框架的上下文可以自动收集哪些地方调用了 getter,也就是使用了 Signal 对象,当 Signal 对象变化后通知订阅者,无需要手动管理,所以 Signals 是响应式的,天生适合做视图层响应式。
那么开头的 Counter 示例我们使用 Signals 的框架 SolidJS 实现一下,代码如下:
export function Counter() {
const [getCount, setCount] = createSignal(0);
function increase(event: MouseEvent) {
setCount(getCount() + 1);
}
return (
<>
<span>Count: {getCount()}</span>
<button onClick={increase}>Increase</button>
</>
);
}
使用 createSignal 函数创建一个 signal 返回 getter 和 setter 访问器,视图通过 getter 访问器绑定 count 。整个渲染过程如下:
# Initial render
<Counter />
Count: 0
# Render on click increase A
(blank)
Count: 1
# Render on click increase A
(blank)
Count: 2
第一次初始化 Counter 组件,视图展示 count 为 0,点击按钮后,框架自动订阅到了 count 的变化更新视图 count 1,不会触发重新渲染,再次点击是类似的道理,和 React 最大区别就是状态变更不会触发组件重新渲染。
Signal 的特点
React 因为每次重复渲染从而为了优化性能,新增了 useRef、useMemo、memo 等 API ,但是不管如何优化还是做不到细粒度的渲染,但是 Signals 可以,最终可以得到一个公式:
Signal 的特点:
- 简单,不会有太多的 API
- 自带缓存
- 自动追踪依赖,可以带来细粒度的视图更新
- 可以在任何地方使用 Signal,不局限于组件内部,甚至比 Angular 当前的响应式更加广泛,约束还小,直接绑定 JS Level 的 Signal 状态
- 读取的时候没有副作用,你读再多次结果都是一样的
- 最后就是 glitch-free(后续再说)
由于 Signal 有很多优势,导致当前使用 Signals 作为响应式的框架很多,比如 Vue3 的组合式 API,Preact、Qwik、SoidJS,以及此次分享的主角 Angular,和即将发布的 Sevlet 5.0 ,其实早在 13 年前,Knockout JS 框架就使用了 Signals,当时可能没有 Signals 这个概念,但是模式是相同的,像 Sevlet 作者说的一样 Knockout 一直做了一些正确的事,当前的框架基于编译器可以对 Signal 做更多的技巧处理,同时目前对 JSX 支持较好,还有一个重要的原因是 13 年前的前端刚从操作 Dom 转变为模型驱动,整体水平和现在比较低,所以加一个包裹器不是很能被接受和采用。
Angular Signals
创建 Signal
如下是 SolidJS、Qwik 创建 Signal 的方式:
// SolidJS
const [getCount, setCount] = createSignal(0);
console.log(`Count: ${getCount()}`);
setCount(1);
setCount((value) => { return value + 1; });
// Qwik
const count = useSignal(0);
console.log(`Count: ${count.value}`);
count.value = count.value + 1;
SolidJS 通过 createSignal 函数创建 Signal,get set 访问器通过数组返回,设置函数同时支持值和函数,Qwik 通过 useSignal 函数返回一个对象,通过对象的 value 访问 Signal,直接操作 value 值触发变更,Angular 结合了两者做了一些微创新:
const count = signal(0);
console.log(`Count: ${count()}`);
count.set(2);
count.update((value) => { return value + 1; });
直接通过 signal 函数创建一个 Signal,返回一个对象,同时也是 getter 函数,通过调用这个函数访问状态值,同时通过对象的 set 和 update 函数更新状态,signal 函数返回的对象是可写入的 Signal:
export declare type Signal<T> = (() => T) & {
[SIGNAL]: unknown;
};
export declare interface WritableSignal<T> extends Signal<T> {
set(value: T): void;
update(updateFn: (value: T) => T): void;
asReadonly(): Signal<T>;
}
除了有 set、update 函数外还有 asReadonly 函数转换为只读 Signal, Angular Signal 更新是不强制要求不可变数据的,可变不可变都支持。
Computed Signal
计算信号就是根据其他 Signal 计算出一个新的 Signal,Angular 很多年前就有一个计算属性的 Issue,一直没有支持,那么 Signal 实现如下:
const count = signal(0);
const isEven = computed(() => {
retrun count() % 2 === 0;
})
这里的 isEven 是通过 count 这个 signal 计算得到的,一旦 isEven 被读取,它会自动订阅 count 的变化,如果 count 变化了重新计算 isEven,计算信号有几个特点:
- 计算 Signal 是只读的,不可以 update
- 延迟计算,就是说这个计算属性没有被视图或者其他地方读取是不会计算值的
- 缓存记忆,如果被读取会执行计算函数后缓存状态,当多次读取时不会重复计算
- 动态计算依赖,计算属性会根据执行的代码动态计算出依赖了哪些其他 Signal,比如下面的示例,Conditional Count 计算属性通过 showCount 判断如果是 true 打印 count 值,初始化的时候 showCount 是 false,即使 count 变化了 conditionalCount 也不会重新计算,当我们设置 shouCount 为 true 的时候它就会自动订阅 showCount 和 count 两个 signal
const showCount = signal(false);
const count = signal(0);
const conditionalCount = computed(() => {
if (showCount()) {
return `The count is ${count()}.`;
} else {
return 'Nothing to see here!';
}
});
- 自动清理,Angular 会自动清理计算信号,无需使用者关心
Effect
effect 函数,顾名思义是状态变化后做一些动作,也就是副作用,比如记录日志,同步存储状态,操作 Dom 等操作,举个例子:
effect(() => {
console.log(`The current count is: ${count()}`);
});
这里的 effect 函数获取了 count 并打印出来,每当 count 有变化的时候都会打印,和计算信号类似,自动追踪订阅。
- Effect 函数是异步执行的,具体执行时机和顺序取决于上下文
- 目前只能在注入器上下文中执行
- 和计算 Signal 不同的是它总会执行一次
如果我们在 Effect 函数内绑定了全局的事件,需要销毁的时候取消事件订阅,通过调用 onCleanup 函数实现。
effect((onCleanup) => {
const user = currentUser();
const timer = setTimeout(() => {
console.log(`1 second ago, the user became ${user}`);
}, 1000);
onCleanup(() => {
clearTimeout(timer);
});
});
默认情况下 Effect 生命周期随着注入上下文的销毁而销毁,如果在组件构造函数中声明 Effect,那么组件销毁了,这个 Effect 也就销毁了,但是有时候如果需要控制 EffectRef 的销毁,需要指定 manualCleanup:
const effectRef = effect(
() => {
console.log(`count is ${count()}`);
},
{ manualCleanup: true }
);
effectRef.destroy();
最终我们发现 Angular 的 Signals 核心就三个函数:
- signal 创建可写信号,对应的就是视图的状态
- computed 根据其他信号计算出一个新的只读信号,对应视图中的衍生状态
- effect 根据其他信号做一些动作,对应的就是副作用
那么除了核心三个函数外,Angular 的信号也提供了一些高级特性:
- 比如可以手动设置 Signal 的对比函数
- 在 computed 和 effect 函数内部如果不想自动追踪可以通过 untracked 函数读取以及手动销毁 effect
- 为了支持销毁钩子提供了 DestroyRef
- 提供了和 rxjs 的互操作转换,takeUntilDestroyed/toObservable/toSignal
- 目前 OnPush 组件可以自动识别 Signals 的变化
为什么不选择 RxJS 作为响应式?
对于 Angular 来说,和 RxJS 响应式库绑定应该是很密切的,官方的 HttpClient 以及很多库都是返回的 Observable,那么 Angular 为什么不采用 RxJS 作为内置的视图响应式呢?我总结了一下主要有三个原因:
- 第一就是 Observable 总是是异步的,对于框架来说无法区分同步还是异步,但是视图层的响应式需要同步,比如视图绑定了一个 value,但是这个 value 是异步返回的,那么如何展示在视图呢?
- 第二个就是 RxJS 有副作用,一个请求 API 的 Observable 如果模板中绑定多次会重复调用 API,如果你熟悉 RxJS 的话会通过 share 或者 shareReplay 操作符把单播转成多播,但是这个成本转加给了开发者
- 第三最重要的就是 RxJS 是非 Glitch Free
在响应式编程中有一个 Glitch 的概念,比如我们有一个 count 变量默认 0 ,根据 count 计算得到一个 isEven 表示是否偶数,然后打印一下字符串表示这个数是偶数还是奇数:
count = 0;
isEven = count % 2 === 0;
message = `${count} is isEven() ? 'even' : 'odd'`
当我们设置 Count 为 1 的时候,期望输出 1 是奇数,但是如果 count 变了后 message 比 isEven 先收到 count 变化,可能会输出 1 是偶数的字符串,isEvent 变化后又会触发 message 的变更,再次输出 1 是奇数。
对应的 RxJS 示例如下:
const count$ = new BehaviorSubject(0);
const isEven$ = count$.pipe(
map(count => {
return count % 2 === 0;
})
);
combineLatest([count$, isEven$]).subscribe(([count, isEven]) => {
console.log(`${count} is ${isEven} ? 'even' : 'odd'`);
});
count$.next(1);
当我们调用count$.next(1);后会短暂打印 1 is 偶数,最后打印 1 is odd
这种短暂的不一致对于视图层来说是比较致命的,我相信大家在实际使用 RxJS 的过程中肯定深有体会。当然不选择 RxJS 还有其他的一些原因, Angular 官方发起了一个调查,有 50% 人赞同和 RxJS 深度绑定,有 50% 的人反对,RxJS 学习门槛还是比较高,所以选择一个更简单的 Signals 作为响应式是明智的,再者就是 Signals 的实现库比较简单,视图响应式需要和框架绑定,使用第三方 RxJS 没有内置 Signals 简单。
最后说明一点 Signals 和 RxJS 不是互斥的,Signals 负责同步,RxJS 负责异步:
随着时间变化的值适合 Signals 存储,比如 Form 的验证状态,路由的参数等等之后都会换成 Signals,事件流复杂的操作符使用 RxJS,HttpClient 以及事件 Emitter 还是继续使用 RxJS。
和其他框架响应式的相同和不同
Angular 和其他框架响应式的相同和不同如下:
Angular Signals 的未来
Signal-based 组件
Angular Signals 目前在 v16 版本中只是开发预览版,可能在 v17 版本正式稳定,Angular 当前的组件为基于 Zone 的组件,将来会提供基于 Signals 的组件:
@Component({
signals: true,
...
})
export class Counter {}
通过 signals: true 设置,在 Signals 组件中只能使用 Signal 绑定。
- 基于 Signal 的组件会采用本地变更检测,ZoneJS 检测到的事件不会触发 Signal 组件的变更检测,只有组件视图绑定的 Signal 变更了才会触发变更检测,目前 Angular 的更新粒度是基于视图的,并不是绑定,组件和 ng-template 都是一个视图
- Signals 组件中所有的输入都是 Signal,会通过新的 input 和 model 函数设置输入参数
- 提供新的 viewChild、viewChildren 等查询函数替代之前的装饰器查询,并返回 Signals
- 生命周期的简化,只保留初始化和销毁,新增 afterNextRender、afterRender 应用级别 Hooks
这个 Signal 组件新的 input 函数,过去是通过 @Input 装饰器设置 Input,新的 API 通过 input 函数设置默认值,别名必填等参数:
这是 Signal 组件的查询,过去通过 @ViewChild 装饰器,新的 API 通过 viewChild 函数进行查询。
未来展望
除了基于 Signal-based 的组件外,Angular 后续会做如下的事情:
- Signal-based 和 Zone-based 组件混合使用
- 实现彻底的 Zoneless,将来新的应用将支持不再引入 Zone.js
- Compatible,提供兼容的工具升级现有的系统,考虑如何兼容基于 Zone-based 组件
- 新的 Control Flow,当前 Angular 的结构性指令依赖即将废弃的一些生命周期,同时本身当前控制流存在一些类型以及复杂的问题,所以重新提供了新的控制流,已经在 v17 rc 版本中发布
@if (showCounter()) {
<app-counter></app-counter>
} @else {
<span>None</span>
}
那么这里我罗列了一些常见的问题:
为什么要加 signals: true 区分 signal-based 组件?
因为 Signal 组件和基于 Zone 的组件差别很大,有破坏性更改,比如变更检测策略,输入,查询,生命周期等等。
目前 Angular 组件中有变更检测的策略,为什么不直接加一个 ChangeDetectionStrategy.Signals 简化 API 呢?
第一因为指令目前没有变更检测策略的配置,第二就是 Signals 组件不只是变更检查策略的不同,还包含输入、生命周期简化等功能。
为什么采用 input() 函数取代装饰器 @Input?
装饰器标记只能存储一些元数据,如果有默认值的话还需要借助编译器转换,其次就是 TS 中的类型还需要手动指定,无法自动推断,使用函数定义 input 会更简灵活,甚至可以封装外层的 input 输入。
关于我们
PingCode 前端团队是一群热爱开源,热爱技术的小伙伴,我们立志于通过技术打造世界最好用的研发管理工具,我们团队还开源了多个项目:
- docgeni :开箱即用的 Angular 组件文档生成工具
- ngx-planet :Angular 框架下功能最全面的微前端解决方案
- slate-angular : Slate 富文本编辑器框架的 Angular 视图适配层,使用 Angular 轻松开发 Slate 编辑器
- ngx-gantt :最好用的 Angular 甘特图组件
- @tethys/store :简单好用的 Angular 状态管理库
欢迎大家点赞收藏。
参考资料
useSignal is the future of web frameworks
[Complete] RFC: Angular Signals
[Complete] Sub-RFC 1: Signals for Angular Reactivity
[Complete] Sub-RFC 2: Signal APIs
[Complete] Sub-RFC 3: Signal-based Components
[Complete] Sub-RFC 4: Observable and Signal Interoperability