Preact团队介绍了Signals的特性

268 阅读11分钟

信号是一种表达状态的方式,确保应用程序保持快速,无论它们变得多么复杂。信号基于反应式原则,提供了优秀的开发者工效学,并为虚拟DOM优化了独特的实现。

在其核心,信号是一个具有.value 属性的对象,它持有一些值。当信号的值发生变化时,从一个组件中访问信号的值属性会自动更新该组件。

除了直截了当和易于编写之外,这还能确保状态更新保持快速,无论你的应用程序有多少个组件。信号默认是快速的,在幕后自动为你优化更新。

import { signal, computed } from "@preact/signals";

const count = signal(0);
const double = computed(() => count.value * 2);

function Counter() {
  return (
    <button onClick={() => count.value++}>
      {count} x 2 = {double}
    </button>
  );
}

在REPL中运行

信号可以在组件内部或外部使用,与钩子不同。信号与钩子和类组件一起使用也很好。 类组件一起使用,所以你可以按照你自己的节奏来介绍它们,并带着你现有的知识。在一些组件中尝试它们,并随着时间的推移逐渐采用它们。

哦,顺便说一下,我们坚持我们的初衷,为你带来尽可能小的库。在Preact中使用信号只给你的软件包增加了1.6kB

如果你想直接进入,请到我们的文档中深入了解信号的情况。

哪些问题是由信号解决的?

在过去的几年里,我们为各种应用程序和团队工作,从小型创业公司到有数百名开发人员同时投入的单体公司。在这段时间里,核心团队的每个人都注意到了应用程序状态管理方式中反复出现的问题。

为了解决这些问题,已经有了一些神奇的解决方案,但即使是最好的解决方案,也需要手动集成到框架中。因此,我们看到开发者对采用这些解决方案犹豫不决,而更倾向于使用框架提供的状态原语来构建。

我们将Signals打造成一个引人注目的解决方案,将最佳的性能和开发者的工效与框架的无缝集成相结合。

全局状态的斗争

应用程序的状态通常在开始时是小而简单的,也许是一些简单的useState 钩子。随着应用程序的发展,越来越多的组件需要访问同一块状态,该状态最终被提升到一个共同的祖先组件。这种模式会重复多次,直到大部分的状态最终都在组件树的根部附近。

Image showing how the depth of the component tree directly affects rendering performance when using standard state updates.

这种情况给传统的基于虚拟DOM的框架带来了挑战,它们必须更新受状态失效影响的整个树。从本质上讲,渲染性能是该树中组件数量的函数。我们可以通过使用memouseMemo ,对组件树的部分内容进行备忘来解决这个问题,这样框架就会收到相同的对象。当没有任何变化时,这可以让框架跳过渲染树的某些部分。

虽然这在理论上听起来很合理,但现实往往要混乱得多。在实践中,随着代码库的增长,很难确定这些优化应该放在哪里。通常,即使是用心良苦的记忆化也会因为不稳定的依赖值而变得无效。由于钩子没有可以分析的明确的依赖树,工具不能帮助开发者诊断出 为什么依赖是不稳定的。

上下文混乱

团队对状态共享达成的另一个常见的解决方法是将状态放入上下文中。这允许通过潜在的跳过上下文提供者和消费者之间的组件的渲染来实现短路。但是有一个问题:只有传递给上下文提供者的值可以被更新,而且只能作为一个整体。更新通过上下文暴露的对象上的属性并不能更新该上下文的消费者--细化更新是不可能的。处理这个问题的可用选项是将状态分割成多个上下文,或者在上下文对象的任何属性发生变化时,通过克隆来使其失效。

Context can skip updating components until you read the value out of it. Then it's back to memoization.

将数值转移到上下文中,起初似乎是一个值得的权衡,但仅仅为了分享数值而增加组件树的大小,其弊端最终成为一个问题。业务逻辑最终不可避免地依赖于多个上下文值,这可能会迫使它在树中的一个特定位置实现。在树的中间添加一个订阅上下文的组件是很昂贵的,因为它减少了更新上下文时可以跳过的组件数量。更重要的是,订阅者下面的任何组件现在必须重新渲染。解决这个问题的唯一办法是大量使用备忘化,这又让我们回到了备忘化所固有的问题。

寻找一种更好的方法来管理状态

我们回到绘图板上,寻找下一代的状态原语。我们想创造一些东西,同时解决当前解决方案中的问题。人工框架集成、过度依赖记忆化、对上下文的次优使用,以及缺乏可编程的可观察性,这些都让人感觉到了落后。

开发人员需要 "选择加入 "这些策略的性能。如果我们能扭转这种局面,提供一个默认快速的系统,使最佳性能成为你必须努力选择的东西,那会怎样?

我们对这些问题的答案是Signals。这是一个默认快速的系统,不需要在你的应用程序中进行记忆化或技巧。信号提供了细粒度状态更新的好处,无论该状态是全局的、通过道具或上下文传递的,还是组件本地的。

通往未来的信号

信号背后的主要想法是,我们不是直接通过组件树传递一个值,而是传递一个包含该值的信号对象(类似于ref )。当一个信号的值发生变化时,信号本身保持不变。因此,信号可以在不重新渲染它们所通过的组件的情况下被更新,因为组件看到的是信号而不是它的值。这让我们跳过了渲染组件的所有昂贵工作,并立即跳到树中实际访问信号值的特定组件。

Signals can continue to skip Virtual DOM diffing, regardless of where in the tree they are accessed.

我们正在利用这样一个事实,即一个应用程序的状态图通常比它的组件树要浅得多。这导致了更快的渲染,因为与组件树相比,更新状态图所需的工作要少得多。这种差异在浏览器中测量时最为明显--下面的截图显示了同一个应用程序两次测量的DevTools Profiler轨迹:一次使用钩子作为状态原语,第二次使用信号。

Showing a comparison of profiling Virtual DOM updates vs updates through signals which bypasses nearly all of the Virtual DOM diffing.

信号版本大大优于任何传统的基于虚拟DOM的框架的更新机制。在我们测试的一些应用中,信号的速度非常快,以至于很难在火焰图中找到它们。

信号的性能是翻转的:信号不是通过备忘或选择器来选择性能,而是默认的快速。有了信号,性能就可以选择退出(通过不使用信号)。

为了达到这样的性能水平,信号被建立在这些关键原则之上。

  • **默认情况下是懒惰的。**只有当前在某处使用的信号才会被观察和更新--断开连接的信号不会影响性能。
  • **最佳的更新。**如果一个信号的值没有改变,使用该信号值的组件和效果就不会被更新,即使该信号的依赖关系已经改变。
  • **最佳的依赖性跟踪。**该框架为你追踪所有的信号--没有像钩子那样的依赖性阵列。
  • **直接访问。**在组件中访问一个信号的值会自动订阅更新,而不需要选择器或钩子。

这些原则使信号很适合于广泛的使用案例,甚至是与渲染用户界面无关的场景。

将信号引入Preact

在确定了正确的状态基元后,我们开始将其与Preact进行连接。我们一直喜欢钩子,因为它们可以直接在组件中使用。与第三方状态管理解决方案相比,这是一个人性化的优势,后者通常依赖于 "选择器 "函数或将组件包裹在一个特殊的函数中来订阅状态更新。

// Selector based subscription :(
function Counter() {
  const value = useSelector(state => state.count);
  // ...
}

// Wrapper function based subscription :(
const counterState = new Counter();

const Counter = observe(props => {
  const value = counterState.count;
  // ...
});

这两种方法都不能让我们感到满意。选择器的方法需要将所有的状态访问包裹在选择器中,这对于复杂的或嵌套的状态来说变得很繁琐。在函数中包装组件的方法需要手工来包装组件,这就带来了一系列的问题,比如缺少组件名称和静态属性。

在过去的几年里,我们有机会与许多开发者紧密合作。一个共同的挣扎,特别是对于那些刚接触(p)react的人来说,像选择器和包装器这样的概念是额外的范式,在感觉到每个状态管理解决方案有成效之前必须学习。

理想情况下,我们不需要知道选择器或包装器函数,只需在组件中直接访问状态。

// Imagine this is some global state and the whole app needs access to:
let count = 0;

function Counter() {
 return (
   <button onClick={() => count++}>
     value: {count}
   </button>
 );
}

代码很清晰,很容易理解发生了什么,但不幸的是,它并没有发挥作用。点击按钮时,组件并没有更新,因为没有办法知道count 已经改变。

虽然我们无法将这个场景从我们的脑海中抹去。我们能做些什么来使如此清晰的模型成为现实呢?我们开始使用Preact的可插拔渲染器对各种想法和实现进行原型化。这需要时间,但我们最终找到了一种方法来实现它。

// Imagine this is some global state that the whole app needs access to:
const count = signal(0);

function Counter() {
 return (
   <button onClick={() => count.value++}>
     Value: {count.value}
   </button>
 );
}

在REPL中运行

没有选择器,没有封装函数,什么都没有。访问信号的值就足以让组件知道,当该信号的值发生变化时,它需要更新。在几个应用程序中测试了这个原型后,很明显我们找到了一些东西。用这种方式写代码感觉很直观,而且不需要任何精神体操来保持事情的最佳状态。

我们能不能更快?

我们本可以在这里停下来,按原样发布信号,但这就是Preact团队:我们需要看看我们能把Preact的集成推到什么程度。在上面的计数器例子中,count 的值只用于显示文本,这确实不应该需要重新渲染整个组件。如果我们不在信号值变化时自动重新渲染组件,而只重新渲染文本,会怎么样呢?更妙的是,如果我们完全绕过虚拟DOM,直接在DOM中更新文本,会怎么样?

const count = signal(0);

// Instead of this:
<p>Value: {count.value}</p>

// … we can pass the signal directly into JSX:
<p>Value: {count}</p>

// … or even passing them as DOM properties:
<input value={count} />

所以是的,我们也这么做了。你可以在通常使用字符串的任何地方将信号直接传递给JJSX。信号的值将被呈现为文本,当信号发生变化时,它将自动更新自己。这也适用于道具。

接下来的步骤

如果你很好奇,想直接进入,请到我们的信号文档中去。我们很想知道你将如何使用它们。

请记住,不要急于切换到信号。钩子将继续得到支持,而且它们与信号的配合也很好。我们建议逐渐尝试使用信号,从一些组件开始,以适应这些概念。