React 与 Signals:十年之后

2025-05-28

React 与 Signals:十年之后

温斯顿·丘吉尔的名言是怎么说的?

那些不吸取历史教训的人注定会重蹈覆辙

尽管可能会增加更具讽刺意味的附录。

当其他人重复历史时,研究历史的人注定只能袖手旁观。

在过去的几周里,我们看到了前端世界对细粒度反应性(称为信号)复兴的兴奋之情达到了顶峰。

有关 JavaScript 中信号的历史,请查看我的文章:

事实是,信号从未消失。它们默默无闻地存在了好几年,要么作为第三方库,要么隐藏在框架的纯对象 API 后面。尽管 React 和虚拟 DOM 带来的普遍论调谴责这些模式不可预测且危险。他们并没有错。

但这并非一场持续了十年的争论。因此,我想谈谈这些年来事情是如何变化的,并以SolidJS为例。


“修复”前端

这场对话的核心是理解 React 是什么。React 不是它的虚拟 DOM。React 也不是 JSX。到目前为止,我对这个主题的解释之一来自 Dan Abramov 最早的一篇文章《你错过了 React 的重点》,他在文中指出 React 的真正优势在于:

组合、单向数据流、摆脱 DSL、显式变异和静态心理模型。

React 有一套非常强大的指导原则,比任何实现细节都重要。甚至多年以后,React 的思想领袖们仍然认为它已经解决了前端问题。

但假设一下,如果他们不这么做呢?如果还有其他方法可以解决当前的问题,而不需要进行如此剧烈的重新调整呢?


可靠的替代方案

Solid 背后的概念同样简单。它甚至继承了诸如组合、单向数据流和显式修改等理念,这些理念让 React 感觉像是 UI 开发的简单答案。不同之处在于,在响应式世界之外,一切都是效果。它几乎与 React 截然相反,React 将你所做的一切都视为纯粹的(即没有副作用)。

当我考虑创建一个不使用任何模板的信号计数器按钮时,我会这样做:



function MyCounter() {
  const [count, setCount] = createSignal();

  const myButton = document.createElement("button");
  myButton.onclick = () => setCount(count() + 1);

  // update the text initially and whenever count changes
  createEffect(() => {
    myButton.textContent = count();
  });

  return myButton;
}


Enter fullscreen mode Exit fullscreen mode

我会调用我的函数并返回一个按钮。如果我需要另一个按钮,我会再做一次。这基本上就是设置好后就忘了。我创建了一个 DOM 元素并设置了一些事件监听器。就像 DOM 本身一样,我不需要调用任何东西来更新按钮。它是独立的。如果我想要一种更符合人体工程学的编写方式,我会使用 JSX。



function MyCounter() {
  const [count, setCount] = createSignal();

  return <button onClick={() => setCount(count() + 1)}>
    {count()}
  </button>
}


Enter fullscreen mode Exit fullscreen mode

Signals 与以往不同。它们的执行毫无故障。它们是推/拉混合体,可以模拟 Suspense 或并发渲染等预定的工作流。并通过自动处置来缓解泄漏的观察者模式。多年来,它们不仅在更新方面,而且在创建方面都一直处于领先地位。


不变性

到目前为止还好吗?也许不是:

显然,这是 React 解决的问题。他们到底解决了什么?

暂且不论 Dan 的具体担忧,归根结底还是在于不可变性。但并非以最直接的方式。信号本身是不可变的。你不能修改它们的内容并期望它们做出反应。



const [list, setList] = createSignal([]);

createEffect(() => console.log(JSON.stringify(list())));

list().push("Doesn't trigger");

setList(() => [...list(), "Does trigger"]);


Enter fullscreen mode Exit fullscreen mode

即使使用 Vue、Preact 或 Qwik 的变体,.value你也只是替换了值,而不是通过赋值来改变它。那么,信号是“可变状态”是什么意思呢?

拥有细粒度的事件驱动架构的好处是可以进行隔离更新。换句话说,就是改变。相比之下,React 的纯渲染模型抽象出了底层可变的世界,每次运行时都会重新创建其虚拟表示。

当查看两个驱动状态更新的声明性库时,如果数据接口明确、副作用得到管理且执行定义明确,这种区别有多重要?


单向流

我不喜欢双向绑定。单向流其实是个好东西。我经历过这些推文中提到的那些事情。你可能已经注意到 Solid 在其原语中采用了读/写隔离。甚至在其嵌套的反应式代理中也是如此。

如果你创建一个响应式原语,你会得到一个只读接口和一个写入接口。这种想法在 Solid 的设计中根深蒂固,以至于社区成员喜欢戏弄我,滥用 getter 和 setter 来伪造可变性。

我想在 Solid 设计中做到的一件事就是保持思考的局部性。Solid 中的所有工作都在效果所在的地方完成,也就是我们插入 DOM 的地方。父级是否使用信号并不重要。您可以根据自己的需求进行编写。prop如果需要,可以将其视为响应式,并在需要时访问它。无需全局思考。无需担心可重构性。

为了进一步强调这一点,我们建议在编写 props 时访问信号的值,而不是向下传递。让你的组件接收值而不是信号。如果 Solid 可以响应式,它会通过将这些值包装在 getter 中来保持响应性。



<Greeting name={name()} />

// becomes
Greeting({ get name() {  return name() })

<Greeting name={"John"} />

// becomes
Greeting({ name: "John" })


Enter fullscreen mode Exit fullscreen mode

它是如何知道的?一个简单的启发式方法。如果表达式包含函数调用或属性访问,它会对其进行包装。JavaScript 中的响应式值必须是函数调用,这样我们才能跟踪读取操作。因此,任何函数调用或属性访问(可能是 getter 或代理)都可能是响应式的,所以我们对其进行包装。

积极的一面是,Greeting无论你如何使用,你都可以以相同的方式访问属性:props.name。无需进行isSignal不必要的检查或过度包装,即可将其转换为信号。props.name始终为string。并且作为值,不存在任何突变的预期。Props 是只读的,数据单向流动。


选择加入与选择退出

这或许是讨论的关键所在。解决这个问题的方法有很多。大多数库出于开发者体验的考虑,选择了响应式,因为自动依赖跟踪意味着无需担心错过更新。

对于 React 开发者来说,这不难想象。想象一下没有依赖数组的 Hooks。Hooks 中依赖数组的存在表明 React 可能会错过更新。同样,use client在使用 React 服务器组件时,您需要选择使用客户端组件 ()。多年来,其他解决方案一直在通过编译自动化实现此操作,但有时需要明确说明。

这通常不是一个单一的决定。在任何框架中,你都会有选择加入和退出的内容。实际上,所有框架可能都更像这样:

框架的理想可能无可非议,但现实却并非如此明确。

这让我想到了这个例子:

从 Solid 的角度来看,这两个函数截然不同,因为 Solid 会处理 JSX,而且它们只运行一次。这一点并不会让人产生歧义,只要你意识到这一点,就可以轻松避免。而且 Solid 甚至还为此提供了 lint 规则。

就像期望它们是一样的:



const value = Date.now();
function getTime1() {
  return value;
}

function getTime2() {
  return Date.now();
}


Enter fullscreen mode Exit fullscreen mode

移动表达式不会改变Date.now()实际的内容,但提升会改变函数的行为。

也许这并不理想,但这种思维模式也并非没有好处:


这真的可以“修复”吗?

这才是合乎逻辑的后续。这很大程度上是一个语言问题。fixed 到底是什么样子?编译器的挑战在于,它更难处理边缘情况,也更难理解出错时会发生什么。这很大程度上也是 React 或 Solid 历来非常谨慎地保持清晰边界的原因。

自从 Solid 首次推出以来,我们就让人们探索 不同的 编译,因为信号作为原语具有很强的适应性并且性能非常好。

2021年,我尝试了一下。

React 团队也宣布他们也在关注这个问题。

两个系统都有各自的规则。React 希望你记住不要在函数体中执行不纯的操作。因为如果你这样做,你可能会发现抽象泄漏,尤其是在他们优化底层代码的情况下。这包括可能不会重新运行组件的某些部分。

Solid 已经进行了优化,不需要编译器或额外的包装器(如React.memo、、 ) useCallbackuseRef但像 React 一样,可以从一些更简化的人体工程学中受益,例如不必担心指示读取信号的位置。

最终结果几乎是一样的。


最后的想法

图片描述

最奇怪的是,React 团队在审视 Reactivity 时,感觉自己不像在照镜子。通过添加 Hooks,他们牺牲了部分重新渲染的纯粹性,换来了一个接近 Signals 的模型。而通过添加编译器来移除与记忆相关的 Hooks,他们完成了这个故事。

现在应该明白,这些都是独立开发的。或者至少从未得到过任何承认,考虑到老派人士的观点,这并不奇怪:

幸运的是,今天的 React 也不是 10 年前的 React。

React 教会了我们构建 UI 的重要原则,从而改变了前端世界。他们凭借坚定的信念,在纷乱的海洋中发出了独特的理性之声。我们今天的成就离不开他们的杰出贡献,无数人从他们的经验中汲取了教训。

时代变了。被“固化”的范式再次浮现,几乎是理所当然的。它完成了整个循环,完成了整个故事。当所有的喧嚣和部落主义消散后,剩下的,是一个良性竞争推动网络前进的故事。


断章取义地拼凑引言来构建叙事,无疑是不礼貌的。时代在进步,观点也在变化。但这正是 React 思想领导力长期以来坚定不移的观点。他们从一开始就一直强调这一点。我几乎不费吹灰之力就从过去几天的对话中找到了所有这些引言。

有关 React 早期历史的更多信息,请观看:

文章来源:https://dev.to/this-is-learning/react-vs-signals-10-years-later-3k71
PREV
成为 Flutter 开发者的路线图。
NEXT
React Refs:完整故事 可变数据存储 带有 Refs 的可视化计时器 DOM 元素引用 组件引用 类组件引用 单向流 将数据添加到 Ref 使用 useEffect 回调 Refs useState Refs 结论