本文主角是 Signal,浅谈一下前端框架响应式设计,希望和大家一起多多交流,有错误之处还请指正。先从下面这张图开始
这是最新公开的 Signals TC39 提案内容,当前的草案基于Angular、Bubble、Ember、FAST、MobX、Preact、Qwik、RxJS、Solid、Starbeam、Svelte、Vue、Wiz等的作者/维护者的设计意见。
为什么提议 Singal 成为 Web 标准,我们接着往下看
一、前言
我们聊它之前,先来聊一聊前端的框架演进、响应式产生契机,以及响应式的几种方案
阶段 | 代表 | 描述 |
---|---|---|
HTML/CSS/JS 时代 | 原生 JS | 在早期,前端开发主要依赖于 HTML、CSS 和 JavaScript这三种基础技术。开发者需要手动编写大量代码来实现页面的布局和功能。 |
jQuery 时代 | jQuery | 随着 jQuery 的出现,前端开发变得更加简便,它简化了 DOM 操作和 Ajax 交互,大大提升了开发效率。 |
MVC 框架 | Backbone.js、Ember.js | 随着单页应用(SPA)的流行,出现了如 Backbone.js、Ember.js 等前端 MVC 框架,它们帮助开发者更好地组织代码,分离视图、数据和逻辑。 |
三分天下 | React、Vue.js 和 Angular | 以 React、 Vue.js 和 Angular 为代表的现代前端框架,将组件化理念广泛推广和应用,使得代码重用和模块管理变得更加容易。这些框架通常提供了数据绑定、状态管理和虚拟DOM等高级功能。 |
新兴框架 | Svelte、Solidjs、Qwik 等新兴框架 | 近几年也诞生了一些后起之秀,如 Svelte、Solidjs、Qwik 等框架,整个前端框架,呈现百家争鸣的状态。 |
随着框架演进,我们从写法上发生了变化:
从最早的原生开发到早期的 Jquery,使用 命令式的代码 实现页面的更新
$("ul li").click(function() {})
let li = $("<li>我是一个li</li>");
$("ul").append(li);
到现在我们通过声明式的代码,通过数据的更新,去触发页面的更新
<template>
<ul>
<li @click="handleClick" v-for="item in list">{{ item }}<li>
<ul>
<template>
const handleClick = (item) => { console.log(item) };
const list = [];
list.push('我是一个li');
我们从命令式的写法,转为了声明式的写法,是框架帮助我们通过组件化的方式,让开发者通过描述 UI 的状态来构建用户界面,而无需手动操作DOM。我们通过声明式的写法,直接改变数据,就想要实现页面的更新,这时候响应式机制就出现了。
目前来看,一个现代的框架,核心逻辑大多是通过写声明式的 UI,通过响应式机制来实现页面的更新,响应式机制是框架的核心,它定义了数据与 DOM 的交互方式,还决定了框架的性能上限,下面就来谈一谈前端的常见的几种响应式机制
二、前端框架响应式几种方案
2.1 脏检查
又称脏数据检测,通过新旧值的对比,来决定是否更新页面
代表: Angular2
@Component({
selector: 'app-counter',
template: `
<span>Count: {{ count }} </span>
<button (click)="increase()">Increase</button>
`,
})
export class Counter {
count = 0;
increase() {
this.count++;
}
}
从代码上看,点击事件中,直接更新类的属性值,实现页面更新。这种没有任何设定,就可以实现页面响应式更新,这是依靠 Angular 内部依赖 zone.js 提供的变更检测的能力:
Zone.js 变更检测时机:
只要发生了异步操作(Events, Timer, XHR),Angular 就会认为有状态可能发生变化了,然后就会进行变更检测。
-
Events::click,mouseover,mouseout,keyup,keydown 等浏览器事件;
-
Timer:setTimeout、setInterval;
-
XHR:各类请求等。
Angular onPush 策略:
- 组件 props 有变更时更新(有点像 react 的 memo)
- 手动调用方法更新
playground:danielwiehl.github.io/edu-angular…
这种全局自动变更检测带来的开发体验其实是最棒的(更符合面向对象编程直觉,直接更新类属性值,页面发生变化),但同时也存在下面这几个问题:
-
性能问题:全局变更检测策略每次都要从根组件向所有子组件检测,性能不好,虽然也提供了 OnPush 策略优化性能。随着应用越来越大,性能问题就变得重要,这种变更检查无法做更细粒度的视图更新
-
无法扩展:zone.js 是一个黑盒,几乎无法扩展,还需要不断的维护浏览器新的 API,如果使用了一些第三方库,频繁绑定浏览器事件容易出现性能问题
-
应用体积:另外 zone.js 增加构建体积(xxx)和运行时间
2.2 useState
代表: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>
);
}
从代码上看,我们需要依赖 useState ,手动调用 setCount
执行 setCount 方法后,会触发组件的 render 方法,只要组件执行了 setCount 函数都会触发当前组件及子组件的重新渲染,相比于脏检查,把自动的变更检测改为了手动调用 setCount,变更范围从所有子组件变为当前组件及子组件
这种响应式机制,同样存在性能问题,虽然 react 通过 memo 、filber 等机制有所优化,但是这增加了开发者使用成本,同时仍无法做到细粒度的视图更新。
2.3 Signal
代表:Vue、Solid、Preact、Qwik、Angular 17、Svelte 5.0(runes)
使用样例,以 solidj.js 为例:
export function Counter() {
const [count, setCount] = createSignal(0);
function increase() {
setCount(count() + 1);
}
return (
<div>
<span>Count: {count()}</span>
<button onClick={increase}>Increase</button>
</div>
);
}
为什么如此多的框架,都选择了 Signal ?
三、Why signal?
3.1 什么是 signal ?
它是一个在访问时(getter)跟踪依赖、在变更时(setter)触发副作用的值容器。
它最早可以追溯到十多年前的 Knockout observables 和 Meteor Tracker 等实现。
它和 useState 之间的主要区别在于 useSignal 返回一个 getter 和一个 setter,而 useState 返回一个 value 和一个 setter。
接下来我们通过一个简易版的实现,再深入了解一下 Signal。
3.2 简易版 signal 实现
我们看下下面的代码:
const [count, setCount] = createSignal(0);
console.log(count()); // 0
setCount(count() + 1);
console.log(count()); // 1
从上面代码可以看出,核心是 createSignal 这个函数,创建了一个响应式数据,并返回的响应式数据的 getter、setter
第一步,实现值容器:
// createSignal 创建了一个值容器(闭包),返回 getter、setter,实现值的获取和修改
export function createSignal(initialValue) {
let value = initialValue;
// 获取 Signal value
function get() {
return value;
}
// 修改 Signal value
function set (newValue) {
value = newValue;
}
return [get, set];
}
很简单,现在我们已经实现了值容器,返回了一个 getter 和 setter ,拥有了创建一个 Signal 的能力。
目前还缺少一些能力:访问时(getter)跟踪依赖、在变更时(setter)触发副作用,增加这样的能力,需要另一个概念 Effects(副作用),响应式数据创建好后,而 Effects 的作用就是消费响应式数据的。我们引入 createEffect 函数,来消费响应式数据:
const [count, setCount] = createSignal(0);
// 通过 createEffect 包裹的函数,当 count 发生变化时,会自动执行,打印当前 count value
createEffect(() => {
console.log('count is', count());
});
vue 中的 watch、wathEffect、computed 以及模板更新,也是基于 Effects
第二步,实现访问时(getter)跟踪依赖、在变更时(setter)触发副作用:
let current;
export function createSignal(initialValue) {
let value = initialValue;
const subscribers = []; // 存储当前 signal 的依赖列表
function get() {
subscribers.push(current); // 跟踪依赖
return value;
}
function set (newValue) {
value = newValue;
subscribers.forEach(sub => sub()); // 触发副作用
}
return [get, set];
}
export function createEffect(fn) {
current = fn;
fn();// fn 执行 会访问 get 函数,get 函数将 fn 放入依赖列表
current = null;
}
/***------------ 使用 createSignal---------------**/
const [count, setCount] = createSignal(0);
createEffect(() => {
console.log(count()); // 执行 get (),把副作用放入 subscribers
});
setCount(100); // 执行 set(),执行 subscribers 里所有副作用
至此,一个简易版的 signal 实现完了。
流程图:
总结:
-
createEffect 执行时,会将 current 指向 createEffect 包裹的副作用函数 fn
-
同时,createEffect 执行会调用 fn(), fn 包含了对 count() 的访问,则会触发 get 函数执行
-
getter 函数执行时,会将 current 放入依赖列表,即将副作用函数 fn 放入了依赖列表
-
每次调用 setter 函数时,会触发副作用函数的执行,最终到页面的更新。
3.3 Signal 的优势
3.3.1 更好的性能
- 自动追踪依赖,实现细粒度更新
对于前端组件开发,不需要更新整个组件,结合更新策略,当依赖项变化时,可以自动追踪到,更新视图或组件内部状态,对视图而言可以带来细粒度的视图更新,提升页面性能。我们都知道 React 在状态发生变化的时候,会重新渲染整个组件,使用 useMemo 可以减少渲染,但一旦判断需要更新,都至少会重新渲染整个组件,Singal 在这一项优势,也是很多框架选择它的原因,当然 Singal 还有很多其他的优势。
- 惰性求值,减少计算
惰性求值是指 Signals 只有在被实际使用时才会被监听和更新,依赖项发生变化时也不会立即求值。这种机制可以显著提高性能,因为它避免了不必要的计算和更新。例如,在 SolidJS 中,createSignal 创建的信号只有在被访问时才会触发依赖追踪和更新
import { createSignal, onCleanup } from "solid-js";
import { render } from "solid-js/web";
const App = () => {
const [count, setCount] = createSignal(0);
const timer = setInterval(() => setCount(count() + 1), 1000);
onCleanup(() => clearInterval(timer));
return <div>{count()}</div>;
};
render(() => <App />, document.getElementById("app"));
在这个例子中,count 信号只有在被访问时才会触发更新,而不是每次 setCount 被调用时都更新
- 记忆化
缓存其最后一个值,以便依赖关系没有变化的计算不需要重新评估,无论访问多少次
3.3.2 更好的开发体验
- Signal 可以感知上下文环境,减少了编码心智负担
在 React 中,useEffect 在使用时需要指明依赖的状态:
useEffect(() => {
// ...state1, state2变化后的逻辑
}, [state1, state2])
如采用 Signal 实现,能感知到自己在 useEffect 上下文环境,可以自动建立两者之间的联系。
- 减少开发者性能优化的心智负担
使用 Signal 的框架通常能获得不错的运行时性能。所以不需要额外的性能优化API。反观 React、Angular(17版本之前),开发者如果遇到性能问题,需要手动调用性能优化API(比如 React.memo、useMemo、PureComponent,Angular 的 OnPush)。
3.3.3 更广的适用范围
Signal 就是一个普通的 JS 对象,可以在任何地方使用 Signal,不局限于组件内部,直接绑定 JS Level 的 Signal 状态。
3.4 和 Vue ref 有什么不同?
看下作者怎么说,链接
我们也能从下面这张图得到答案,这是vue2 的响应式原理图,vue3 中通过 proxy 做代理,整体流程也差不多。和 Signal 相比都是访问时跟踪依赖、在变更时触发副作用的值容器。从根本上说,Signal 是与 Vue 中的 ref 相同的响应性基础类型,换句话说,Vue ref 也是 signal。
不过整体来看,有一些设计上的差异:
- API 风格不同:
- () 比 .value 略微省事,但更新值却更冗长;
- 没有 ref 解包:总是需要通过 () 来访问值,这使得值的访问在任何地方都是一致的。这也意味着你可以将原始信号作为组件的参数传递下去。
const state = ref({ count: 1 })
console.log(state.value); // 访问值
state.value.count = 2 // 更新值
state.value = { count: 2 } // 更新值
Solid.js:
const [count, setCount] = createSignal({ count: 1 })
export function Counter() {
const [count, setCount] = createSignal({ count: 1 })
console.log(count()); // 访问值
setCount({
count: count().count + 1
}) // 更新值
return (
<div>
<child count={count}></child>
</div>
);
}
2. 不会深层递归地转为响应式,类似与 vue 的 shallowRef,只有对 .value 的访问是响应式的。signal setter 会更新整个对象
const state = shallowRef({ count: 1 })
// 不会触发更改
state.value.count = 2
// 会触发更改
state.value = { count: 2 }
// signal setter 更新整个对象
const [count, setCount] = createSignal({ count: 1 })
setCount({
count: count().count + 1
})
在 Vue 中利用 shallowRef 可以实现相同效果的 Signal API 链接:
import { shallowRef, triggerRef } from 'vue'
export function createSignal(value, options) {
const r = shallowRef(value)
const get = () => r.value
const set = (v) => {
r.value = typeof v === 'function' ? v(r.value) : v
if (options?.equals === false) triggerRef(r)
}
return [get, set]
}
四、未来趋势
在文章开头,signal 成为了标准化提案,不出意外,未来也会成为前端响应式的 web 标准。下面是我认为的两大趋势:
4.1 细粒度的视图更新
在 Angular 17 版本,已经正式使用 Signals 作为其响应式机制,优化其渲染更新。
另外再来看看 Svelte。
2019 年 4 月,Svelte 团队发布了一篇 Svelte 3: Rethinking reactivity,文章介绍了 Svelte3 采用编译器实现响应式,即在代码编译阶段确定响应依赖关系,来替代旧版的语法。
旧版语法:
const { count } = this.get();
function increment() {
this.set({
count: count + 1
});
}
svelte3 语法:
let count = 0;
function increment() {
count += 1;
}
依靠于 svelte 编译检测能力,会大致编译为以下内容:
count += 1;
$$invalidate('count', count);
抛弃了类似 React setState 的语法,Svelte3 Compiler 会吧把赋值的代码通通使用 $$invalidate 包裹起来,每次 increment 函数触发,会触发 invalidate 函数,通过检测标记需要更新的组件,再由统一的队列去更新。Svelte3 响应式更新是也是组件级别的。
有趣的是,在 2023 年 9 月,Svelte 团队又发布了一篇文章:Introducing runes,这篇文章的主题是 Rethinking 'rethinking reactivity',再次重新思考,他们肯定了 Knockout 一直在做正确的事情,在 Svelte5 将引入了 runes,而这正是基于 Signal 实现。在 SIgnal 的加持下,Svelte5 的速度快得惊人。
4.2 无虚拟 DOM
近一两年有不同的声音,觉得虚拟DOM反而是渲染性能的累赘,所以也出了一些无虚拟DOM的框架,比如 Svelte 和 SolidJS,也正是因为基于框架的细粒度更新的能力,无虚拟 DOM 变得更加从容。
Vue Vapor
Vue 由于使用了虚拟 DOM,Vue 目前依靠编译器来实现类似的优化, 编译器可以静态分析模板并在生成的代码中留下标记,使得运行时尽可能地走捷径。Vue 也在探索一种新的受 Solid 启发的编译策略 (Vapor Mode),它不依赖于虚拟 DOM,而是更多地利用 Vue 的内置响应性系统。
仓库地址: github.com/vuejs/core-…
五、结语
随着前端技术的不断演进,响应式机制已经成为现代前端框架不可或缺的一部分。从最初的命令式代码到现在的声明式编程,从脏检查到 useState
,再到如今的 Signal
,每一步都在推动前端开发向着更高性能、更佳开发体验的方向发展。特别是 Signal
的出现,不仅带来了细粒度的视图更新和惰性求值的优势,还极大地简化了开发者的编码工作,提高了应用的性能。
希望本文能帮助你更好地理解前端响应式机制及其未来的发展趋势。如果你有任何问题或建议,欢迎留言讨论,让我们一起探索前端技术的无限可能!