Angular以及Qwik的作者MIŠKO HEVERY写了篇文章:useSignal()才是Web框架的未来(useSignal() is the Future of Web Frameworks),也引起一些讨论。包括Qwik在内的多个框架也已经实现了useSignal()
,这些框架还有Vue、Preact、Solid以及Svelte等。
实际上signals并不是一个全新的概念,大约十年前的框架Knockout就曾经使用过。那为什么最近突然又重提此项技术?这主要得益于当今更加先进的编译技巧以及与jsx的深度集成,使signals的开发体验变得更好了。
什么是signal
如果了解过React的话,signal其实和useState
非常类似,也是一种用来存储应用状态的方式。但是和useState
存在一些区别:
useState
是返回一个值以及一个set方法,而useSignal
则是返回一个getter
和一个setter
,参见如下表示:
useState() => value + setter
useSignal() => getter + setter
这两种返回有什么区别吗?我们来讨论一下state的意义。
State的意义
state有两个独立的含义:
- 状态引用——对当前状态引用。
- 状态值——状态引用中存储的状态值。
举个例子,在React中,我们是这样定义state的:
export function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}
在这个里面,useState
返回的内容包括两项:
count
——状态的值setCount
——状态的setter
我们再来看一看使用signal的例子:
export function Counter() {
const [getCount, setCount] = createSignal(0);
return (
<button onClick={() => setCount(getCount() + 1)}>
{getCount()}
</button>
);
}
createSignal返回的部分也包括两部分:
getCount
——状态的引用setCount
——状态的setter
看起来似乎也没太大区别对不对?他们到底有什么用呢?
Signal与State
二者之间的最大区别在于:Signal可以通过getter
去感知上下文,知道是谁在什么地方引用(订阅)了这个状态,从而可以在这个状态发生改变的时候直接通知这个对象(订阅者)。通过这个上下文感知,从而可以做到更细颗粒度的局部更新。而State返回的是一个状态值,所以State无法知道是组件的哪一部分使用了这个状态,因此在状态发生改变时必须重新渲染整个组件。
还是用上面的React的例子,当点击按钮时,调用setState
方法更新状态,React并不知道这个页面的哪些部分引用了这个状态,因此会对整个Counter
组件进行渲染。当这个组件变得复杂时,可以想象这个开销是非常大的。
我们再来看看在Qwik中使用useSignal
的用法:
export function Counter() {
const count = useSignal(0);
return (
<button onClick$={() => count.value++}>
{count.value}
</button>
);
}
在这个例子中,.value
属性代表的其实是一个getter
和一个setter
,只是语法不一样而已。在这个里面,由于使用了getter
,signal知道了只有一个文本节点使用了count.value
,于是当点击按钮时,只需要更新这个文本节点即可,而无需更新整个组件。
复杂一点的例子
一个稍微复杂一点的例子,包含两个计数器按钮以及两个展示组件:
export function Counter() {
console. log('<Counter/>');
const countA = useSignal (0);
const countB = useSignal (0);
return (
<div>
<button onclick$={() => countA.value++}>A</button>
<button onClick$={() => countB.value++}>B</button>
<Display count={countA.value} />
<Display count={countB.value} />
</div>
);
}
export const Display = component$(
({ count }: { count: number }) => {
console. log ('<Display count={${count}}/>');
return <div>{count}!‹/div>;
}
);
在这个例子中,只有需要更新的Display
组件才会更新,另外一个无需更新的组件则不会重新渲染。
# 初始渲染输出
<Counter/>
<Display count={0}/>
<Display count={0}/>
# 单击时的渲染
(空白)
在React中,我们无法做到这样,当然我们可以用useMemo
来优化渲染次数。
export default function Counter() {
console. log('<Counter/>');
const [countA, setCountA] = useState (0) ;
const [countB, setCountB] = useState (0) ;
return (
<div>
<button onClick={() => setCountA(countA + 1)]>A</button>
<button onClick={() => setCountB(countB + 1)}>B</button>
<MemoDisplay count={countA} />
<MemoDisplay count={countB} />
</div>
);
}
export const MemoDisplay = memo (Display);
export function Display({ count }: { count: number }) {
console. log(`<Display count={${count}}/>`);
return <div>{count}!</div>;
}
即便优化之后,也会产生多次渲染:
# 初始渲染输出
<Counter/>
<Display count={0}/>
<Display count={0}/>
# 单击时的渲染
<Counter/>
<Display count={1}/>
因此,开发也会相对来说简单很多,不用花费更多的时间关心如何优化渲染,研究如何使用useMemo,PuerComponent等等。
总结
Signal是应用中存储状态的一种方式,与React的useState
类似,两者的关键区别在于Signal返回一个getter
和一个setter
,而State只返回一个值和一个setter
。
Signal是响应式的,通过跟踪调用堆栈上下文实现相关订阅,达到局部更新的目的。
相比之下,React无法只能重新渲染整个组件树以响应状态变化。
另外,React团队成员对此的看法:
Rect可能引入类似的原语,虽然它性能很好,但是并不认为这是写UI代码的好方式,React的目标是希望通过编译器来达到同等性能。