useSignal()才是Web框架的未来

signal 是一种存储应用程序状态的方法,类似于 React 中的 useState()。但有一些关键的差异赋予了 Signals 另外的优势。

什么是 signal

difference between usesignal and useState

信号和状态之间的主要区别在于信号返回一个 getter 和一个 setter,而非响应式系统返回一个值(和一个 setter)。注意:一些反应式系统一起返回 getter/setter,有些作为两个单独的引用返回,但想法是相同的。

状态参考 vs. 状态值

问题在于“State”一词混淆了两个不同的概念。

StateReference:状态引用是对状态的引用。
StateValue:这是存储在状态引用/存储中的实际值。

为什么返回 getter 比返回值更好?因为通过返回 getter,您可以将状态引用的传递与状态值的读取分开。

让我们以这个 SolidJS 代码为例。

1
2
3
4
export function Counter() {
const [getCount, setCount] = createSignal(0);
return <button onClick={() => setCount(getCount() + 1)}>{getCount()}</button>;
}

createSignal():分配 StateStorage 并将其初始化为 0。
getCount:对您可以传递的 store 的引用。
getCount():表示检索状态值。

我不明白!对我来说看起来一样

上面的例子解释了信号与良好的旧状态有何不同,但没有解释为什么我们应该这样做。

信号是反应性的!这意味着他们需要跟踪谁对状态(订阅)感兴趣,并且如果状态发生变化,则通知订阅者状态变化。

为了做出反应,信号必须收集谁对信号的值感兴趣。他们通过观察状态获取器在什么上下文中被调用来获取此信息。通过从 getter 检索值,您可以告诉信号该位置对该值感兴趣。如果值发生变化,则需要重新评估该位置。换句话说,调用 getter 会创建一个 subscription。

这就是为什么传递状态获取器而不是状态值很重要。状态值的传递不会向信号提供有关该值实际使用位置的任何信息。这就是为什么区分状态参考和状态值在信号中如此重要。

为了进行比较,这里是 Qwik 中的相同示例。请注意, (getter/setter) 已被替换为具有.value 属性(代表 getter/setter)的单个对象。虽然语法不同,但内部工作原理保持不变。

1
2
3
4
export function Counter() {
const count = useSignal(0);
return <button onClick={() => count++}>{count.value}</button>;
}

重要的是,当单击按钮并且值增加时,框架只需将文本节点从 0 更新为 1。它可以做到这一点,因为在模板的初始渲染期间,信号已获知 count.value 仅由文本节点访问。因此它知道如果 count 值发生变化,它只需要更新文本节点而无需更新其他内容。

useState()的缺点

我们来看看 React 是如何使用的 useState()以及它的缺点。

1
2
3
4
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

React useState()返回一个状态值。这意味着 useState()不知道状态值如何在组件或应用程序内部使用。这意味着,一旦您通过 setCount()调用通知 React 状态更改,React 就不知道页面的哪一部分需要更改,因此必须重新渲染整个组件。这种计算开销是昂贵的。

useRef()与 useSignal()的区别

React 有 useRef(),与 类似 useSignal(),但它不会导致 UI 重新渲染。这个例子看起来很相似,但是它与 useSignal()还是有一些区别。

1
2
3
4
export function Counter() {
const count = useRef(0);
return <button onClick={() => count.current++}>{count.current}</button>;
}

useRef()与 useSignal() 表面上看起来完全相同,用于传递对状态的引用而不是状态本身。但是 useRef()缺少的是订阅跟踪和通知。

好的一点是,在基于信号的框架中,useSignal() 和 useRef()是写法上几乎是相同的。useSignal()可以算是 useRef()加上订阅跟踪。这进一步简化了框架的 API 接口。

useMemo()内置

信号很少需要记忆,因为它们开箱即用的工作量最少。

考虑这个包含两个计数器和两个子组件的示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

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 在上面的示例中,仅更新两个组件之一的文本节点。未更新的文本节点在初始渲染后将永远不会打印。

1
2
3
4
5
6
# Initial render output
<Counter />
<Display count="{0}" />
<Display count="{0}" />

# Subsequent render on click (blank)

实际上你无法在 React 上实现同样的目标,因为至少有一个组件需要重新渲染。 那么让我们看看如何在 React 中记住组件以最大程度地减少重新渲染的次数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

export 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>

<Display count={countA}>
<Display count={countB}>
</div>
);
}

export const MemoDisplay = memo(Display)

export function Display ({count}: {count: number}) => {
console.log('<Display count={${count}}/>');
return <div> {count}!</div>;
}
)

但即使有了记忆,React 也会重新运行更多的重新渲染。

1
2
3
4
5
6
7
8
# Initial render output
<Counter />
<Display count="{0}" />
<Display count="{0}" />

# Subsequent render on click
<Counter />
<Display count="{1}" />

如果没有记忆,我们会看到:

1
2
3
4
5
6
7
8
9
# Initial render output
<Counter />
<Display count="{0}" />
<Display count="{0}" />

# Subsequent render on click
<Counter />
<Display count="{1}" />
<Display count="{0}" />

这比 Signals 要做的工作要多得多。因此,这就是为什么信号的工作方式就好像您记住了所有内容,而实际上不必自己记住任何内容.

Prop drilling

让我们举一个实现购物车的常见示例。

app

1
2
3
4
5
6
7
8
9
10
11
12

export default function App() {
console.log("<App/>");
const [cart, setCart] = useState([]);
return {
<div>
<Main setCart={setCart} />
<NavBar cart={cart} />
</div>
}
}

Main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export function Main({ setCart }: any) {
console.log("<Main/>");
return (
<div>
<Product setCart={setCart} />
</div>
);
}
export function Product({setCart}: any) {
console.log('<Product/>')
return (
<div>
<button onClick={
()=> setCart((cart: any)=>[...cart, "product"])
}>
Add to cart
</button>
</div>
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

export function NavBar({cart}: any) {
console.log('<NavBar/>')
return (
<div>
<Cart cart={cart}>
</div>
)
}

export function Cart({cart}: any) {
console.log('<Cart />')
return <div>
Cart: {JSON.stringify(cart)}
</div>;
}

购物车的状态通常会被拉到购买按钮和呈现购物车的位置之间的最高公共父级。由于购买按钮和购物车在 DOM 中相距较远,因此通常非常接近组件渲染树的顶部。在我们的例子中,我们将其称为共同祖先组件。

共同祖先组件有两个分支:

  1. 一种setCart通过多层组件钻取功能直到到达购买按钮的方法。
  2. 另一个cart通过多层组件钻取状态,直到到达呈现购物车的组件。

问题是,每次单击购买按钮时,大部分组件树都必须重新渲染。这会导致类似于以下的输出:

1
2
3
4
5
6
# "buy" Button clicked
<App />
<Main />
<Product />
<Navbar />
<Cart />

如果您确实使用记忆化,那么您可以避免setCartprop-drilling 分支,但不能避免cartprop-drilling 分支,因此输出仍然如下所示:

1
2
3
4
# "buy" Button clicked
<App />
<Navbar />
<Cart />

对于信号,输出如下:

1
2
# "buy" Button clicked
<Cart />

这大大减少了需要执行的代码量。

哪些框架支持 signal?

支持信号的一些更流行的框架包括Vue、Preact、Solid和Qwik。

web framewoks support signal

现在,信号并不是什么新鲜事。在此之前它们已经存在于Knockout和可能的其他框架中。不同的是,信号近年来通过巧妙的编译器技巧和与JSX 的深度集成极大地改进了它们的 DX ,这使得它们非常简洁并且使用起来很愉快 - 这部分是真正的新部分。

结论

信号是在应用程序中存储状态的一种方式,类似于 useState()React。然而,关键的区别在于信号返回一个 getter 和一个 setter,而非响应式系统仅返回一个值和一个 setter。

这很重要,因为信号是反应性的,这意味着它们需要跟踪谁对状态感兴趣并通知订阅者状态更改。这是通过观察调用状态获取器的上下文来实现的,这会创建订阅。

相比之下,useState()React 只返回状态值,这意味着它不知道状态值是如何使用的,并且必须重新渲染整个组件树以响应状态变化。

近年来,信号已经达到了 DX,这使得它们并不比传统系统更难使用。因此,我认为您将使用的下一个框架将是响应式的并且基于信号。

参考文档

useSignal() is the Future of Web Frameworks