🚦 JavaScript Signals 标准提案🚦

Signals 的目标是提供管理此类应用程序状态的基础设施,以便开发人员可以专注于业务逻辑,而不是这些重复的细节。

阶段 1 (说明)

TC39 提案倡导者:Daniel Ehrenberg、Yehuda Katz、Jatin Ramanathan、Shay Lewis、Kristen Hewell Garrett、Dominic Gannaway、Preston Sego、Milo M、Rob Eisenberg

原始作者:Rob Eisenberg 和 Daniel Ehrenberg

本文件描述了 JavaScript 中 Signals 的早期共同方向,类似于在 TC39 于 ES2015 中标准化 Promises 之前进行的 Promises/A+ 努力。您可以亲自尝试,使用 一个 polyfill。

与Promises/A+类似,此项工作致力于统一JavaScript生态系统。若该统一工作取得成功,则可基于此经验制定相关标准。多位框架作者正在此合作制定一个通用模型,该模型可作为其响应式核心的基础。当前草稿基于Angular、Bubble、 Ember、FAST、MobX、Preact、Qwik、 RxJS、Solid、Starbeam、 Svelte,Vue,Wiz,以及更多…

与Promises/A+不同,我们并非试图为开发者提供一个通用的API接口,而是专注于底层 Signals 图的精确核心语义。本提案确实包含一个完整的具体API,但该API并非针对大多数应用程序开发者。相反,这里的 Signals API更适合框架构建,通过共同的 Signals 图和自动跟踪机制实现互操作性。

本提案的计划是进行大量早期原型设计,包括集成到多个框架中,然后再推进到阶段 1 之后。我们只对标准化 Signals 感兴趣,如果它们适合在多个框架中实际使用,并提供比框架提供的 Signals 更实际的优势。我们希望早期原型设计能为我们提供这些信息。请参阅下文的“状态和发展计划”以获取更多详细信息。

元素周期表

背景:为何需要 Signals ?

为了开发复杂的用户界面(UI),JavaScript 应用程序开发人员需要以高效的方式存储、计算、无效化、同步并推送状态到应用程序的视图层。UI 通常不仅涉及管理简单值,还常涉及渲染依赖于其他值或状态的复杂树结构的计算状态。Signals 的目标是提供管理此类应用程序状态的基础设施,以便开发人员可以专注于业务逻辑,而不是这些重复的细节。

类似于 Signals 的构造在非 UI 环境中也被发现是有用的,特别是在构建系统中,以避免不必要的重新构建。

Signals 在响应式编程中用于消除应用程序中管理更新的需要。

基于状态变化的声明式更新模型。

来自 什么是响应式编程?。

示例 – VanillaJS 计数器

给定一个变量 counter,你希望根据计数器是偶数还是奇数来渲染到 DOM 中。每当 counter 发生变化时,你希望更新 DOM 以显示最新的奇偶性。在 Vanilla JS 中,你可能会这样做:

let counter = 0;
const setCounter = (value) => {
  counter = value;
  render();
};

const isEven = () => (counter & 1) == 0;
const parity = () => isEven() ? “even” : “odd”;
const render = () => element.innerText = parity();

// 模拟对计数器的外部更新...
setInterval(() => setCounter(counter + 1), 1000);

[!NOTE]
这里使用全局变量仅用于演示目的。正确的状态管理有许多解决方案,本提案中的示例旨在尽可能简化。本提案不鼓励使用全局变量。

这存在多个问题…

  • counter 的初始化过程冗余且重复性高。
  • counter 的状态与渲染系统紧密耦合。
  • 若 counter 变化但 parity 未更新(例如 counter 从 2 变为 4),则会进行不必要的奇偶性计算和渲染。
  • 若 UI 的另一部分仅需在 counter 更新时渲染,该如何处理?
  • 如果 UI 的另一部分仅依赖于 isEven 或 parity 呢?

即使在这种相对简单的场景中,问题也迅速浮现。我们可以尝试通过引入 counter 的发布/订阅机制来绕过这些问题。这将允许 counter 的其他消费者订阅并添加自己的状态变化响应。

然而,我们仍然面临以下问题:

  • 渲染函数仅依赖于 parity,但实际上它需要订阅 counter。
  • 仅基于 isEven 或 parity 无法更新 UI,必须直接与 counter 进行交互。
  • 我们的冗余代码增加了。每次使用某个组件时,不仅仅是调用函数或读取变量,而是需要订阅并在此处进行更新。管理取消订阅也特别复杂。

现在,我们可以通过将 pub/sub 不仅应用于 counter,还应用于 isEven 和 parity 来解决几个问题。然后,我们必须将 isEven 订阅到 counter,将 parity 订阅到 isEven,并将 render 订阅到 parity。不幸的是,不仅我们的冗余代码爆炸式增长,而且我们还陷入了大量订阅管理的困境,如果不以正确的方式清理一切,还可能引发内存泄漏灾难。因此,我们虽然解决了一些问题,却引入了全新类别的问题并产生了大量代码。更糟糕的是,我们必须对系统中的每个状态都重复这一整个过程。

引入 Signals

在多个编程语言的 UI 框架中,模型与视图之间的数据绑定抽象一直是核心功能,尽管 JavaScript 或 Web 平台本身并未内置此类机制。在 JavaScript 框架和库中,人们对数据绑定的不同实现方式进行了大量实验,实践证明,单向数据流与一种表示状态单元或从其他数据派生出的计算结果的一级数据类型相结合,具有强大功能,这种数据类型现在常被称为“ Signals ”。
这种基于一等式反应值的方法似乎首次在开源 JavaScript 网页框架中通过 Knockout 于 2010 年 流行起来。此后数年间,出现了许多变体和实现方式。在过去3至4年间, Signals 原语及相关方法进一步普及,几乎每个现代JavaScript库或框架都以不同名称提供了类似功能。

要理解 Signals ,让我们重新审视上述示例,并结合下方进一步阐述的 Signals API进行分析。

示例 – Signals 计数器

const counter = new Signal.State(0);
const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);
const parity = new Signal.Computed(() => isEven.get() ? “even” : “odd”);

// 库或框架基于其他 Signals 原语定义效果
declare function effect(cb: () => void): (() => void);

effect(() => element.innerText = parity.get());

// 模拟对计数器的外部更新...
setInterval(() => counter.set(counter.get() + 1), 1000);

我们立即可以看出以下几点:

  • 我们消除了之前示例中围绕 counter 变量的冗余代码。
  • 存在一个统一的 API 来处理值、计算和副作用。
  • counter 和 render 之间不存在循环引用问题或倒置依赖关系。
  • 没有手动订阅,也不需要进行任何记录。
  • 存在控制副作用时机/调度的机制。

Signals 提供的功能远不止 API 表面所示:

  • 自动依赖追踪 – 计算 Signals 会自动发现其依赖的其他 Signals ,无论这些 Signals 是简单值还是其他计算。
  • 懒惰评估 – 计算在声明时不会立即评估,即使其依赖项发生变化也不会立即评估。它们仅在值被显式请求时才进行评估。
  • 备忘录 – 计算 Signals 会缓存其最后一个值,因此如果依赖项没有变化,计算无需重新评估,无论被访问多少次。

标准化 Signals 的动机

互操作性

每个 Signals 实现都有自己的自动跟踪机制,用于记录在评估计算 Signals 时遇到的来源。这使得在不同框架之间共享模型、组件和库变得困难——它们往往与视图引擎存在不必要的耦合(因为 Signals 通常作为 JavaScript 框架的一部分实现)。

本提案的目标是完全解耦响应式模型与渲染视图,使开发者能够在不重写非 UI 代码的情况下迁移至新渲染技术,或在 JavaScript 中开发可部署于不同场景的共享响应式模型。遗憾的是,由于版本控制和重复实现的问题,通过 JavaScript 层面的库实现高度共享变得不切实际——内置功能提供了更强的共享保证。

性能/内存使用

由于常用库已内置,减少代码量确实能带来一定的性能提升,但 Signals (Signals)的实现通常非常简洁,因此我们不认为这种效果会非常显著。

我们推测,原生C++实现的与Signals相关的数据结构和算法可能比JS中实现的略微高效,但效率提升仅为常数倍。然而,与polyfill中存在的实现相比,不预期有算法上的变化;引擎在此处不会有魔法般的表现,而reactivity算法本身将被明确定义且无歧义。

冠军组计划开发各种 Signals 实现,并利用这些实现探索性能优化可能性。

开发工具

使用现有 JavaScript 语言的 Signals 库时,难以追踪以下内容:

  • 计算 Signals 链中的调用堆栈,展示错误的因果链
  • Signals 之间的引用图,当一个 Signals 依赖于另一个时——这在调试内存使用时尤为重要

内置的 Signals 使 JavaScript 运行时和开发工具能够潜在地提供改进的 Signals 检查支持,特别是在调试或性能分析方面,无论是内置于浏览器中还是通过共享扩展实现。现有工具如元素检查器、性能快照和内存分析器可以更新,以在信息呈现中特别突出显示 Signals 。

次要好处

标准库的好处

总体而言,JavaScript 的标准库一直较为简陋,但 TC39 的趋势是将 JS 打造为一种“开箱即用”的语言,提供高质量的内置功能集。例如,Temporal正在取代moment.js,而一些小型功能,如Array.prototype.flat和Object.groupBy正在取代许多lodash的使用场景。好处包括更小的包大小、更高的稳定性和质量、加入新项目时需要学习的内容更少,以及JS开发者之间普遍使用的共同术语。

HTML/DOM 集成(未来可能性)

W3C 和浏览器实现者当前的工作旨在为 HTML 引入原生模板功能(DOM Parts 和 Template Instantiation)。此外,W3C Web Components 工作组正在探索将 Web Components 扩展为提供完全声明式 HTML API 的可能性。为了实现这两个目标,HTML 最终将需要一个响应式原语。此外,通过集成 Signals 对 DOM 进行许多人体工程学改进是可行的,并且社区也提出了相关需求。

注意,此集成将作为后续独立努力进行,不属于本提案本身。

生态系统信息交换(非推出理由)

标准化努力有时仅在“社区”层面就有助益,无需浏览器变更。Signals 努力正汇聚众多框架作者,就反应性本质、算法及互操作性展开深入讨论。这已证明是有用的,但不足以纳入 JavaScript 引擎和浏览器;Signals 仅应在带来超越生态系统信息交换的显著好处时,才被添加到 JavaScript 标准中。

Signals 的设计目标

事实证明,现有的 Signal 库在核心上并没有太大差异。本提案旨在基于它们的成功,实现这些库中许多重要的特性。

核心特性

  • 表示状态的 Signals 类型,即可写 Signals 。这是其他对象可以读取的值。
  • 依赖于其他 Signals 并懒加载计算及缓存的计算/备忘/派生 Signals 类型。
    • 计算是懒加载的,即当依赖项之一发生变化时,计算 Signals 不会默认重新计算,而仅在有人实际读取时才运行。
    • 计算是“无故障”(glitch)的,即不会执行任何不必要的计算。这意味着,当应用程序读取一个计算型 Signals 时,会对图中可能脏的部分进行拓扑排序,以消除任何重复计算。
    • 计算结果会被缓存,即如果自上次依赖项发生变化以来,没有其他依赖项发生变化,则在访问时不会重新计算该计算 Signals 。
    • 对于计算 Signals 和状态 Signals ,均可进行自定义比较,以确定依赖于它们的其他计算 Signals 何时需要更新。
  • 当计算得到的 Signals (Signal)的某个依赖项(或嵌套依赖项)发生变化(即“变脏”)时,该 Signals 的值可能已过时,此时会触发相应反应。
    • 该反应旨在安排后续执行更重要的任务。
    • 效果通过这些反应以及框架级别的调度机制实现。
    • 计算 Signals 需要能够响应其是否被注册为这些反应的(嵌套)依赖项。
  • 允许 JS 框架进行自己的调度。不提供类似 Promise 的内置强制调度。
    • 需要同步反应以基于框架逻辑调度后续工作。
    • 写入操作是同步的且立即生效(批量写入的框架可在其上实现此功能)。
    • 可能将检查效果是否“脏”与实际运行效果分离(启用两阶段效果调度器)。
  • 能够在不触发依赖项记录的情况下读取 Signals (untrack)
  • 允许组合使用 Signals /反应性的不同代码库,例如:
    • 在跟踪/反应性本身方面,可以同时使用多个框架(不包括遗漏,见下文)
    • 框架独立的反应性数据结构(例如,递归反应性存储代理、反应性 Map 和 Set 以及 Array 等)

安全性

  • 阻止/禁止对同步反应的简单滥用。
    • 安全性风险:若使用不当,可能暴露“故障”问题:若在设置 Signals 时立即进行渲染,可能导致应用程序状态不完整地暴露给最终用户。因此,该功能仅应用于智能地安排后续任务,待应用程序逻辑完成后再执行。
    • 解决方案:禁止在同步反应回调内读取或写入任何 Signals 。
  • 禁止使用untrack并标记其不安全性质。
    • 安全性风险:允许创建其值依赖于其他 Signals 但当这些 Signals 变化时不会更新的计算 Signals 。它应仅在未跟踪的访问不会改变计算结果时使用。
    • 解决方案:API 名称中标记为“不安全”。
  • 注意:此提案允许从计算 Signals 和效果 Signals 中读取和写入 Signals ,且不限制读取后的写入操作,尽管存在安全性风险。此决策旨在保持与框架集成的灵活性和兼容性。

表面 API

  • 必须为多个框架实现其 Signals /反应机制提供坚实的基础。
    • 应为递归存储代理、装饰器基于的类字段反应性,以及 .value 和 [state, setState] 风格的 API 提供良好基础。
    • 语义能够表达不同框架启用的有效模式。例如,这些 Signals 应可作为立即反映写入或批量写入并稍后应用的写入的基础。
  • 该 API 直接供 JavaScript 开发者使用会很方便。
    • 若某功能与生态系统概念匹配,使用通用术语是可取的。
      • 然而,切勿直接使用完全相同的名称!
    • “JavaScript 开发者易用性”与“为框架提供所有钩子”之间的矛盾
      • 思路:提供所有钩子,但若可能,在误用时触发错误。
      • 想法:将微妙的 API 放在一个 subtle 命名空间中,类似于 crypto.subtle,以区分用于更高级用途(如实现框架或构建开发工具)的 API 与用于日常应用开发(如为框架使用而实例化 Signals )的 API。
  • 确保实现和使用时具有良好的性能——表面 API 不会造成过多开销
    • 支持子类化,以便框架可以添加自己的方法和字段,包括私有字段。这对于避免在框架级别进行额外分配至关重要。参见下文的“内存管理”。

内存管理

  • 若可能:若无活跃引用可能在未来读取该 Signals ,则计算 Signals 应可被垃圾回收,即使其被链接到一个保持活跃的更广泛图中(例如通过读取一个保持活跃的状态)。
    • 注意:目前大多数框架要求显式销毁计算 Signals ,若其与另一个保持活跃的 Signals 图存在引用关系。
    • 当其生命周期与 UI 组件的生命周期绑定时,这种情况并不算太糟糕,因为效果本就需要被释放。
    • 如果按照这些语义执行成本过高,则应在当前缺乏此功能的 API 中添加对计算 Signals 的显式释放(或“断开链接”)功能。
  • 另一个相关目标:尽量减少内存分配次数,例如:
    • 创建可写入的 Signal(避免使用两个独立的闭包 + 数组)
    • 实现效果(避免为每个单独的反应创建闭包)
    • 在观察 Signal 变化的 API 中,避免创建额外的临时数据结构
    • 解决方案:基于类的 API,允许重用子类中定义的方法和字段

API 概述

以下是 Signals API 的初步构想。请注意,这只是早期草稿,未来可能会进行修改。我们先从完整的 .d.ts 文件开始,以了解整体结构,然后再讨论具体细节。

interface Signal<T> {
    // 获取 Signals 的值
    get(): T;
}

命名空间 Signal {
    // 可读写的 Signals 
    类 State<T> 实现 Signal<T> {
        // 创建一个初始值为 t 的状态 Signals 
        构造函数(t: T, 选项?: SignalOptions<T>);

        // 获取 Signals 的值
        获取(): T;

        // 将状态 Signals 的值设置为 t
        set(t: T): void;
    }

    // 基于其他 Signals 的公式计算的 Signals 
    class Computed<T = unknown> implements Signal<T> {
        // 创建一个 Signals ,其值为回调函数返回的值。
        // 回调函数调用时,此 Signals 作为 this 值。
        constructor(cb: (this: Computed<T>) => T, options?: SignalOptions<T>);

        // 获取 Signals 的值
        get(): T;
    }

    // 该命名空间包含“高级”功能,建议框架作者而非应用程序开发者使用。
    // 类似于 `crypto.subtle`
    namespace subtle {
        // 禁用所有跟踪后调用回调
        function untrack<T>(cb: () => T): T;

        // 获取当前被跟踪的计算 Signals (如有)
        function currentComputed(): Computed | null;

        // 返回该 Signals 在最近一次评估时引用的所有 Signals 的有序列表
        // 对于监视器,列出其正在监视的 Signals 集。
        function introspectSources(s: Computed | Watcher): (State | Computed)[];

        // 返回包含此 Signals 的监视器,以及任何
        // 在上次评估时读取此 Signals 的计算 Signals ,
        // 如果该计算 Signals 被(递归地)监视。
        function introspectSinks(s: State | Computed): (Computed | Watcher)[];

        // 如果此 Signals 是“活跃的”(即被监听器监听,
        // 或被一个(递归地)活跃的计算 Signals 读取),则返回 true。
        function hasSinks(s: State | Computed): boolean;

        // 如果此元素是“反应性的”(即依赖于其他 Signals ),
        // 则返回 true。一个计算 Signals 如果 hasSources 为 false,
        // 将始终返回相同的常量。
        function hasSources(s: Computed | Watcher): boolean;

        class Watcher {
            // 当一个(递归的)Watcher 源被写入时,调用此回调,
            // 如果自上次 `watch` 调用以来尚未调用过。
            // 在通知过程中不得读取或写入任何 Signals 。
            constructor(notify: (this: Watcher) => void);

            // 将这些 Signals 添加到 Watcher 的集合中,并设置监听器在集合中的任何 Signals (或其依赖项)发生变化时
            // 运行其通知回调。
            // 该方法可不带参数调用,仅用于重置“已通知”状态,
            // 从而使通知回调再次被调用。
            watch(...s: Signal[]): void;

            // 从监视集合中移除这些 Signals (例如,当效果被销毁时)
            unwatch(...s: Signal[]): void;

            // 返回监视器集合中仍处于脏状态的 Signals 源集合,或是一个计算 Signals 
            // 其源 Signals 处于脏状态或待处理状态且尚未重新评估
            getPending(): Signal[];
        }

        // 用于观察是否被监视或不再被监视的钩子
        var watched: Symbol;
        var unwatched: Symbol;
    }

    interface SignalOptions<T> {
        // 自定义旧值与新值的比较函数。默认使用 Object.is。
        //  Signals 作为上下文的 this 值传递进来。
        equals?: (this: Signal<T>, t: T, t2: T) => boolean;

        // 当 isWatched 变为 true 时调用的回调函数(若之前为 false)
        [Signal.subtle.watched]?: (this: Signal<T>) => void;

        // 当 isWatched 变为 false 时调用的回调函数,如果之前为 true
        [Signal.subtle.unwatched]?: (this: Signal<T>) => void;
    }
}

Signals 的工作原理

Signals 表示一个可能随时间变化的数据单元。 Signals 可以是“状态”(仅手动设置的值)或“计算”(基于其他 Signals 的公式)。

计算 Signals 通过自动跟踪在其评估过程中被读取的其他 Signals 来工作。当一个计算 Signals 被读取时,它会检查其之前记录的依赖项中是否有任何变化,如果有,则重新评估自身。当多个计算 Signals 嵌套时,所有跟踪的归因都归属于最内层的 Signals 。

计算 Signals 是懒加载的,即基于拉取:它们仅在被访问时重新评估,即使其依赖项在之前已发生变化。

传递给计算 Signals 的回调函数应通常是“纯函数”,即对其他 Signals 的访问具有确定性且无副作用。同时,回调函数被调用的时机是确定性的,这允许在谨慎使用的情况下引入副作用。

Signals 机制具备显著的缓存/备忘功能:状态 Signals 和计算 Signals 均会记住其当前值,仅在引用它们的计算 Signals 实际发生变化时触发重新计算。甚至不需要反复比较旧值与新值——比较仅在源 Signals 重置/重新评估时进行一次,而 Signals 机制会跟踪哪些引用该 Signals 的对象尚未根据新值更新。内部实现通常通过“图着色”来表示,如(Milo 的博客文章)中所描述。

计算 Signals 会动态跟踪其依赖关系——每次运行时,它们可能依赖于不同的对象,而精确的依赖关系集会在 Signals 图中保持最新。这意味着,如果某个依赖关系仅在某一分支上需要,而前一次计算选择了另一分支,那么对该暂时未使用的值的更改不会导致计算 Signals 被重新计算,即使被拉取也是如此。

与 JavaScript 承诺不同, Signals 中的所有操作均同步执行:

  • 将 Signals 设置为新值是同步操作,且此更改会立即反映在后续读取依赖于它的任何计算 Signals 中。此类变异操作不支持内置批处理。
  • 读取计算 Signals 是同步操作——其值始终可用。
  • 监听器中的 notify 回调,如下面所述,在触发它的 .set() 调用期间同步运行(但在图着色完成之后)。

与 Promise 类似, Signals 可以表示错误状态:如果计算 Signals 的回调抛出异常,则该错误会被缓存,就像另一个值一样,并在每次读取 Signals 时重新抛出。

理解 Signals 类

一个 Signal 实例表示读取一个动态变化值的能力,其更新会随时间被跟踪。它还隐式包含订阅 Signals 的能力,通过另一个计算 Signals 的跟踪访问隐式实现。

此处的 API 设计旨在与大量 Signals 库在使用“signal”、“computed”和“state”等名称时形成的粗略生态系统共识相匹配。然而,访问计算 Signals 和状态 Signals 是通过 .get() 方法,这与所有流行的 Signals API 不一致,后者要么使用 .value 风格的访问器,要么使用 signal() 调用语法。

该 API 旨在减少内存分配次数,使 Signals 适合嵌入 JavaScript 框架,同时达到与现有框架定制 Signals 相同或更优的性能。这意味着:

  • 状态 Signals 是一个可写对象,可通过同一引用进行访问和设置。(详见下文“能力分离”部分的说明。)
  • 状态 Signals 和计算 Signals 均设计为可继承,以便框架通过公共和私有类字段(以及使用该状态的方法)添加额外属性。
  • 各种回调(如equals、计算回调)在调用时会将相关 Signals 作为this值用于上下文,因此无需为每个 Signals 创建新闭包。相反,上下文可以保存在 Signals 本身的额外属性中。

此 API 强制执行的某些错误条件:

  • 递归读取计算 Signals 是错误的。
  • 监视器的 notify 回调不能读取或写入任何 Signals
  • 如果计算 Signals 的回调抛出异常,则后续对该 Signals 的访问将重新抛出缓存的错误,直到其中一个依赖项发生变化并重新计算。

以下条件未被强制执行:

  • 计算 Signals 可在其回调函数内同步写入其他 Signals 。
  • 监听器 notify 回调函数中排队的操作可读取或写入 Signals ,这使得在 Signals 系统中复现 经典 React 反模式 成为可能!

实现效果

上述定义的 Watcher 接口为实现典型的 JS 效果 API 提供了基础:当其他 Signals 发生变化时重新运行的回调函数,仅用于其副作用。初始示例中使用的 effect 函数可定义如下:

// 该函数通常应位于库/框架中,而非应用程序代码中
// 注意:此调度逻辑过于简单,无法实际使用。请勿直接复制粘贴。
let pending = false;

let w = new Signal.subtle.Watcher(() => {
    if (!pending) {
        pending = true;
        queueMicrotask(() => {
            pending = false;
            for (let s of w.getPending()) s.get();
            w.watch();
        });
    }
});

// 一个效果 Signals ,评估为 cb,当其依赖项之一可能发生变化时,
// 将自身读取任务调度到微任务队列
export function effect(cb) {
    let destructor;
    let c = new Signal.Computed(() => { destructor?.(); destructor = cb(); });
    w.watch(c);
    c.get();
    return () => { destructor?.(); w.unwatch(c) };
}

Signal API 并未内置类似 effect 的函数。这是因为效果调度机制较为复杂,通常与框架渲染周期及其他高级框架特定状态或策略相关联,而 JavaScript 无法直接访问这些内容。

逐一解析此处使用的不同操作:传递给 Watcher 构造函数的 notify 回调函数是在 Signals 从“干净”状态(即缓存已初始化且有效)切换到“已检查”或“脏”状态(即缓存可能有效也可能无效,因为至少一个此递归依赖的状态已发生变化)时被调用的函数。

对 notify 的调用最终由对某个状态 Signals 的 .set() 调用触发。此调用是同步的:它在 .set 返回之前发生。但无需担心此回调在 Signals 图处于半处理状态时观察 Signals ,因为在 notify 回调期间,即使在 untrack 调用中,也无法读取或写入任何 Signals 。由于 notify 在 .set() 期间被调用,它会中断另一个逻辑线程,而该线程可能尚未完成。若需在 notify 中读取或写入 Signals ,请将相关操作安排在后续执行,例如将 Signals 写入列表以供后续访问,或使用如上所述的 queueMicrotask。

需要注意的是,无需使用 Signal.subtle.Watcher 即可通过调度计算 Signals 的轮询来有效使用 Signals ,例如 Glimmer 就是这样做的。然而,许多框架发现,让此调度逻辑同步运行非常有用,因此 Signals API 包含了这一功能。

计算 Signals 和状态 Signals 均像任何 JavaScript 值一样被垃圾回收。但监听器有一种特殊机制来保持对象存活:只要底层状态仍可访问,任何被监听器监听的 Signals 都会保持存活,因为这些状态可能触发未来的 notify 调用(进而触发未来的 .get())。因此,请务必调用 Watcher.prototype.unwatch 来清理影响。

一个不安全的逃生 hatch

Signal.subtle.untrack 是一个逃生 hatch,允许在不跟踪读取的情况下读取 Signals 。这种能力是不安全的,因为它允许创建其值依赖于其他 Signals 的计算 Signals ,但当这些 Signals 发生变化时,它们不会更新。它应在未跟踪的访问不会改变计算结果时使用。

暂不包含

这些功能可能在未来添加,但目前未包含在草稿中。其缺失是因为框架在设计空间中尚未形成共识,以及已证明可以通过本文档中描述的 Signals 概念之上的机制绕过其缺失。然而,遗憾的是,这种缺失限制了框架之间的互操作性潜力。随着本文件中描述的 Signals 原型被实现,将重新评估这些省略是否为恰当决策。

  • 异步:在本模型中, Signals 始终可同步获取以进行评估。然而,在某些情况下,需要异步过程触发 Signals 设置,并了解 Signals 何时仍处于“加载”状态。一种简单的建模加载状态的方法是使用异常,而计算 Signals 的异常缓存行为与该技术在一定程度上合理地结合。改进的技术在Issue #30中有所讨论。
  • 事务:在视图之间切换时,通常需要同时维护“源”状态和“目标”状态的实时状态。目标状态在后台渲染,直到准备好切换(提交事务),而源状态保持交互式。同时维护两个状态需要“分叉” Signals 图的状态,甚至可能需要支持同时处理多个待处理的过渡。相关讨论请参见Issue #73。

一些可能的便利方法也被省略。

状态与发展计划

该提案已列入2024年4月TC39议程的第1阶段。目前可将其视为“第0阶段”。

一个 polyfill 已经可用,包含一些基本测试。一些框架作者已经开始尝试用这个 Signals 实现进行替换,但这种用法仍处于早期阶段。

Signals 提案的协作团队希望在推进该提案时特别保持保守态度,以避免陷入推出后又后悔且实际不使用的陷阱。我们的计划是执行以下额外任务(非TC39流程要求),以确保该提案按计划推进:

在提出 Stage 2 提案前,我们计划:

  • 开发多个生产级别的 polyfill 实现,这些实现需稳定可靠、经过充分测试(例如通过各种框架的测试以及 test262 风格的测试),并在性能上具有竞争力(通过全面的 Signals /框架基准测试集验证)。
  • 将提议的 Signals API 集成到我们认为具有一定代表性的多个 JS 框架中,以及一些大型应用程序中。测试其在这些场景中是否高效且正确地运行。
  • 对 API 的潜在扩展空间有深入理解,并确定其中哪些(如有)应纳入本提案。

Signals 算法

本节将从实现的算法角度,描述暴露给 JavaScript 的每个 API。这可视为一个原型规范,在早期阶段纳入以确定一组可能的语义,同时对未来变更保持开放态度。

算法的某些方面:

  • 在计算中读取 Signals 的顺序是重要的,并且可以通过某些回调(如 Watcher 被调用、equals、new Signal.Computed 的第一个参数以及 watched/unwatched 回调)的执行顺序来观察。这意味着计算 Signals 的来源必须按顺序存储。
  • 这四个回调都可能抛出异常,这些异常会以可预测的方式传播到调用 JavaScript 代码。异常不会停止该算法的执行,也不会使图处于半处理状态。对于在 Watcher 的 notify 回调中抛出的错误,该异常会通过 AggregateError(如果抛出多个异常)传递给触发它的 .set() 调用。其他回调(包括watched/unwatched?)会被存储在 Signals 的值中,当读取时重新抛出,且此类重新抛出的 Signals 可像具有正常值的其他 Signals 一样标记为~clean~。
  • 在计算 Signals 未被“监视”(即未被任何监视器观察)的情况下,会特别注意避免循环引用,以便其可独立于 Signals 图的其他部分进行垃圾回收。内部实现可通过始终回收的代号系统实现;需注意优化实现可能包含节点级代号或对监视 Signals 的代号追踪进行简化。

隐藏的全局状态

Signals 算法需要引用某些全局状态。此状态对整个线程或“代理”而言是全局的。

  • computing:当前因 .get 或 .run 调用而重新评估的内层计算或效果 Signals ,或 null。初始值为 null。
  • frozen:布尔值,表示是否存在当前正在执行的回调,该回调要求图不可修改。初始值为 false。
  • generation:一个从 0 开始递增的整数,用于跟踪值的最新程度并避免循环引用。

Signal 命名空间

Signal 是一个普通对象,用于作为与 Signals 相关的类和函数的命名空间。

Signal.subtle 是类似的内部命名空间对象。

Signal.State 类

Signal.State 内部槽

  • value:状态 Signals 的当前值
  • equals:更改值时使用的比较函数
  • watched:当 Signals 被效果观察时调用的回调函数
  • unwatched:当 Signals 不再被效果观察时调用的回调函数
  • sinks:依赖于此 Signals 的被观察 Signals 集合

构造函数:Signal.State(initialValue, options)

  1. 将此 Signals 的 value 设置为 initialValue。
  2. 将此 Signals 的 equals 设置为 options?.equals
  3. 将此 Signals 的 watched 设置为 options?.[Signal.subtle.watched]
  4. 将此 Signals 的 unwatched 设置为 options?.[Signal.subtle.unwatched]
  5. 将此 Signals 的 sinks 设置为空集

方法:Signal.State.prototype.get()

  1. 如果 frozen 为 true,则抛出异常。
  2. 如果 computing 不为 undefined,将此 Signals 添加到 computing 的 sources 集合中。
  3. 注意:我们不会将 computing 添加到此 Signals 的 sinks 集合中,直到它被监视器监视。
  4. 返回此 Signals 的 value。

方法:Signal.State.prototype.set(newValue)

  1. 如果当前执行上下文为 frozen,则抛出异常。
  2. 使用此 Signals 和第一个参数作为值运行“设置 Signals 值”算法。
  3. 如果该算法返回 ~clean~,则返回 undefined。
  4. 将此 Signals 所有 sinks 的 state 设置为(如果是计算 Signals )~dirty~(如果之前为 clean),或(如果是监视器)~pending~(如果之前为 ~watching~)。
  5. 将所有接收器的计算 Signals 依赖项(递归地)的状态设置为 ~checked~(如果它们之前为 ~clean~,即保留脏标记),或者对于监视器,如果之前为 ~watching~,则设置为 ~pending~。
  6. 在递归搜索中遇到每个之前为 ~watching~ 的监视器时,按深度优先顺序:
  7. 将 frozen 设置为 true。
  8. 调用其 notify 回调(保存任何抛出的异常,但忽略 notify 的返回值)。
  9. 将 frozen 恢复为 false。
    1. 将监听器的 state 设置为 ~waiting~。
  10. 如果从 notify 回调中抛出了任何异常,则在所有 notify 回调运行后将其传播给调用者。如果有多个异常,则将它们打包成一个 AggregateError 并抛出该异常。
  11. 返回 undefined。

Signal.Computed 类

Signal.Computed 状态机

Computed Signal 的 state 可能为以下状态之一:

  • ~clean~: Signals 的值存在且已知不为过期。
  • ~checked~:该 Signals 的间接来源已发生变化;该 Signals 有值但可能已过期。是否过期仅在所有直接来源被评估后才能确定。
  • ~computing~:该 Signals 的回调函数正在作为 .get() 调用的副作用被执行。
  • ~dirty~:该 Signals 的值已知为过期,或从未被评估过。

状态转换图如下:

stateDiagram-v2
    [*] --> dirty
    dirty --> computing: [4]
    computing --> clean: [5]
    clean --> dirty: [2]
    clean --> checked: [3]
    checked --> clean: [6]
    checked --> dirty: [1]

转换规则如下:

编号 源 目标 条件 算法
1 checked ~dirty~ 该 Signals 的直接来源(为计算 Signals )已被评估,且其值发生变化。 算法:重新计算脏计算 Signals
2 ~clean~ ~dirty~ 该 Signals 的直接来源(为状态)已被设置,且其值与之前值不相同。 方法:Signal.State.prototype.set(newValue)
3 ~clean~ ~checked~ 该 Signals 的递归但非立即来源(即状态)已被设置,且其值与之前值不相同。 方法:Signal.State.prototype.set(newValue)
4 ~dirty~ ~computing~ 我们即将执行 callback。 算法:重新计算脏计算 Signals
5 ~computing~ ~clean~ callback 已完成评估,并返回了值或抛出了异常。 算法:重新计算脏计算 Signals
6 ~checked~ ~clean~ 该 Signals 的所有直接来源均已评估,且均未发现变化,因此我们现在确定该 Signals 不再过时。 算法:重新计算脏计算 Signals

Signal.Computed 内部槽

  • value: Signals 的先前缓存值,或对于从未被读取的计算 Signals 为 ~uninitialized~。该值可能是异常,当读取值时会重新抛出。对于效果 Signals ,该值始终为 undefined。
  • state:可能是 ~clean~、~checked~、~computing~ 或 ~dirty~。
  • sources:此 Signals 依赖的 Signals 的有序集合。
  • sinks:依赖此 Signals 的 Signals 的有序集合。
  • equals:选项中提供的 equals 方法。
  • callback:用于获取计算 Signals 值的回调函数。设置为构造函数传递的第一个参数。

Signal.Computed 构造函数

构造函数设置

  • callback 为其第一个参数
  • equals 基于选项,若未指定则默认为 Object.is
  • state 为 ~dirty~
  • value 为 ~uninitialized~

使用 AsyncContext,传递给 new Signal.Computed 的回调函数会捕获构造函数调用时的快照,并在执行过程中恢复该快照。

方法:Signal.Computed.prototype.get

  1. 如果当前执行上下文为 frozen,或该 Signal 的状态为 ~computing~,或该 Signal 是 Watcher 且正在计算一个计算型 Signal,则抛出异常。
  2. 如果 computing 不为 null,则将该 Signal 添加到 computing 的 sources 集合中。
  3. 注意:我们不会将 computing 添加到此 Signals 的 sinks 集合中,直到/除非它被监视器监视。
  4. 如果此 Signals 的状态为 ~dirty~ 或 ~checked~:重复以下步骤,直到此 Signals 为 ~clean~:
    1. 通过 sources 向上递归查找最深、最左侧(即最早被观察到的)递归源,该源是一个标记为 ~dirty~ 的计算 Signals (在遇到 ~clean~ 的计算 Signals 时停止搜索,并将该计算 Signals 作为最后一个搜索对象)。
  5. 对该 Signals 执行“重新计算脏计算 Signals ”算法。
  6. 此时,该 Signals 的状态将变为 ~clean~,且所有递归源均不会处于 ~dirty~ 或 ~checked~ 状态。返回 Signals 的 value。若值为异常,则重新抛出该异常。

Signal.subtle.Watcher 类

Signal.subtle.Watcher 状态机

监视器的状态可能为以下之一:

  • ~waiting~:notify 回调已执行,或监视器为新创建,但尚未主动监听任何 Signals 。
  • ~watching~:监视器正在主动监听 Signals ,但尚未发生需要触发 notify 回调的变更。
  • ~pending~:Watcher 的依赖项已发生变化,但 notify 回调尚未被调用。

状态转换图如下:

stateDiagram-v2
    [*] --> waiting
    waiting --> watching: [1]
    watching --> waiting: [2]
    watching --> pending: [3]
    pending --> waiting: [4]

过渡规则如下:

编号 从 到 条件 算法
1 ~waiting~ ~watching~ 监听器的 watch 方法已被调用。 方法:Signal.subtle.Watcher.prototype.watch(...signals)
2 ~watching~ ~waiting~ 监听器的 unwatch 方法已被调用,且最后一个监听的 Signals 已被移除。 方法:Signal.subtle.Watcher.prototype.unwatch(...signals)
3 ~watching~ ~pending~ 被监视的 Signals 可能已更改值。 方法:Signal.State.prototype.set(newValue)
4 ~pending~ ~waiting~ notify 回调已执行。 方法:Signal.State.prototype.set(newValue)

Signal.subtle.Watcher 内部槽

  • state:可能为 ~watching~、~pending~ 或 ~waiting~
  • signals:此监听器正在监听的有序 Signals 集合
  • notifyCallback:当发生变化时调用的回调函数。设置为构造函数传递的第一个参数。

构造函数:new Signal.subtle.Watcher(callback)

  1. state 设置为 ~waiting~。
  2. 初始化 signals 为空集合。
  3. notifyCallback 设置为回调参数。

使用 AsyncContext 时,传递给 new Signal.subtle.Watcher 的回调不会捕获构造函数调用时的快照,因此写入操作的上下文信息可见。

方法:Signal.subtle.Watcher.prototype.watch(...signals)

  1. 如果 frozen 为 true,则抛出异常。
  2. 如果任何参数不是 Signals ,则抛出异常。
  3. 将所有参数追加到该对象的 signals 数组末尾。
  4. 对于每个新监听的 Signals ,按从左到右的顺序:
  5. 将此监听器作为 sink 添加到该 Signals 。
  6. 如果这是第一个 sink,则向上递归到源 Signals 并将其作为 sink 添加。
  7. 将 frozen 设置为 true。
    1. 如果存在 watched 回调,则调用它。
  8. 将 frozen 恢复为 false。
  9. 如果 Signals 的 state 为 ~waiting~,则将其设置为 ~watching~。

方法:Signal.subtle.Watcher.prototype.unwatch(...signals)

  1. 如果 frozen 为 true,则抛出异常。
  2. 如果任何参数不是 Signals ,或未被此监听器监听,则抛出异常。
  3. 对于参数中的每个 Signals ,按从左到右的顺序,
  4. 将该 Signals 从此监听器的 signals 集合中移除。
    1. 将此监听器从该 Signals 的 sink 集合中移除。
  5. 如果该 Signals 的 sink 集合已变为空,则将其作为源从每个源中移除。
  6. 将 frozen 设置为 true。
  7. 如果存在,调用 unwatched 回调。
  8. 将 frozen 恢复为 false。
  9. 如果监听器现在没有 signals,且其 state 为 ~watching~,则将其设置为 ~waiting~。

方法:Signal.subtle.Watcher.prototype.getPending()

  1. 返回一个数组,其中包含处于 ~dirty~ 或 ~pending~ 状态的计算 Signals 的子集。

方法:Signal.subtle.untrack(cb)

  1. 令 c 为执行上下文的当前 computing 状态。
  2. 将 computing 设置为 null。
  3. 调用 cb。
  4. 将 computing 恢复为 c(即使 cb 抛出了异常)。
  5. 返回 cb 的返回值(重新抛出任何异常)。

注意:untrack 不会使您脱离 frozen 状态,该状态严格维持。

方法:Signal.subtle.currentComputed()

  1. 返回当前 computing 值。

常用算法

算法:重新计算脏计算 Signals
  1. 清空此 Signals 的 sources 集合,并将其从这些源的 sinks 集合中移除。
  2. 保存之前的 computing 值,并将 computing 设置为此 Signals 。
  3. 将此计算 Signals 的状态设置为 ~computing~。
  4. 运行此计算 Signals 的回调函数,使用此 Signals 作为 this 值。保存返回值,如果回调函数抛出异常,则存储该异常以供重新抛出。
  5. 恢复之前的 computing 值。
  6. 将回调函数的返回值应用于“设置 Signals 值”算法。
  7. 将此 Signals 的状态设置为 ~clean~。
  8. 如果该算法返回 ~dirty~:将此 Signals 的所有接收器标记为 ~dirty~(之前,接收器可能包含已检查和未检查的混合状态)。(或者,如果此 Signals 未被监视,则采用新的代数来指示脏状态,或类似的机制。)
  9. 否则,该算法返回 ~clean~: 在此情况下,对于该 Signals 的每个 ~checked~ 接收端,如果该 Signals 的所有源现在均为干净状态,则将该 Signals 也标记为 ~clean~。将此清理步骤递归应用于后续接收端,对任何具有已检查接收端的全新干净 Signals 进行处理。(或者,如果该 Signals 未被监视,则以某种方式指示相同状态,以便清理操作可懒加载进行。)
设置 Signals 值算法
  1. 如果此算法接收了一个值(而非来自重新计算脏计算 Signals 算法的异常以重新抛出):
  2. 调用此 Signals 的 equals 函数,传入当前 value、新值和此 Signals 作为参数。如果抛出异常,将该异常保存为 Signals 的值(以便在读取时重新抛出),并继续处理,如同回调函数返回 false 一样。
    1. 如果该函数返回 true,则返回 ~clean~。
  3. 将此 Signals 的 value 设置为参数值。
  4. 返回 ~dirty~

常见问题

Q:在 Signals 刚刚成为 2022 年的热门新事物时,现在就标准化与 Signals 相关的内容是否有点早?难道不应该给它们更多时间来发展和稳定吗?

A: 当前 web 框架中 Signals 的状态是超过 10 年持续发展的结果。随着投资的增加,如近年来所见,几乎所有 web 框架都正在接近一个非常相似的 Signals 核心模型。该提案是当前众多Web框架领军人物共同参与的设计成果,且在未经过该领域专家在不同场景下的验证前,不会推进标准化进程。

Signals 是如何使用的?

Q:考虑到 Signals 与渲染及所有权的紧密集成,框架能否使用内置 Signals ?

A: 更具框架特性的部分主要集中在效果、调度以及所有权/释放等领域,而本提案并未试图解决这些问题。我们在制定标准轨道的 Signals 原型时的首要任务是验证其能否与现有框架兼容并保持良好性能。

Q: Signals API 是供应用程序开发者直接使用,还是由框架封装?

A: 虽然该 API 可直接由应用程序开发者使用(至少不属于 Signal.subtle 命名空间的部分),但其设计并未特别注重易用性。相反,库/框架作者的需求是优先考虑的。大多数框架预计会将基本的 Signal.State 和 Signal.Computed API 包装成体现其易用性倾向的接口。实际上,通常最好通过框架使用 Signals ,因为框架会管理更复杂的功能(如监听器、untrack),以及处理所有权和释放(如确定何时应将 Signals 添加到或从监听器中移除),并安排渲染到 DOM——本提案并未试图解决这些问题。

Q:当小部件被销毁时,是否需要拆除与该小部件相关的 Signals ?相关 API 是哪个?

A:此处的相关拆除操作是 Signal.subtle.Watcher.prototype.unwatch。仅需清理被监听的 Signals (通过取消监听),而未被监听的 Signals 可自动被垃圾回收。

Q: Signals 是与虚拟 DOM 配合使用,还是直接与底层 HTML DOM 配合使用?

A: 是的! Signals 与渲染技术无关。现有的使用类似 Signals 构造的 JavaScript 框架可以与 VDOM(例如 Preact)、原生 DOM(例如 Solid)以及两者的组合(例如 Vue)集成。同样,内置 Signals 也将支持这种集成。

Q:在基于类的框架(如 Angular 和 Lit)中使用 Signals 是否会更具人体工学性?对于基于编译器的框架(如 Svelte)又如何?

A: 类字段可以通过简单的访问器装饰器转换为 Signal 类型,具体示例请参见 Signal polyfill 读我文件。Signals 与 Svelte 5 的 Runes 非常契合——编译器可以轻松将 Runes 转换为这里定义的 Signal API,事实上 Svelte 5 内部就是这样做的(但使用了它自己的 Signals 库)。

Q:Signals 是否支持服务器端渲染(SSR)?动态加载(Hydration)?可恢复性(Resumability)?

A:是的。Qwik 通过 Signals 有效地实现了这两种特性,而其他框架也针对 Signals 的 hydration 特性提出了不同权衡方案的成熟实现。我们认为可以通过将状态与计算 Signals 结合来建模 Qwik 的可恢复 Signals ,并计划通过代码验证这一方案。

Q: Signals 是否支持像 React 那样的一向数据流?

A: 是的,Signals 是一向数据流的机制。基于 Signals 的 UI 框架允许你将视图表示为模型(其中模型包含 Signals )的函数。状态和计算 Signals 构成的图在构造上是无环的。在 Signals 中也可以重现 React 的反模式(!),例如, Signals 中与 setState 在 useEffect 中的等价实现是使用监听器来安排对状态 Signals 的写入。

Q: Signals 与 Redux 等状态管理系统有何关系? Signals 是否鼓励使用无结构化状态?

A: Signals 可以作为高效的存储式状态管理抽象的基础。多个框架中常见的模式是基于 Proxy 的对象,其内部使用 Signals 表示属性,例如 Vue reactive() 或 Solid stores。这些系统能够在特定应用程序的适当抽象层级上灵活地分组状态。

Q: Signals 提供了什么,而代理目前尚未处理?

A: 代理和 Signals 是互补的,它们配合得很好。代理允许你拦截浅层对象操作,而 Signals 协调依赖关系图(由单元组成)。使用 Signals 支持代理是一种创建嵌套响应式结构并提供良好使用体验的绝佳方式。

在此示例中,我们可以使用代理使 Signals 具有 getter 和 setter 属性,而非直接使用 get 和 set 方法:

const a = new Signal.State(0);
const b = new Proxy(a, {
  get(target, property, receiver) {
    if (property === ‘value’) {
      return target.get():
    }
  }
  set(target, property, value, receiver) {
    if (property === ‘value’) {
      target.set(value)!
    }
  }
});

// usage in a hypothetical reactive context:
<template>
  {b.value}

  <button onclick={() => {
    b.value++;
  }}>change</button>
</template>

当使用针对细粒度响应性优化的渲染器时,点击按钮会导致 b.value 单元格更新。

参见:

Signals 是如何工作的?

Q: Signals 是基于推送还是基于拉取?

A: 计算型 Signals 的评估是拉取式的:计算型 Signals 仅在调用 .get() 时才会被评估,即使底层状态在更早的时候已经发生变化。同时,修改一个状态 Signals 可能会立即触发监听器的回调,从而“推送”通知。因此, Signals 可以被视为一种“推拉”结构。

Q: Signals 是否会引入 JavaScript 执行的非确定性?

A: 不会。首先,所有 Signals 操作都有明确的语义和顺序,且在符合规范的实现中不会有所不同。在更高层次上, Signals 遵循一组不变量,相对于这些不变量,它们是“正确的”。一个计算 Signals 始终观察 Signals 图的一致状态,其执行不会被其他修改 Signals 的代码中断(除非是它自己调用的操作)。参见上述描述。

Q: 当我向状态 Signals 写入时,计算 Signals 的更新何时被调度?

A:它并未被安排!计算得到的 Signals 会在下次有人读取它时重新计算。同时,监听器的notify回调可能会被调用,从而使框架能够在认为合适的时机安排读取操作。

Q:对状态 Signals 的写入何时生效?是立即生效,还是会被批量处理?

A: 对状态 Signals 的写入会立即反映——当依赖于状态 Signals 的计算 Signals 下次被读取时,如果需要,它会重新计算自身,即使是在紧随其后的代码行中。然而,这种机制固有的懒惰性(即计算 Signals 仅在被读取时才进行计算)意味着,实际上,计算可能以批处理的方式进行。

Q: Signals 如何实现“无故障”执行?

A: 早期基于推送的反应模型存在冗余计算的问题:如果状态 Signals 的更新导致计算 Signals 立即运行,最终可能触发对 UI 的更新。但如果在下一帧之前,原始状态 Signals 还有其他变化,那么对 UI 的写入可能为时过早。有时,由于此类“故障”(https://en.wikipedia.org/wiki/Reactive_programming#Glitches),不准确的中间值甚至会被显示给最终用户。Signals 通过采用拉取式而非推送式机制避免了这种动态:当框架调度 UI 渲染时,它会拉取相应的更新,从而避免在计算和写入 DOM 时浪费工作。

Q: Signals 的“有损”特性意味着什么?

A:这是无故障执行的另一面: Signals 代表一个数据单元——仅当前值(可能变化),而非随时间推移的数据流。因此,如果你连续两次写入一个状态 Signals ,而没有执行其他操作,第一次写入将被“丢失”且不会被任何计算 Signals 或效果看到。这被视为特性而非缺陷——其他构造(如异步迭代器、可观察对象)更适合处理数据流。

Q:原生 Signals 是否比现有 JS Signals 实现更快?

A:我们希望如此(以一个小的常数因子),但这仍需通过代码验证。JS 引擎并非魔法,最终仍需实现与 JS Signals 实现相同的算法。参见上文关于性能的讨论。

为什么 Signals 被设计成这样?

Q:为什么这个提案不包含一个 effect() 函数,而效果对于 Signals 的实际使用是必要的?

A:效果本质上与调度和释放相关,这些由框架管理且超出本提案的范围。相反,本提案包含通过更底层的 Signal.subtle.Watcher API 实现效果的基础。

Q: 为什么订阅是自动的,而不是提供手动接口?

A: 经验表明,反应性中的手动订阅接口既不 ergonomics 也不易出错。自动跟踪更具可组合性,是 Signals 的核心功能。

Q: 为什么 Watcher 的回调函数是同步执行的,而不是在微任务中调度?

A: 因为回调无法读取或写入 Signals ,因此同步调用不会引入不安全问题。典型的回调会将 Signals 添加到数组中以供后续读取,或在某个位置标记位。为这些操作单独创建微任务既不必要又成本过高。

Q:这个 API 缺少我最喜欢的框架中的一些实用功能,这些功能使得使用 Signals 编程更加方便。这些功能能否也添加到标准中?

A:可能可以。各种扩展仍在考虑中。如果您发现有重要的缺失功能,请提交一个问题以引发讨论。

Q:这个 API 能否在大小或复杂度上进一步简化?

A: 保持这个 API 的简洁性绝对是我们的目标,我们已经在上面提到的内容中努力实现了这一点。如果您有更多可以移除的内容的建议,请提交一个问题以进行讨论。

Signals 是如何被标准化的?

Q: 我们是否应该从一个更原始的概念,如可观察对象,开始在这个领域的标准化工作?

A: 可观察对象对于某些场景可能是个好主意,但它们无法解决 Signals 旨在解决的问题。如上所述,可观察对象或其他发布/订阅机制并非许多类型 UI 编程的完整解决方案,因为它们会给开发者带来过多易出错的配置工作,且由于缺乏懒加载特性导致工作量浪费,等等。

Q: 为何在 TC39 而不是 DOM 中提出 Signals ,尽管其大部分应用场景是基于 Web 的?

A:本提案的部分共同作者对非网页用户界面环境感兴趣,但如今,这两种环境均可满足这一需求,因为网页API正越来越多地被应用于网页之外的场景。最终,Signals无需依赖任何DOM API,因此两种方案均可行。若有人有充分理由建议本组切换方案,请在问题中告知我们。目前,所有贡献者已签署 TC39 知识产权协议,计划将此提案提交给 TC39。

Q: 多久之后我才能使用标准的 Signals?

A: 目前已有一个 polyfill 可用,但不建议依赖其稳定性,因为该 API 在审查过程中仍在演进。在数月或一年内,一个高质量、高性能的稳定 polyfill 应该可以使用,但这仍将受到委员会修订的影响,尚未成为标准。遵循TC39提案的典型轨迹,预计至少需要2-3年时间,才能实现Signals在所有浏览器(包括几个版本之前的浏览器)中原生可用,从而无需使用polyfill。

Q: 如何避免过早标准化错误类型的Signals,就像{{JS/你不喜欢的网页功能}}一样?

A:该提案的作者计划在向 TC39 申请阶段推进前,通过额外努力进行原型设计和验证。请参阅上文的“状态与发展计划”。若您发现该计划存在不足或改进机会,请提交问题说明。

元素周期表抱枕

本文由 TecHug 分享,英文原文及文中图片来自 🚦 JavaScript Signals standard proposal🚦。

共有{326}条精彩评论

  1. 难道只有我觉得原生 JavaScript 示例实际上更易于阅读和使用吗?

    – “设置过程冗余且模板代码过多。” 实际上,信号示例在我看来同样冗余且模板代码过多。而且它引入了新的模板概念,这对初学者来说难以理解。

    – “如果计数器变化但奇偶性未变(例如计数器从 2 变为 4),那么我们会进行不必要的奇偶性计算和渲染。” – 这听起来像是他们想要过早的备忘录化。

    – “如果 UI 的另一部分只是在计数器更新时渲染,该怎么办?” 那么我同意这个稻草人示例可能不是你想要的。此时你可能需要通过信号、事件处理、中央状态存储(如 Redux 类工具)或其他方法来管理状态。我认为这也是他们所说的“计数器状态与渲染系统紧密耦合”的意思?这份文档的部分内容感觉有些重复。

    – “如果我们 UI 的另一部分仅依赖于 isEven 或奇偶性怎么办?”当然,如果你认为这是应用程序的核心部分,你可以因此改变整个方法,但大多数情况下并非如此。而“渲染函数仅依赖于奇偶性,因此必须‘知道’它实际上需要订阅计数器”通常并非不合理的要求。我的意思是,这是纯计算函数的优点之一——很容易识别其输入。

    1. 你为什么认为这是过早的备忘录化?这是一个简化到简单函数的示例。你认为人们在从未需要它的情况下就想出了这个用例吗?

      我认为,努力标准化信号(一个在 UI 开发中越来越常用的概念)是一项值得称赞的努力。我不想深入讨论什么是过多的 boilerplate 代码,或者是否应该构建一个事件系统,但既然信号在各种框架中都被使用,可能有很好的理由这样做?为什么不努力在未来标准化它们呢?

      1. > 一个在UI开发中越来越被广泛使用的概念

        对于桌面应用程序开发者来说,这是一个相当有趣的陈述,因为Qt框架早在90年代中期就引入了信号和槽机制。

        我很好奇有多少网页开发者认为信号是一个新概念。(我并不一定指的是楼主。)

        1. 尽管它们共享相同的名字,并且都是响应式基本元素,但这些信号与 Qt 的信号和槽机制之间存在一些相当关键的差异。

          主要区别在于,据我所知,Qt 信号是一种相对静态的构造——在构建应用程序的各个组件时,你同时构建了响应式图。这个图可能会随时间更新,但通常是在组件挂载和卸载时。JS信号则不同,它们每次执行时都会重新构建,这使得它们更加动态。

          此外,JS信号的依赖关系是自动建立的,无需显式定义。无需调用connect、addEventListener或subscribe等函数,只需在计算上下文中调用原始信号,计算过程就会自动订阅该信号。

          第三,在 JS 信号中,您无需拥有信号对象即可订阅该信号。您可以构建一个不直接暴露信号值的抽象层,而是提供可能调用底层信号获取函数的获取函数。该抽象层可在其他响应式计算的内部和外部均被使用。

          因此,一方面,是的,JS 信号只是另一种反应性工具,因此将与许多现有工具(如信号与槽、可观察对象、事件发射器等)共享特性。但在这个领域中,它们在反应性发生和使用方式上也存在显著差异。

          1. 感谢您的精彩回复!我确实需要仔细研究一下。

        2. 这是一个有趣的话题,所以我尝试深入研究了一下。

          根据我的阅读,我理解 Qt 信号与槽(以及 Qt 事件)与 JavaScript 事件(原生和自定义)有着更密切的关联。

          在两者中,你都可以显式地触发、处理和监听事件/信号。JavaScript 事件似乎结合了 Qt 信号与槽和 Qt 事件的特性。当然,它不具备类型安全特性。

          例如,摘自https://doc.qt.io/qt-6/signalsandslots.html

          “信号是由对象在状态发生变化时触发的,这种变化可能对其他对象感兴趣。”

          然而,我认为文章中提到的是一种更为复杂的抽象概念:他们希望实现自动化,使得当状态图的任何部分发生变化时,所有依赖于该特定状态的代码片段都能自动收到通知,而无需程序员显式编写代码来通知其他代码片段,或执行connect()或addEventListener()等操作。

          你对此有何看法?我很想听听你的意见,因为我确信你比我更有经验。

          1. 这听起来很有趣。代码示例让我想起了Qt信号,但所有对我的帖子的回答都表明,JS信号会强大得多。说实话,我需要仔细研究一下。

            1. JS信号源自函数式响应式编程,这是对20世纪80年代和90年代Lustre和Esterel编程语言中同步响应式编程的泛化。我认为第一个版本是2004年发布的FrTime。

              你可以将响应式信号视为将底层事件系统与值构建相结合,最终定义一个对象图,当用于构建它的任何参数发生变化时,该对象图会自动更新。你可以将这个图想象成一个具有多个输入和输出的电子电路,就像电路一样,输出会在输入发生变化时更新。

      2. > 我不想深入讨论什么是过多的 boilerplate 代码,以及是否应该构建一个事件系统

        你基本上是在说你想要这个东西,但你不想为此辩护

        1. 其合理性在于多个框架都提供了此机制的自有版本。提案是将极受欢迎且常见的功能从框架空间迁移到语言/运行时空间。React 的流行本身就是这一想法实用性的理由,而任何简短的理由陈述只是为了展示。这样的理由是否足够?也许是,也许不是,但你是在攻击信使。

          1. 最重要的是:原帖作者关于“原生示例最易读”的观点是正确的。阅读该提案后,我完全不明白“Signal”这个词除了增加复杂性外还能带来什么。

            次要问题:我真的、真的、真的、真的不愿考虑这是否需要标准化。

            免责声明:我无法确定这个概念在所有框架中是否真的完全一致。

            但坦率地说,我怀疑这一点,如果它真的那么相似,为什么会有至少十几个框架各自拥有自己的版本?*

            此外,我亲眼目睹了 React、Redux、效果等从“根本必要”到“不再必要”的过程。通常,当某件事物真正成为基础时,你甚至能在 JavaScript 之外感受到它的存在。(例如,Promises 与 Futures 的对应关系)我见过上千个 Rx 框架兴起又衰落,从 JavaScript 到 Objective-C,再到 Kotlin 和 Dart。让它们自由发展,不要将它们与浏览器绑定。

            * 我知道这有点偷换概念,但换个更复杂的说法:如果它们真的那么相似且已经定型到可以规范化的地步,为什么它们之间又存在足够的差异,以至于能衍生出十几个不同且被广泛使用的框架?

            1. > 免责声明:我无法确定这个概念在所有框架中是否真的完全一致。

              几乎[1]所有当前框架现在都拥有类似的概念,它们都基于相同的通用基础:某种原子状态单元,某种通过在跟踪上下文中读取状态来订阅其状态变化的机制,以及某种在状态被写入时通知这些订阅的内部逻辑。它们都有一系列相关的抽象概念,这些抽象概念基于这些基本概念构建,而…

              > 但坦率地说,我对此表示怀疑。如果它们真的如此相似,为什么至少有十几个框架都有自己的版本?*

              ……正是这些差异使每个框架独具特色。另一个区别在于,状态管理和衍生计算只是这些框架功能的一部分。它们除了拥有多样化的互补响应式抽象外,还各自对模板、渲染模型、数据获取、路由、组合、与其他工具和系统的集成等功能有不同的实现方式。

              此外,这些框架之间的基础相似性是相对较新的现象。这是围绕一套成功的基本抽象概念的 convergence,而这些概念在很大程度上源于各框架相互学习。这种 convergence 如此普遍,以至于推动了标准化努力。

              这一点尤其引人注目,因为参考 polyfill 源自 Angular 的实现,而 Angular 仅在最近才采纳这一概念。从阅读 PR 注释可以看出,该实现仅进行了微小改动以满足提案规范。这是因为 Angular 的实现本身非常新颖,内部化了大量来自先前的技术经验,这些经验也影响了规范本身的设计思路。

              这与Promises的类比非常相似,经过多年竞争性方法的探索后,最终围绕一套基本核心概念实现了类似的重大转变。

              [1]: 值得注意的是,React在很大程度上避免了使用信号,而许多受其启发的框架则逐渐转向了信号。

              1. 使用state与使用信号有什么不同?!

                1. 显式与隐式依赖(useEffect 与 Signal.Computed/effect)的区别,以及信号与 useState 的区别在于,信号可以在 React 环境之外使用,我认为这是件好事。

                  我个人更倾向于显式处理“可观察值”,即函数签名中明确显示内部使用的信号/可观察值。

                2. 它们非常相似,你确实可以眯着眼睛将它们视为本质上相同的概念……如果你在眯着眼睛时也将 React 组件本身视为一个反应性效果。这在技术上是正确的(最好的类型),但通常不是人们在实际讨论信号时所指的意思。

                3. 信号是细粒度的响应性。React是粗粒度的响应性。Legend-state为React添加了信号,我推荐它而非我们之前使用的Redux/zustand。

            2. > 它们之间为何存在足够多的差异,以至于能衍生出十几个活跃使用的不同框架?

              因为它们不在语言的标准库中?因为它们在不同时间点得出了解决方案,并不得不适应每个库的各种独特实现方式?因为这种情况在每种语言中都会发生:人们会提出类似但不同的解决方案,直到这些解决方案被纳入语言/标准库?

            3. > 最重要的是:原帖作者说得对,原生示例是最易读的。阅读提案后,我完全不明白这个“Signal”一词除了增加复杂性外还能带来什么。

              目标是在依赖的值发生变化时才执行计算或副作用。

              这是完全正常的场景,你不想在应用程序树的 UI 中每次有 某些 变化时都更新所有数据。

              DOM 更新是最常见的例子,但实际上可以是任何内容。

              当然,在简单示例(如这个计数器)中,你可能不需要重新计算每个值并重建 DOM 的每个部分(除了焦点问题和其他细节)。

              但总体而言,每款 JavaScript 密集型响应式网页应用都需要某种形式的这种逻辑。

              无论实现方式如何,当涉及到这一点时,我不确定将这种逻辑内置到语言中有什么好处。

            4. >通常当它确实是根本性的,你可以在 JavaScript 之外也能感受到它。(例如,Promises 与 Futures 的对应关系)

              优秀的标准

            5. >但坦率地说,我怀疑这一点,如果它真的那么相似,为什么至少有十几个框架都有自己的版本?*

              欢迎来到 JavaScript 的时尚循环。给几年时间,每个旧概念都会被重新发明,然后你会有半打框架,它们基本上相同但足够不同,以至于你必须重新学习 API。这就是我认为标准化有助于避免的情况

              一个好的标准库可以防止那些足够好的想法被不断重新发明而导致的碎片化

            6. > 但坦白说,我对此表示怀疑,如果它们真的那么相似,为什么会有至少十几个框架各自推出自己的版本?*

              具体来说:信号是大多数框架中相对底层的部分。一旦有了信号,仍然有许多其他决策需要做出,以决定特定框架的工作方式,从而使一个框架与另一个框架区分开来。例如:

              * 不同框架以不同方式暴露信号的底层机制。SolidJS明确将信号的读取和写入部分分离,以鼓励单向数据流,而Vue则通过代理将信号暴露为可变对象,以提供更传统的、命令式的API。

              * 不同框架会将信号与渲染过程的不同部分绑定。例如,通常信号用于决定何时重新渲染组件——Vue 和 Preact(大部分情况下)就是这样工作的。这样,你仍然拥有渲染函数和某种形式的虚拟 DOM。另一方面,SolidJS 和 Svelte 等框架使用编译器将信号更新直接绑定到更新 DOM 部分的指令。

              * 不同框架在信号机制之外,对框架中包含的额外功能做出不同选择。Angular 提供了自己的服务和依赖注入机制,Vue 捆绑了一个用于隔离组件样式的工具,SolidJS 剥离了大部分功能但设计上旨在生成非常高效的代码,等等。

              因此,即使所有框架都使用相同的信号机制,它们的行为方式和使用方法仍会大不相同。

              至于为什么不同框架会采用不同的实现方式而非统一使用单一库,据我所知,这主要与当前信号机制通常与不同框架的组件生命周期紧密关联有关。由于信号需要循环引用,因此在 JavaScript 中很难以一种能够在适当时候被垃圾回收的方式构建它们。因此,许多框架将监听器的生命周期与组件本身的生命周期绑定,这意味着当组件不再使用时,监听器可以被销毁。这要求信号通常需要相对深度地集成到框架中。

              提案中对此有所提及,并提到了垃圾回收方面的问题(如果直接向引擎添加新的原始类型,这个问题更容易解决),以及提供大量钩子以实现将订阅与组件生命周期绑定。因此,我怀疑他们正在考虑这个问题,尽管我认为这将是一个相当困难的问题。

              顺便说一句,作为一个与信号打交道很多的人,我对这个提案也有些怀疑。信号非常强大和有用,但我不确定它们是否足以代表一种基本的机制,值得嵌入到语言中。

      3. > …但由于信号在各种框架中都被使用…

        …常见的使用场景并不能成为将其纳入语言标准的理由。浏览读我文件时,我没有看到任何需要修改语言语法且无法通过常规第三方库实现的内容。

        几年后,另一种花哨的技术可能会流行起来,让信号看起来很愚蠢,然后我们就会在语言中留下更多无法移除的遗留负担,因为要保持向后兼容性(让 C++ 成为一个警告)。

        1. 据我所知,一些/许多大型框架正在向信号靠拢,另一位评论者提到Qt在90年代就已支持信号https://news.ycombinator.com/item?id=39891883。我理解你的担忧,希望能听到非JS UI领域人士的见解,尤其是那些拥有20年以上相关经验的人。

          1. 每个框架都在转向信号机制,除了React。我认为如果这成为行业标准,就连React也会跟进。这就像Promise一样,是一个合理的共享概念。

    2. 我同意。但看看Preact的信号文档——

      https://preactjs.com/guide/v10/signals

      “在Preact中,当信号作为 props 或 context 传递到树结构时,我们只传递对信号的引用。信号可以更新而不需要重新渲染任何组件,因为组件看到的是信号本身而非其值。这使我们能够跳过所有昂贵的渲染工作,直接跳转到树结构中实际访问信号 .value 属性的组件。”

      “信号还有一个重要特性,即它们会跟踪其值被访问和更新的时机。在 Preact 中,从组件内部访问信号的 .value 属性时,当该信号的值发生变化,组件会自动重新渲染。”

      我认为在这种上下文中,这要合理得多。

      1. >> 在 Preact 中,当信号作为 props 或上下文传递到树中时,

        我发现传递 props 会使 React 风格的应用程序变得非常复杂和混乱,因此应尽可能避免使用 props。

        避免使用 props 的机制是自定义事件。

        看到信号被作为 props 传递的概念让我感到担忧,因为信号/事件本应消除对 props 的需求?

        1. 你不需要将 Preact 信号作为 props 传递来实现响应性。如果你使用 Preact,信号引用会使你的组件默认具有响应性,如果你使用 React,你可以通过 useSignals 钩子或 Babel 插件引入响应性。(1)

          React 信号已成为我首选的状态管理工具。使用起来非常简单且灵活。

          1: https://www.npmjs.com/package/@preact/signals-react

          1. >> React信号已成为我首选的状态管理工具。

            我几乎在 React 应用中放弃了所有状态,除了组件本地状态。

            自定义事件负责在应用中传递信息并引导活动。

            信号能提供什么,而事件不能?

            1. 我也喜欢本地状态,但有些情况下使用全局状态是有意义的——主要是用户上下文。

              然而,你也可以使用信号来处理本地状态,而且它们的效果非常出色。能够在无需通过设置器的情况下为信号赋予新值,在我看来是一种更加简洁的模式。

              另一个使用场景是微前端之间的通信。能够直接导入/导出信号并获取其响应性,这非常方便。在使用信号之前,我需要创建一个发布/订阅模式,而那显然不够简洁。

    3. 由于响应性并未内置于 JavaScript 中,添加响应性会增加抽象开销。它应在需要时使用,而非默认的状态管理方式。

      根据我的经验,最大的好处是能够将反应性状态模块化。在命令式风格中,需要额外的状态来跟踪变化。模块化是通过抽象实现的。仅在需要时使用。

      > 听起来他们想要过早的备忘录化

      这是一个平衡,即呈现一个简单且适用的示例。反应性有明显优势的案例往往是更复杂的示例。这比一个简单但不那么适用的示例更难演示。

    4. 我认为我们解释这一点的方式还有改进的余地。这些问题在这个小样本中并不明显,但在更大规模的项目中会更突出。欢迎提交PR。

      1. 或许可以提及简单易解释的示例与更全面示例之间的权衡。附上更复杂代码库的链接?附上前后对比?

      2. 我不会惊讶于Ryan Carniato已经某个地方有完美的解释 🙂

    5. 我认为在复杂度达到某个阈值时,避免设计变更是有价值的。原生 JavaScript 方法在状态图复杂度方面存在可扩展性限制,问题不在于阈值上下的人性化设计差异,而在于跨越阈值时人性化设计的不连续性变化

      1. 确实,在一定规模下,“简单”方法最终会变得一团糟。一个简单的计数器不够复杂,但这是一个好主意,对语言来说是积极的。

    6. 我讨厌这两个例子,但认为Signals那个例子糟糕得多,毫无疑问。

      你可以那样做,但……为什么?当你可以不那样做的时候?

    7. 我同意初始示例更易于阅读,但它确实存在问题,如所言。

    8. > 我是唯一一个认为原生 JavaScript 示例实际上更易于阅读和使用的?

      即使对于这个示例而言,基于信号的模型会随着派生节点的数量线性增长复杂度和开销。基于回调的版本在复杂度上是超线性的,因为回调的评估顺序未定义且不可预测,这会导致可能的副作用轨迹发生组合爆炸。它也扩展效率较低,因为你可能需要多次运行副作用和更新,而信号版本提供了额外的保证,可以防止这种情况发生。

    9. 我对这段尴尬的代码感到好奇:

          counter.set(counter.get() + 1)
      

      人们可能会认为,将代码与语言 Properly 集成也意味着消除这些“噪音”的 setter/getter 调用。

      1. 我更喜欢显式的 get/set 方法。MobX 和 Svelte 似乎都采用了魔法方法,但我认为 Svelte 已经意识到这是个错误。这使得代码更难理解,显式定义更好。

    10. 绝对不是。我也更喜欢原生版本。

  2. 当他们为 JavaScript 添加 Promise 时,我对可能需要在到处编写 `new Promise` 的想法感到不满。

    实际上,我用两只手就能数清自己编写 `new Promise` 的次数。不过,确实发生的是,我开始大量使用 `.then`,尤其是在与第三方库合作时。

    最终,Promise 添加到 JavaScript 后的实际日常影响是,它为我提供了一个相对简单、通常可靠且大多通用的接口,用于处理第三方库提供的各种特殊行为和功能。无论是文件读取、API 请求还是构建步骤输出,我知道我可以编写 .then(res => …),就已经完成了可用的解决方案的一半。

    如果这个 Signal 提案能在面对反应式 UI 框架的“寒武纪大爆发”时为我提供类似的功能,我表示支持!更重要的是,它或许还能帮助将反应式编程扩展到 UI 之外;我经常幻想有一种能够增量重新计算的状态树,用于处理除 UI 状态之外的其他状态。

    1. 我原本以为Promises的主要添加目的是为了引入async/await,而这才是真正带来重大质量提升的地方。实际上,你很少需要手动显式地创建“new Promise”。

      虽然在初始承诺中使用 .then 相比嵌套委托是一个巨大的改进,并且对于简单的链式承诺来说已经足够,但一旦你开始条件性地链式调用不同的承诺,或者需要对特定的承诺链进行不同的错误处理,或者希望进行早期返回,代码就会变得难以阅读和维护。

      而使用 async/await 时,你可以基本上将调用写成不像是承诺的形式,并可以轻松地在特定承诺调用周围添加 try/catch,轻松实现提前返回等操作。

      1. > 在实际中,你很少需要显式地自己创建“new Promise”

        然而,你经常需要使用 Promise.all,即使在使用 async/await 时也是如此。

        1. 此外,封装回调 API 或包含副作用:

            function sleep(ms) {
              return new Promise((resolve) => setTimeout(resolve, ms));
            }
            await sleep(100);
          

          Promise 类仍有一些使用场景,当你需要时,拥有这种控制功能非常有用。

          1. Promise.withResolvers 现在可以填补这一空白(尽管它在 Node 中尚未原生支持):

              function sleep(ms) {
                const { promise, resolve } = Promise.withResolvers();
                return setTimeout(resolve, ms), promise;
              }
            
        2. 我认为这并非 JavaScript 的问题。这是代码在处理并发操作时固有的复杂性,而非语言本身带来的额外复杂性。

        3. 我从未需要过这样做。现实世界中的用例是什么?

          1. 同时执行多个异步任务而非顺序执行。如果你从未使用过这个功能,要么你一直在处理极其简单的系统,要么你错过了大量性能提升的机会。

            1. 或者,像大多数人一样,他们只在前端使用JS,而前端很少需要并行获取多个数据。

            2. 这与await a、await b、await c有什么不同?

              1. 如果每个 await 都是对 setTimeout 的调用,等待 1000 毫秒,那么等待所有 3 个将大约需要 3000 毫秒。

                如果你使用 Promise.all 等待一个包含这些承诺的数组,它将大约需要 1000 毫秒。

                简而言之,使用单独的 await 会串行执行它们,而 Promise.all 会并行执行它们。

                如果你在没有 worker 的情况下进行 CPU 密集型工作,差异不大,但如果你在进行 I/O 密集型任务,如 HTTP 请求,那么并行执行很可能会带来显著差异。

                1. 我明白你的意思,但这与我的实际经验不符。我在 Codepen 上有一个复现示例。我漏掉了什么吗?

                  https://codepen.io/tomtheisen/pen/QWPOmjp

                      function delay(ms) {
                        return new Promise(resolve => setTimeout(resolve, ms));
                      }
                  
                      async function test() {
                        const start = new Date;
                        const promises = Array(3).fill(null).map(() => delay(1000));
                        for (const p of promises) await p;
                        const end = new Date;
                        console.log(“elapsed”, end - start); // 显示约 1010
                      }
                  
                      test();
                  
                  1. 你同时启动了三个承诺,然后等待第一个完成,接着等待第二个,最后等待第三个。但因为它们是同时启动的,所以会并行执行。

                    而如果你依次启动一个承诺,等待它完成,然后启动下一个,依此类推,那么它将花费三秒钟,因为它们不会并行运行。

                    你编写的代码可以被视为一个“简陋的”Promise.all,因为它基本上在做同样的事情,只是不够清晰。在拒绝处理方面,两者的行为也略有不同:如果Promise.all版本中的最后一个承诺立即拒绝,整个承诺将立即失败。然而,在你的版本中,如果最后一个承诺拒绝,该拒绝不会立即被await评估(因此不会抛出异常),直到所有其他任务完成。

                    出于清晰度和正确性的考虑,因此通常最好直接使用Promise. all,而不是依次等待一组已经启动的承诺。

                    1. 要使用`Promise.all`,你仍然需要在不等待的情况下构建所有承诺。这似乎就是整个陷阱和认知负担所在。

                      但早期拒绝是相对于“穷人版”的具体改进。我被说服了。

                  2. 根据你最初的描述,我以为你在做以下事情:

                      for (var i = 0; i < 3; i++) {
                        await delay(1000);
                      }
                    

                    然而(你可能已经知道),你示例中的这行代码会立即并行启动所有任务:

                      const promises = Array(3).fill(null).map(() => delay(1000));
                    

                    因此,你的 for…of 循环的时机是:第一个元素可能需要大约 1000 毫秒完成,而其他两个似乎会立即发生。

                    Promise.all 只是编写 for…of await 循环的一种替代方案:

                      await Promise.all(promises); 
                    

                    我猜这需要你已经熟悉 Promise API,但我觉得 Promise.all() 的可读性稍低,且其意图更直观。

                    支持使用 Promise.all() 的有力理由是,Promise.allSettled()、Promise.any() 和 Promise.race() 同样用于处理承诺集合,而与 Promise.all() 不同的是,它们无法通过一行 for… 循环,因此期望 JavaScript 开发者了解 Promise.all() 是合理的,这意味着它没有理由不是我上述理由中首选的语法。

                    1. 好吧,我信了。我误以为 Promise.all() 有更多复杂性,但实际上并非如此。我对分配不必要的数组有些抵触。但这更多是迷信而非可量化的性能问题。

                      Promise.allSettled也有一个简陋的实现。但其他方法真的没有这样的东西。

                      我的印象是,Promise.all()还不错,但它真的不是什么大不了或重要的事。如果它不存在,你可以在不改变调用代码大小的情况下,获得相同的理想路径代码行为。

                      但它本身并没有问题。总体而言,它似乎比简易实现稍好一些。在过去五年中,我可能只用过它两次。

          2. 我经常使用 Promise.all 将一系列 X 映射到 Y。例如,将一系列图像 URI 映射到已加载的 Image 元素。

            这样写比使用 for 循环更简洁,而且更重要的是,所有图像将并行加载而非顺序加载,这可能显著提升速度。

              images = Promise.all(uris.map(loadImage))
            
            1. 你也可以通过一个循环启动 Promise,再用另一个循环等待它们完成。这只是更简洁的表达方式吗?

              1. 如我之前回复你所说,两者存在差异:for循环会依次执行每个图像的fetch请求,而Promise.all会并行发送请求,因此总耗时取决于最慢的请求。

                1. 正如我之前对你另一条回复的回复,当我编写实际在循环中使用 await 的代码时,它已经并发地完成了所有工作。有一个代码示例可以说明我所熟悉的情况。

                  另一方面,语言设计者并非随意的框架作者。他们知道自己在做什么。一定有某种原因导致 Promise.all 的存在。我只是不知道具体是什么。

                  再次强调,我理解串行任务与并行任务的区别。但我发现使用循环中的 await 也能实现并行任务。因此我仍然有些困惑。

                  1. 对于我描述的情况(从 URI 中获取最终的图像数组),在循环中执行并行任务会非常繁琐,而你的实现基本上等同于 Promise.all 的内部工作原理。

                    如其他人所提到的;如果你不使用 Promise.all,你很可能错过了许多编写更简单、更高效的异步代码的机会。

                    1. 对于不抛出异常的 Promise,await Promise.all(promises) 的性能与 for (const p of promises) await p; 基本相当。如果你认为后者会串行执行,这里有一个 Codepen 供你参考。https://codepen.io/tomtheisen/pen/QWPOmjp

                      其内部实现可能是 for 循环,但拒绝的 Promise 除外。唯一可能更快解决的情况是当其中一个 Promise 被拒绝时,此时 Promise.all() 会立即拒绝。

                      我认为“更简单”的指控也被严重夸大了。

                    2. 你说得对。我错误地认为替代方案(简单方法)是在 for 循环内编写创建承诺的任务,但如果它们不在循环内,功能基本上等同于 Promise.all。

                        // A -- 异步且并行
                        const results = await Promise.all(data.map((i) => delay(i)));
                      
                        // B -- 与上述功能等价
                        const promises = data.map((i) => delay(i));
                        const result = [];
                        for (let a of promises) {
                          const r = await a;
                          result.push(r);
                        }
                      
                        // C -- 问题且低效的方法:速度较慢
                        const result = [];
                        for (let i of data) {
                          const r = await delay(i);
                          result.push(r);
                        }
                      
  3. 为什么它需要成为语言的一部分?这可以是一个库。确实有这样的库。它们很小,因此在代码中包含它们并不是什么大问题。将此添加到语言中甚至不应成为目标。

    认为当前这批 JavaScript UI 库在设计信号机制时做得如此出色,以至于它应该成为语言的一部分,这种想法是自大的。信号机制有许多可能的实现方式,每种实现都有不同的权衡,而其中没有一种值得在 JavaScript 规范中占据特殊地位。

    在这些库使用信号机制之前,它们或其前身使用的是虚拟 DOM。幸运的是,虚拟 DOM 没有成为 JavaScript 的一部分,但信号机制与之有何不同?它们没有不同。将信号标准化的论点甚至比虚拟 DOM 更糟糕。

    难道我们要将每个流行趋势都堆砌到一个运行时中,而这个运行时基本上无法在不破坏网络的情况下移除不再需要的特性?这实在太短视了。

    1. 说得有道理。我们不想要错误的东西,但我们想要正确的东西!

      响应式 UI 已经胜出。阻碍我使用原生 JavaScript 的主要原因是,即使是小型应用程序,管理状态的复杂性也呈爆炸式增长。对我来说,任何响应式框架都比原生 JavaScript 更好,那么是否缺少某种构造?既然已经过去十年左右,我们应该开始考虑标准化可能的切入点。就像承诺一样,如果做得好,这可以降低极常见用例的复杂性。

      我认为更好的评估方式是:“这个提案是否会被现有响应式框架采用?”如果没有,为什么?缺少了什么?有什么是多余的?其他语言在 UI 和响应性方面的经验又能带来什么启发?虽然这些经验非常分散,但值得深入挖掘,我认为这是值得的努力。

      1. 如果你想做“正确的事情”——将你的提案实现为一个库,并说服人们基于其技术优势使用它。当所有人都使用它(因为它显然是“正确的事情”)时,你可以开始询问是否有人希望将你的库集成到语言中。

        但你现在做的并非如此。你从一开始就瞄准成为标准——你试图说服人们使用你的草案实现,是因为它作为提案标准的地位,而非基于其自身技术优势。

        放弃这个地位,看看是否还有人想要它。

        —

        是的,响应式 UI 已经成功了——即使没有信号在 JS 标准中。因为没有信号在语言中从来都不是阻碍 JS 中响应式 UI 发展的实际问题。

        你的提案并没有“降低复杂性”。它只是将复杂性从 UI 库转移到 JS 标准中。这样做,你强行将生态系统与这种特定的复杂性风格绑定在一起。每个浏览器供应商都必须实现并支持它……要持续多少年?

        那么,这样做的目的是什么?与例如 Promise 不同,Promise 本身就有用,你的提案远不够便捷,无法在原生 JavaScript 中构建响应式 UI。用户仍然需要使用库来实现这一点,就像他们今天所做的那样。你只是将库中的一小部分移入标准,却没有说明为什么它需要在那里。

        —

        你的提案花了数页篇幅向读者推销信号,但这并非你需要推销的内容。我们已经拥有许多信号的实现。你需要推销的是,为什么你的(或任何)信号实现需要被纳入 JavaScript 标准。

        你在“次要好处”部分有一个小小的“标准库的好处”子部分,但这简直荒谬。你基本上是在说,我们应该将信号添加到 JavaScript 中,因为我们之前已经将(更简单或更必要)的东西添加到 JavaScript 中——这真的是你最好的论点吗?

        还有……“节省打包大小”?你想要通过采用一个复杂问题的特定实现来节省多少?5KB的这个:https://cdn.jsdelivr.net/npm/s-js

        抱歉,这对我来说毫无意义。

        1. 我对此没有立场。我也尚未看到该提案获得主流支持。所以我不明白为什么非要如此激烈?

          主要好处是互操作性。承诺也是如此。你可以用自定义回调实现所有承诺——事实上这很简单。但竞争实现通常不会在API兼容性上达成一致,仅仅因为它们在解决同一个问题。这导致了碎片化的生态系统。也许信号的互操作性很重要?如果真是这样,他们应该这样论证!

          > 用户仍需使用库来实现这些功能,就像今天一样。

          是的?但库可以减少实现难度——理想情况下,这能使一类基础用例得到明显改进。你可以说 querySelector 是多余的,因为可以在库中实现。或者 filter,或者 map。标准化难道不能涵盖类似标准库的功能吗?

          这并不意味着我支持。我认为除非有强有力的、一致的、经过验证的益处,否则应该默认反对。但为什么不就这个问题能解决或不能解决的具体问题进行善意的讨论呢?例如,假设React或Svelte有不同的模型,这些模型根本无法使用这些信号,那么这可能表明它们并不理想。我对提案的哲学是,在说“是”之前,要平衡好奇心和诚实的探究与一种不耐烦的防御性审问。不过,发脾气真的没有帮助。

          > 你基本上是在说,我们应该向JS添加信号,因为我们之前已经向JS添加了(更简单或更必要)的东西

          > 节省包大小

          是的,我同意这些是弱论点。

        2. 我认为你说得对,我看不出来将这些功能构建到基础库中能让某些之前无法实现的事情变得可能。

          我认为Promise API并不是人们直接想要的东西(而且它本身没有足够的理由被添加到基础库中),但标准化Promise API是必要的,以便添加极其有用的async/await关键字,而这些关键字在不修改语言的情况下是无法实现的。

          然而,我非常喜欢一个真正优秀的基准库(这是我喜欢使用 .NET 的原因之一),但他们应该专注于具有最广泛适用性的功能(即普通 JS 开发人员在日常任务中遇到的最常见功能),例如更好的日期和时间处理工具。

          1. > 我认为 Promise API 并非人们直接想要的东西(仅凭自身并无充分理由被添加到基础库中),但标准化 Promise API 是为了添加极具实用性的 async/await 关键字,而这些关键字在不修改语言的情况下是无法实现的。

            在 JavaScript 中,async 语法主要是围绕使用 .then() 链式调用 Promise 的语法糖。Promise 本身解决了回调地狱问题,并为多阶段异步操作带来了结构和作用域——至少在 JavaScript 这种动态环境中是如此。与主要入口点、微任务以及与运行时交互相关的仅有少量 minor 添加,才构成了我们今天享有的主要 async/await 体验。这实际上是一件好事,因为你可以基于其他核心构造构建抽象,而不会过多影响语言的其他部分。

            未来可能会有类似的语法优化构造,基于信号、反应性或可观察对象( whichever is sensible )构建,以组合出真正强大的面向用户的功能。因此,在我的观点中,承诺和异步的成功仍然是一个支持性的故事。需要进行大量研究和审查,但这无疑是目前最有趣的想法之一。

        3. > 将你的提案实现为一个库,并说服人们基于其技术优势使用它。

          这几乎就是字面意义上的事实:大多数主要 JavaScript 框架(除了 React)都采用了信号。

          > 因为语言中缺乏信号机制从未真正阻碍过 JavaScript 的响应式 UI 开发。

          哦,但它确实阻碍了响应式开发。当前信号实现的许多限制恰恰是因为语言本身缺乏对许多功能的 Proper 支持。

          > 你需要解释为什么你的(或任何)信号实现需要纳入 JavaScript 标准。

          这就是为什么它是:

          – 一个提案,

          – 需要实施者、用户、库开发者等提供反馈。

          > 与例如Promises不同,Promises本身就有用,

          但Promises的情况也完全相同:每个人都有自己的实现,没有必要提出将特定API添加到标准库的提案。Deferred(Promises的前身)在Promises出现前已存在多年。完整历史请见:https://samsaccone.com/posts/history-of-promises.html

          然而,15年后的今天我们仍面临此问题

    2. 将信号标准化的一個有力理由是调试它们似乎是一场噩梦。想象一个由计算出的信号组成的深层树结构,这些信号相互触发,而你需要找到引发连锁反应的源头。标准化将使开发工具能够围绕它进行开发。

      1. 它们并非相互触发,而是像函数一样相互依赖:

          a = () => 42
          b = () => a() - 1
          c = () => a() + b() * 2
        

        这并不比调试纯函数更令人头疼。c 的来源是 a 和 b。所有信号值(如提案所示)将在依赖信号的本体中词法上可用,因此无需导航隐藏的注册表。如果浏览器内 IDE 希望为激活记录记录调用树,它们可以在没有标准的情况下做到这一点。

        1. 仍然存在一个 watch() 机制,从消费者的角度来看,它会隐藏更新的原始事件。否则,如果你只需要函数,那就直接使用函数。

          当监听机制失控时,你需要调试工具来理解为什么你的 render() 函数被调用的频率比预期更高。

          这种问题在 React 中经常发生,你需要向上追踪以发现,在组件链中向上 7 个组件处,有人意外地在状态中包含了 new Date(),该状态通过 props 传播并导致所有组件重新渲染。

          1. 信号本质上是懒加载函数。如果性能是你的关注点,你不能“仅仅”使用函数,因为这是保持一切一致性最无效的方式。

            既然监听在提案中似乎是同步的,你为什么认为需要额外的调试工具?你可以使用常规调试器在 render() 中设置断点并查看堆栈跟踪。

    3. 你可以对标准库中的大多数内容这么说。但如动机部分所述,目前存在一种趋势,即扩展 JavaScript 提供的相对较小的标准库,因此无需为每个常见任务都准备一个包。

      你可以争论这种需求的必要性,但如果我们要扩展标准库,那么参考流行方案是一个不错的做法,我认为。

      > 在这些库使用信号之前,它们或其前身使用虚拟 DOM

      信号并非虚拟 DOM 的替代品。

      1. 在我看来,Observable提案更有说服力,因为Observables提供了一个在应用逻辑与库之间边界处功能独特且有用的接口,因此采用单一标准方法有其优势。而信号则正处于应用状态与UI绑定的位置。这里真的需要人们拼凑各种库来使用一致的API吗?我并不确定。

        1. 信号在除 React 之外的所有框架中都被使用。可观察对象则没有。这是巨大的差异。

  4. 当我需要在应用程序中传递信号时,我会使用事件:

        window.dispatchEvent(new Event(‘counterChange’));
    

    而应用程序中任何希望对此做出反应的部分都可以通过以下方式订阅:

        window.addEventListener(‘counterChange’, () => {
            ... 执行某些操作 ...
        });
    

    这样做有什么问题吗?

    1. 从历史上看,这个示例正是Web演变为jQuery,进而分支出Angular和React等框架的主要原因。

      事件处理很容易变得混乱。如果你想深入了解,可以看看事件冒泡和传播。

      大型应用程序需要健壮的事件处理机制。这是如今Angular、Vue等框架的隐含优势。

      相信我,你不想在没有框架的情况下使用标准事件处理 API。在多个元素上添加、删除、克隆、触发、移除、仅触发一次等操作可能会产生严重的意外副作用。

      1. >如果你想深入了解,可以看看事件冒泡和传播。

        在此示例中,事件发生在窗口上。没有冒泡。它已经处于顶级。

        >相信我,你不想在没有框架的情况下使用标准事件处理 API。在多个元素上添加、删除、克隆、触发、移除、仅触发一次等操作可能会产生严重的意外副作用。

        我不知道这是什么意思。框架与这个主题关系不大。

        1. 我认为之前的评论是在讨论事件的一般概念。

          框架会批量处理更改以提高更新效率,并在许多情况下确定“正确”的操作顺序。如果你在大型复杂的 UI 中自行处理所有这些操作,很可能你更新 DOM 的效率会低于框架,而且很可能引入了一些微妙的 bug。这是我亲身经历的。

      2. 我在大型应用程序中广泛使用事件,从未遇到过问题。事实上,它们解决了复杂性。

          1. 内存泄漏可能发生在组件将事件添加到组件外部的元素(如窗口)后,该组件从 DOM 中移除时未移除窗口上的事件处理程序。在原生 Web Components 中,通过 mount/unmount 方法可解决此问题,当组件被卸载时,可运行代码移除事件监听器。

            对于其他事件监听器,它们会在 DOM 元素被移除时自动移除。

            这些框架并不能更好地解决这个问题。它们只是让一切变得不可见且隐藏在后台,由于其声明式特性,使得调试变得困难,但这是另一个话题。

          2. 这是一个需要提供数据或结论性结果来支持的论点,因为你正在反驳一个在浏览器生态系统中存在数十年的模式。我曾在大型应用程序中使用过这种模式,并未遇到问题。

            宽容地说,目前我能想到的可能导致内存泄漏的情况是,有人遇到了老式的 JavaScript 作用域问题,并在处理程序中捕获了不应捕获的内容。不过,问题不在于处理程序本身,而在于开发者。

            (是的,我们可以一直抱怨 JavaScript 内置的糟糕设计决策,但这个话题已经被讨论得够多了)

          3. 您是否暗示浏览器内部事件实现存在内存泄漏?因为我认为问题出在“用户空间”脚本未正确清理自身资源,而添加另一个需要注意的 API 并不会改善这一情况。

            1. 我认为这更可能与在“实时”监听器中缓存 DOM 结构有关,该结构随后被移除出 DOM 但因事件监听器中的引用而未被垃圾回收。正如楼主所提,这是开发者错误——而非语言或浏览器实现的根本缺陷。

              1. 如果语言或浏览器实现无法回收这部分未使用的内存从而导致内存泄漏,那这无疑就是一个缺陷。这实际上是一个可被浏览器沙箱中运行的不可信脚本利用的拒绝服务(DoS)攻击向量。

    2. 根据原文,事件发射器/可观察对象在多次调用时会产生不必要的开销。

      与信号的不同之处在于,最终值仅在终端消费者读取值时计算——因此,您可以异步安排渲染更新,与实际写入信号的操作分离,而监听器执行的计算链仅在渲染时执行一次。

      发送到信号的中间值将丢失,因此您真的无法在其中进行太多有趣的操作。它本质上只是一个用于协调渲染周期的高级抽象层。

    3. 信号本质上也是发布/订阅机制,但提供了更友好的 API。更友好是因为监听器会自动添加和释放。

      它也可以更高效,例如,假设有一个依赖于两个值的计算:

      `result = a ? b : 0`

      如果 a 为假值,当 b 变化时无需重新计算。这在信号中是自动实现的,但使用经典的发布/订阅模式则需要大量代码。

    4. 我已经使用这种模式超过十年了。困难在于,随着时间的推移,你可能会遇到一个监听器触发另一个事件,然后另一个事件又回到第一个例程,现在你得到了一个不会停止的监听循环。

      而且很难确保所有监听器不会引发这种触发级联。

      1. 难道不能以声明式方式定义所有监听器/发布者,并编译为代码以捕获此类问题吗?

        1. 是的,可以。大多数发布/订阅或观察者架构和设计模式都很好地解决了“循环”问题。

          事实上,GOF在《设计模式》(1995年)的观察者模式章节第299页上专门花了一段文字讨论复杂更新语义的问题。

          因此,虽然这是一个真实存在的问题,但它已经被解决了 (至少已有29年)

          1. 是的,我想提一下,既然我之前提过,这个问题已经解决,但并非所有工具或环境都已为此做好准备。

            当应用程序较小时,你可能不需要它。但随着应用程序的增长,你绝对需要它。

            在绿地项目中,最好使用已为此做好准备的框架/工具。

            1. 听到你解决了这个问题,真好!

              > 在绿地项目中,最好使用已经准备好的框架/工具。

              我并不完全同意。工具,尤其是框架,往往伴随着巨大的权衡。有些是“画在角落里的画”式的权衡。因此,我建议不要仅仅为了解决潜在的未来问题而引入框架。甚至可以说,这是导致项目或初创公司失败或陷入严重困境的十大因素之一。这实际上是一种“过早优化”的表现。

      2. 我喜欢将信号作为基本元素,但在这种特定情况下,它们与事件一样容易导致意外循环。我认为这个问题在使用信号时不会变得更好或更糟。

        1. 信号稍好一些,因为它们只在计算值实际发生变化时传播。这是隐含的固定点/停止条件,而事件本身并不具备这一特性。

    5. > 这样做有什么问题吗?

      它具备提案中提到的发布/订阅架构的所有缺点。

    6. 通常希望 UI 以声明式方式描述,即 instead of “做某事”(将按钮颜色设置为红色),重构为 “是某事”(如果状态为 x,则按钮为红色)

      我可能没有描述得很清楚,因为一旦走这条路,它真的会成为一种贯穿整个程序的整体方法(如函数式响应式编程),因此它更多是关于整个流程从上到下如何有机结合,这可以非常优雅。

      我认为这并不适合所有场景,例如在游戏开发中,直接以命令式方式更新某个对象的位置可能更合理,但对于用户界面而言,这种方法通常效果不错。

    7. 事件处理的问题在于,你实际上不知道在事件触发时需要做什么。假设你有20个组件,在counterChange事件触发时,这20个组件中的哪些需要更新?以及如何更新?你可以选择简单(但非常低效)的方法,即按照React的概念,重新渲染所有20个组件并使用新的counter值,例如:

          window.addEventListener(‘counterChange’, () => {
              element.innerHTML = 20components.map(|c| c.renderHTML(newCounterValue)).join(‘’);
          });
      

      或者,你需要逐个检查组件,判断 a. 该组件是否需要更新,以及 b. 更新该组件的最有效方式。在 TFA 中,如果计数器从奇数变为奇数,标签无需更新。

      此外,事件数量与组件数量的乘积会让应用程序迅速失控。

      1. 如果组件仅在计数器从奇数变为偶数或从偶数变为奇数时才重新渲染,它可以缓存该值并按需操作。

        1. 但此时必须确保元素缓存该值。使用信号时无需如此。如果组件(或其部分)从未使用该信号,则永远不会重新渲染。

    8. 这种模式正是大家最喜欢的“看,JS/Electron 可以实现高性能!”示例所采用的。(VS Code)

    9. LGTM!这让人联想到 Redux(以一种积极的方式)。但最终,你可能希望事件更新你的“模型”,然后这会导致“视图”的更新。这就是信号可以派上用场的地方。

      1. 看看 legend-state,它绝对不是 Redux(以一种积极的方式,我认为)。

    10. 主要是拆卸逻辑和不可避免的内存泄漏。

      一个替代方案是,如果监听器超出上下文范围,自动移除监听器

        1. 不错,但将 TypeScript 混入提案标准会造成混淆。

            1. 我指的是那个链接。试图用TypeScript演示一个拟议功能。

      1. 可能是个愚蠢的问题,但关闭标签页时内存不是会被释放吗?那为什么内存泄漏会重要呢?

        1. 这取决于你泄漏了多少内存,以及泄漏的速度有多快。

          最佳情况是,它只是稍微拖慢了垃圾回收的速度,因为你持有大量不会消失的引用。

          另一方面,我记得AngularJS某个版本中存在一个漏洞,当使用路由器导航时,组件的DOM节点不会被清理,除非你手动将模板中使用的所有作用域值设置为null。

          我们有一个数据密集型应用程序,包含表格等内容,你可以清楚地看到在Chrome开发工具中切换页面时内存跳跃数十兆甚至更多。

          最终(这是一个旨在连续使用数小时的单页应用程序),页面会崩溃。

        2. 不是“愚蠢”,但可能过于草率/浅薄?许多SPA都是长期运行的,内存泄漏会迅速积累,破坏性能。

        3. 我不是网页开发人员,但有些“标签页”具有非常长的生命周期,例如网页邮件客户端、WhatsApp等。

        4. 问题在于,如果你不关闭标签页,内存泄漏会导致该标签页的内存膨胀至1GB或更多,因为内存未被释放,而该标签页本应仅消耗50MB。

        5. 你是否见过Chrome的“哎呀,出错了!”界面(如果你使用Chrome的话)?

          大多数情况下,这是由于内存泄漏引起的。

    11. 如果你误输入‘countenChange’,那可能会非常令人沮丧!

      1. 如果你真的因为这个原因选择或放弃某种软件设计模式,那你用 JavaScript 到底在做什么?

      2. 到了 2024 年,我们已经有 linters 和其他静态分析工具可以在 IDE 中直接检测这类问题。

    12. > 这样有什么问题吗?

      嗯,事件不是只向上冒泡吗?你需要确保该元素在 DOM 树中不处于更低层级。

      事件太过混乱,所以我写了一个基于主题正则表达式的发布/订阅消息队列。DOM 中的任何位置都可以根据主题正则表达式订阅消息。

      这让事情简单多了,尤其是当我添加了 Web 组件来包裹现有元素时,发布和订阅操作可以通过属性完成,而不是 JavaScript。

      1. 你也可以在几乎任何对象上触发事件,本质上创建了一个通道,其中消息总线队列就是虚拟机事件队列。

        1. > 你也可以在几乎任何对象上触发事件,

          只有当你拥有该对象的引用时才可以。

          我之所以创建自己的消息队列发布/订阅系统,是因为事件需要大量复杂操作来获取 DOM 中正确对象或子树的正确引用。

          使用发布/订阅类型的消息队列,任何元素都可以发出主题为“POST /getnetpage”的消息,而监听(订阅)POST消息的函数在响应返回时发出RESPONSE消息。这允许第三个元素监听(订阅)主题为“RESPONSE FROM /getnextpage”的消息,并处理它。

          上述三个参与方无需相互持有引用,甚至无需彼此知晓,且我可以注入一些不错的可观察性工具,因为只需为特定消息类型添加订阅者,这使得调试变得轻而易举。

    13. > 这样做有什么问题吗?

      它仅在浏览器环境中有效。

        1. 浏览器环境不具备 EventEmitter。浏览器与 Node.js 之间的事件 API 存在差异。需要使用第三方库或自定义库来实现事件代码的异构性。

    14. 作为最终用户,我非常喜欢网页应用通过 DOM 树传递事件,因为这让我能够轻松通过钩子事件创建插件。YouTube 就是一个很好的示例,展示了如何正确实现这一功能。

      遗憾的是,现代框架倾向于使用自己的事件通道,这使得挂钩变得困难。

    15.   欢迎使用 Node.js v21.6.2。
        输入 “.help” 以获取更多信息。
        > window.dispatchEvent(new Event(‘counterChange’))
        Uncaught ReferenceError: window 未定义
      
      1.     $ node
            欢迎使用 Node.js v20.6.1。
            输入 “.help” 以获取更多信息。
            > const target = new EventTarget()
            undefined
            > target.dispatchEvent(new Event(“counterChange”))
            true
        
    16. 你必须给函数命名,否则无法删除它 😛

    17. 随着应用程序变大,调试变得非常困难

    18. 你读过那份文件吗?里面有一个例子,和你提到的情况非常相似,并且解释了问题所在。

      1. 不,他们没有。事实上,如果你在提案中搜索“event”,只会得到一个结果,即“eventually”的前缀。这是提案中一个严重的缺陷,需要解决。

        1. 我认为你回复的评论指的是提案中的发布/订阅部分。他们没有明确提到事件,但事件是发布/订阅模式的一个子集。

          1. 但信号也是如此。

            这里提出的信号所提供的唯一“好处”是让你对图的精确分发模式控制较少,例如去抖动、限流、批处理等。也就是说,如果你想创建一个类似高性能应用程序的东西,这些都是你绝对必须控制的。

            1. 信号并非事件的子集。信号将事件订阅与值构建相结合,从而促进了远程声明式更新模型。

              > 这里提出的信号模式唯一“好处”是让你对图的精确分发模式控制更少,例如去抖动、限流、批处理等。

              事件对这些属性的控制并不比信号更多,只是需要更多冗余代码。

        2. 哦,别开玩笑了。他们确实有一个架构示例,而不是事件的字面示例。无论你选择哪种观察者模式的实现都无关紧要。

          1. 除了其中一种已经内置于语言中?而且它提供了信号的所有优点,却没有缺点?(前提是正确使用using指令)

      2. 你能指出文档中提到此示例的具体位置吗?我重新检查了文档,但找不到。

        1. 只需查找设计模式的示例(「示例 – VanillaJS 计数器」部分),而非通过事件实现的字面示例。概念上它们是相同的。

  5. 我多年来一直试图理解为什么人们觉得跟踪状态并更新 DOM 如此困难。

    当然,这需要一些纪律,但对我来说,这比每隔几年出现的各种解决方案(Backbone、Knockout、Angular、React、修改语言本身等)简单得多。我的思维方式一定有某种根本性的不同。

    这种差异甚至体现在函数命名上。他们将更新innerText称为“渲染”。你并没有在渲染任何东西。最多是浏览器在渲染,但它做的其他与绘制相关的事情也是如此。这感觉像是对一个最简单的DOM函数的刻意复杂化。这真的让我感到困惑。

    1. 在简单应用中很容易。

      更复杂的应用则不容易。

      1. 我写网页应用已有25年以上。我从未主动选择过React及其相关技术。但我知道自己属于少数派。我只是不确定原因。

        1. 你可能工作50年都用不到这些……这取决于你创建什么、有多少开发人员等等。

          我认识一些人,多年来他们自己编写了框架。对他们来说效果很好……

          1. 没错,唯一能说服我的就是团队和招聘动态。但这些工具宣传的并非如此。

            总是这样:你在这个视图中有几十个交互控件,已经失控了,你应该使用这种编译为HTML和JavaScript的语言,并携带所有这些依赖项。对此我总是回答:不用了,我宁愿处理这些控件。

            1. > 团队和招聘动态。但这些工具宣传的并非这些。

              我认为这是任何拟定标准的必然要求。我们需要一种共同理解事物的方式,甚至可以仅凭此进行沟通。

              1. 我的观点是,这不是他们的卖点。当你访问 React 的页面时,不会看到这样的宣传语:“React 是招聘开发人员和管理团队的绝佳方式”。

                他们推销的解决方案是技术性的,比如状态管理、可复用组件等。但我认为这并不令人信服。

            2. > 没错,唯一能说服我的就是团队和招聘动态。但这些工具并没有宣传这一点。

              这没关系,这些工具并没有解决你所寻找的问题。

              数十个控件乘以数十个事件,已经是一个相当大的故障点数量,所以人们乐意用依赖项来交换(依赖项有什么问题呢?)。

              所以,确实,很少有人愿意变得优秀,他们只会招聘像你这样优秀的人来使用手动工具并“正确地完成工作”。大多数人承认这些工具的缺点,并使用适当的工具来辅助他们。

        2. Facebook 的网站拥有比普通网页应用更复杂的设置子页面,它是一个由大量其他应用组成的单页应用(SPA)风格的超级应用。该应用通常在整个系统中保持高度一致的状态。手动实现这一点是行不通的,你可能会遗漏某些内容,或者更可能的是,这些内容会在构建此类应用所需的数十人协作过程中丢失。

          1. 1) 几乎没有人正在构建 Facebook 或 Gmail,却表现得好像自己在做。我仍然不明白其中的确切原因。

            2) 对于其他用例,手动更新 DOM 中的某些元素并不难。你很快就会学会如何避免自掘坟墓。显然比处理 React 生态系统的混乱要容易(且快速)得多。

            1. 你不需要在网页中拥有大量状态来构建Facebook或Google。显然,如果你在做一个基本的仪表盘、博客或其他类似的东西,这并不真的需要。但对于更复杂的东西,我认为React提供了一种比使用原生JS更好的方式来处理状态变化。使用 React 并不会让应用变得更复杂,因为如果你想用原生 JavaScript 实现相同功能,最终会得到一个更混乱的代码。

              我同意有些网页不需要这些功能,但这类网页通常也不需要大量开发工作。

              1. 你能给个更复杂场景的具体例子吗?

                1. 当然!在我工作的公司,我们开发的是非常专业化、用户数量极少的实时远程非破坏性测试软件,可以在浏览器上运行。不过以前是原生应用,但客户抱怨很多,部分原因在于IT部门对更新的限制。这意味着我们必须有一个远程处理单元,随时与前端同步状态,同时也要与扫描设备同步。这意味着需要多个不同的画布和图表、不同标签页等,所有这些都包含状态信息,有时需要在后台更新状态,并且需要保持状态变化的一致性。我平时不太做前端开发,但曾参与搭建我们的新推理模块,因为我负责AI/ML方向,即使主要在WebGL画布上开发,处理起来也会非常棘手。使用的是Angular,虽然我并不特别喜欢它,但相比于直接使用原生JavaScript,它仍然要好得多。

                  我知道这比较小众,但我认为大多数非trivial(博客、内容管理系统、表单)的前端都会处理大量状态。如果它们不处理状态,那它们本身就相对简单。这是一种概括,但你很快就会达到这样一个临界点:使用React或其他框架的复杂性开销,与直接用原生JavaScript实现的复杂性相比,前者变得值得。

                2. 也许可以尝试手动实现类似Grafana仪表盘的功能。

            2. 这听起来更像是孩子们不愿吃萝卜和碎虫(只要挑出腿和翅膀就行!)的抱怨,就像我们当年必须吃的一样,而他们的新潮“三明治”太过花哨。如果他们在森林里迷路,只剩下三明治,那可就得好好学学了。

              1. 哈哈,如果要用食物来比喻,我认为他们在讨论肉串品牌,却从未尝过真正的牛排。

                或者更确切地说,他们正在经营一家肉串工厂,因为洗碗太麻烦了。

                但我不知道这样的比喻除了好笑之外,还有什么用处。

        3. 如果你在一个由5名或更多开发者组成的团队中工作,他们负责UI开发,需要快速复用组件并将其应用于大型复杂应用程序(这些应用程序包含大量数据,且数据可通过HTTP请求获取和更新),那么几乎不可能不使用任何框架。

          如果你是独自工作,或仅开发小型网页应用,当然,在某些情况下可以避免使用框架。

          1. > 几乎不可能不使用这些框架中的任何一个。

            同意,但因为现在搜索“React开发者”要比评估大量JavaScript候选人容易得多,后者涵盖的范围更广、技能水平差异更大,且需要确保他们被录用后能顺利融入团队。

            但这并非因为直接操作DOM本身不具备可扩展性。例如,看看puter[1],这是一个相当复杂的、超过10万行代码的jQuery项目。

            https://github.com/HeyPuter/puter

        4. 我认为如果你能提到你正在构建的应用程序类型,会更有帮助。

          你这里说的“网页应用”指的是什么?我对1999年网页的记忆是,唯一丰富的网页用户体验是在Java小程序中。

          1. 当时有一种叫做DHTML的东西。它不是Ajax,但你可以隐藏和显示元素、更改其内容、响应事件等。跨浏览器兼容性是一场噩梦。而服务器端处理了大部分工作。

            如今我用各种技术构建前端交互丰富的应用,但尽量避免使用React。

        5. 你只是使用 document.createElement()、getElementById() 及其相关方法吗?我写过很多只使用这些方法的网页应用。但我也曾几次尝试使用 React,想看看它是否更好、更快,或者有什么其他优势。我认为对于模板风格的网页应用来说,这是一种合理的做法——也就是说,当你需要构建大量相互交互的 HTML 节点,就像复杂的用户界面那样。但我发现自己在尝试理解 useState() 和 useEffect() 的一些奇怪的响应性机制时会遇到困难。我认为这主要是因为我不是专家,但这也感觉像是与语言本身存在某种不匹配。

          但我认为 React 并非适用于每一个应用程序,这真的取决于你正在开发什么类型的应用程序。

          当然,你可以做那种模板在服务器端,JS只是用来连接现有节点的传统风格的应用。JS社区早在几年前就基本上放弃了那种“Rails”风格的应用……

    2. > 当然,这需要一点纪律性

      我强烈怀疑,在过去的时代,你可能会是一个ASM程序员,对那些疯狂的可移植C程序员挥舞拳头,然后又是一个C程序员,对那些疯狂的内存安全的Java程序员愤怒地挥舞拳头。

      编程的进步可以体现在消除实现良好结果所需的仪式化严格纪律的必要性上。

      这并不是说React是某种进化的下一阶段,但信号无疑是朝着正确方向迈出的一步。

    3. 几十年是很长的时间。你一定记得在不同浏览器之间更新 DOM 的复杂性,对吧。

      保持 DOM 与数据状态同步并不太难,但以高度性能(60 fps)的方式做到这一点 _是_ 极其困难的。尤其是在创建既不泄露数据又不至于过于繁琐的 API 时。

      坦白说,直接以游戏风格将像素绘制到画布上,比将变化转换为动态 DOM 树要简单得多。

      1. 如果你需要 60fps,就不应该使用 DOM。它不是游戏引擎。就像你说的,使用画布吧。

        1. 60fps并不是一个高标准。一个每10毫秒更新一次的计时器(实际上是下一个事件循环间隔)常被用作教程示例,这样的组件没有理由不具备高刷新率。

          原评论的重点是,DOM更新应快速、高效且避免用户肉眼可见的延迟,这并不容易。如果你不慎,大型应用程序中的微小更改可能导致过多的 DOM 更新——这就是框架大放异彩的地方。

          而画布并非万能解决方案,它本身也存在问题。首先是可访问性。

          1. 我完全同意。我最近做了一个包含数百个元素、3D 变换等的时间轴。一切运行流畅,并非因为我特别聪明,而是因为浏览器速度快。所有操作都是直接DOM操作。

            我对现代框架的抱怨并非在于帧率,而是一个简单的“Hello World”示例往往需要20个文件加上编译步骤,而最终结果对我来说并不特别清晰。我从未访问过其官网并想:哇,这就是我遇到的问题,而这看起来像是完美的解决方案。

            相反,我清楚记得第一次看到jQuery时的情景。通过Ajax加载内容并在加载完成后淡入显示,仅需3行代码且跨浏览器兼容。那一刻我便被彻底征服。

    4. 我从事外汇交易应用开发,代码量达数十万行。这是对桌面应用的完整替代方案。不用框架试试看。20到30名开发者,多个客户,每个客户都有自己的开发团队。

    5. 说得好,兄弟!我完全同意。我开发过复杂且高度交互的单页应用程序,而这些所谓的解决方案所针对的问题,我至今尚未遇到。

  6. Promises是一个不错的成功案例,但没有async/await,标准化其实并不必要

    > 当前草案基于Angular、Bubble、Ember、FAST、MobX、Preact、Qwik、RxJS、Solid、Starbeam、Svelte、Vue、Wiz等框架的作者/维护者的设计输入…

    很想知道现有库的作者对这个提案的看法。有趣的是 React 没有出现在这个列表中

    信号有点像通道,只是它们是广播而不是单个接收者。如果能以某种方式利用这一点,允许 Web 工作者通过通道而不是 onMessage 回调进行通信,那将非常有趣。具体来说,如果能像 Go 语言一样对信号/通道/承诺进行 `select` 操作,将比通过回调管理多个并发消息机制更具语法优势(也许可以通过允许信号包含在 `Promise.any` 中实现)

    1. > 没有 async/await,标准化其实没有必要

      强烈反对。

      x instanceof Promise 根本行不通。如果我的库有一个接受 catch 回调的 then 方法,而你的库没有,它们就会悄无声息地无法互操作,而且无法检测到这一点。finally 什么时候执行?你对回调的异步性有什么期望?没有标准,每个使用 Promise 的库都必须提供自己的 polyfill,因为你无法信任现有实现。而且你无法真正使用其他库的 Promise,因为无法确保它们的行为符合你的预期。

      我并非在猜测,这是过去多年来的现实,也是许多人不得不承受的噩梦。

    2. > 承诺是一个不错的成功故事,但没有异步/等待,标准化其实并不必要

      标准化带来的一个好处,与异步/等待无关,是 JavaScript 引擎能够进行一些否则无法实现的性能优化,这有利于依赖承诺的应用程序

    3. > “有趣的是 React 没有出现在那个列表中”

      信号并非 React 核心 API 的组成部分,与 Preact 不同。

      我模糊的直觉是,信号太过类似于泛化的 useEffect(),会通过混淆渲染周期中的行为进一步增加 React 的复杂性。无论好坏,React 在更新机制上与信号采取了不同的策略。但也许我对它们的适用性判断有误。

        1. 我读到他辩护说这两个函数显然做的是完全不同的事情:

            function One(props) {
              const doubleCount = props.count * 2;
              return &lt;div&gt;Count: {doubleCount}&lt;/div&gt;;
            }
          
            function Two(props) {
              return &lt;div&gt;Count: {props.count * 2}&lt;/div&gt;;
            }
          

          这让我不禁怀疑这篇文章是不是4月1日的,我是不是被耍了。

          更宽容地说,JS框架设计确实困难。如果你有野心,最终会与语言本身抗争,而你的运行时范式会像不合身的衣服一样挂在它的语法上。上面的One/Two示例展示了在这个扩展叠加的扩展世界中,期望是如何轻易被打破的。在不了解特定框架的细节的情况下,无法知道看似简单的代码实际上会做什么。

          1. > 我读到他辩护说这两个函数显然做完全不同事情的地方

            > [code]

            > 这让我怀疑这篇文章是不是4月1日的,我是不是被耍了。

            如果你的组件模型是它们会重新渲染,那么它们确实没有区别。但这种模型只是实现组件的一种方式,而JSX对这种语义恰恰是无立场的。这是有意为之的设计。

            如果你只熟悉React和其他具有类似渲染模型的框架,当然会对这两个函数行为不同感到惊讶。但如果你熟悉其他JSX实现,如Solid,你就会立即发现差异:组件不会重新渲染,只有JSX会重新渲染。第一个函数始终渲染相同的内容,因为doubleCount在组件创建时被初始化,并在返回的div挂载期间保持静态。

            你可以选择偏好 React 的模型。它确实有一些认知优势。但它也不是天生唯一正确的模型。

          2. > 没有办法知道一段看似简单的代码实际上会做什么,除非你知道特定框架的细节。

            是的。在 solid-js 中,JSX 插值需要被视为具有隐式 lambda 表达式。你需要了解框架的工作原理才能使用它

            这在 Ryan 和 Dan 的讨论中有所涉及。solid-js 的精细反应性涉及大量 lambda 表达式,在 JSX 中它们是隐式的,但 JSX 之外的代码必须显式指定它们

          3. 对于我们中的一些人来说,这实际上是有意义的。比 React 更有意义。

      1. 我认为这在哲学上超出了 React 的范畴,因为 React 的重点是渲染(以及组件及其状态,但不包括全局状态)。RxJS 和 MobX 都可与 React 配合使用并支持信号,而 Redux 则采取了另一种路径,React 处于这些选择之上。

        1. React 的渲染将极大受益于细粒度的响应性(这是信号所提供的)。然而,出于某种原因,React 团队坚持认为其系统中响应性的最低级别是组件,无论该组件多么庞大或复杂。

    4. React 没有出现在这个列表中,因为它的效果是声明式的,而不是命令式的(除了 props 更改和重新渲染,你可以争论说这些实际上也是声明式的,只是抽象了一层)。UseEffect 整洁地将命令式行为隔离起来。

      这看起来很像 Ember 数据绑定,它会变成一个命令式噩梦。它的默认状态是“自掘坟墓”,需要大量认知开销和元模式来防止它变成这样。

      1. 不,我使用 Legend-State 配合 Context 提供组件数据存储,这比充满“自掘坟墓”问题的 useEffect 标准 React 方案要好得多。细粒度的响应性非常出色。

      1. 这看起来符合主题,但事件难以组合且容易泄漏,因为需要显式地附加/移除(不过我不确定这里的提案是否解决了这些问题)

        1. 难道不能直接创建一个新的EventEmitter构造函数,使用新的FinalizationRegistry API来移除所有已被垃圾回收的主题的处理程序吗?

    5. React 没有被列入列表,因为创建者在 2013 年使用 backbone.js 时有过不好的经历,并且不喜欢 Signals。团队一直遵循他的偏好,而没有考虑现代 Signal 方法、组件、依赖注入和 TypeScript 等概念,以及提供比 2013 年更优封装性、可发现性和模块化的 npm 包。

  7. 我没有理解链接的 README 中的示例。

      // 库或框架基于其他 Signal 原语定义效果
      declare function effect(cb: () => void): (() => void);
    

    什么库?什么框架?我在这儿迷路了。什么是效果?

      effect(() => element.innerText = parity.get());
    

    效果如何知道在 parity 发生变化时需要调用这个 lambda?它会在任何信号变化时调用这个 lambda 吗?为什么还要提缓存?可能不会。

    无论如何,我认为信号的概念是合理的,如果我正确理解了作者试图传达的内容。我对这些解耦架构的主要问题是,一旦应用程序足够复杂,你就会在试图弄清楚为什么会触发这个特定事件时迷失方向。理想情况下,信号应该通过修改堆栈跟踪来解决这个问题,这样当我的回调被调用时,它已经包含了触发该信号的代码的堆栈跟踪。

    1. > 是什么库?什么框架?我在这儿迷路了。什么是效果?

      有各种库会导出一个名为“效果”的函数,允许你在信号更新时运行任意代码。Preact 文档对信号和效果有很好的入门指南:https://preactjs.com/guide/v10/signals#effectfn

      据我所知,这些效果函数会在初始化时调用回调函数一次,以确定在回调函数执行期间访问了哪些信号,然后在依赖的信号更新时再次调用回调函数。只要信号访问是同步且单线程的,就可以确定如果在回调函数执行期间访问了某个信号,那么回调函数应该订阅该信号。

      > 效果函数如何知道在奇偶校验发生变化时需要调用这个 lambda?它会在任何信号变化时调用这个 lambda 吗?

      你可以通过获取器实现这一点[1],其中效果函数在获取器方法中跟踪信号的哪些属性被访问(我认为 Vue 在版本 2 中就是这样做的),但你也可以通过代理跟踪对象访问[2]。提案中的示例只是有一个用于访问信号值的 ‘get’ 方法,执行此方法允许跟踪依赖关系。

      [1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe…

      [2] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe…

    2. > 效果如何知道在奇偶性发生变化时需要调用这个lambda?

      调用`parity.get()`会为传递给`effect()`的函数注册一个依赖关系。当`parity`更新时,该函数会被调用。

      > 它会在任何信号变化时调用这个lambda吗?

      仅当依赖的信号发生变化时才会调用。

      在此情况下,`parity` 依赖于 `isEven`,而 `isEven` 依赖于 `counter`。因此当 `counter` 更新时,整个依赖链将失效,导致 `parity` 失效,并触发回调重新运行。

      1. 这在正常情况下工作良好,直到你意外地遇到一个if/else分支,然后就会出现难以追踪的错误(在一般情况下会导致停机问题)。

        我猜这就是为什么他们不建议将此功能添加到标准中的原因:这个事实使得它看起来不太美观。

        1. if/else 并不是问题。如果条件不依赖于其他因素,那么未使用分支的更新并不重要

        2. 嗯,你能解释一下 if/else 分支如何导致难以追踪的 bug 吗?

    3. > 效果如何知道在奇偶性发生变化时需要调用这个 lambda?

      基本上,所有信号的实现(无论叫什么名字)都会构建一个动态依赖图,其中边由读取节点建立。在这种假设的 `effect` 跟踪上下文中,读取操作还会在信号的状态节点与效果的计算节点之间建立边—— effectively 使后者订阅前者后续的写入操作,以便确定何时重新运行计算。

      1. 效果是任何你想调用的函数

      2. 通过信号的依赖跟踪机制,系统知道哪些值需要重新计算,因此系统知道需要重新调用哪些函数

    4. 我怀疑监视器是效果实现所需要的

  8. 相关的是 S.js:https://github.com/adamhaile/s

    我喜欢信号。在制作 UI 时,我更倾向于使用信号而非其他原始类型(除了可能的鸸鹋约束算法)。我尝试在使用的每种语言中复制信号,只是为了好玩。

    我也不认为它们应该属于JavaScript语言。让语言保持现状吧,人们已经难以跟上它的步伐。TC-39已经让人们对这门语言望而却步。

    1. 如果人们对JavaScript望而却步,想象一下流行语言会是什么样子!

  9. 这看起来非常像mobx,这是我最喜欢的JS效果系统。

    这是mobx版本:

      import { observable, computed, autorun } from ‘mobx’;
    
      const counter = observable.box(0);
    
      const isEven = computed(() => (counter.get() & 1) === 0);
    
      const parity = computed(() => isEven.get() ? “even” : “odd”);
    
      autorun(() => {
        element.innerText = parity.get();
      });
    
      // 模拟对计数器的外部更新...
      setInterval(() => counter.set(counter.get() + 1), 1000);
    
    1. 嗯,MobX 是信号。但这里的信号是通过代理对象隐式跟踪依赖关系,而不是通过显式的 getter。

  10. “让我们把当前流行的框架整合到标准库中!”

    这有点像在自己身上纹上女朋友的名字。

    1. 但这并非此处的核心。

      这是将一个构建块整合到标准库中,而大多数框架已趋向于使用该构建块。

      Promises 得以广泛应用后,最终被纳入标准库。这与之类似。

  11. 我认为 signals 并不能帮助开发者摆脱进一步的复杂性,而这正是我们当前所需的。

    现代网站/应用为何会采用信号、内存管理、混合渲染模式等模式,这是一个根本性问题。我不敢说自己掌握所有答案,但显然平台在支持用户希望实现的模式方面存在缺口,我不确定将信号作为标准是否能帮助我们更好地理解是平台本身还是我们的思维模式需要更新以重新同步。

    个人而言,我发现当前端仅负责真正临时且完全不存在于后端的状态时,代码维护起来要容易得多。对我来说,任何持久化状态都应放在服务器端,任何依赖它的渲染也应如此。这在很大程度上使信号变得多余,因为很少有应用程序需要如此复杂的临时状态,以至于需要复杂的设置来管理它。

  12. 看起来很有用,但让我困惑的是……为什么每个框架都使用“setX”函数来设置状态或他们的“信号”?为什么不直接使用内置的获取器和设置器,你可以代理它们或直接覆盖它们?

    这感觉更干净:something = “else”;

    而不是:setSomething(“else”);

    1. 一些采用信号式响应模式的库确实使用了getter和setter(例如ember.js和mobx)。然而,对于原始API来说,使用函数是有道理的,因为getter和setter本质上是函数,并且作为属性描述符的一部分应用于对象。此外,并不总是希望将响应式值嵌入到对象中,有时你只是想传递一个可变的值。

      至于为什么有些库选择使用 [thing, setThing] = signal() 接口(如 solid.js),这通常被称为读写分离。这种设计本质上鼓励默认传递只读值,并通过显式传递其设置函数来允许消费者对值进行写入操作。这种设计理念是由 React 钩子流行起来的。

      无论如何,该提案并未限制库的 API 选择,因为你可以围绕原始类型创建任何类型的封装。

    2.     something = “else”;
      

      对于那些不是对象且没有方法、属性或 getter/setter 的原始 JavaScript 类型(如字符串、数字、布尔值、未定义、符号、空值),这不可观察。

          some.thing = “else”;
      

      `some` 可以被代理并观察。大多数框架都会设置 `some` 容器/代理/对象,以便一切都可以一致地访问和观察。框架是暴露对象、函数,还是将其隐藏在 DSL 中,取决于实现方式。

    3. 目前的一个大问题是它们的使用速度极其缓慢。(至少通过原生 Proxy 类时是这样。)不确定这是 JIT 的副作用还是原型继承的本质。

    4. 因为 JavaScript 缺乏“作用域作为对象”的特性。你无法通过它追踪 var x; x = value。setSomething() 在赋值后发送通知。此外,DOM 元素只能接受原始值,必须手动从数据流中更新。

      在浏览器中添加这些功能会严重拖慢 DOM 和 JavaScript,从而影响所有网站的性能。因此,我们转而加载数兆字节的 JavaScript 抽象封装层,并在浏览器中运行它们以模拟该效果。

    5. 例如,我可以编写 .set,而 IDE 会自动补全所有可设置的 something 选项,即使我对具体有哪些选项一无所知。

      我非常欣赏这种一致性(为常见行为使用共同前缀,在此案例中为设置器)

    6. 要使用设置器,必须采用“foo.something = else”的写法,因为 JavaScript 无法覆盖普通局部绑定——自“with”语句被废弃后更是如此。一旦这样做,你确实可以拥有一个生成获取器和设置器的框架,这正是 Vue 2 所做的。改用代理而不是 get/set,你就得到了 Vue 3——信号 API 与 Vue 组合 API 几乎完全相同。

  13. 过去曾有过将可观察对象引入语言的尝试,因为它们当时很流行(rxjs也是如此)。

    很高兴这没有发生。我想其他人也是这么认为的。也许我们在标准化框架功能时应该记住这一点。

    1. 尽管几乎每个前端框架都没有使用可观察对象,而信号却被广泛采用

  14. 难道不应该先解决语言的互操作性问题吗?“让我们在标准中添加所有功能的单一实现!”这种回应似乎对“语言用户因虚假耦合而难以实现互操作性”的问题有些奇怪。

    1. 这很奇怪吗?

      Vue 的响应式特性与 Svelte 或 Angular 不兼容。

      作为对你问题的反例,如果我们都有对象原语的竞争实现,库之间几乎无法协同工作,需要在它们之间添加一个互操作层(就像今天的响应式层一样!)

      1. 我承认我很少使用 JavaScript,但多态性的状态难道不能得到改善吗?例如,C++ 最近添加了概念,而大多数(现代)语言都有某种方式来描述接口。

        至于你的反例,我同意当前的 JavaScript 确实会遇到问题,但如果有良好的语言支持,这当然是可能的。例如,Rust(和 C++?)对全局分配器有竞争性的实现,而大多数用户永远不会注意到这一点。

        1. 反应性层都与框架的核心紧密相连。对于任何框架来说,向最终用户暴露这样的东西以利用竞争性的实现都没有任何优势。

          至于多态性,即使是当前的类语法,也基本上与原始的原型继承机制以相同的方式运作,只有构造函数行为中支持某些内置对象的子类化有一些例外。

          你可以非常轻松地创建运行时特性——类似于原型函数,类构造是一个表达式,其结果值可以传递、变异等。

          例如,你可以编写一个函数,该函数接受一个类作为参数,并返回一个继承自该类的全新类。

    2. 我认为该语言并未对互操作性造成太大阻碍?它提供了丰富的工具,可将对象在不同形态间进行适配,当我们需要桥接两个相似但接口不同的对象时,这些工具便能发挥作用。

      我很难想象还有什么更理想的了。您认为有什么特定的功能可以帮助庞大的软件包生态系统协同工作吗?例如,当不同的软件包有不同的信号实现时?

      1. 更好的描述多态性的方法,更确切地说,是更好的类型系统。请参阅 async Rust,了解此类互操作性的一个很好的例子。

    3. 这难道不类似于其他项目中常见的“小核心 vs 大核心”辩论,尤其是在涉及驱动程序时?(即关于耦合层如何实现的问题)

  15. 示例只展示了简单值。当修改嵌套对象和数组时会发生什么?

    其他框架通常在这方面遇到很大困难。需要通过在 useMemo() 中重写相等性函数,或在修改后即使传入与之前相同的实例也必须调用 .set 方法等变通方案。

  16. 天啊。这听起来很复杂。

    信号的难点与过度复杂化事件类似。

    当出现问题时,很难调试到底发生了什么以及该如何修复。

  17. 嗯,如果能优化所有网页应用的响应式状态管理,那听起来很酷。

    如果这是 VueJS 的基础,它应该能够处理深度更改。他们提到支持 Map 和 Set,但能够控制深度在 VueJS 中很不错。(我认为 watch() 应该默认是深度的,因为非深度是一种优化,可能会导致意外的不一致状态。)

    流。一般来说,我认为 RxJS 有些过时。通常你只需要“状态”,所以这应该是最容易实现的部分。但我不得不承认,这种编程模型非常优雅。如果不考虑流就标准化“状态”,这对我来说似乎有些奇怪。“computed”会创建一个更新管道,与使用流进行映射操作非常相似。如果 RxJS 不存在,我可能不会在意这种二元性。

    异步。当然,信号可以是同步的,但Computed必须与异步函数良好兼容。这是VueJS的一个重大缺陷(人们只能自行绕过)。这还意味着需要优雅地处理“待处理计算”以提高可调试性。我看到有一个“计算中”状态,但这必须暴露出来才能调试卡住的承诺。

    异常。我喜欢 Computed 中的 .get() 重新抛出异常的思路。VueJS 在这方面有些模糊,只是直接停止。

    1. > 它应该处理深度更改。

      这些可以通过代理在用户空间实现——我认为应该这样做,正如这组工具所证明的: https://twitter.com/nullvoxpopuli/status/1772669749991739788

      如果我们试图将所有内容都实现为响应式版本,那将永无止境,实现也无法跟上——通过将响应式 Map/Set 等推送到用户空间/库空间,我们可以根据需要逐步实现所需功能,基于信号原语的坚实基础进行构建。

      > 因为非深度解析是一种可能导致意外不一致状态的优化。

      相反,始终深度解析会带来性能开销,我们不希望将其设为默认。Svelte 和 Ember 采用这种需要手动启用的深度响应式设计。

  18. 依赖跟踪问题是否可以静态解决(无需调用效果并订阅所有 .get)?

    我认为这不需要成为语言特性,而是现有特性的抽象。换句话说,这不能是一个库吗?

    我知道 SolidJS 能够确定依赖信号,但可能是在首次执行时进行的。

    1. 我认为 Svelte 是在构建时确定的。

      这让我对提案中提及 Svelte 感到疑惑,也让我好奇 Svelte 开发者对此的看法——因为据我所知,他们确实不需要这个(在运行时),如果我没记错的话。

      1. 当前版本的 Svelte 是在构建/编译时完成的。即将发布的 Svelte 5 将使用信号,而反应性则移至运行时。

    2. > 换句话说,这不能是一个库吗?

      你已经回答了自己的问题:

      > 我知道SolidJS能够

      显然它已经实现了。但SolidJS如何与其他非SolidJS代码配合工作?它做不到。除非每个库都为其他库提供支持,否则它们不可能互操作。

      1. > 显然,它已经实现了。但SolidJS如何与其他非SolidJS代码配合工作?

        谁会写这样的代码?人们通常使用一些信号图库来编写应用程序代码,我从未见过有人在应用程序代码中混合使用SolidJS和MobX,或者因为库依赖关系而这样做。

        1. 让我给你描述一个场景:你想要构建一个使用状态管理的 UI 网页组件,但希望它能在 Svelte 和 React(使用 solidjs)中都能正常使用。目前的情况是,这简直是一场噩梦,因为你必须将所有状态从代码中移出,放入一个“驱动”模块中,然后根据目标框架的不同来切换这个模块。整个库的架构因此变得更糟(可读性/可维护性下降,性能可能更差,贡献难度增加),只为实现一套统一的UI代码。

          当运行时对状态处理的底层实现进行标准化后,上述问题将不复存在。

          1. 或许Svelte、Solid、React等框架应成立工作组,共同制定跨框架兼容标准

      2. 遗憾的是,JS 生态系统如此匮乏。

        例如,在 Rust 中,即使只是基本的绑定,也更常见的是依赖于现有的“构建模块”,如 futures、tokio、syn、serde,以实现互操作性。

        1. 我认为你的理解是错误的。十五年前,我们只有 jQuery,没有其他东西。每个人都只使用 js 的“构建模块”库。但随后社区和生态系统呈指数级增长,我们有了无数选择。Python 也是如此:我能随口说出六种构建 web 应用的方式。至少 Python 在标准化方面做得不错,比如 WSGI,但即使如此,你也能看到一些粗糙的边缘,比如并发代码的非互操作性(twisted vs tornado vs greenlets vs threading vs multiprocessing vs asyncio 等)。

          Rust 在未来十年左右也会发生同样的情况。它的普及将发展社区,社区的发展将推动生态系统的发展,而积极的反馈循环将意味着有大量选择。这是一件好事,但也会带来一些弊端,比如互操作性的下降(除非语言发展)。JavaScript 长期以来一直受到领导层的诅咒,他们基本上是在睡大觉。

    3. >换句话说,这不能是一个库吗?

      提案中明确提到了这一点。第三方库的问题在于其互操作性和实现的多样性,而这些对于此类场景可能并不必要。

  19. 作为一名长期使用 React.js 的用户,这些示例看起来像是声明式与一些强制性操作的奇怪混合。比如,foo.set 依赖于 foo.get,并且必须手动在副作用中设置元素的 innerText,这让人感到不适,我只能想象在稍微复杂一些的应用程序中会有多么混乱。

    我认为 React 的模板代码在此场景下要好得多,看看这个

    “`

    function Component() {

      const [counter, tick] = useReducer(st => st + 1, 0)
    
      useEffect(() => setInterval(tick, 1000))
    
      return counter % 2 ? ‘odd’ : ‘even’
    

    }

    “`

    三行代码,声明式,函数式,不错。

    1. 函数式通常意味着输出只能依赖于输入。但这里依赖于通过钩子引入的外部状态。顺便说一句,有些人更喜欢其他替代方案。

      1. 这并不完全正确,useReducer钩子并非某种被引入的外部状态。它是调解器(reconciler)的实际输入,用于定义该组件的输出,因此即使按照你的定义,它也是完全函数式的。

        1. 计数器值在后续调用中会发生变化。也许 React 重新定义了某些标准术语,但这其实很直观。如果 useReducer 不是纯函数,而它确实不是,那么调用它的任何函数都是不纯的。

          1. 这个 reducer 显然是纯函数,你把它和 `st => st++` 混淆了

            1. 告诉我,这个“纯”函数调用返回的值 `counter` 是多少?

              const [counter, tick] = useReducer(st => st + 1, 0)

              答案:这取决于情况。有时是 0,有时更多。

              任何对相同输入返回不同结果的函数都是不纯的。因此,`useReducer` 是不纯的。它可以对相同的输入返回不同的结果。React 可能对“函数”或“纯函数”有不同的定义,但这些术语在 React 出现之前就已存在。

              1. 如果函数是纯函数,那么对于任何给定的输入状态,它都会返回状态 + 1,状态是所有调用积累的结果,没有参数或外部因素会导致它行为不同。

                它也没有副作用,仅依赖其输入状态来产生输出,不会修改其作用域外的任何变量,不会与外部世界交互,也不会修改任何全局状态。

                因此,根据经典的纯函数定义,它完全符合纯函数的标准。React 并未在该方面重新定义任何内容,它本质上就是一个纯函数渲染器。

                1. 关键在于,你所指的状态存在于函数参数之外。

                  调用返回的 reducer 函数会修改组件纤维对象上的 `memoizedState` 属性。纤维对象是函数外部的状态。它不是函数的参数之一。

                  这是一个函数。它是纯函数吗?

                      let current = 0;
                      function getNext() { return current++; }
                  

                  注意,结果是所有调用的累积结果。我认为这不是一个纯函数。现在假设 current 是通过钩子分发器访问的 React 纤维对象。

                  具体区别是什么?

                  1. 一切似乎都正确,但 reducer 函数并未修改纤维对象。

                    你的函数显然不是纯函数,它通过增量运算符大胆地修改了外部状态。将此推断到纤维的工作原理是错误的。区别在于,React 钩子(包括状态和设置器)都位于分发器内部,调用钩子设置器仅将更新任务加入队列,由分发器处理。因此从技术上讲,钩子并未直接修改外部状态,而是分发器在处理队列时更新其内部状态。

                    1. 将更新任务加入队列本身就是对更新队列的修改。

                      这些语义上的区分并不影响这样一个事实:count 的值并不只取决于函数的参数。

                    2. 我不得不承认:你在这里完全正确。React 的实现当然一直依赖于可变状态——只是这样我们就不必亲自处理它。我在这儿跑题了很多,只是为了让这个有趣的讨论继续下去 😉 我仍然不同意你对“函数式”的定义,因为你将其与“纯函数式”视为同义词。函数式仅指通过应用和组合函数来构建,而 React UI 正是这样创建的。有一个很棒的代数效果提案[1], 希望有一天能被添加到 JavaScript 中,届时 React 将利用它成为纯函数式。

                      1: https://github.com/macabeus/js-proposal-algebraic-effects

                    3. 我承认“功能性”在组合与“纯粹”之间的区别。这是一个重要的区别。

                      这个提案很有趣。与典型的 TC39 提案相比,它看起来相当简洁。我之前从未接触过这个语言特性。我目前还不确定对它的看法。我怀疑它在未来 10 年内能否成为标准,除非 React 相关方以某种方式接管该委员会。

                      不可变性在许多问题领域中是一个很好的工具。但我对 React 不满的主要原因(而且有很多)是,我不仅“不需要”依赖可变状态,甚至无法选择是否使用它。或者至少,他们让这变得困难。

  20. 本文缺少“什么是信号”部分。而且,这确实无法完成任务:

    > 在 JavaScript 框架和库中,人们对表示这种绑定的方式进行了大量实验,经验表明,单向数据流与表示状态或从其他数据派生的计算单元的一级数据类型相结合,具有强大的功能,现在常被称为“信号”。

  21. 这看起来足够先进,值得提供语言支持。

    如果信号依赖关系能在运行时使用前静态发现,这一功能将强大得多。

    既然你认为库应标准化,为何不更进一步将其深度集成?

    1. 因为没有规定信号必须在顶层赋值给常量变量。你可以根据用户输入动态创建信号对象,当前的提案或实现并未阻止这一点。因此无法对信号图进行静态分析。

  22. > 当前草稿基于Angular、Bubble、Ember、FAST、MobX、Preact、Qwik、RxJS、Solid、Starbeam、Svelte、Vue、Wiz等框架的作者/维护者的设计输入…

    这是该标准库的主要用户群体。

    1. 这基本上涵盖了除 React 之外的所有主要框架,并包括一些主要的 React 库。

  23. 非常期待这项工作能带来优秀的开发工具

  24. 缺乏信号机制并非 JavaScript 的最大问题,添加信号对大多数 JavaScript 用户的影响微乎其微。最大的问题是缺乏标准库,导致大多数项目陷入 npm 地狱。

    1. JavaScript 确实有标准库,而这个提案旨在扩展它,这很好,对吧?

      1. 除非它敷衍了事、笨拙且目光短浅,而且我们不得不永远使用它,因为它是标准。直到某一实现因作为库而胜出(如jQuery所做)并成为标准,它就不应被偷偷地强加到语言中。

    2. 这在提案中有所提及:

      > JavaScript一直拥有一个相当简洁的标准库,但TC39的趋势是让JS成为一个“开箱即用”的语言,提供高质量的内置功能集

      我认为“简洁”的描述比“没有”更公平,就标准库而言。

    3. 强烈反对——对于一个项目来说,添加一个小型标准库、将lodash作为单一依赖项添加,或直接将代码作为源代码添加都是轻而易举的事。

      JS项目陷入npm地狱,是因为人们被教导使用库来节省输入10个字符的麻烦。没有标准库能解决这个问题。因为有人可以直接引入一个新库,对标准库中的某个功能进行微小改进(如缓存)并将其封装。

    4. 你认为截至 ES2024,标准库中还缺少什么?

      这是我从 ES3 时代(甚至更早)就听到的抱怨,而自 ES2015 以来,趋势一直是解决这个问题,所以我真心想知道你认为 JavaScript 的“标准库”(即内置函数)目前还缺少什么。

      考虑到 JavaScript 当前运行的不同环境,我认为可以将“标准库”的定义扩展为“浏览器应作为内置功能提供的内容”或“Node.js 应作为内置模块提供的内容”。

    5. 情况正在好转。我可以用12个依赖项构建一个中等规模的现代TS/React应用。大型企业应用的依赖项数量会接近30-40个,这相较于之前的依赖项列表仍是一个显著的改进。

  25. 离题了,但我很好奇对这个话题感兴趣的人能否帮我理解为什么 JavaScript 没有宏。

    我清楚关于宏的许多讨论,通常是在糟糕的开发体验背景下——但这听起来像是肤浅的否定。

    归根结底,JavaScript 生态系统中确实存在类似宏的功能,但这些功能并未被语言本身支持,而是被移交给了转译器和编译器。

    有没有人能指点我一些权威资料,讨论 JavaScript 中的宏?我自己搜索时很难找到深入且认真的讨论。

    1. 解释型语言很少有宏。

      但更重要的是,你真的希望网页上的脚本标签定义宏,这些宏会全局影响其他文件的解析/解释方式吗?如果宏引用了非全局标识符怎么办?如果我在一个脚本中定义宏,而该脚本在其他 JavaScript 代码执行后加载,又该如何处理?宏会影响 eval() 和 Function.prototype.toString 的输出吗?

      当然,你可以将宏的作用域限制在一个脚本/模块中,以防止代码到处出错,但现在你需要在创建的每个文件中重复宏的定义。你可以通过将 JavaScript 文件打包到一个文件中来避免这一点,但现在你又回到了使用编译器的阶段,这使得整个事情变得毫无意义。

      1. 事实证明,现在 everyone 使用 Typescript,引入编译步骤可能确实有好处……看到宏被添加会非常有趣,不过我怀疑这与 Typescript 的宗旨相悖,即在 JavaScript 之上尽可能少地添加新功能。

    2. 宏在 JavaScript 运行时规范中并不真正有意义。因为你基本上可以通过使用 eval 或 new Function 来实现宏级别的功能,但这并不高效。宏在构建时最有意义,而且已经有一些尝试使用各种打包器/转译器插件来实现通用构建宏。我认为这个领域需要更多时间来成熟。我乐观地认为,我们最终会看到某种(非)官方的宏规范出现。

      1. 这是一个我本应自行发现的绝佳资源。感谢分享。我稍后会仔细查看。快速浏览后,我看到了一些与其他地方相同的表述;这里提到宏“过于复杂”。

        我不明白为什么人们对宏持谨慎态度。如我在第一个评论中简要提到的,我了解到许多人对宏作为工具持否定态度,但这些否定在上下文中对我来说并不合理。我可能缺少一些概念历史背景或关键的思维转变节点。

        有哪些好的资料可以帮助理解TC39成员在探讨宏概念时所秉持的背景视角?

  26. 我不是很明白这些信号在使用拉取式评估时是如何实现高效的。拉取式模型可能需要遍历整个对象图来检查某个值是否需要在 get() 方法中重新计算。这虽然简单,但效率低下。

  27. 信号在过去的桌面编程语言中才有意义,因为主流语言缺乏lambda表达式和闭包。

    Smalltalk 以及衍生自 Lisp 的语言在没有它们的情况下也运作良好。

    现代 JavaScript 已经具备这些功能,无需再使用本质上是带有监听器列表的闭包。

  28. 我喜欢这个提案的质量,它让我想起了 JSRs。这确实很有意义。

  29. 唯一重要的问题是:这作为原始类型有用吗?

    我倾向于说不。信号似乎主要是 UI 概念。只需使用任何信号库即可。实用库无需理解信号。

  30. 我多年来一直享受以 re-frame 订阅形式存在的“信号”。

    它们解决了前端开发中的一小部分问题。但并非所有问题。

    将信号作为语言构造添加到 JavaScript 中是没有必要的。

  31. 我支持。Mobx 很好。这将使开发工具的改进更加专注。

  32. 从初步观察来看,信号似乎与事件/行为风格的功能响应式编程(FRP)相平行。希望能够从FRP领域引入一些有用的想法。

  33. 看起来不好。它似乎增加了更多无意义的复杂性。此外,“信号”作为名称并不描述所提议的内容(例如Qt信号)。

    1. 是的。谢谢。我已经厌倦了试图弄清楚这如何改善我的生活,也对阅读他人代码中那些更加抽象的垃圾感到烦躁。

      1. 从技术上讲,承诺(Promises)就是“抽象的垃圾”。

        那么,抽象的垃圾只有在它是JS标准库的一部分时才被接受吗?

        1. Promises在被添加到JavaScript之前,已经在库中证明了其价值。

          1. Signals也是如此。而且这在读我文件的介绍部分中明确写明了

            1. 他们确实创建了一个 polyfill,但读我文件中提到它是基于其他项目的设计输入,而非与多个现有设计对齐。这至少与承诺的情况相反,因为承诺在 Promises/A 设计出台前已存在于多个库中。

              1. > 因为它们在多个库中已经存在

                信号在多个库中存在,只是在主题上有些微小的差异。

                > 在Promises/A设计发布之前。

                这就是为什么当前的提案要求提供设计输入。

                据我所知,Promises也有多个设计迭代。有人提议让它们更具单子性、更少单子性、可取消、不可取消等。

                而原始提案与最终的 API 完全不同:https://groups.google.com/g/commonjs/c/6T9z75fohDk [1]

                并且仍在向它们添加新功能(如 Promise.withResolvers 等)

                [1] 这里有一份关于承诺历史的精彩长篇演讲:https://samsaccone.com/posts/history-of-promises.html

          2. 我讨厌Promise。编写:

            let result; let error; await blah .then(r=>result=r) .catch(e=>error=3) .final(callback(err, result));

            这令人作呕。

    2. 我个人认为“信号”这个命名不太理想,但它已成为 JavaScript 生态系统中该概念最广为人知的术语。可选的简洁命名方案也不多,但“响应式值”或许更合适?

  34. 好奇,使用代理(Proxies)来处理状态如何?

  35. 但原生代码比提案更干净、更易于阅读和理解!

    说真的,JavaScript 生态系统真是奇怪……

  36. 抱歉,如果这个受 Preact/Angular 启发的提案被批准并纳入规范,而 Observable 被刻意排斥,我可能会有点生气。

    1. > 如果这个受Preact/Angular启发的方案

      信号机制早于两者,至少起源于KnockoutJS。近年来由SolidJS推广,随后被Preact、Vue等采用。Angular是信号机制领域的后来者。

      编辑:这段内容直接出现在引言部分:

      — 开始引用 —

      这种一流的响应式值方法似乎首次在2010年的开源JavaScript网页框架Knockout中崭露头角。此后数年间,出现了许多变体和实现方式。在过去的3至4年间,信号原语及相关方法进一步获得关注,几乎每个现代JavaScript库或框架都以不同名称实现了类似功能。

      — 引文结束 —

      README中的库列表按字母顺序排列,并未反映框架和库中信号演进的历程。

      1. 信号实际上是20世纪70年代在Smalltalk中引入的更古老概念,最初被称为ValueHolder。后来Qt重新采用了这一概念并将其命名为“信号与槽”。

    1. 自动依赖跟踪、防止循环引用、提升可观察性及潜在更好的开发工具

        1. 读者练习:在实现 this 的 Firefox 补丁中找到一个隐蔽的点。

      1. 这不足以与现有的“足够好”的解决方案竞争。

          1. 获取器和设置器工作得相当不错。

                addEventListener(“foo”, () => {...}, {once: true})
            

            这是处理一次性事件的相当简单的方法。

            这些对我来说已经足够好,可以用来构建大型、复杂的医疗应用程序。

            1. 没有人说你不能使用当前工具构建大型复杂应用程序

            2. >> 获取器和设置器工作得相当不错。

              你能解释得更详细一点吗?

              1. 当然。

                我使用轻量级 DOM 原生 Web 组件来结构化我的应用程序,并使用 lit-html(而非 lit)作为渲染器。

                我将以一个假设的患者档案组件为例。假设这是一个顶级“页面”,需要支持深度链接。

                set patient_id(value) 会触发 loadPatient() 方法。loadPatient() 在加载完成后会设置 this.patient。this.patient 的设置器会触发渲染并绘制组件。

                你可以根据需要扩展这一逻辑。也许有时我想在模态窗口中渲染该组件,也许有时作为侧边抽屉。也许根据其宿主环境的不同,标题部分会有细微差异——我只需添加一个类似“display_mode”(此处虚构)的设置器,在值改变时触发渲染。

                这与任何响应式流程并无本质区别,且在总代码行数上与大型框架相当,但这里一切都是原生且简单的实现。

                对于跨组件通信,自定义事件效果很好,尤其是单次触发的事件。在组件构造函数中,我可以添加监听器、赋值属性或调用组件函数,响应式流程仍会正常进行。

                无需框架。

                与任何渲染器兼容,如果你不喜欢 lit-html,还有 JSX 等其他选项。我喜欢 lit-html 因为它能快速克隆模板节点,因此无需担心重复调用 render(),这很高效。

                1. 注意需要手动完成的工作量:

                  – 不要忘记从 getter 中触发渲染。如果组件依赖于多个正在变化的数据点,可能不仅需要从一个 getter 中触发渲染

                  – 不要忘记在需要将此数据传播到其他地方时触发自定义事件

                  – 不要忘记在需要此数据的地方订阅自定义事件,并在数据更新时触发渲染

                  – (良好的编程实践:)不要忘记取消订阅这些自定义事件,以避免内存泄漏

                  我并不是说这不可能做到,或者说人们没有在许多项目中成功地这样做多年。然而,你可以实现的是让同样的事情自动发生:当一个响应式值更新时,所有使用该值的地方都会自动更新。正如具有细粒度响应性的框架/库所示,实际上只有那些地方会更新。例如,你不需要因为某些数据发生了变化,就重新渲染整个组件。

                  1. > 注意你需要手动完成的工作量

                    实际上,我发现整体工作量更少且更简单,否则我就会使用框架。

                    > 正如具有细粒度响应性的框架/库所示,实际上只有那些地方会得到更新

                    也许可以看看lit-html是如何工作的。

  37. 这是一个糟糕的主意。与其在语言的这个小众功能中加入依赖跟踪机制,JS应该提供一种通用方法,让框架开发者能够清理资源,而无需让API用户手动完成这一操作。

  38. 不错。我最不喜欢、最繁琐且最脆弱的Ember使用方式,现在成了JavaScript核心功能的一部分。

    去他妈的,谢谢。

  39. 这并非坏事……但这与 React 中所谓的原子化状态管理系统的思维模式如出一辙。我认为 Jotai 是最流行的。

  40. 将 React 移植到 VHDL 或 Verilog 更容易吗?

  41. 看起来Svelte 5只是某种程度上更糟糕?

    1. 该提案非常明确,它不是试图看起来漂亮,而是试图合理且正确,以便像Svelte这样的框架可以基于它们构建,并与其他库和框架互操作。

  42. “让我们将我的随机 UI 状态跟踪框架纳入 JavaScript 规范”

  43. 我对 JavaScript 社区始终能让事情变得越来越复杂的能力感到惊讶。

  44. 天啊,早在1972年就已经有“信号”这个概念了。

  45. 我们不能直接把JavaScript称为“完成”吗?

    我们一直在向语言中添加新内容,却从未删除任何内容,这意味着学习这门语言正变得越来越困难。

    1. 他们没有将这些添加到语言中。他们将这些添加到(目前基本上不存在的)标准库中。

      鉴于几乎所有框架(除了 React)都已采用信号,将信号移至浏览器是有意义的。这就是网络应该运作的方式。

    2. 我不知道。我有时也有同样的感觉,但最近添加的许多功能在清理代码方面非常出色。??

      是我的最爱。

      我还希望添加集合函数和可能的匹配语句功能。

  46. 如果他们围绕 React 或类似框架进行标准化,我会感到更安心。与让开发者先实际使用库再提议上游相比,作为内置功能的优势是什么?

  47. – 他们说“社区希望减少冗余代码”

    – 他们引入了一个实质上是黑盒系统的方案

    – 新系统预计将处理所有应用程序状态

    – 他们试图向前端开发者推广该系统,同时指出它对构建系统也有用

    这与xz事件有相同的警示信号。

    我们难道没有吸取教训吗?

    这里有很多“用户”为该模式背书并希望它被采纳。我敢打赌这会得到一些漂亮的危机公关回复,因为这里正在进行社会工程学操作,而大多数人似乎并未意识到这一点。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

你也许感兴趣的: