JavaScript 中的信号

2025-05-28

JavaScript 中的信号

最近几周,围绕 Signals 的讨论铺天盖地,我却忘了说说可以说是最重要的话题。你为什么要关心它?我已经介绍了它的演变反对意见,但实际上我还没有提出使用它们的必要性。

关于它们的性能,一直存在一种说法,这并非毫无道理,但远不止于此。它不仅仅关乎开发人员的体验。它颠覆了当前的范式。

React 因以下几点而广受欢迎:

视图 = fn(状态)

这是一个非常强大的UI思维模型。但它更代表着一种理想,一种值得为之奋斗的东西。

现实情况要复杂得多。底层 DOM 是持久且可变的。单纯的重新渲染不仅成本过高,还会从根本上破坏用户体验。(输入失去焦点、动画等等……)

缓解这种情况的方法有很多。我们构建越来越好的构想,希望以此重塑现实,使其适应现实。但到了一定时候,我们需要将现实与理想分开,才能坦诚地谈论这些事情。

所以今天我们来看一下信号的本质以及它能提供什么。


将性能与代码组织分离

图片描述

正是在这时,你第一次意识到发生了一些真正不同的事情。然而,事情远不止于此。这并不是鼓励你在你的应用中堆砌全局状态,而是一种说明状态独立于组件的方式。

function Counter() {
  console.log("I log once");

  const [count, setCount] = createSignal(0);
  setInterval(() => setCount(count() + 1), 1000);

  return <div>{count()}</div>
}
Enter fullscreen mode Exit fullscreen mode

类似地,console.log当计数器更新时不会重新执行是一个巧妙的技巧,但并没有说明全部情况。

事实是,这种行为会持续贯穿整个组件树。源自父组件并在子组件中使用的状态不会导致父组件或子组件重新运行。只有依赖它的 DOM 部分才会重新运行。Prop 钻取、Context API 或其他任何东西都是一样的。

这不仅涉及跨组件传播状态变化的影响,还涉及同一组件内的多种状态的影响。

function MoreRealisticComponent(props) {
  const [selected, setSelected] = createSignal(null);

  return (
    <div>
      <p>Selected {selected() ? selected().name : 'nothing'}</p>

      <ul>
        {props.items.map(item =>
          <li>
            <button onClick={() => setSelected(item)}>
              {item.name}
            </button>
          </li>
        )}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

将示例从 Svelte 创作者 Rich Harris 的Virtual DOM移植到 SolidJS 会产生纯开销,重要的是要理解,使用信号时,更新选中状态除了更改其中的文本外,不会引起任何其他执行<p>。无需重新运行列表或进行 diff 操作。即使其中一行的名称更新,情况也是如此。使用信号,我们可以直接更新该按钮的文本。

注意:在 Solid 中,通常在渲染循环时使用<For>组件而不是.map,以便在items插入、删除或移动条目时不必重新创建每一行。

你可能会想:“我明白了。它确实很快。但我从来没有遇到过在像 React 这样的东西中确保良好性能的问题。” 但收获远不止于此。

您不再需要关心组件以实现最佳执行。

您可以将整个应用放在一个或多个组件中,并获得同样的好处。您可以根据自己的需要拆分组件。您可以根据自己的需求组织应用,而不是因为需要隔离 UI 中某些会发生变化的部分。如果性能成为问题,那也并非因为组件的结构需要进行昂贵的重构。

这对于开发人员体验来说不是一个微不足道的好处。


动态与静态分离

有人讨论过这是一件坏事。如果你想了解更多观点,请参阅@dan_abramov对我之前文章的回复

我不仅想谈论这为什么是一件好事,还想谈谈它实际上是多么了不起的事情。能够针对每个方面进行优化是有益的。这是与底层平台保持一致会带来回报的领域之一。

传统的分布式事件系统(例如 Signals)与自上而下运行的系统之间存在权衡。虽然事件系统的更新速度更快,但在创建时,它会产生设置订阅的额外开销。

由于 Web 界面通常以文档为导向,这种情况更加严重。即使在单页应用中,你也需要进行大量的导航,这需要大量的创作。

有道理。然而,Web 平台意识到了这种成本,并已使批量创建元素比单独创建更高效​​。提取静态部分进行批量创建比订阅式创建更具影响力。

而且其好处远不止于浏览器。基于 Signals 的系统,代码的复杂性、大小和执行速度会随着 UI 的交互程度而变化,而不是随着 UI 中包含的元素数量而变化。

假设一个服务器渲染的页面,交互很少。比如一个电商网站。静态部分是服务器渲染的 HTML。你甚至不需要相关的代码就能让它具有交互性。只需从 bundle 中删除静态部分即可。

来自 Builder.io 的 Steve(1:16)解释了Qwik中的工作原理

诚然,这主要是为了性能。这与Islands 架构React 服务器组件的初衷相同。它解决了我们今天面临的一个非常现实的痛点: JavaScript 包越来越大,初始页面加载速度越来越慢。

总的来说,我的立场是,这种分离会带来一定程度的透明度。它使得解释和推理实际情况变得更容易。虽然不如理想状态简单,但它使应急出口(任何系统的重要组成部分)更加连贯。


普及用户界面语言

信号最强大的地方之一,在于它能将其影响视为一种语言。我指的并非编译型语言。信号完全是一种运行时机制。这里没有什么魔法,只是一个有向无环图。

虽然状态、派生状态和效果的概念显然存在趋同,但并非所有的思维模型和实现都一致。

信号独立于任何组件或渲染系统,并且仅表示状态关系。与 React Hooks 不同,它需要额外的原语来描述如何保护执行useCallback,例如 ,React.memo以及稳定引用( useRef) 等概念来处理效果的协调。

文章底部列出的 Dan 的两篇文章都很好地探索了如何在 React 中有效地使用这些原语。

此外,信号有助于实现可追溯性。它能让你了解更新了什么以及为什么更新。

它们鼓励使用能够生成更具声明性代码的模式。通过围绕数据而非组件流来组织代码,我们能够看到哪些数据在驱动变化。(感谢 Dan 提供的示例)。

// control flow
const count = videos.length;
let heading = emptyHeading;
let somethingElse = 42;
if (count > 0) {
  const noun = count > 1 ? 'Videos' : 'Video';
  heading = count + ' ' + noun;
  somethingElse = someOtherStuff();
}

// derived data
const format = (count) => count > 1 ? 'Videos' : 'Video';

const count = videos.length;
const heading = count > 0 ? format(count) : emptyHeading;
const somethingElse = count > 0 ? someOtherStuff : 42;
Enter fullscreen mode Exit fullscreen mode

这提出了一个关于代码用途的有趣问题:我们应该优化代码,使其更易于编写还是更易于阅读?


好的,但是权衡又如何呢?

图片描述

当然,这其中也存在一些权衡。最明显的一点是,它们使数据变得特殊,而不是使数据的应用变得特殊。我们不再处理普通对象,而是处理原语。这与 Promises 或事件发射器非常相似。你考虑的是数据流,而不是控制流。

JavaScript 并非数据流语言,因此可能会失去响应性。公平地说,任何 JavaScript UI 库或框架,在没有工具或编译帮助的情况下,都会失去响应性。对于信号而言,这一点尤为突出,因为访问值的位置至关重要。

我把这称为 Signal 的(单数)钩子规则。这会带来一些后果。它有一个学习曲线。它会迫使你以某种方式编写代码。使用代理之类的东西时,还有一些额外的注意事项,例如 JavaScript 语言中的某些机制(例如扩展、解构)限制了其使用。

另一个考虑因素是关于数据处置的。订阅是双向链接的,因此如果一方的生命周期较长,则可能会占用比预期更长的内存。现代框架非常擅长自动处理这种数据处置,但这是 Signal 的设计固有的。

最后,历史上曾有人担心大型不可控的图、循环和不可预测的传播。由于过去几年所做的工作,这些担忧在很大程度上已成为过去。我想说,这些问题正是 Signals 所解决的,也是为什么你会选择它而不是其他消息/事件系统的原因。


结论

应对创建出色用户界面的挑战有很多方法。我力求让讨论保持务实,但我认为其中有很多值得探讨的地方。

当你基于原语进行构建时,你可以做很多事情。探索减少 JavaScript 加载开销和增量交互是 Signals 自然而然适合的一个领域。

使用信号的最大好处是,您不需要编译器。甚至模板也不需要。您可以使用标记模板字面量,无需构建步骤即可完成所有操作。虽然我们倾向于使用编译,以使人机工程学更流畅。但信号也是编译的绝佳选择。

当你拥有高效的构建模块时,编译器和语言探索就会变得更容易。这不仅适用于我们,也适用于人工智能。我们已经看到有人建议改进方方面面,从使用分析技术来驱动代码拆分,到优化初始负载,再到优化编译器理解代码意图的能力。

无论信号最适合由开发人员持有还是作为机器的低级原语,它们似乎都是不断发展的 Web 前端世界中的重要一步。


相关资源:

细粒度反应性的实践介绍
JavaScript 中信号的演变
React 与信号:10 年后
虚拟 DOM 是纯粹的开销 作者: Rich Harris
组件是纯粹的开销
形而上学和 JavaScript作者:Rich Harris
使用 React Hooks 使 setInterval 具有声明性作者:Dan Abramov
在你 memo() 之前作者:Dan Abramov

文章来源:https://dev.to/this-is-learning/making-the-case-for-signals-in-javascript-4c7i
PREV
微前端:我的经验教训
NEXT
JavaScript vs JavaScript:第二轮。战斗!