帮助
支持我们
,作者
Marvin HagemeisterJason Miller

介绍信号

信号是一种表达状态的方式,可确保应用程序保持快速,无论其变得多么复杂。信号基于响应式原则,并提供出色的开发人员人体工程学,并针对虚拟 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添加到您的捆绑包大小。

如果您想直接开始,请访问我们的文档以更深入地了解信号。

信号解决了哪些问题?

在过去的几年中,我们与广泛的应用和团队合作,从小型初创公司到同时有数百名开发人员提交代码的庞然大物。在此期间,核心团队中的每个人都注意到了应用程序状态管理方式的反复出现的问题。

已经创建了针对这些问题进行处理的出色解决方案,但即使是最好的解决方案仍然需要手动集成到框架中。结果,我们看到开发人员在采用这些解决方案时犹豫不决,而是更愿意使用框架提供的状态基元进行构建。

我们构建了信号,使其成为一个引人注目的解决方案,它将最佳性能和开发人员人体工程学与无缝的框架集成相结合。

全局状态难题

应用程序状态通常从很小很简单的开始,可能只有几个简单的 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.

一开始,将值移动到上下文中似乎是一个值得的权衡,但仅仅为了共享值而增加组件树大小的缺点最终会成为一个问题。业务逻辑不可避免地最终取决于多个上下文值,这可能会迫使其在树中的特定位置实现。在树的中间添加一个订阅上下文的组件代价很高,因为它减少了更新上下文时可以跳过的组件数量。更重要的是,订阅者下方的任何组件现在都必须重新渲染。解决此问题的唯一方法是大量使用记忆化,这让我们回到了记忆化固有的问题。

寻找管理状态的更好方法

我们回到绘图板,寻找下一代状态基元。我们希望创建一种同时解决当前解决方案中问题的东西。手动框架集成、过度依赖记忆化、对上下文的次优使用以及缺乏程序可观察性感觉落后了。

开发人员需要使用这些策略“选择加入”性能。如果我们能够扭转这一点并提供一个默认情况下很快的系统,让最佳性能成为您必须努力选择不执行的事情,那会怎样?

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

面向未来的信号

信号背后的主要思想是,我们不直接通过组件树传递值,而是传递包含该值的信号对象(类似于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} onInput={...} />

所以是的,我们也这么做了。你可以在任何通常使用字符串的地方将信号直接传递到 JSX 中。信号值将作为文本呈现,并且当信号更改时它将自动更新自身。这同样适用于道具。

下一步

如果你好奇并想直接参与,请访问我们的 文档 了解信号。我们很想知道你将如何使用它们。

请记住,不必急于切换到信号。钩子将继续得到支持,它们与信号也非常匹配!我们建议逐步尝试信号,从几个组件开始,以习惯这些概念。