preact状态管理Signals
前端科代表张继科 人气:0前言
Signals是一种用来描述状态的方式,可以确保应用程序保持快速,无论应用程序变得多么的复杂。Signals基于响应式原则,提供了优秀的开发者工效学,并且针对虚拟DOM优化做了独特实现。
Signals的核心在于,一个signal就是一个具有具有.value
属性的对象,并持有一些值。当signal的value值发生变化时,从一个组件中访问signal的value
属性会自动更新该组件。
除了直截了当和易于编写之外,无论你的应用程序中有多少个组件,都还能确保状态更新保持快速。Signals默认是很快的,在幕后自动为你优化更新性能。
import { render } from "preact"; 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> ); } render(<Counter />, document.getElementById("app"));
Signals可以在组件内部或者组件外部使用,它跟hooks不一样。Signals也可以和hooks、类组件一起使用,所以你可以带着你现有的知识,按照你自己的节奏一步一步地引入它们。在一些组件中尝试它们,并随着时间的推移逐渐采用Signals。
哦顺便说一下,我们一直坚持我们的初心,为大家提供尽可能小的类库。在Preact
中使用signals最终只给你的软件包体积增加了1.6KB。
如果你想跳过直接进入学习,请到我们的文档中去深入了解signals。
signals解决了哪些问题?
在过去的几年里,我们在各种应用程序和团队中工作,从小型创业公司到有数百名开发人员同时投入的巨石应用。在这段时间里,核心团队的每个人都注意到了应用程序状态管理方式中反复出现的问题。
为了解决这些问题,我们提出了一些神奇的解决方案,但是即使是最好的解决方案,也需要手动集成到框架中。因此,我们看到了开发者对采用这些解决方案时的犹豫不决,而更倾向于使用框架原本提供的状态来构建。
我们将Signals打造成一个引人注目的解决方案,将最佳性能、开发人员的工效学与框架的无缝集成结合起来。
全局状态的斗争
应用程序的状态一开始时通常是小而简单的,也许是几个简单的useState
hooks。随着应用程序的迭代,越来越多的组件需要访问相同的状态,这些状态最终被提升到一个共同的祖先组件上面。这种模式会重复多次,直到大部分的状态最终都在组件树的根组件附近。
这种场景给传统的基于虚拟DOM的框架带来了挑战,它们必须更新受状态失效影响的整个树。从本质上来讲,渲染性能是该树中组件数量的函数。我们可以通过使用memo
或者useMemo
对组件树的部分进行记忆备忘来解决这个问题,这样框架就会收到相同的对象。当没有变化时,可以让框架跳过渲染树的某些部分。
虽然这在理论上听起来很合理,但是实际情况往往要混乱得多。在实践过程中,随着代码库的增长,很难确定这些优化应该放在哪里。通常,即使是用心良苦的记忆优化也会因为不稳定的依赖值而变得无效。由于hooks没有可以分析的明确的依赖树,所以工具不能帮助开发者诊断为什么依赖是不稳定的。
上下文混乱
另一个常见的解决方法就是将状态放到上下文中。这样就可以通过跳过上下文提供者和消费者之间的组件渲染来实现短路渲染。但是有一个问题:只有传递给上下文提供者的值可以被更新,而且只能作为一个整体。更新通过上下文暴露的对象上的属性并不能更新该上下文的消费者--细化更新是不可能的。解决这个问题的可行选项是将状态分割成多个上下文,或者在上下文对象的任何属性发生变化时通过克隆它来使其失效。
将状态值转移到上下文中,起初似乎是一个值得考虑的权衡,但是为了共享值而增加组件树的大小,最终其弊端成为一个问题。业务逻辑最终不可避免地依赖于多个上下文值,这可能会迫使它在组件树中的特定位置实现。在树的中间添加一个订阅上下文的组件是很昂贵的,因为它减少了更新上下文时可以跳过的组件数量。更重要的是,订阅者下面的任何组件现在必须重新渲染。解决这个问题的唯一方法是大量使用记忆化,这又让我们回到了记忆化固有的问题上。
寻找更好的方式来管理状态
我们又回到了寻找下一代状态基元的绘图板上。我们想创造一些能解决当前解决方案中问题的东西。手动进行框架集成、过渡依赖记忆化、对上下文的次优使用以及缺乏可编程的可观察性,这些都让人感觉很落后。
开发者需要考虑选择加入这些策略的性能。如果我们能扭转这种情况,提供一个默认快速的系统,使最佳性能成为你必须努力选择的东西,那会怎么样?
我们对这些问题的答案是Signals。这是一个默认快速的系统,不需要在你的应用程序中使用记忆化或其它技巧。Signals提供了细粒度状态更新的好处,无论该状态是全局的、通过props或者上下文传递的,还是在某个组件的局部。
通往未来的Signals
Signals背后的主要思想是,我们不是通过组件树传递一个值,而是传递一个包含该值的signal对象(类似于ref
)。当一个signal的value属性改变时,signal本身保持不变。因此,signal可以被更新而不需要重新渲染它们所经过的组件,因为组件看到的是signal而不是它的值。这让我们跳过了渲染组件的昂贵工作,并立即跳到树中实际访问signal的value属性的特定组件。
我们正在运用这样一个事实:一个应用程序的状态图通常比它的组件树要浅得多。这样可以做出更快的渲染,因为与组件树相比,更新状态图所需的工作量要少得多。这种差异在浏览器中测量时最为明显--下面的截图显示了同一个应用程序两次测量的DevTools Profiler轨迹:第一次使用hooks,第二次使用Signals。
signals版本大大优于任何传统的基于虚拟DOM的框架的更新机制。在我们测试的一些应用程序中,signals的速度非常快,以至于在火焰图中根本难以找到它们。
signals的性能是翻转的:signals不是通过记忆化或者选择器来选择性能,signals默认就是快速的。有了signals,性能就可以选择性不考虑(之前不使用signals)。
为了达到这样的性能水平,signals是建立在这些关键原则之上的:
- 默认情况下是懒惰的:只观察和更新目前在某处被使用到的signal--断开连接的signal不会影响性能。
- 最佳更新:如果一个signal的value没有被修改,使用该signal的value的组件和effects就不会被更新,即使该signal的依赖关系已经发生改变。
- 最佳的依赖性跟踪:框架为你跟踪所有东西所依赖的signal--没有像钩子那样的依赖性数组。
- 直接访问:在组件中访问一个signal的value会自动订阅更新,不再需要选择器或者hook。
这些原则使得signal很适合广泛的使用场景,甚至与渲染用户界面无关的场景。
将signals带入Preact
在确认了正确的状态基元后,我们开始将其与Preact进行连接。我们一直都很喜欢hooks,因为它们可以直接在组件中使用。与第三方状态管理解决方案相比,这是一个具备人性化的优势,后者通常依靠“选择器”函数或将组件包裹在一个特殊的函数中来订阅更新。
// 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的人来说,像选择器和包装器这样的概念是额外的范式,必须在感觉到每个状态管理解决方案有成效之前学会。
理想情况下,我们不需要知道选择器或包装器函数,可以直接访问组件中的状态:
// 假设这是一个全局状态,整个应用程序可以访问: let count = 0; function Counter() { return ( <button onClick={() => count++}> value: {count} </button> ); }
上面的代码很清晰,很容易理解发生了什么,但是不幸的是,它并没有发挥作用。当点击按钮时,组件并没有更新,因为没有办法知道count
已经改变。
我们无法将这个场景从我们的脑海中抹去。我们能做些什么来使如此清晰的模型成为现实呢?我们开始使用preact的可插拔渲染器,对各种想法和实现进行原型设计。功夫不负有心人,我们最终找到了一个实现的方法:
// 假设这是一个全局状态,整个应用程序可以访问: const count = signal(0); function Counter() { return ( <button onClick={() => count.value++}> Value: {count.value} </button> ); }
没有选择器,没有封装函数,什么都没有。访问signal的value就足以让组件知道它需要在该signal的value发生变化时进行更新。在几个应用程序中测试了这个原型后,很明显我们发现了一些事情。这样写代码感觉很直观,而且不需要任何精神体操来保持事物的最佳状态。
我们的代码可以跑得更快吗?
我们本可以在这里停下来,按原来这样发布signals,但这是Preact团队:我们需要看看我们能把Preact的集成推动到什么程度。在上面的Counter例子中,count
的value只是用来显示文本,这确实不应该去重新渲染整个组件。如果我们不在signal的value变化时自动重新渲染组件,而只重新渲染文本呢?更妙的是,如果我们完全绕过虚拟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} />
所以,是的,我们也这样做了。你可以在通常使用字符串的任何地方将signal直接传入JSX。signal的value将被呈现为文本,当signal发生变化时,它将自动更新自己。这也适用于props。
下一步
如果你很好奇并想直接进入查看,请到我们的文档中查看signals。我们很乐意听到你将如何使用它们。
请记住,不要急于切换到signals。hooks将继续被支持,而且它们与signals一起使用也很好!我们建议逐渐尝试signals。我们建议逐渐尝试使用signals,从一些组件开始,以适应这些概念。
加载全部内容