大家好,这里是大家的林语冰。本期《前端翻译计划》共享的是 Preact 的一篇官方博客。
地球人都知道,一大坨 JS 框架都已经引入了 Signal(信号)筑基的响应式原理:
- Vue Ref
- Solid Signal
- Angular Signal
- Preact Signal
Signal 是一种表达状态的方式,它能确保无论 App 变得多复杂,它们都能保持高性能。Signal 基于响应式原理,并提供出色的开发者人体工程学设计,且具有针对虚拟 DOM 优化的独特实现。
免责声明
本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请临幸 Introducing Signals。
从本质上讲,Signal 是一个具有 .value
属性并保存某些值的对象。当 Signal 的值变化时,从组件内访问 Signal 的 value
属性会自动更新该组件。
除了简单易写之外,这还可以确保无论您的 App 有多少组件,状态更新都能保持高性能。默认情况下,Signal 性能惊人,它会在幕后自动为您优化更新。
js
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>
)
}
与 Hook 不同,Signal 可以在组件内外使用。Signal 还可以与 Hook 和类组件"梦幻联动",因此您可以按照自己的节奏引入它们,并随时运用现有的知识。在若干组件中小试牛刀,并随着时间的推移渐进式采用它们。
顺便一提,我们始终坚持为您提供尽可能小的库的初衷。在 Preact 中使用 Signal 仅会增加 1.6kB 的打包体积。
Signal 可以解决哪些问题?
在过去的几年里,我们开发了各种各样的 App 和团队,从小型初创公司到拥有数百名开发者同时交付的大型企业。在此期间,核心团队中的每个人都注意到 App 状态管理方式反复出现的问题。
人们已经创建了某些出色的技术方案来解决这些问题,但即使是最好的解决方案,也仍然需要手动集成到框架中。因此,我们看到开发者在采用这些解决方案时犹豫不决,它们反而更喜欢诉诸框架提供的状态原语构建。
我们将 Signal 打造成一个引人注目的解决方案,它将最佳性能和开发者人体工程学与无缝框架集成相结合。
全局状态混乱
App 状态起初通常短小精悍,可能是若干简单的 useState
钩子。随着 App 的增长,更多的组件需要访问同一状态,该状态最终会提升到一个公共的祖先组件。这种模式会重复多次,直到大多数状态最终接近组件树的根组件。
这种情况对传统的虚拟 DOM 筑基的框架提出了挑战,该框架必须更新受状态失效影响的整个树。本质上,渲染性能是该树中组件数量的函数。我们可以通过使用 memo
或 useMemo
对组件树的部分内容记忆化来解决这个问题,以便框架接收相同的对象。当没有任何变化时,这可以让框架跳过渲染树的某些部分。
虽然理论很合理,但是现实更混乱。实际上,随着代码库的增长,很难确定在哪优化。通常,即使是善意的记忆化也会因不稳定的依赖值而变得无效。由于 Hook 没有可以分析的显式依赖关系树,因此工具无法帮助开发者诊断依赖关系不稳定的原因。
Context 混乱
团队实现状态共享的另一个常见解决方法是将状态置于 context
中。这允许通过潜在地跳过 context provider
和 consumer
之间的组件的渲染来短路渲染。但有一个问题:只能更新传递给 context provider
的值,并且只能作为一个整体进行更新。更新通过 context
公开的对象属性不会更新该 context consumer
------ 粒度更新是不可能事件。处理此问题的可用选项是将状态拆分为多个 context
,或者在 context
对象的任何属性发生更改时,通过克隆 context
对象来使 context
对象失效。
起初,将值转移到 context
中似乎值得权衡,但仅仅为了共享值而增加组件树大小的缺点最终会成为一个问题。业务逻辑最终不可避免地取决于多个 context
值,这可能迫使它在树中的特定位置实现。在树中间添加订阅 context
的组件成本很高,因为它减少了更新 context
时可以跳过的组件数量。此外,订阅者下面的任何组件现在都必须再次渲染。解决这个问题的唯一方法是大量使用记忆化,这让我们回到了记忆化固有的问题。
寻找更好的状态管理方法
我们回到绘图板寻找下一代状态原语。我们想要创造某些能够同时解决当前解决方案中的问题的东东。手动框架集成、过度依赖记忆、上下文使用欠佳以及缺乏程序可观察性都让人感觉倒退。
开发者需要"选用"这些策略来提高性能。如果我们可以扭转这种情况并提供一个默认速度很快的系统,使最佳情况下的性能成为您必须努力选择退出的东西,那该怎么办?
我们对这些问题的答案是信号。这是一个默认速度很快的系统,不需要在整个 App 中实现记忆化或技巧。Signal 提供了细粒度状态更新的好处,无论该状态是全局的、通过 props 或上下文传递的,还是组件本地的。
向未来发出信号
Signal 背后的主要思想是,我们不直接通过组件树传递值,而是传递包含该值的 Signal 对象(类似于 ref
)。当 Signal 的值变化时,Signal 本身保持不变。因此,Signal 可以更新,而无需重新渲染它们所经过的组件,因为组件看到的是 Signal 而不是它的值。这让我们可以跳过渲染组件的所有昂贵工作,并立即跳转到树中实际访问 Signal 值的特定组件。
我们正在利用这样一个事实:App 的状态图通常比其组件树浅得多。这会带来更快的渲染速度,因为与组件树相比,更新状态图所需的工作要少得多。在浏览器中测量时,这种差异最为明显 ------ 下面的屏幕截图显示了对同一 App 测量两次的 DevTools Profiler 跟踪:一次使用 Hook 作为状态原语,第二次使用 Signal:
Signal 版本远远优于任何传统的虚拟 DOM 筑基的框架的更新机制。在我们测试过的某些 App 中,Signal 速度快得多,以至于很难在火焰图中找到它们。
Signal 扭转了性能调高:Signal 默认很快,而不是通过记忆化或选择器选择性能。对于 Signal,性能可以选择弃用(通过不使用 Signal)。
为了达到这种性能水平,Signal 是基于以下关键原则构建的:
- 默认是惰性的:仅观察和更新当前在某处使用的 Signal ------ 断开连接的 Signal 不会影响性能。
- 最佳更新:如果 Signal 的值未更改,则使用该 Signal 值的组件和作用(effect)将不会更新,即使 Signal 的依赖性已更改。
- 最佳依赖项跟踪:框架跟踪哪些 Signal 由您决定 ------ 没有像 Hook 那样的依赖数组。
- 直接访问:访问组件中 Signal 的值会自动订阅更新,无需选择器或 Hook。
将 Signal 引入 Preact
确定了正确的状态原语后,我们开始将其连接到 Preact。我们始终喜欢 Hook 的一点是,Hook 可以直接在组件内部使用。与第三方状态管理解决方案相比,这是一个符合人体工程学的优势,第三方状态管理解决方案通常依赖于"选择器"函数或将组件包装在特殊函数中来订阅状态更新。
js
// 选择器筑基的订阅 :(
function Counter() {
const value = useSelector(state => state.count)
// ...
}
// 基于订阅的包装函数 :(
const counterState = new Counter()
const Counter = observe(props => {
const value = counterState.count
// ...
})
这两种方法都令我们满意。选择器方法需要将所有状态访问包装在选择器中,这对于复杂或嵌套状态来说变得乏味。将组件包装在函数中的方法需要手动包装组件,这会带来许多问题,比如缺少组件名称和静态属性。
在过去的几年里,我们有机会与许多开发者密切合作。一个常见的难题,特别是对于那些刚接触 (p)react 的人来说,是选择器和包装器之类的概念是额外的范式,必须先学习这些范式,然后才能对每个状态管理解决方案感到富有成效。
理想情况下,我们不需要了解选择器或包装函数,并且可以直接在组件内访问状态:
js
// 请想象这是某个全局状态,且整个 App 需要访问它:
let count = 0
function Counter() {
return <button onClick={() => count++}>value: {count}</button>
}
代码一目了然,浅显易懂,但不幸的是,它不起作用。单击按钮时组件不会更新,因为无法知道 count
已更改。
但我们无法摆脱这种场景。我们可以做些什么来使如此清晰的模型变成现实?我们开始使用 Preact 的可插入渲染器对各种想法和实现进行原型设计。这需要时间,但我们最终找到了实现这一目标的方法:
js
// 请想象这是某个全局状态,且整个 App 需要访问它:
const count = signal(0)
function Counter() {
return <button onClick={() => count.value++}>Value: {count.value}</button>
}
没有选择器,没有包装函数,什么都没有。访问 Signal 的值足以让组件知道,当该 Signal 的值变化时,它需要更新。在若干 App 中测试了原型后,很明显我们正在做某些事情。以这种方式编写代码感觉很直观,不需要任何"心理体操"就能让事情保持最佳状态。
我们还能走得更快吗?
我们本可以停在这里并按原样发布 Signal,但这就是 Preact 团队:我们需要看看我们可以将 Preact 集成推进到什么程度。在上面的 Counter
示例中,count
的值仅用于显示文本,这实际上不需要重新渲染整个组件。如果我们只重新渲染文本,而不是在 Signal 的值变化时自动重新渲染组件,那会怎样?更好的是,如果我们完全绕过虚拟 DOM 并直接在 DOM 中更新文本又会怎样?
jsx
const count = signal(0);
// 取而代之:
<p>Value: {count.value}</p>
// 我们可以直接将 Signal 传递给 JSX:
<p>Value: {count}</p>
// 或者甚至将它们用作 DOM 属性传递:
<input value={count} onInput={...} />
是的,我们也干了。您可以将 Signal 直接传递到 JSX 中通常使用字符串的任何位置。Signal 的值将渲染为文本,并且当 Signal 变化时,它会自动更新。这也适用于 props
。
您现在收看的是《前端翻译计划》,学废了的小伙伴可以订阅此专栏合集,我们每天佛系投稿,欢迎持续关注前端生态。谢谢大家的点赞,掰掰~