Signal 简介

大家好,这里是大家的林语冰。本期《前端翻译计划》共享的是 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 筑基的框架提出了挑战,该框架必须更新受状态失效影响的整个树。本质上,渲染性能是该树中组件数量的函数。我们可以通过使用 memouseMemo 对组件树的部分内容记忆化来解决这个问题,以便框架接收相同的对象。当没有任何变化时,这可以让框架跳过渲染树的某些部分。

虽然理论很合理,但是现实更混乱。实际上,随着代码库的增长,很难确定在哪优化。通常,即使是善意的记忆化也会因不稳定的依赖值而变得无效。由于 Hook 没有可以分析的显式依赖关系树,因此工具无法帮助开发者诊断依赖关系不稳定的原因

Context 混乱

团队实现状态共享的另一个常见解决方法是将状态置于 context 中。这允许通过潜在地跳过 context providerconsumer 之间的组件的渲染来短路渲染。但有一个问题:只能更新传递给 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

您现在收看的是《前端翻译计划》,学废了的小伙伴可以订阅此专栏合集,我们每天佛系投稿,欢迎持续关注前端生态。谢谢大家的点赞,掰掰~

相关推荐
WeiXiao_Hyy3 分钟前
成为 Top 1% 的工程师
java·开发语言·javascript·经验分享·后端
吃杠碰小鸡20 分钟前
高中数学-数列-导数证明
前端·数学·算法
kingwebo'sZone26 分钟前
C#使用Aspose.Words把 word转成图片
前端·c#·word
xjt_09011 小时前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农1 小时前
Vue 2.3
前端·javascript·vue.js
夜郎king1 小时前
HTML5 SVG 实现日出日落动画与实时天气可视化
前端·html5·svg 日出日落
辰风沐阳1 小时前
JavaScript 的宏任务和微任务
javascript
夏幻灵2 小时前
HTML5里最常用的十大标签
前端·html·html5
冰暮流星2 小时前
javascript之二重循环练习
开发语言·javascript·数据库
Mr Xu_3 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js